From c45e6b1dbb351d2345c7376fc6af12a8a483aa72 Mon Sep 17 00:00:00 2001 From: wooshings Date: Tue, 27 Oct 2026 21:13:12 -0700 Subject: [PATCH] update docs --- README.md | 101 +++++++++++++++++++++++--- examples/bouncing_balls.odin | 39 +++++----- src/componenets.odin | 21 +++--- src/constants.odin | 4 +- src/ecs.odin | 109 ++++++++++++++++++++-------- src/entities.odin | 135 +++++++++++++++++++++++++++-------- src/signals.odin | 12 ++-- src/systems.odin | 41 ++++++----- src/timer.odin | 31 +++++++- 9 files changed, 372 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index c23a12f..e80b4a6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ my silly little ecs engine for odin and raylib :3 ## contents - [install](#install) - [usage](#usage) + - [systems](#systems) + - [components](#components) + - [spawn entities](#spawn-entities) + - [query](#query) + - [plugins](#plugins) + - [signals](#signals) + - [timers](#timers) + - [ui](#ui) + - [tween](#tween) ## install clone this repo in your project directory @@ -39,15 +48,21 @@ main :: proc() { ### systems systems are procedures that run on set schedules -a schedule just determines when a producure runs\ -schedules are: -- STARTUP -- UPDATE -- DRAW +a schedule just determines when a procedure runs\ +there are 8 of them, and `run()` walks them in this order: -`STARTUP` runs once at the start of an app\ -`UPDATE` runs every frame\ -`DRAW` runs every frame *after* update, and makes draw calls to raylib\ +- `STARTUP` — once, before the first frame +- `PRE_UPDATE` — every frame, first thing +- `UPDATE` — every frame, your main logic +- `POST_UPDATE` — every frame, last stop before drawing (`signal_system` clears the signal buses here) +- `PRE_DRAW` — every frame, opens the raylib frame (`DefaultPlugin` calls `BeginDrawing` + clears the background here) +- `DRAW` — every frame, inside the frame — this is where your raylib draw calls go +- `POST_DRAW` — every frame, closes the raylib frame (`DefaultPlugin` calls `EndDrawing` here) +- `DESTROY` — once, after the loop exits (`DefaultPlugin` calls `CloseWindow` here) + +so: `STARTUP` runs once up front, the six update/draw ones run every frame in that order, `DESTROY` runs once when the window closes. + +systems registered on the same schedule run in registration order — whoever was `add_system()`ed first runs first (that includes the systems your plugins add, so `add_plugin(&app, ecs.DefaultPlugin)` before your own systems means shoal's defaults run first). ```odin main :: proc() { @@ -80,6 +95,8 @@ and create it's storage Example: ecs.Storage(ExampleComponent) ``` +shoal ships a few built-ins already declared as globals, so there's nothing to create — just pass `&ecs.Window`, `&ecs.Cursor`, `&ecs.Sprite` or `&ecs.Transform`. `DefaultPlugin` reads and writes them: it opens the window from `Window`, keeps `Cursor` on the mouse, and draws every `Sprite` at its `Transform`. + ### spawn entities create a new entity with `spawn_entity()` @@ -98,8 +115,16 @@ main :: proc() { } ``` +or fill it in on the spot with the data overload + +```odin +main :: proc() { + ecs.add_component(entity, &Example, ExampleComponent{...}) +} +``` + ### query -since components are saved in storage next to their entity, you can loop through a Storage and pick out what you like +since components are saved in storage next to their entity, you can loop through a Storage and pick out what you like. `slice()` and `slice_entity()` pair up by index: `entities[i]` owns `components[i]` ```odin main :: proc() { @@ -111,6 +136,8 @@ main :: proc() { } ``` +one caveat: `slice()` hands back the storage's live array, not a copy, so adding components afterwards can reallocate it out from under you — don't hold one across `add_component()` calls. + or if you know your storage only has one entry use `single()` ```odin @@ -118,6 +145,17 @@ main :: proc() { component, entity_id := ecs.single(&Example) } ``` + +and once you have an entity id, `get_component()` reaches into any storage to get that entity's copy of the component — handy for components that live alongside another one (a sprite's `Transform`, say) + +```odin +main :: proc() { + component, entity_id := ecs.single(&Example) + other := ecs.get_component(entity_id, &OtherExample) +} +``` + +no existence check though: only ask for entities you know are in that storage. ### plugins plugins are just a procedure that takes `App` as a parameter, meaning it can make changes to `app`, just like main() @@ -136,6 +174,8 @@ main :: proc() { plugins make your scripts very reuseable. in fact, much of shoal's default behaviors come from plugins via `DefaultPlugin()` +run() fires every plugin in the order you added them, once, before the STARTUP systems — so everything they register is in place before anything executes + ### signals signals are structs your systems can talk to each other with. one system writes a signal with data, another system reads it later in the same frame — as long as the reader runs before `signal_system` clears the buses at POST_UPDATE (see the caveat at the end of this section) @@ -165,3 +205,46 @@ scheduling caveat: every system sees exactly the signals written since the last - systems running before POST_UPDATE (STARTUP/PRE_UPDATE/UPDATE/early POST_UPDATE) see same-frame writes - a DRAW system sees none of them — POST_UPDATE already cleared the bus. it only sees signals written after POST_UPDATE by an earlier system in the same frame (say, one DRAW system reacting to another's write) - writes made after POST_UPDATE survive the rest of the frame and are picked up by the next frame's PRE_UPDATE/UPDATE systems, then cleared at the next POST_UPDATE — so the next frame's DRAW never sees them either + +### timers +timers are named countdowns. `start_timer()` starts one (or restarts one already stored under that name), and `get_timer()` reads it back — anything that never started or already finished reads back zeroed, so a nonzero `lifetime` means "still ticking" + +```odin +example_system :: proc() { + if ecs.get_timer("cooldown").lifetime != 0 {return} // still cooling down + ecs.start_timer(0.5, "cooldown") +} +``` + +timers tick down every frame in `run_timers`, which `DefaultPlugin` registers at UPDATE. when one runs out its entry is reset to `Timer{}` — that's the auto-clear, and why `get_timer()` goes back to zero when you've waited it out. + +on expiry a `TimerFired{name = ...}` signal is *meant* to be queued for `read(TimerFired)` — wip: as written the reset zeroes the entry before the name is read, so `TimerFired` currently never fires (reading the timers themselves works fine). once that's fixed the usual signal caveat applies: a reader has to run before `signal_system` clears the buses at POST_UPDATE, see the end of the [signals](#signals) section + +### ui +`shoal/ui` wraps microui with its own plugin. add it before any system that wants to draw ui + +```odin +import ui "shoal/ui" +import mu "vendor:microui" + +main :: proc() { + ecs.add_plugin(&app, ui.UIPlugin) + ecs.add_system(&app, ecs.DRAW, my_ui) +} +``` + +`ui.UIPlugin` registers `init()` at STARTUP (uploads microui's atlas texture), `begin()` at PRE_DRAW (forwards raylib input and starts the frame), frees the frame's temp memory and runs `end()` at POST_DRAW (renders the ui), and `destroy()` at DESTROY. + +your system builds the ui against the context `get_ctx()` hands back during the frame + +```odin +my_ui :: proc() { + if mu.begin_window(ui.get_ctx(), "hello", {100, 100, 100, 100}) { + defer mu.end_window(ui.get_ctx()) + mu.label(ui.get_ctx(), "Hello, world") + } +} +``` + +### tween +wip: tweens don't do anything yet. `tween.odin` ships `Tween`, `start_tween()`, `create_tween()` (a stub that returns an empty `Tween` and ignores its arguments), `update_tween_system()` (empty, and no schedule registers it) and a few sine easing helpers nothing calls — don't build on it. the `EASE_IN`/`EASE_OUT`/`EASE_IN_OUT` constants are ids for the unused `Tween.ease` field. diff --git a/examples/bouncing_balls.odin b/examples/bouncing_balls.odin index 5804f23..33e223a 100644 --- a/examples/bouncing_balls.odin +++ b/examples/bouncing_balls.odin @@ -3,7 +3,7 @@ package main import "core:math/rand" import "core:strconv" import "core:strings" -import ecs "shoal/src" +import ecs "../src" import rl "vendor:raylib" app: ecs.App @@ -13,7 +13,6 @@ BallComponent :: struct { radius: f32, speed: f32, } -Ball: ecs.Storage(BallComponent) main :: proc() { ecs.add_plugin(&app, ecs.DefaultPlugin) @@ -25,18 +24,22 @@ main :: proc() { ecs.run(&app) } -setup_window :: proc() { - window, _ := ecs.single(&ecs.Window) +// STARTUP system taking the world: the window lives in it, so adjusting the +// default WindowComponent is a world lookup now +setup_window :: proc(world: ^ecs.World) { + window, _ := ecs.single(world, ecs.WindowComponent) window.background = rl.BLACK } -spawn_balls :: proc() { +// STARTUP system taking the world: spawn_entity wants it, and the balls' +// components live inside it, no global storage to declare +spawn_balls :: proc(world: ^ecs.World) { for i in 0 ..< ball_count { - ball := ecs.spawn_entity(&app) - ecs.add_component(ball, &Ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)}) + ball := ecs.spawn_entity(world) + ecs.add_component(world, ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)}) ecs.add_component( + world, ball, - &ecs.Transform, ecs.TransformComponent { translation = {400, 300}, rotation = rand.float32_range(0, 360), @@ -45,13 +48,13 @@ spawn_balls :: proc() { } } -update_balls :: proc() { - window, _ := ecs.single(&ecs.Window) +update_balls :: proc(world: ^ecs.World) { + window, _ := ecs.single(world, ecs.WindowComponent) - balls := ecs.slice(&Ball) - entities := ecs.slice_entity(&Ball) + balls := ecs.slice(world, BallComponent) + entities := ecs.slice_entity(world, BallComponent) for ball, i in balls { - transform := ecs.get_component(entities[i], &ecs.Transform) + transform := ecs.get_component(world, entities[i], ecs.TransformComponent) pos := transform.translation if pos.x <= 0 || pos.x >= f32(window.width) || pos.y <= 0 || pos.y >= f32(window.height) { @@ -63,11 +66,11 @@ update_balls :: proc() { } } -draw_balls :: proc() { - balls := ecs.slice(&Ball) - entities := ecs.slice_entity(&Ball) +draw_balls :: proc(world: ^ecs.World) { + balls := ecs.slice(world, BallComponent) + entities := ecs.slice_entity(world, BallComponent) for ball, i in balls { - transform := ecs.get_component(entities[i], &ecs.Transform) + transform := ecs.get_component(world, entities[i], ecs.TransformComponent) rl.DrawCircle( i32(transform.translation.x), i32(transform.translation.y), @@ -81,6 +84,6 @@ show_framerate :: proc() { fps_buf: [19]byte framerate := strconv.write_int(fps_buf[:], i64(1 / rl.GetFrameTime()), 10) rl.DrawText(strings.clone_to_cstring(framerate), 0, 0, 24, rl.WHITE) - rl.DrawText(" fps", 0, 0, 24, rl.WHITE) + rl.DrawText(" fps", 0, 24, 24, rl.WHITE) rl.DrawText("10000 balls", 0, 24, 24, rl.WHITE) } diff --git a/src/componenets.odin b/src/componenets.odin index b90e91d..90a0030 100644 --- a/src/componenets.odin +++ b/src/componenets.odin @@ -2,35 +2,36 @@ package shoal import rl "vendor:raylib" -// what the app's window opens with: size, title, and the clear color -// DefaultPlugin paints at PRE_DRAW +// what the app's window opens with: size, title, and the clear color. +// DefaultPlugin opens the window from it at STARTUP and paints the clear +// color at PRE_DRAW WindowComponent :: struct { width: i32, height: i32, title: string, background: rl.Color, } -// pre-created global storage — just pass &Window, no need to declare it. -// the same goes for every storage in this file -Window: Storage(WindowComponent) // the mouse position, refreshed every UPDATE by DefaultPlugin CursorComponent :: struct { position: rl.Vector2, } -Cursor: Storage(CursorComponent) // a texture DefaultPlugin draws at DRAW, placed by the same entity's -// Transform (so every sprite entity needs a Transform component too) +// TransformComponent (so every sprite entity needs a Transform component +// too) SpriteComponent :: struct { texture: rl.Texture2D, } -Sprite: Storage(SpriteComponent) // 2D translation + rotation, read back by draw_sprite when it places the -// entity's Sprite +// entity's SpriteComponent TransformComponent :: struct { translation: rl.Vector2, rotation: f32, } -Transform: Storage(TransformComponent) + +// components are plain struct declarations. the world holds every storage +// internally, keyed by the component's typeid and created on first add (see +// get_storage in entities.odin), so there's nothing to declare next to a +// component diff --git a/src/constants.odin b/src/constants.odin index 060b12c..1e59da8 100644 --- a/src/constants.odin +++ b/src/constants.odin @@ -1,6 +1,6 @@ package shoal -// schedule ids for add_system — run() walks them in this order: +// schedule ids for add_system. run() walks them in this order: // // STARTUP once, before the first frame (plugins run before this) // PRE_UPDATE \ @@ -22,7 +22,7 @@ DRAW :: 5 POST_DRAW :: 6 DESTROY :: 7 -// easing ids for the (wip) Tween.ease field — see tween.odin, nothing reads +// easing ids for the (wip) Tween.ease field. see tween.odin, nothing reads // them yet EASE_IN :: 0 EASE_OUT :: 1 diff --git a/src/ecs.odin b/src/ecs.odin index 6838c2c..e5aae25 100644 --- a/src/ecs.odin +++ b/src/ecs.odin @@ -2,59 +2,112 @@ package shoal import rl "vendor:raylib" -// the app itself: systems grouped by schedule id (see the STARTUP..DESTROY -// constants), the plugins that run before everything else, and the list of -// every entity id handed out by spawn_entity +// the engine backend: systems grouped by schedule id (see the STARTUP..DESTROY +// constants), the plugins that run before everything else, and the world, +// holding every entity and component at a lower scope than the app itself. only +// main() and plugins ever see an ^App; systems are handed ^World and so +// structurally can't reach the registration machinery (add_system, plugins, +// run) App :: struct { - systems: map[int][dynamic]proc(), - plugins: [dynamic]proc(app: ^App), - entities: [dynamic]Entity, + systems: map[int][dynamic]System, + plugins: [dynamic]proc(app: ^App), + world: World, +} + +// a system in either shape add_system accepts: a plain proc(), or one that +// takes the world as proc(world: ^World). stored uniformly so run() can give +// every system the same dispatch. more parameter shapes can go here later +System :: union { + proc(), + proc(world: ^World), } // register a system on a schedule (one of the STARTUP..DESTROY constants). // systems on the same schedule run in registration order, every time that -// schedule comes around -add_system :: proc(app: ^App, schedule: int, system: proc()) { - if _, ok := app.systems[schedule]; !ok { - app.systems[schedule] = [dynamic]proc(){} - } - append(&app.systems[schedule], system) +// schedule comes around. a system is either a plain proc(), or takes the +// world as proc(world: ^World) when it needs to spawn entities or touch +// components +add_system :: proc { + add_plain_system, + add_world_system, } -// register a plugin: a proc that gets ^App and can add_system/add_plugin just -// like main() would. run() executes them in registration order, once, before -// the STARTUP systems +@(private) +add_plain_system :: proc(app: ^App, schedule: int, system: proc()) { + if _, ok := app.systems[schedule]; !ok { + app.systems[schedule] = [dynamic]System{} + } + append(&app.systems[schedule], System(system)) +} + +@(private) +add_world_system :: proc(app: ^App, schedule: int, system: proc(world: ^World)) { + if _, ok := app.systems[schedule]; !ok { + app.systems[schedule] = [dynamic]System{} + } + append(&app.systems[schedule], System(system)) +} + +// register a plugin: a proc that gets ^App, the one privilege over regular +// systems, which only ever see ^World. a plugin can add_system/add_plugin +// the same way main() would, and reach the game data through get_world. run() +// executes plugins in registration order, once, before the STARTUP systems. +// the systems a plugin adds can be plain proc() or take the world, same as +// any other add_system call add_plugin :: proc(app: ^App, plugin: proc(app: ^App)) { append(&app.plugins, plugin) } -// everything registered on a schedule, in registration order — or an empty -// list if nothing was added. this is what run() iterates each frame -get_system :: proc(app: ^App, schedule: int) -> [dynamic]proc() { +// everything registered on a schedule, in registration order, or an empty +// list if nothing was added. each entry is a System union: switch on it to +// tell a plain proc() from a proc(world: ^World). this is what run() walks +// each frame +get_system :: proc(app: ^App, schedule: int) -> [dynamic]System { if systems, ok := app.systems[schedule]; ok && len(systems) > 0 { return systems } - return [dynamic]proc(){} + return [dynamic]System{} +} + +// the world tucked inside the app, the ^App equivalent of the ^World +// systems get handed. plugins use it to spawn entities or add components +// before the frame loop starts; regular systems never need it, run() gives +// them the same world directly +get_world :: proc(app: ^App) -> ^World { + return &app.world } // run every plugin (in registration order), then the STARTUP systems once, // then the frame loop: PRE_UPDATE -> UPDATE -> POST_UPDATE -> PRE_DRAW -> // DRAW -> POST_DRAW, over and over until rl.WindowShouldClose() says stop, -// and finally the DESTROY systems +// and finally the DESTROY systems. world systems get handed the app's own +// world, the same one run() is holding. there's no second copy run :: proc(app: ^App) { for p in app.plugins { p(app) } - for i in app.systems[STARTUP] {i()} + for s in get_system(app, STARTUP) {run_system(app, s)} for !rl.WindowShouldClose() { - for i in get_system(app, PRE_UPDATE) {i()} - for i in get_system(app, UPDATE) {i()} - for i in get_system(app, POST_UPDATE) {i()} - for i in get_system(app, PRE_DRAW) {i()} - for i in get_system(app, DRAW) {i()} - for i in get_system(app, POST_DRAW) {i()} + for s in get_system(app, PRE_UPDATE) {run_system(app, s)} + for s in get_system(app, UPDATE) {run_system(app, s)} + for s in get_system(app, POST_UPDATE) {run_system(app, s)} + for s in get_system(app, PRE_DRAW) {run_system(app, s)} + for s in get_system(app, DRAW) {run_system(app, s)} + for s in get_system(app, POST_DRAW) {run_system(app, s)} } - for i in get_system(app, DESTROY) {i()} + for s in get_system(app, DESTROY) {run_system(app, s)} +} + +// hand a stored system its call: a plain one just runs, a world one gets the +// app's world +@(private) +run_system :: proc(app: ^App, system: System) { + switch s in system { + case proc(): + s() + case proc(world: ^World): + s(&app.world) + } } diff --git a/src/entities.odin b/src/entities.odin index a781434..fab54fe 100644 --- a/src/entities.odin +++ b/src/entities.odin @@ -1,76 +1,155 @@ package shoal -import "core:fmt" +import "core:mem" -// an entity is just an id — a u32 handed out by spawn_entity and recorded in -// the app's entity list. no components hang off it, they live in storages +// an entity is an id, a u32 handed out by spawn_entity and recorded in +// the world's entity list. no components hang off it, they live in storages Entity :: distinct u32 +// all the game data: every entity id handed out by spawn_entity, plus one +// storage per component type, created lazily the first time anything touches +// that component. a zero World{} is ready to use: the entity list grows on +// append and the storage map makes itself on first component add. App holds +// one of these; systems are handed a ^World directly, and plugins reach it +// through their ^App via get_world. never copy a World by value: the copy +// duplicates the header but shares the heap storages, so destroying both +// copies double-frees. pass ^World around, don't copy +World :: struct { + entities: [dynamic]Entity, + storages: map[typeid]Storage_Entry, +} + +// one entry in the world's storage map: the storage itself, type-erased so +// every component fits the same map, plus a proc that knows how to destroy +// it. storages are heap-allocated once per world so the map can rehash +// without moving them around +Storage_Entry :: struct { + storage: rawptr, + drop: proc(storage: rawptr), +} + // all the components of one type, kept together: dense holds them in add // order, entities[i] is who owns dense[i], and sparse maps entity -> index // into dense so get_component is a lookup instead of a scan. -// storages are usually declared as a global: `MyComp: Storage(MyCompType)` +// you never declare one of these yourself; the world creates storages +// on first access, see get_storage below Storage :: struct($T: typeid) { dense: [dynamic]T, entities: [dynamic]Entity, sparse: map[Entity]int, } -// hand out a fresh entity id (the next index in app's entity list) and +// the storage for component type T inside this world, created on first +// access, the same lazy pattern as the signal buses in signals.odin. every +// world has its own copy, so two worlds never share components. package- +// private on purpose: outside code goes through add_component/slice/single/ +// get_component, which find storages for you +@(private) +get_storage :: proc(world: ^World, $T: typeid) -> ^Storage(T) { + if world.storages == nil { + world.storages = {} + } + if entry, exists := world.storages[T]; exists { + return (^Storage(T))(entry.storage) + } + storage := new(Storage(T)) + world.storages[T] = { + storage = storage, + drop = drop_storage(T), + } + return storage +} + +// called once per component type, hands destroy_world a rawptr drop for +// that storage +@(private) +drop_storage :: proc($T: typeid) -> proc(storage: rawptr) { + return proc(storage: rawptr) { + s := (^Storage(T))(storage) + delete(s.dense) + delete(s.entities) + delete(s.sparse) + mem.free(s) + } +} + +// free everything the world owns: every storage's data and the storages +// themselves, the storage map, and the entity list. the world is left empty +// and safe to drop. this is teardown, not per-frame cleanup; nothing needs +// calling while the app runs +destroy_world :: proc(world: ^World) { + for _, entry in world.storages { + entry.drop(entry.storage) + } + delete(world.storages) + delete(world.entities) + world.storages = nil + world.entities = nil +} + +// hand out a fresh entity id (the next index in the world's entity list) and // remember it. ids are never reused -spawn_entity :: proc(app: ^App) -> Entity { - entity_num := len(&app.entities) +spawn_entity :: proc(world: ^World) -> Entity { + entity_num := len(world.entities) entity: Entity = Entity(entity_num) - append(&app.entities, entity) + append(&world.entities, entity) return entity } -// add a component to an entity's storage — zero-initialized when you pass -// just the storage: add_component(entity, &Store), or filled in when you pass -// data too: add_component(entity, &Store, StoreComponent{...}) +// add a component to an entity, zero-initialized when you pass just the +// type: add_component(world, entity, MyComponent), or filled in when you +// pass data too: add_component(world, entity, MyComponent{...}). the world's +// storage for that type is created on the spot if this is the first one add_component :: proc { add_fresh_component, add_component_with_data, } -// append a zeroed T to the storage and note where it landed in the sparse map -add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) { +// append a zeroed T to the entity's storage and note where it landed in the +// sparse map +add_fresh_component :: proc(world: ^World, entity: Entity, $T: typeid) { + storage := get_storage(world, T) append(&storage.dense, T{}) append(&storage.entities, entity) i := len(storage.dense) - 1 - defer storage.sparse[entity] = i + storage.sparse[entity] = i } // same, but starts from a copy of the component you pass in -add_component_with_data :: proc(entity: Entity, storage: ^Storage($T), component: T) { +add_component_with_data :: proc(world: ^World, entity: Entity, component: $T) { + storage := get_storage(world, T) append(&storage.dense, component) append(&storage.entities, entity) i := len(storage.dense) - 1 - defer storage.sparse[entity] = i + storage.sparse[entity] = i } -// the storage's dense array — the live backing array, not a copy of the data, -// so index i lines up with slice_entity()[i]. a later add_component that grows -// past capacity reallocates it, so don't hold the result across adds -slice :: proc(storage: ^Storage($T)) -> [dynamic]T { - return storage.dense +// every component of type T in the world: the live backing array, not a +// copy of the data, so index i lines up with slice_entity()[i]. a later +// add_component that grows past capacity reallocates it, so don't hold the +// result across adds +slice :: proc(world: ^World, $T: typeid) -> [dynamic]T { + return get_storage(world, T).dense } // the entity ids backing the dense array, index-aligned with slice(). // same invalidation caveat: adds can reallocate it -slice_entity :: proc(storage: ^Storage($T)) -> [dynamic]Entity { - return storage.entities +slice_entity :: proc(world: ^World, $T: typeid) -> [dynamic]Entity { + return get_storage(world, T).entities } -// ^T of the storage's first entry plus the entity that owns it — handy for -// singletons like Window. panics if the storage is empty -single :: proc(storage: ^Storage($T)) -> (^T, Entity) { +// ^T of the world's first entry of that component plus the entity that owns +// it, handy for singletons like WindowComponent. panics if no entity has +// the component yet +single :: proc(world: ^World, $T: typeid) -> (^T, Entity) { + storage := get_storage(world, T) return &storage.dense[0], storage.entities[0] } // a pointer to that entity's component, looked up through the sparse map. // no existence check: an entity that was never added to this storage reads -// back as dense[0], and an empty storage panics -get_component :: proc(entity: Entity, storage: ^Storage($T)) -> ^T { +// back as dense[0], and a world where nobody has the component panics +get_component :: proc(world: ^World, entity: Entity, $T: typeid) -> ^T { + storage := get_storage(world, T) return &storage.dense[storage.sparse[entity]] } diff --git a/src/signals.odin b/src/signals.odin index 218bf9d..1235bab 100644 --- a/src/signals.odin +++ b/src/signals.odin @@ -3,7 +3,7 @@ package shoal // per-type signal bus, created on first use. // one system writes a signal with data, any number of other systems read it // after. same-frame reads only work until signal_system clears the buses at -// POST_UPDATE — see read/signal_system below for the details. +// POST_UPDATE, see read and signal_system below Bus :: struct($T: typeid) { pending: [dynamic]T, } @@ -40,7 +40,7 @@ clear_bus :: proc($T: typeid) -> proc(b: rawptr) { } // queue a signal, readable until signal_system clears it at POST_UPDATE. -// note POST_UPDATE runs before PRE_DRAW/DRAW, so a reader sees exactly what's +// POST_UPDATE runs before PRE_DRAW/DRAW, so a reader only sees what's // been written since the last POST_UPDATE: UPDATE systems see same-frame // writes, but a DRAW system only sees writes made after this frame's // POST_UPDATE (by PRE_DRAW or earlier DRAW systems) @@ -63,8 +63,8 @@ write_with_data :: proc(data: $T) { // every signal of type T written since the last POST_UPDATE, non-consuming, // empty if none. iterate it: for s in read(MySignal) // copied into context.temp_allocator so write() during iteration can't -// reallocate the pending buffer out from under you — but that means the result -// dies with the frame's temp arena, don't hold onto it past that +// reallocate the pending buffer out from under you. that also means the +// result dies with the frame's temp arena, don't hold onto it past that read :: proc($T: typeid) -> []T { bus := get_bus(T) if len(bus.pending) == 0 {return nil} @@ -76,8 +76,8 @@ read :: proc($T: typeid) -> []T { // runs at POST_UPDATE, clears every bus so the next frame starts fresh. // writes made after it (e.g. in DRAW) survive until the *next* frame's // POST_UPDATE, so the rest of this frame and the next frame's -// PRE_UPDATE/UPDATE systems still read them — but the next frame's DRAW -// never does, POST_UPDATE gets there first +// PRE_UPDATE/UPDATE systems still read them. the next frame's DRAW +// never does though, POST_UPDATE gets there first signal_system :: proc() { for _, e in buses { e.clear(e.bus) diff --git a/src/systems.odin b/src/systems.odin index 4360a18..7a47492 100644 --- a/src/systems.odin +++ b/src/systems.odin @@ -5,53 +5,56 @@ import rl "vendor:raylib" // shoal's out-of-the-box behavior. registers, in frame order: // -// STARTUP startup_window (InitWindow from the Window component, 60 fps cap) +// STARTUP startup_window (InitWindow from the WindowComponent, 60 fps cap) // UPDATE run_timers, update_cursor_position // POST_UPDATE signal_system (clears every signal bus) // PRE_DRAW BeginDrawing + ClearBackground(window.background) -// DRAW draw_sprite (each Sprite drawn at its Transform) +// DRAW draw_sprite (each SpriteComponent drawn at its TransformComponent) // POST_DRAW EndDrawing // DESTROY CloseWindow // // and spawns two entities: a display holding the default WindowComponent -// (800x600, "shoal window", black background) and a cursor holding a Cursor -// component. add it before your own systems if you want theirs to run first — -// order within a schedule is registration order +// (800x600, "shoal window", black background) and a cursor holding a +// CursorComponent. add it before your own systems if you want theirs to run +// first; order within a schedule is registration order DefaultPlugin :: proc(app: ^App) { add_system(app, STARTUP, startup_window) add_system(app, DRAW, draw_sprite) add_system(app, POST_UPDATE, signal_system) add_system(app, UPDATE, run_timers) add_system(app, UPDATE, update_cursor_position) - add_system(app, PRE_DRAW, proc() {rl.BeginDrawing() - window, _ := single(&Window) + add_system(app, PRE_DRAW, proc(world: ^World) {rl.BeginDrawing() + window, _ := single(world, WindowComponent) rl.ClearBackground(window.background)}) add_system(app, POST_DRAW, proc() {rl.EndDrawing()}) add_system(app, DESTROY, proc() {rl.CloseWindow()}) - display := spawn_entity(app) + // a plugin is one of the few callers that holds an ^App, so it can reach + // the world through it and set up entities before the frame loop starts + world := get_world(app) + display := spawn_entity(world) add_component( + world, display, - &Window, WindowComponent{width = 800, height = 600, title = "shoal window", background = rl.BLACK}, ) - cursor := spawn_entity(app) - add_component(cursor, &Cursor) + cursor := spawn_entity(world) + add_component(world, cursor, CursorComponent) } @(private) -update_cursor_position :: proc() { - cursor, _ := single(&Cursor) +update_cursor_position :: proc(world: ^World) { + cursor, _ := single(world, CursorComponent) cursor.position = rl.GetMousePosition() } @(private) -draw_sprite :: proc() { - components := slice(&Sprite) - entities := slice_entity(&Sprite) +draw_sprite :: proc(world: ^World) { + components := slice(world, SpriteComponent) + entities := slice_entity(world, SpriteComponent) for &v, i in components { - sprite_transform := get_component(entities[i], &Transform) + sprite_transform := get_component(world, entities[i], TransformComponent) rl.DrawTexture( v.texture, i32(sprite_transform.translation.x), @@ -62,8 +65,8 @@ draw_sprite :: proc() { } @(private) -startup_window :: proc() { - window, _ := single(&Window) +startup_window :: proc(world: ^World) { + window, _ := single(world, WindowComponent) rl.InitWindow(window.width, window.height, strings.clone_to_cstring(window.title)) rl.SetTargetFPS(60) } diff --git a/src/timer.odin b/src/timer.odin index c4b6229..c76ceb4 100644 --- a/src/timer.odin +++ b/src/timer.odin @@ -3,14 +3,29 @@ package shoal import "core:fmt" import rl "vendor:raylib" +// a named countdown: how many seconds are left (lifetime) and the name it +// fires under (also the key it's stored under) Timer :: struct { lifetime: f32, signal: string, } +// queued when a timer runs out, for systems to pick up with +// read(TimerFired). run_timers below has the current caveat +TimerFired :: struct { + name: string, +} + +// timers stay in a package global for now, deliberately outside the +// World: they're keyed by name, not owned by an entity, so there's nothing +// per-world to hang them on yet. same goes for the signal buses and the +// (wip) tween queue in their own files @(private) timers: map[string]Timer +// start (or restart) a timer under this name. a name that's already running +// gets overwritten. it starts ticking on the next UPDATE, where run_timers +// counts it down start_timer :: proc(time: f32, signal: string) { new_timer := Timer { lifetime = time, @@ -19,6 +34,9 @@ start_timer :: proc(time: f32, signal: string) { timers[signal] = new_timer } +// the timer stored under this name, or a zero Timer if there is none. an +// expired timer is reset to Timer{} too, so lifetime != 0 means "still +// ticking" and lifetime <= 0 means "done (or never started)" get_timer :: proc(signal: string) -> Timer { if timer, exists := timers[signal]; exists { return timers[signal] @@ -26,13 +44,24 @@ get_timer :: proc(signal: string) -> Timer { return Timer{} } +// registered at UPDATE by DefaultPlugin: ticks every timer down by the frame +// time, and when the remaining lifetime drops to roughly one frame's delta it +// resets its entry to Timer{} so get_timer reads it back zeroed. it compares +// against a fresh GetFrameTime() call, so it can reset a frame or two +// early. it's meant to then queue TimerFired{name = } for +// read(TimerFired). like any signal, it's readable only until signal_system +// clears the buses at POST_UPDATE, see signals.odin for how that lands per +// schedule. +// known bug: the reset writes the same map slot t points at, so t.signal is +// already "" when it's checked just after and nothing gets queued, so +// TimerFired doesn't actually fire yet (the timers themselves work fine) run_timers :: proc() { for i, &t in timers { t.lifetime -= rl.GetFrameTime() if t.lifetime <= rl.GetFrameTime() { timers[i] = Timer{} if t.signal != "" { - write(t.signal) + write(TimerFired{name = t.signal}) } } }