update docs

This commit is contained in:
wooshings
2026-10-27 21:13:12 -07:00
parent f2654d8f8a
commit c45e6b1dbb
9 changed files with 372 additions and 121 deletions
+92 -9
View File
@@ -7,6 +7,15 @@ my silly little ecs engine for odin and raylib :3
## contents ## contents
- [install](#install) - [install](#install)
- [usage](#usage) - [usage](#usage)
- [systems](#systems)
- [components](#components)
- [spawn entities](#spawn-entities)
- [query](#query)
- [plugins](#plugins)
- [signals](#signals)
- [timers](#timers)
- [ui](#ui)
- [tween](#tween)
## install ## install
clone this repo in your project directory clone this repo in your project directory
@@ -39,15 +48,21 @@ main :: proc() {
### systems ### systems
systems are procedures that run on set schedules systems are procedures that run on set schedules
a schedule just determines when a producure runs\ a schedule just determines when a procedure runs\
schedules are: there are 8 of them, and `run()` walks them in this order:
- STARTUP
- UPDATE
- DRAW
`STARTUP` runs once at the start of an app\ - `STARTUP` — once, before the first frame
`UPDATE` runs every frame\ - `PRE_UPDATE` — every frame, first thing
`DRAW` runs every frame *after* update, and makes draw calls to raylib\ - `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 ```odin
main :: proc() { main :: proc() {
@@ -80,6 +95,8 @@ and create it's storage
Example: ecs.Storage(ExampleComponent) 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 ### spawn entities
create a new entity with `spawn_entity()` 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 ### 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 ```odin
main :: proc() { 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()` or if you know your storage only has one entry use `single()`
```odin ```odin
@@ -118,6 +145,17 @@ main :: proc() {
component, entity_id := ecs.single(&Example) 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
plugins are just a procedure that takes `App` as a parameter, meaning it can make changes to `app`, just like main() 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()` 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
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) 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 - 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) - 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 - 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.
+21 -18
View File
@@ -3,7 +3,7 @@ package main
import "core:math/rand" import "core:math/rand"
import "core:strconv" import "core:strconv"
import "core:strings" import "core:strings"
import ecs "shoal/src" import ecs "../src"
import rl "vendor:raylib" import rl "vendor:raylib"
app: ecs.App app: ecs.App
@@ -13,7 +13,6 @@ BallComponent :: struct {
radius: f32, radius: f32,
speed: f32, speed: f32,
} }
Ball: ecs.Storage(BallComponent)
main :: proc() { main :: proc() {
ecs.add_plugin(&app, ecs.DefaultPlugin) ecs.add_plugin(&app, ecs.DefaultPlugin)
@@ -25,18 +24,22 @@ main :: proc() {
ecs.run(&app) ecs.run(&app)
} }
setup_window :: proc() { // STARTUP system taking the world: the window lives in it, so adjusting the
window, _ := ecs.single(&ecs.Window) // default WindowComponent is a world lookup now
setup_window :: proc(world: ^ecs.World) {
window, _ := ecs.single(world, ecs.WindowComponent)
window.background = rl.BLACK 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 { for i in 0 ..< ball_count {
ball := ecs.spawn_entity(&app) ball := ecs.spawn_entity(world)
ecs.add_component(ball, &Ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)}) ecs.add_component(world, ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)})
ecs.add_component( ecs.add_component(
world,
ball, ball,
&ecs.Transform,
ecs.TransformComponent { ecs.TransformComponent {
translation = {400, 300}, translation = {400, 300},
rotation = rand.float32_range(0, 360), rotation = rand.float32_range(0, 360),
@@ -45,13 +48,13 @@ spawn_balls :: proc() {
} }
} }
update_balls :: proc() { update_balls :: proc(world: ^ecs.World) {
window, _ := ecs.single(&ecs.Window) window, _ := ecs.single(world, ecs.WindowComponent)
balls := ecs.slice(&Ball) balls := ecs.slice(world, BallComponent)
entities := ecs.slice_entity(&Ball) entities := ecs.slice_entity(world, BallComponent)
for ball, i in balls { 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 pos := transform.translation
if pos.x <= 0 || pos.x >= f32(window.width) || pos.y <= 0 || pos.y >= f32(window.height) { 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() { draw_balls :: proc(world: ^ecs.World) {
balls := ecs.slice(&Ball) balls := ecs.slice(world, BallComponent)
entities := ecs.slice_entity(&Ball) entities := ecs.slice_entity(world, BallComponent)
for ball, i in balls { for ball, i in balls {
transform := ecs.get_component(entities[i], &ecs.Transform) transform := ecs.get_component(world, entities[i], ecs.TransformComponent)
rl.DrawCircle( rl.DrawCircle(
i32(transform.translation.x), i32(transform.translation.x),
i32(transform.translation.y), i32(transform.translation.y),
@@ -81,6 +84,6 @@ show_framerate :: proc() {
fps_buf: [19]byte fps_buf: [19]byte
framerate := strconv.write_int(fps_buf[:], i64(1 / rl.GetFrameTime()), 10) 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(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) rl.DrawText("10000 balls", 0, 24, 24, rl.WHITE)
} }
+11 -10
View File
@@ -2,35 +2,36 @@ package shoal
import rl "vendor:raylib" import rl "vendor:raylib"
// what the app's window opens with: size, title, and the clear color // what the app's window opens with: size, title, and the clear color.
// DefaultPlugin paints at PRE_DRAW // DefaultPlugin opens the window from it at STARTUP and paints the clear
// color 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)
// the mouse position, refreshed every UPDATE by DefaultPlugin // the mouse position, refreshed every UPDATE by DefaultPlugin
CursorComponent :: struct { CursorComponent :: struct {
position: rl.Vector2, position: rl.Vector2,
} }
Cursor: Storage(CursorComponent)
// a texture DefaultPlugin draws at DRAW, placed by the same entity's // 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 { SpriteComponent :: struct {
texture: rl.Texture2D, texture: rl.Texture2D,
} }
Sprite: Storage(SpriteComponent)
// 2D translation + rotation, read back by draw_sprite when it places the // 2D translation + rotation, read back by draw_sprite when it places the
// entity's Sprite // entity's SpriteComponent
TransformComponent :: struct { TransformComponent :: struct {
translation: rl.Vector2, translation: rl.Vector2,
rotation: f32, 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
+2 -2
View File
@@ -1,6 +1,6 @@
package shoal 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) // STARTUP once, before the first frame (plugins run before this)
// PRE_UPDATE \ // PRE_UPDATE \
@@ -22,7 +22,7 @@ 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 // easing ids for the (wip) Tween.ease field. see tween.odin, nothing reads
// them yet // them yet
EASE_IN :: 0 EASE_IN :: 0
EASE_OUT :: 1 EASE_OUT :: 1
+81 -28
View File
@@ -2,59 +2,112 @@ package shoal
import rl "vendor:raylib" import rl "vendor:raylib"
// the app itself: systems grouped by schedule id (see the STARTUP..DESTROY // the engine backend: systems grouped by schedule id (see the STARTUP..DESTROY
// constants), the plugins that run before everything else, and the list of // constants), the plugins that run before everything else, and the world,
// every entity id handed out by spawn_entity // 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 { App :: struct {
systems: map[int][dynamic]proc(), systems: map[int][dynamic]System,
plugins: [dynamic]proc(app: ^App), plugins: [dynamic]proc(app: ^App),
entities: [dynamic]Entity, 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). // register a system on a schedule (one of the STARTUP..DESTROY constants).
// systems on the same schedule run in registration order, every time that // systems on the same schedule run in registration order, every time that
// schedule comes around // schedule comes around. a system is either a plain proc(), or takes the
add_system :: proc(app: ^App, schedule: int, system: proc()) { // world as proc(world: ^World) when it needs to spawn entities or touch
if _, ok := app.systems[schedule]; !ok { // components
app.systems[schedule] = [dynamic]proc(){} add_system :: proc {
} add_plain_system,
append(&app.systems[schedule], system) add_world_system,
} }
// register a plugin: a proc that gets ^App and can add_system/add_plugin just @(private)
// like main() would. run() executes them in registration order, once, before add_plain_system :: proc(app: ^App, schedule: int, system: proc()) {
// the STARTUP systems 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)) { 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 // everything registered on a schedule, in registration order, or an empty
// list if nothing was added. this is what run() iterates each frame // list if nothing was added. each entry is a System union: switch on it to
get_system :: proc(app: ^App, schedule: int) -> [dynamic]proc() { // 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 { if systems, ok := app.systems[schedule]; ok && len(systems) > 0 {
return systems 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, // run every plugin (in registration order), then the STARTUP systems once,
// then the frame loop: PRE_UPDATE -> UPDATE -> POST_UPDATE -> PRE_DRAW -> // then the frame loop: PRE_UPDATE -> UPDATE -> POST_UPDATE -> PRE_DRAW ->
// DRAW -> POST_DRAW, over and over until rl.WindowShouldClose() says stop, // 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) { run :: proc(app: ^App) {
for p in app.plugins { for p in app.plugins {
p(app) p(app)
} }
for i in app.systems[STARTUP] {i()} for s in get_system(app, STARTUP) {run_system(app, s)}
for !rl.WindowShouldClose() { for !rl.WindowShouldClose() {
for i in get_system(app, PRE_UPDATE) {i()} for s in get_system(app, PRE_UPDATE) {run_system(app, s)}
for i in get_system(app, UPDATE) {i()} for s in get_system(app, UPDATE) {run_system(app, s)}
for i in get_system(app, POST_UPDATE) {i()} for s in get_system(app, POST_UPDATE) {run_system(app, s)}
for i in get_system(app, PRE_DRAW) {i()} for s in get_system(app, PRE_DRAW) {run_system(app, s)}
for i in get_system(app, DRAW) {i()} for s in get_system(app, DRAW) {run_system(app, s)}
for i in get_system(app, POST_DRAW) {i()} 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)
}
} }
+107 -28
View File
@@ -1,76 +1,155 @@
package shoal package shoal
import "core:fmt" import "core:mem"
// an entity is just an id — a u32 handed out by spawn_entity and recorded in // an entity is 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 // the world's entity list. no components hang off it, they live in storages
Entity :: distinct u32 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 // 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 // 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. // 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) { Storage :: struct($T: typeid) {
dense: [dynamic]T, dense: [dynamic]T,
entities: [dynamic]Entity, entities: [dynamic]Entity,
sparse: map[Entity]int, 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 // remember it. ids are never reused
spawn_entity :: proc(app: ^App) -> Entity { spawn_entity :: proc(world: ^World) -> Entity {
entity_num := len(&app.entities) entity_num := len(world.entities)
entity: Entity = Entity(entity_num) entity: Entity = Entity(entity_num)
append(&app.entities, entity) append(&world.entities, entity)
return entity return entity
} }
// add a component to an entity's storage — zero-initialized when you pass // add a component to an entity, zero-initialized when you pass just the
// just the storage: add_component(entity, &Store), or filled in when you pass // type: add_component(world, entity, MyComponent), or filled in when you
// data too: add_component(entity, &Store, StoreComponent{...}) // 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_component :: proc {
add_fresh_component, add_fresh_component,
add_component_with_data, add_component_with_data,
} }
// append a zeroed T to the storage and note where it landed in the sparse map // append a zeroed T to the entity's storage and note where it landed in the
add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) { // sparse map
add_fresh_component :: proc(world: ^World, entity: Entity, $T: typeid) {
storage := get_storage(world, T)
append(&storage.dense, T{}) append(&storage.dense, T{})
append(&storage.entities, entity) append(&storage.entities, entity)
i := len(storage.dense) - 1 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 // 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.dense, component)
append(&storage.entities, entity) append(&storage.entities, entity)
i := len(storage.dense) - 1 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, // every component of type T in the world: the live backing array, not a
// so index i lines up with slice_entity()[i]. a later add_component that grows // copy of the data, so index i lines up with slice_entity()[i]. a later
// past capacity reallocates it, so don't hold the result across adds // add_component that grows past capacity reallocates it, so don't hold the
slice :: proc(storage: ^Storage($T)) -> [dynamic]T { // result across adds
return storage.dense 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(). // the entity ids backing the dense array, index-aligned with slice().
// same invalidation caveat: adds can reallocate it // same invalidation caveat: adds can reallocate it
slice_entity :: proc(storage: ^Storage($T)) -> [dynamic]Entity { slice_entity :: proc(world: ^World, $T: typeid) -> [dynamic]Entity {
return storage.entities return get_storage(world, T).entities
} }
// ^T of the storage's first entry plus the entity that owns it — handy for // ^T of the world's first entry of that component plus the entity that owns
// singletons like Window. panics if the storage is empty // it, handy for singletons like WindowComponent. panics if no entity has
single :: proc(storage: ^Storage($T)) -> (^T, Entity) { // the component yet
single :: proc(world: ^World, $T: typeid) -> (^T, Entity) {
storage := get_storage(world, T)
return &storage.dense[0], storage.entities[0] return &storage.dense[0], storage.entities[0]
} }
// a pointer to that entity's component, looked up through the sparse map. // 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 // no existence check: an entity that was never added to this storage reads
// back as dense[0], and an empty storage panics // back as dense[0], and a world where nobody has the component panics
get_component :: proc(entity: Entity, storage: ^Storage($T)) -> ^T { get_component :: proc(world: ^World, entity: Entity, $T: typeid) -> ^T {
storage := get_storage(world, T)
return &storage.dense[storage.sparse[entity]] return &storage.dense[storage.sparse[entity]]
} }
+6 -6
View File
@@ -3,7 +3,7 @@ package shoal
// per-type signal bus, created on first use. // per-type signal bus, created on first use.
// one system writes a signal with data, any number of other systems read it // 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 // 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) { Bus :: struct($T: typeid) {
pending: [dynamic]T, 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. // 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 // 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 // writes, but a DRAW system only sees writes made after this frame's
// POST_UPDATE (by PRE_DRAW or earlier DRAW systems) // 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, // every signal of type T written since the last POST_UPDATE, non-consuming,
// empty if none. iterate it: for s in read(MySignal) // empty if none. iterate it: for s in read(MySignal)
// copied into context.temp_allocator so write() during iteration can't // copied into context.temp_allocator so write() during iteration can't
// reallocate the pending buffer out from under you — but that means the result // reallocate the pending buffer out from under you. that also means the
// dies with the frame's temp arena, don't hold onto it past that // result dies with the frame's temp arena, don't hold onto it past that
read :: proc($T: typeid) -> []T { read :: proc($T: typeid) -> []T {
bus := get_bus(T) bus := get_bus(T)
if len(bus.pending) == 0 {return nil} 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. // 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 // 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 // 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 // PRE_UPDATE/UPDATE systems still read them. the next frame's DRAW
// never does, POST_UPDATE gets there first // never does though, POST_UPDATE gets there first
signal_system :: proc() { signal_system :: proc() {
for _, e in buses { for _, e in buses {
e.clear(e.bus) e.clear(e.bus)
+22 -19
View File
@@ -5,53 +5,56 @@ import rl "vendor:raylib"
// shoal's out-of-the-box behavior. registers, in frame order: // 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 // UPDATE run_timers, update_cursor_position
// POST_UPDATE signal_system (clears every signal bus) // POST_UPDATE signal_system (clears every signal bus)
// PRE_DRAW BeginDrawing + ClearBackground(window.background) // 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 // POST_DRAW EndDrawing
// DESTROY CloseWindow // DESTROY CloseWindow
// //
// and spawns two entities: a display holding the default WindowComponent // and spawns two entities: a display holding the default WindowComponent
// (800x600, "shoal window", black background) and a cursor holding a Cursor // (800x600, "shoal window", black background) and a cursor holding a
// component. add it before your own systems if you want theirs to run first — // CursorComponent. add it before your own systems if you want theirs to run
// order within a schedule is registration order // 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)
add_system(app, POST_UPDATE, signal_system) add_system(app, POST_UPDATE, signal_system)
add_system(app, UPDATE, run_timers) add_system(app, UPDATE, run_timers)
add_system(app, UPDATE, update_cursor_position) add_system(app, UPDATE, update_cursor_position)
add_system(app, PRE_DRAW, proc() {rl.BeginDrawing() add_system(app, PRE_DRAW, proc(world: ^World) {rl.BeginDrawing()
window, _ := single(&Window) window, _ := single(world, WindowComponent)
rl.ClearBackground(window.background)}) rl.ClearBackground(window.background)})
add_system(app, POST_DRAW, proc() {rl.EndDrawing()}) add_system(app, POST_DRAW, proc() {rl.EndDrawing()})
add_system(app, DESTROY, proc() {rl.CloseWindow()}) 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( add_component(
world,
display, display,
&Window,
WindowComponent{width = 800, height = 600, title = "shoal window", background = rl.BLACK}, WindowComponent{width = 800, height = 600, title = "shoal window", background = rl.BLACK},
) )
cursor := spawn_entity(app) cursor := spawn_entity(world)
add_component(cursor, &Cursor) add_component(world, cursor, CursorComponent)
} }
@(private) @(private)
update_cursor_position :: proc() { update_cursor_position :: proc(world: ^World) {
cursor, _ := single(&Cursor) cursor, _ := single(world, CursorComponent)
cursor.position = rl.GetMousePosition() cursor.position = rl.GetMousePosition()
} }
@(private) @(private)
draw_sprite :: proc() { draw_sprite :: proc(world: ^World) {
components := slice(&Sprite) components := slice(world, SpriteComponent)
entities := slice_entity(&Sprite) entities := slice_entity(world, SpriteComponent)
for &v, i in components { for &v, i in components {
sprite_transform := get_component(entities[i], &Transform) sprite_transform := get_component(world, entities[i], TransformComponent)
rl.DrawTexture( rl.DrawTexture(
v.texture, v.texture,
i32(sprite_transform.translation.x), i32(sprite_transform.translation.x),
@@ -62,8 +65,8 @@ draw_sprite :: proc() {
} }
@(private) @(private)
startup_window :: proc() { startup_window :: proc(world: ^World) {
window, _ := single(&Window) window, _ := single(world, WindowComponent)
rl.InitWindow(window.width, window.height, strings.clone_to_cstring(window.title)) rl.InitWindow(window.width, window.height, strings.clone_to_cstring(window.title))
rl.SetTargetFPS(60) rl.SetTargetFPS(60)
} }
+30 -1
View File
@@ -3,14 +3,29 @@ package shoal
import "core:fmt" import "core:fmt"
import rl "vendor:raylib" 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 { Timer :: struct {
lifetime: f32, lifetime: f32,
signal: string, 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) @(private)
timers: map[string]Timer 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) { start_timer :: proc(time: f32, signal: string) {
new_timer := Timer { new_timer := Timer {
lifetime = time, lifetime = time,
@@ -19,6 +34,9 @@ start_timer :: proc(time: f32, signal: string) {
timers[signal] = new_timer 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 { get_timer :: proc(signal: string) -> Timer {
if timer, exists := timers[signal]; exists { if timer, exists := timers[signal]; exists {
return timers[signal] return timers[signal]
@@ -26,13 +44,24 @@ get_timer :: proc(signal: string) -> Timer {
return 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 = <timer 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() { run_timers :: proc() {
for i, &t in timers { for i, &t in timers {
t.lifetime -= rl.GetFrameTime() t.lifetime -= rl.GetFrameTime()
if t.lifetime <= rl.GetFrameTime() { if t.lifetime <= rl.GetFrameTime() {
timers[i] = Timer{} timers[i] = Timer{}
if t.signal != "" { if t.signal != "" {
write(t.signal) write(TimerFired{name = t.signal})
} }
} }
} }