update docs

This commit is contained in:
wooshings
2026-10-27 19:18:43 -07:00
parent 56adba8ea5
commit f2654d8f8a
6 changed files with 107 additions and 13 deletions
+31 -1
View File
@@ -11,7 +11,7 @@ my silly little ecs engine for odin and raylib :3
## install ## install
clone this repo in your project directory 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 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()` 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
+9
View File
@@ -2,24 +2,33 @@ package shoal
import rl "vendor:raylib" import rl "vendor:raylib"
// what the app's window opens with: size, title, and the clear color
// DefaultPlugin paints at PRE_DRAW
WindowComponent :: struct { WindowComponent :: struct {
width: i32, width: i32,
height: i32, height: i32,
title: string, title: string,
background: rl.Color, 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) Window: Storage(WindowComponent)
// the mouse position, refreshed every UPDATE by DefaultPlugin
CursorComponent :: struct { CursorComponent :: struct {
position: rl.Vector2, position: rl.Vector2,
} }
Cursor: Storage(CursorComponent) 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 { SpriteComponent :: struct {
texture: rl.Texture2D, texture: rl.Texture2D,
} }
Sprite: Storage(SpriteComponent) Sprite: Storage(SpriteComponent)
// 2D translation + rotation, read back by draw_sprite when it places the
// entity's Sprite
TransformComponent :: struct { TransformComponent :: struct {
translation: rl.Vector2, translation: rl.Vector2,
rotation: f32, rotation: f32,
+15
View File
@@ -1,5 +1,18 @@
package shoal 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 STARTUP :: 0
PRE_UPDATE :: 1 PRE_UPDATE :: 1
UPDATE :: 2 UPDATE :: 2
@@ -9,6 +22,8 @@ DRAW :: 5
POST_DRAW :: 6 POST_DRAW :: 6
DESTROY :: 7 DESTROY :: 7
// easing ids for the (wip) Tween.ease field — see tween.odin, nothing reads
// them yet
EASE_IN :: 0 EASE_IN :: 0
EASE_OUT :: 1 EASE_OUT :: 1
EASE_IN_OUT :: 2 EASE_IN_OUT :: 2
+15 -3
View File
@@ -2,13 +2,18 @@ package shoal
import rl "vendor:raylib" 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 { App :: struct {
systems: map[int][dynamic]proc(), systems: map[int][dynamic]proc(),
plugins: [dynamic]proc(app: ^App), plugins: [dynamic]proc(app: ^App),
entities: [dynamic]Entity, 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()) { add_system :: proc(app: ^App, schedule: int, system: proc()) {
if _, ok := app.systems[schedule]; !ok { if _, ok := app.systems[schedule]; !ok {
app.systems[schedule] = [dynamic]proc(){} app.systems[schedule] = [dynamic]proc(){}
@@ -16,11 +21,15 @@ add_system :: proc(app: ^App, schedule: int, system: proc()) {
append(&app.systems[schedule], system) 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)) { add_plugin :: proc(app: ^App, plugin: proc(app: ^App)) {
append(&app.plugins, plugin) 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() { get_system :: proc(app: ^App, schedule: int) -> [dynamic]proc() {
if systems, ok := app.systems[schedule]; ok && len(systems) > 0 { if systems, ok := app.systems[schedule]; ok && len(systems) > 0 {
return systems return systems
@@ -28,7 +37,10 @@ get_system :: proc(app: ^App, schedule: int) -> [dynamic]proc() {
return [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) { run :: proc(app: ^App) {
for p in app.plugins { for p in app.plugins {
p(app) p(app)
+23 -9
View File
@@ -2,15 +2,22 @@ package shoal
import "core:fmt" 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 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) { Storage :: struct($T: typeid) {
dense: [dynamic]T, dense: [dynamic]T,
entities: [dynamic]Entity, entities: [dynamic]Entity,
sparse: map[Entity]int, 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 { spawn_entity :: proc(app: ^App) -> Entity {
entity_num := len(&app.entities) entity_num := len(&app.entities)
entity: Entity = Entity(entity_num) entity: Entity = Entity(entity_num)
@@ -18,13 +25,15 @@ spawn_entity :: proc(app: ^App) -> Entity {
return 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_component :: proc {
add_fresh_component, add_fresh_component,
add_component_with_data, 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)) { add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) {
append(&storage.dense, T{}) append(&storage.dense, T{})
append(&storage.entities, entity) append(&storage.entities, entity)
@@ -32,7 +41,7 @@ add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) {
defer storage.sparse[entity] = i 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) { add_component_with_data :: proc(entity: Entity, storage: ^Storage($T), component: T) {
append(&storage.dense, component) append(&storage.dense, component)
append(&storage.entities, entity) append(&storage.entities, entity)
@@ -40,23 +49,28 @@ add_component_with_data :: proc(entity: Entity, storage: ^Storage($T), component
defer storage.sparse[entity] = i defer storage.sparse[entity] = i
} }
// get components saved in Storage // the storage's dense array — the live backing array, not a copy of the data,
// returns storage.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 { slice :: proc(storage: ^Storage($T)) -> [dynamic]T {
return storage.dense 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 { slice_entity :: proc(storage: ^Storage($T)) -> [dynamic]Entity {
return storage.entities return storage.entities
} }
// get component saved in Storage // ^T of the storage's first entry plus the entity that owns it — handy for
// returns Entity and component of type // singletons like Window. panics if the storage is empty
single :: proc(storage: ^Storage($T)) -> (^T, Entity) { single :: proc(storage: ^Storage($T)) -> (^T, Entity) {
return &storage.dense[0], storage.entities[0] 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 { get_component :: proc(entity: Entity, storage: ^Storage($T)) -> ^T {
return &storage.dense[storage.sparse[entity]] return &storage.dense[storage.sparse[entity]]
} }
+14
View File
@@ -3,6 +3,20 @@ package shoal
import "core:strings" import "core:strings"
import rl "vendor:raylib" 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) { DefaultPlugin :: proc(app: ^App) {
add_system(app, STARTUP, startup_window) add_system(app, STARTUP, startup_window)
add_system(app, DRAW, draw_sprite) add_system(app, DRAW, draw_sprite)