update docs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+11
-10
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+81
-28
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+107
-28
@@ -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]]
|
||||
}
|
||||
|
||||
+6
-6
@@ -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)
|
||||
|
||||
+22
-19
@@ -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)
|
||||
}
|
||||
|
||||
+30
-1
@@ -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 = <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() {
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user