From f2654d8f8a9a3945f41931122d47a83563d69c5a Mon Sep 17 00:00:00 2001 From: wooshings Date: Tue, 27 Oct 2026 19:18:43 -0700 Subject: [PATCH] update docs --- README.md | 32 +++++++++++++++++++++++++++++++- src/componenets.odin | 9 +++++++++ src/constants.odin | 15 +++++++++++++++ src/ecs.odin | 18 +++++++++++++++--- src/entities.odin | 32 +++++++++++++++++++++++--------- src/systems.odin | 14 ++++++++++++++ 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5012c9d..c23a12f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ my silly little ecs engine for odin and raylib :3 ## install clone this repo in your project directory -`git clone https://git.egfk.net/harper/shoal` +```git clone https://git.egfk.net/harper/shoal``` and import the package @@ -135,3 +135,33 @@ main :: proc() { ``` plugins make your scripts very reuseable. in fact, much of shoal's default behaviors come from plugins via `DefaultPlugin()` + +### 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) + +```odin +DamageEvent :: struct { amount: int } +``` + +`write()` queues a signal on a per-type bus. `read()` gives you back every signal of that type written since the last POST_UPDATE (an empty slice if there were none), so you can iterate it. reads don't consume, so any number of systems can read the same signal and its data + +```odin +attack :: proc() { + ecs.write(DamageEvent{amount = 5}) +} + +// registered after attack in UPDATE, so it sees the event the same frame +check_damage :: proc() { + for e in ecs.read(DamageEvent) { + fmt.println("took damage!", e.amount) + } +} +``` + +you can also write an empty payload with the bare type: `ecs.write(DamageEvent)` + +scheduling caveat: every system sees exactly the signals written since the last `signal_system()` run, and that runs at POST_UPDATE — *before* PRE_DRAW/DRAW. so: + +- 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 diff --git a/src/componenets.odin b/src/componenets.odin index 9cd55ce..b90e91d 100644 --- a/src/componenets.odin +++ b/src/componenets.odin @@ -2,24 +2,33 @@ package shoal import rl "vendor:raylib" +// what the app's window opens with: size, title, and the clear color +// DefaultPlugin paints 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) SpriteComponent :: struct { texture: rl.Texture2D, } Sprite: Storage(SpriteComponent) +// 2D translation + rotation, read back by draw_sprite when it places the +// entity's Sprite TransformComponent :: struct { translation: rl.Vector2, rotation: f32, diff --git a/src/constants.odin b/src/constants.odin index 1a90a70..060b12c 100644 --- a/src/constants.odin +++ b/src/constants.odin @@ -1,5 +1,18 @@ package shoal +// schedule ids for add_system — run() walks them in this order: +// +// STARTUP once, before the first frame (plugins run before this) +// PRE_UPDATE \ +// UPDATE | every frame, before any drawing +// POST_UPDATE / (signal_system clears the buses here) +// PRE_DRAW \ +// DRAW | every frame, the raylib drawing part +// POST_DRAW / +// DESTROY once, after the loop exits and the window closes +// +// DefaultPlugin opens the raylib frame at PRE_DRAW (BeginDrawing + clear) and +// closes it at POST_DRAW (EndDrawing), so DRAW is where your draw calls go. STARTUP :: 0 PRE_UPDATE :: 1 UPDATE :: 2 @@ -9,6 +22,8 @@ DRAW :: 5 POST_DRAW :: 6 DESTROY :: 7 +// easing ids for the (wip) Tween.ease field — see tween.odin, nothing reads +// them yet EASE_IN :: 0 EASE_OUT :: 1 EASE_IN_OUT :: 2 diff --git a/src/ecs.odin b/src/ecs.odin index 2ee115b..6838c2c 100644 --- a/src/ecs.odin +++ b/src/ecs.odin @@ -2,13 +2,18 @@ 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 App :: struct { systems: map[int][dynamic]proc(), plugins: [dynamic]proc(app: ^App), entities: [dynamic]Entity, } -// add a new system to the app that will run based on the provided schedule +// 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(){} @@ -16,11 +21,15 @@ add_system :: proc(app: ^App, schedule: int, system: proc()) { append(&app.systems[schedule], system) } -// add a new plugin to the app. a modular way to add new functionality from beyond the main procedure +// 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 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() { if systems, ok := app.systems[schedule]; ok && len(systems) > 0 { return systems @@ -28,7 +37,10 @@ get_system :: proc(app: ^App, schedule: int) -> [dynamic]proc() { return [dynamic]proc(){} } -// run the app +// 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 run :: proc(app: ^App) { for p in app.plugins { p(app) diff --git a/src/entities.odin b/src/entities.odin index 028a892..a781434 100644 --- a/src/entities.odin +++ b/src/entities.odin @@ -2,15 +2,22 @@ package shoal import "core:fmt" +// 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 Entity :: distinct u32 +// 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)` Storage :: struct($T: typeid) { dense: [dynamic]T, entities: [dynamic]Entity, sparse: map[Entity]int, } -// adds a new entity id to app and returns it +// hand out a fresh entity id (the next index in app's entity list) and +// remember it. ids are never reused spawn_entity :: proc(app: ^App) -> Entity { entity_num := len(&app.entities) entity: Entity = Entity(entity_num) @@ -18,13 +25,15 @@ spawn_entity :: proc(app: ^App) -> Entity { return entity } -// create a new component of type and add to Storage +// 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_component :: proc { add_fresh_component, add_component_with_data, } -// initializes new component of type and add to Storage +// 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(&storage.dense, T{}) append(&storage.entities, entity) @@ -32,7 +41,7 @@ add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) { defer storage.sparse[entity] = i } -// creates a copy of component type with data and add to Storage +// same, but starts from a copy of the component you pass in add_component_with_data :: proc(entity: Entity, storage: ^Storage($T), component: T) { append(&storage.dense, component) append(&storage.entities, entity) @@ -40,23 +49,28 @@ add_component_with_data :: proc(entity: Entity, storage: ^Storage($T), component defer storage.sparse[entity] = i } -// get components saved in Storage -// returns storage.data +// 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 } +// 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 } -// get component saved in Storage -// returns Entity and component of type +// ^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) { return &storage.dense[0], storage.entities[0] } -// get component of type from entity +// 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 { return &storage.dense[storage.sparse[entity]] } diff --git a/src/systems.odin b/src/systems.odin index 668c1a0..4360a18 100644 --- a/src/systems.odin +++ b/src/systems.odin @@ -3,6 +3,20 @@ package shoal import "core:strings" 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) +// 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) +// 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 DefaultPlugin :: proc(app: ^App) { add_system(app, STARTUP, startup_window) add_system(app, DRAW, draw_sprite)