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