update docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-3
@@ -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)
|
||||
|
||||
+23
-9
@@ -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]]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user