Compare commits

..
1 Commits
Author SHA1 Message Date
harper 7fd3c96d01 Delete .DS_Store 2026-10-09 17:36:50 -07:00
14 changed files with 118 additions and 1926 deletions
-1
View File
@@ -1 +0,0 @@
.DS_Store
+37 -181
View File
@@ -7,22 +7,11 @@ 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
```sh
git clone https://git.egfk.net/harper/shoal
```
`git clone https://git.egfk.net/harper/shoal`
and import the package
@@ -31,21 +20,17 @@ import ecs "shoal/src"
```
## usage
first create a new app struct. it holds your systems and plugins, and tucked
inside it a `World` holding every entity and component (your game data).
systems never see the app itself, only the world
first create a new app struct. this is where your systems and plugins live.
```odin
import ecs "shoal/src"
main :: proc() {
app: ecs.App
}
app: ecs.App
```
you can run your app with `run()`. run takes a pointer to your app and drives its systems.
you can run your app with `run()`. run takes a reference to your app to run it's systems.
```odin
``` odin
main :: proc() {
ecs.run(&app)
}
@@ -54,15 +39,15 @@ main :: proc() {
### systems
systems are procedures that run on set schedules
a schedule determines when a procedure runs\
there are 4 of them, and `run()` walks them in this order:
a schedule just determines when a producure runs\
schedules are:
- STARTUP
- UPDATE
- DRAW
- `STARTUP`: once, before the first frame
- `UPDATE`: every frame, your main logic
- `DRAW`: every frame, inside the frame. this is where your raylib draw calls go
- `DESTROY`: once, after the last frame, on shutdown
systems registered on the same schedule run in registration order: whoever was `add_system()`ed first runs first, including the systems your plugins add. plugins themselves always run once before *all* the `STARTUP` systems, no matter when you `add_plugin()` them, so adding `add_plugin(&app, ecs.DefaultPlugin)` first doesn't change *when* DefaultPlugin runs. it just puts shoal's defaults at the front of each schedule, which is still a good habit.
`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\
```odin
main :: proc() {
@@ -81,36 +66,26 @@ example_update :: proc() {
}
```
a system can also take the world. declare it as `proc(world: ^ecs.World)` and `run()` hands it the app's world, the same one holding every entity and component. that's all a system ever gets: systems structurally can't reach the app itself (add_system, plugins, run); only plugins and `main()` ever hold an `^App`
```odin
main :: proc() {
ecs.add_system(&app, ecs.STARTUP, example_spawn)
}
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world) // the world, no global needed
ecs.add_component(world, entity, ExampleComponent{...})
}
```
plain `proc()` systems work exactly as before. only ask for the world parameter when you actually need it
### components
components are plain structs. declare one and you're done. the data lives in the world: every component type gets a storage inside it, created automatically the first time you add that component to an entity. there's no global to declare and no registration step
components are just structs with data, and kept with other components of that type in `Storage`.\
create a new component
```odin
ExampleComponent :: struct {}
```
each world owns its own storages, keyed by the component's type: two worlds never share data, and a zero `ecs.World{}` is ready to use.
### spawn entities
create a new entity with `spawn_entity()`, which hands out the next id from the world's entity list. ids are never reused. systems get the world as a parameter; in `main()` or a plugin it's `&app.world` (or `ecs.get_world(app)`)
and create it's storage
```odin
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world)
Example: ecs.Storage(ExampleComponent)
```
### spawn entities
create a new entity with `spawn_entity()`
```odin
main :: proc() {
entity := ecs.spawn_entity(&app)
}
```
@@ -118,82 +93,35 @@ example_spawn :: proc(world: ^ecs.World) {
and give it new components with `add_component()`
```odin
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world)
ecs.add_component(world, entity, ExampleComponent) // just the type: a zero-initialized component
main :: proc() {
ecs.add_component(entity, &Example) // make sure to pass a reference to the storage, not the component type
}
```
or fill it in on the spot by passing data instead
```odin
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world)
ecs.add_component(world, entity, ExampleComponent{...})
}
```
either way the world's storage for that component type is created on the spot if this is the first one
### query
since a world keeps components of one type together, you can loop through them and pick out what you like. `slice()` and `slice_entity()` pair up by index: `entities[i]` owns `components[i]`
since components are saved in storage next to their entity, you can loop through a Storage and pick out what you like
```odin
example_system :: proc(world: ^ecs.World) {
components := ecs.slice(world, ExampleComponent)
entities := ecs.slice_entity(world, ExampleComponent)
for v, i in components {
e := entities[i] // get entity that owns the component
main :: proc() {
for entity_id, component in ecs.slice(&Example) {
//...
}
}
```
one caveat: `slice()` hands back the live array inside the world, 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 only one entity has the component use `single()`
or if you know your storage only has one entry use `single()`
```odin
example_system :: proc(world: ^ecs.World) {
component, entity_id := ecs.single(world, ExampleComponent)
main :: proc() {
entity_id, component := ecs.single(&Example)
}
```
`single()` panics with a message naming the component (`single: no entity has component ExampleComponent`) if nobody in the world has it yet
and once you have an entity id, `get_component()` reaches into any component type to get that entity's copy, handy for components that live alongside another one (a sprite's `TransformComponent`, say)
```odin
example_system :: proc(world: ^ecs.World) {
component, entity_id := ecs.single(world, ExampleComponent)
other := ecs.get_component(world, entity_id, OtherExampleComponent)
}
```
`get_component()` panics with a message naming the entity and the component (`get_component: entity 3 has no component OtherExampleComponent`) if that entity never got that component. it used to hand back whatever was at index 0 of the storage instead, which crashed the app with a bounds error as soon as nothing was there — the panic is the fixed version of that
if you're not sure whether the entity has it (probing a component another system or plugin owns, say), ask first. `has_component()` answers yes/no, and `try_get_component()` hands back the component and a flag, `(nil, false)` instead of a panic when it's missing
```odin
example_system :: proc(world: ^ecs.World) {
if ecs.has_component(world, entity_id, OtherExampleComponent) {
// ...
}
if other, ok := ecs.try_get_component(world, entity_id, OtherExampleComponent); ok {
// use other
}
}
```
### plugins
plugins are a procedure that takes `^App` as a parameter, the one bit of privilege over regular systems, which only ever see `^World`. a plugin can register systems and plugins on the app the same way main() does, and reach the game data through `get_world(app)`
### plugins
plugins are just a procedure that takes `App` as a parameter, meaning it can make changes to `app`, just like main()
```odin
ExamplePlugin :: proc(app: ^App) {
ecs.add_system(app, ecs.STARTUP, some_plugin_system)
entity := ecs.spawn_entity(ecs.get_world(app)) // same world the systems will get
ecs.add_component(ecs.get_world(app), entity, ExampleComponent{...})
}
```
@@ -204,76 +132,4 @@ main :: proc() {
}
```
plugins make your scripts reusable. much of shoal's default behavior comes 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)
```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. the next frame's DRAW never sees them either
### timers
timers are named countdowns. they live in a package global for now, deliberately outside the world, since they're keyed by name rather than owned by an entity. `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. the reset fires when the remaining lifetime drops to roughly one frame's delta (it compares against a fresh `GetFrameTime()` call, so it can reset a frame or two early) and resets the entry 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), `end()` at LATE_DRAW (renders the ui after all game drawing, inside the raylib frame), frees the frame's temp memory at POST_DRAW, 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")
}
}
```
plugins make your scripts very reuseable. in fact, much of shoal's default behaviors come from plugins via `DefaultPlugin()`
-349
View File
@@ -1,349 +0,0 @@
package main
import "core:fmt"
import "core:strings"
import ecs "../src"
import rl "vendor:raylib"
// demo for the audio and sprite systems. arrow keys cycle sprite properties
// (tint, scale, flip), w/a/s/d cycle sprite sheet cells, j/k/l/i trigger
// audio actions. the sprite rendering draws procedurally (colored
// rectangles), so it runs without any asset files. audio and texture
// loading require actual files on disk — placeholder comments show the
// real API usage.
app: ecs.App
// sprite demo state
ball_color : rl.Color = rl.BLUE
ball_scale : rl.Vector2 = {1.0, 1.0}
ball_flip_h : bool = false
ball_flip_v : bool = false
// sprite sheet: 4x3 grid, track which cell is selected
sheet_selection: int = 0
// audio demo state
show_audio: bool = true
// main: add DefaultPlugin (window, sprite drawing, cursor, lifecycle),
// add AudioPlugin (audio device init, sound/music processing, cleanup),
// register demo systems, then run. see ecs.DefaultPlugin and AudioPlugin
// in shoal/src for what they do.
main :: proc() {
ecs.add_plugin(&app, ecs.DefaultPlugin)
ecs.add_plugin(&app, ecs.AudioPlugin) // enables audio
ecs.add_system(&app, ecs.STARTUP, startup_demo)
ecs.add_system(&app, ecs.UPDATE, update_demo)
ecs.add_system(&app, ecs.DRAW, draw_demo)
ecs.run(&app)
}
// startup: placeholder comments showing the real API usage for loading
// sprites, sprite sheets, and audio. see load_sprite, load_sprite_sheet,
// unload_sprite, unload_sprites in systems.odin, and play_sound,
// play_music, stop_sound, stop_music, and the Audio component in
// audio.odin.
startup_demo :: proc(world: ^ecs.World) {
// load a sprite from file: returns a Sprite with defaults
// (source = full texture, scale = 1.0, tint = white).
// in a real game:
// sprite := ecs.load_sprite("images/player.png")
// ecs.add_component(world, player, sprite)
// ecs.add_component(world, player, ecs.Transform{
// translation = {400, 300},
// rotation = 0,
// })
// load a sprite sheet: splits a texture into a grid, returns
// [dynamic]Sprite — each cell shares the same underlying texture
// but has its own source rect. in a real game:
// sprites := ecs.load_sprite_sheet("images/tiles.png", 4, 3)
// // sprites[0] = row 0, col 0 (top-left)
// // sprites[5] = row 1, col 1 (center)
// // sprites[11] = row 2, col 3 (bottom-right)
// ecs.add_component(world, tile, sprites[5])
// free all at once:
// ecs.unload_sprites(sprites)
// global audio API:
// one-shot sounds (cached by path):
// h := ecs.play_sound("sounds/explosion.wav")
// ecs.set_sound_volume(h, 0.5)
// ecs.stop_sound(h)
// ecs.is_sound_playing(h) // → bool
// background music (streaming):
// h := ecs.play_music("sounds/theme.ogg")
// ecs.set_music_volume(h, 0.7)
// ecs.seek_music(h, 30.0)
// ecs.get_music_time_played(h) // → f32
// ecs.get_music_time_length(h) // → f32
// ecs.pause_music(h)
// ecs.resume_music(h)
// master volume (affects all audio):
// ecs.set_master_volume(0.8)
// entity-owned audio (Audio component):
// add an Audio component to any entity:
// ecs.add_component(world, entity, ecs.Audio{
// sound = rl.LoadSoundFromWave(rl.LoadWave("sound.wav")),
// state = ecs.AudioState.Playing,
// volume = 0.8,
// pitch = 1.0,
// pan = 0.0, // center
// })
// or with music:
// ecs.add_component(world, entity, ecs.Audio{
// music = rl.LoadMusicStream("music.ogg"),
// state = ecs.AudioState.Playing,
// volume = 0.5,
// looping = true,
// })
}
// handle input: arrow keys cycle sprite properties, w/a/s/d cycle sprite
// sheet cells, j/k/l/i trigger audio actions (via GetCharPressed). see
// the Sprite component in componenets.odin and the global API in
// audio.odin.
update_demo :: proc(world: ^ecs.World) {
// arrow keys: cycle sprite properties
if rl.IsKeyPressed(rl.KeyboardKey.UP) {
// cycle tint color (demonstrates the `tint` field)
switch ball_color {
case rl.BLUE:
ball_color = rl.RED
case rl.RED:
ball_color = rl.GREEN
case rl.GREEN:
ball_color = rl.YELLOW
case rl.YELLOW:
ball_color = rl.PURPLE
default:
{ ball_color = rl.BLUE }
}
fmt.println("Sprite tint → ", color_name(ball_color))
}
if rl.IsKeyPressed(rl.KeyboardKey.DOWN) {
// cycle scale (demonstrates the `scale` field)
if ball_scale.x == 1.0 {
ball_scale = {2.0, 2.0}
} else if ball_scale.x == 2.0 {
ball_scale = {0.5, 0.5}
} else {
ball_scale = {1.0, 1.0}
}
fmt.println("Sprite scale → ",
fmt.tprintf("(%.1f, %.1f)", ball_scale.x, ball_scale.y))
}
if rl.IsKeyPressed(rl.KeyboardKey.LEFT) {
// toggle horizontal flip (demonstrates `flip_h`)
ball_flip_h = !ball_flip_h
fmt.println("Sprite flip_h → ", ball_flip_h)
}
if rl.IsKeyPressed(rl.KeyboardKey.RIGHT) {
// toggle vertical flip (demonstrates `flip_v`)
ball_flip_v = !ball_flip_v
fmt.println("Sprite flip_v → ", ball_flip_v)
}
// w/a/s/d: cycle sprite sheet cells
// w = next cell, s = previous cell
if rl.IsKeyPressed(rl.KeyboardKey.W) {
sheet_selection = (sheet_selection + 1) % 12
}
if rl.IsKeyPressed(rl.KeyboardKey.S) {
if sheet_selection == 0 {
sheet_selection = 11
} else {
sheet_selection -= 1
}
}
// a = previous column (wrap from col 0 → col 3 of previous row)
if rl.IsKeyPressed(rl.KeyboardKey.A) {
if sheet_selection % 4 == 0 {
// at column 0: wrap to column 3 of previous row
if sheet_selection >= 4 {
sheet_selection -= 1 // e.g., 4→3, 8→7
}
// sheet_selection == 0 stays at 0 (no row above to wrap to)
} else {
sheet_selection -= 1
}
}
// d = next column (wrap from col 3 → col 0 of next row)
if rl.IsKeyPressed(rl.KeyboardKey.D) {
if sheet_selection % 4 == 3 {
// at column 3: wrap to column 0 of next row
if sheet_selection < 8 {
sheet_selection += 1 // e.g., 3→4, 7→8
}
// sheet_selection == 11 stays at 11 (no row below to wrap to)
} else {
sheet_selection += 1
}
}
// j/k/l/i: audio actions (via GetCharPressed)
char := rl.GetCharPressed()
if char != 0 {
rune := char
if rune == 'j' || rune == 'J' {
// toggle audio info overlay
show_audio = !show_audio
fmt.println("Audio overlay → ", show_audio ? "ON" : "OFF")
}
if rune == 'k' || rune == 'K' {
// fire one-shot sound (global API)
// real usage:
// h := ecs.play_sound("sounds/ping.wav")
// ecs.set_sound_volume(h, 0.8)
fmt.println("Sound fired (placeholder — needs actual file)")
}
if rune == 'l' || rune == 'L' {
// entity-owned audio: add an Audio component
// real usage:
// ecs.add_component(world, entity, ecs.Audio{
// sound = rl.LoadSoundFromWave(rl.LoadWave("sound.wav")),
// state = ecs.AudioState.Playing,
// volume = 0.5,
// })
fmt.println("Entity audio added (placeholder — needs actual file)")
}
if rune == 'i' || rune == 'I' {
// unload a sprite (free GPU memory)
// real usage:
// ecs.unload_sprite(sprite)
fmt.println("Sprite unloaded (placeholder — needs actual texture)")
}
}
}
// draw: renders the sprite demo (procedural rectangle), sprite sheet grid,
// and audio info overlay. see draw_sprite in systems.odin, load_sprite
// and load_sprite_sheet in systems.odin, and the global API in audio.odin.
draw_demo :: proc(world: ^ecs.World) {
// procedural "sprite" — rectangle at center, demonstrating the extended
// Sprite component fields. in a real game you'd use actual textures:
//
// ecs.add_component(world, player, ecs.Sprite{
// texture = rl.LoadTexture("images/player.png"),
// source = rl.Rectangle{0, 0, 32, 32}, // crop to 32x32
// scale = rl.Vector2{2.0, 2.0}, // 2x scale
// tint = rl.RED, // red tint
// flip_h = true, // face left
// })
// ecs.add_component(world, player, ecs.Transform{
// translation = {400, 300},
// rotation = 45, // 45° rotation
// })
// procedural "sprite" — rectangle at center
w := f32(64) * ball_scale.x
h := f32(64) * ball_scale.y
// handle flip by adjusting position (DrawTexturePro negates dest size)
draw_x := f32(400)
draw_y := f32(300)
if ball_flip_h {
draw_x = draw_x - w
}
if ball_flip_v {
draw_y = draw_y - h
}
rl.DrawRectangle(i32(draw_x), i32(draw_y), i32(w), i32(h), ball_color)
rl.DrawRectangleLines(
i32(draw_x), i32(draw_y), i32(w), i32(h), rl.WHITE,
)
// sprite sheet demo: draws a 4x3 grid of cells, highlights the selected
// one. see load_sprite_sheet in systems.odin for the real API.
sheet_x := f32(600)
sheet_y := f32(100)
cell_w := f32(48)
cell_h := f32(48)
for row := 0; row < 3; row += 1 {
for col := 0; col < 4; col += 1 {
idx := row * 4 + col
cx := sheet_x + f32(col) * (cell_w + f32(4))
cy := sheet_y + f32(row) * (cell_h + f32(4))
if idx == sheet_selection {
rl.DrawRectangle(
i32(cx), i32(cy), i32(cell_w), i32(cell_h), rl.YELLOW,
)
rl.DrawRectangleLines(
i32(cx), i32(cy), i32(cell_w), i32(cell_h), rl.ORANGE,
)
} else {
rl.DrawRectangle(
i32(cx), i32(cy), i32(cell_w), i32(cell_h), rl.DARKGRAY,
)
rl.DrawRectangleLines(
i32(cx), i32(cy), i32(cell_w), i32(cell_h), rl.GRAY,
)
}
label := fmt.tprintf("%d", idx)
rl.DrawText(
strings.clone_to_cstring(label), i32(cx+16), i32(cy+16),
16, rl.WHITE,
)
}
}
// audio info overlay: shows the global API reference. see audio.odin
// for the full API: play_sound, play_music, stop_sound, stop_music,
// set_sound_volume, set_music_volume, seek_music,
// get_music_time_played, get_music_time_length, pause_music,
// resume_music, set_master_volume, and the Audio component.
if show_audio {
rl.DrawText("audio api", 16, 560, 20, rl.LIME)
rl.DrawText("j — toggle overlay (this text)", 16, 585, 14, rl.GRAY)
rl.DrawText("k — fire sound (placeholder)", 16, 600, 14, rl.GRAY)
rl.DrawText("l — entity audio (placeholder)", 16, 615, 14, rl.GRAY)
rl.DrawText("i — unload sprite (placeholder)", 16, 630, 14, rl.GRAY)
}
// sprite info: shows the current sprite state and key bindings
rl.DrawText("sprite api", 16, 85, 20, rl.LIME)
rl.DrawText("up — tint (cycle color)", 16, 110, 14, rl.GRAY)
rl.DrawText("down — scale (cycle size)", 16, 125, 14, rl.GRAY)
rl.DrawText("left — flip_h (toggle)", 16, 140, 14, rl.GRAY)
rl.DrawText("right — flip_v (toggle)", 16, 155, 14, rl.GRAY)
rl.DrawText("w/s/a/d — sheet cell (cycle 0..11)", 16, 170, 14, rl.GRAY)
// sprite state label
state := fmt.tprintf(
"tint=%s scale=(%.1f,%.1f) flip=(%s,%s)",
color_name(ball_color),
ball_scale.x, ball_scale.y,
ball_flip_h ? "H" : "—",
ball_flip_v ? "V" : "—",
)
rl.DrawText(strings.clone_to_cstring(state), 16, 195, 14, rl.WHITE)
}
// helper: returns a string name for a color, used in the state label.
color_name :: proc(c: rl.Color) -> string {
switch c {
case rl.BLUE:
return "blue"
case rl.RED:
return "red"
case rl.GREEN:
return "green"
case rl.YELLOW:
return "yellow"
case rl.PURPLE:
return "purple"
}
return "custom"
}
+24 -27
View File
@@ -1,18 +1,20 @@
package main
import "core:fmt"
import "core:math/rand"
import "core:strconv"
import "core:strings"
import ecs "../src"
import ecs "shoal/src"
import rl "vendor:raylib"
app: ecs.App
ball_count: int = 10000
ball_count: int = 1000
BallComponent :: struct {
radius: f32,
speed: f32,
}
Ball: ecs.Storage(BallComponent)
main :: proc() {
ecs.add_plugin(&app, ecs.DefaultPlugin)
@@ -24,22 +26,22 @@ main :: proc() {
ecs.run(&app)
}
// 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)
setup_window :: proc() {
_, window := ecs.single(&ecs.Window)
window.background = rl.BLACK
}
// 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) {
spawn_balls :: proc() {
for i in 0 ..< ball_count {
ball := ecs.spawn_entity(world)
ecs.add_component(world, ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)})
ball := ecs.spawn_entity(&app)
ecs.add_component(
world,
ball,
&Ball,
BallComponent{radius = 10, speed = rand.float32_range(4, 6)},
)
ecs.add_component(
ball,
&ecs.Transform,
ecs.TransformComponent {
translation = {400, 300},
rotation = rand.float32_range(0, 360),
@@ -48,13 +50,11 @@ spawn_balls :: proc(world: ^ecs.World) {
}
}
update_balls :: proc(world: ^ecs.World) {
window, _ := ecs.single(world, ecs.WindowComponent)
update_balls :: proc() {
_, window := ecs.single(&ecs.Window)
balls := ecs.slice(world, BallComponent)
entities := ecs.slice_entity(world, BallComponent)
for ball, i in balls {
transform := ecs.get_component(world, entities[i], ecs.TransformComponent)
for entity, ball in ecs.slice(&Ball) {
transform := ecs.get_component(entity, &ecs.Transform)
pos := transform.translation
if pos.x <= 0 || pos.x >= f32(window.width) || pos.y <= 0 || pos.y >= f32(window.height) {
@@ -63,14 +63,13 @@ update_balls :: proc(world: ^ecs.World) {
dir := rl.Vector2Rotate(rl.Vector2{0, ball.speed}, transform.rotation)
transform.translation += dir
fmt.println(transform.rotation)
}
}
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(world, entities[i], ecs.TransformComponent)
draw_balls :: proc() {
for entity, ball in ecs.slice(&Ball) {
transform := ecs.get_component(entity, &ecs.Transform)
rl.DrawCircle(
i32(transform.translation.x),
i32(transform.translation.y),
@@ -81,9 +80,7 @@ draw_balls :: proc(world: ^ecs.World) {
}
show_framerate :: proc() {
fps_buf: [19]byte
framerate := strconv.write_int(fps_buf[:], i64(1 / rl.GetFrameTime()), 10)
buf: [19]byte
framerate := strconv.write_int(buf[:], i64(1 / rl.GetFrameTime()), 10)
rl.DrawText(strings.clone_to_cstring(framerate), 0, 0, 24, rl.WHITE)
rl.DrawText(" fps", 0, 24, 24, rl.WHITE)
rl.DrawText("10000 balls", 0, 24, 24, rl.WHITE)
}
-327
View File
@@ -1,327 +0,0 @@
package shoal
import "core:strings"
import rl "vendor:raylib"
// a signal fired when audio starts playing, only on the transition from
// stopped/paused to playing, not every frame. see AudioFinished below for
// the matching end signal.
AudioStarted :: struct {
entity: Entity,
is_music: bool,
}
// a signal fired when a sound or music stream finishes. fires after the
// playing state transitions to Stopped, and after the audio device is
// closed (headless builds might never fire this, see AudioDeviceError).
AudioFinished :: struct {
entity: Entity,
is_music: bool,
}
// fires when InitAudioDevice fails, usually in headless builds or when no
// audio hardware is available. see startup_audio below.
AudioDeviceError :: struct {
message: string,
}
// opaque handle for sounds and music loaded through the global API.
// backed by a pool index, see audio_pool and audio_count below.
AudioHandle :: u32
// load a sound from file and return a handle. the sound is cached by path
// so subsequent calls with the same path return the same handle without
// reloading from disk. you can control it with stop_sound, set_sound_volume,
// etc. see path_to_handle below for the cache.
//
// handle := play_sound("sounds/explosion.wav")
// stop_sound(handle)
play_sound :: proc(path: string) -> AudioHandle {
// check path cache first, play it again if already loaded
if cached, ok := path_to_handle[path]; ok {
rl.PlaySound(audio_pool[int(cached)].sound)
return cached
}
cpath := strings.clone_to_cstring(path)
wave := rl.LoadWave(cpath)
sound := rl.LoadSoundFromWave(wave)
rl.UnloadWave(wave)
handle := AudioHandle(audio_count)
if int(handle) >= len(audio_pool) {
append(&audio_pool, AudioEntry{})
}
audio_pool[int(handle)] = AudioEntry {
sound = sound,
playing = true,
is_music = false,
volume = 1.0,
}
path_to_handle[path] = handle
audio_count += 1
return handle
}
// stop a playing sound by its handle. no-op if the handle is invalid or
// the sound was never loaded. see audio_pool and audio_count below.
stop_sound :: proc(handle: AudioHandle) {
if int(handle) < audio_count {
rl.StopSound(audio_pool[int(handle)].sound)
audio_pool[int(handle)].playing = false
}
}
// set volume for a specific sound handle, clamped to 0.0 .. 1.0.
// the value is also stored in the pool entry so it survives across calls.
// see audio_pool below.
set_sound_volume :: proc(handle: AudioHandle, volume: f32) {
if int(handle) < audio_count {
rl.SetSoundVolume(audio_pool[int(handle)].sound, volume)
audio_pool[int(handle)].volume = volume
}
}
// check if a sound is currently playing. returns false for invalid handles
// or sounds that were never loaded. see audio_pool below.
is_sound_playing :: proc(handle: AudioHandle) -> bool {
if int(handle) < audio_count {
return rl.IsSoundPlaying(audio_pool[int(handle)].sound)
}
return false
}
// load and start playing a music stream from file. returns a handle.
// music streams are for longer audio (background music, ambient loops)
// and are updated every frame by process_audio_components, see below.
// the sound is cached by path so subsequent calls with the same path
// return the same handle without reloading. see path_to_handle below.
//
// handle := play_music("sounds/theme.ogg")
play_music :: proc(path: string) -> AudioHandle {
// check path cache first, play it again if already loaded
if cached, ok := path_to_handle[path]; ok {
rl.PlayMusicStream(audio_pool[int(cached)].music)
return cached
}
cpath := strings.clone_to_cstring(path)
music := rl.LoadMusicStream(cpath)
handle := AudioHandle(audio_count)
if int(handle) >= len(audio_pool) {
append(&audio_pool, AudioEntry{})
}
audio_pool[int(handle)] = AudioEntry {
music = music,
playing = true,
is_music = true,
volume = 1.0,
}
path_to_handle[path] = handle
audio_count += 1
return handle
}
// stop the currently playing music by handle. no-op if the handle is
// invalid or the music was never loaded. see audio_pool below.
stop_music :: proc(handle: AudioHandle) {
if int(handle) < audio_count {
rl.StopMusicStream(audio_pool[int(handle)].music)
audio_pool[int(handle)].playing = false
}
}
// pause the currently playing music. no-op if the handle is invalid or
// not a music stream. see audio_pool below.
pause_music :: proc(handle: AudioHandle) {
if int(handle) < audio_count && audio_pool[int(handle)].is_music {
rl.PauseMusicStream(audio_pool[int(handle)].music)
}
}
// resume paused music. no-op if the handle is invalid or not a music
// stream. see audio_pool below.
resume_music :: proc(handle: AudioHandle) {
if int(handle) < audio_count && audio_pool[int(handle)].is_music {
rl.ResumeMusicStream(audio_pool[int(handle)].music)
}
}
// seek music to a position in seconds. no-op if the handle is invalid or
// not a music stream. see audio_pool below.
seek_music :: proc(handle: AudioHandle, position: f32) {
if int(handle) < audio_count && audio_pool[int(handle)].is_music {
rl.SeekMusicStream(audio_pool[int(handle)].music, position)
}
}
// get current music playback position in seconds. returns 0.0 for invalid
// handles or non-music streams. see audio_pool below.
get_music_time_played :: proc(handle: AudioHandle) -> f32 {
if int(handle) < audio_count && audio_pool[int(handle)].is_music {
return rl.GetMusicTimePlayed(audio_pool[int(handle)].music)
}
return 0.0
}
// get total music length in seconds. returns 0.0 for invalid handles or
// non-music streams. see audio_pool below.
get_music_time_length :: proc(handle: AudioHandle) -> f32 {
if int(handle) < audio_count && audio_pool[int(handle)].is_music {
return rl.GetMusicTimeLength(audio_pool[int(handle)].music)
}
return 0.0
}
// set volume for a specific music handle, clamped to 0.0 .. 1.0.
// the value is also stored in the pool entry so it survives across calls.
// see audio_pool below.
set_music_volume :: proc(handle: AudioHandle, volume: f32) {
if int(handle) < audio_count {
rl.SetMusicVolume(audio_pool[int(handle)].music, volume)
audio_pool[int(handle)].volume = volume
}
}
// set/get the master volume for all audio (listener volume). this affects
// everything: sounds, music, everything that goes through raylib's audio
// device. see startup_audio below.
set_master_volume :: proc(volume: f32) {
rl.SetMasterVolume(volume)
}
// global pool for sounds and music loaded through the global API.
// handles are 0-based indices into this array. see audio_count below.
@(private)
audio_pool: [dynamic]AudioEntry
@(private)
audio_count: int = 0
// path cache: reuse loaded sounds/music instead of reloading from disk.
// maps file path strings to handles, see play_sound and play_music above.
@(private)
path_to_handle: map[string]AudioHandle
// a single entry holding both an rl.Sound and an rl.Music, plus flags
// and metadata. raylib's types are small enough that one struct for
// both works fine. see audio_pool above.
AudioEntry :: struct {
sound: rl.Sound,
music: rl.Music,
playing: bool,
is_music: bool,
volume: f32,
}
// audio plugin: initializes the audio device at STARTUP, processes Audio
// components at UPDATE, cleans up finished sounds at UPDATE, and frees
// everything at DESTROY. add it like: ecs.add_plugin(&app, AudioPlugin)
// see startup_audio, process_audio_components, cleanup_finished_sounds,
// and destroy_audio below.
AudioPlugin :: proc(app: ^App) {
add_system(app, STARTUP, startup_audio)
add_system(app, UPDATE, process_audio_components)
add_system(app, UPDATE, cleanup_finished_sounds)
add_system(app, DESTROY, destroy_audio)
}
// init the audio device. only called once at STARTUP by AudioPlugin.
// if this fails (headless builds, no audio hardware), systems that try
// to play audio will silently do nothing. see AudioDeviceError above.
@(private)
startup_audio :: proc(world: ^World) {
rl.InitAudioDevice()
}
// close the audio device and unload all sounds/music from the pool.
// called once at DESTROY by AudioPlugin. see audio_pool and audio_count
// above.
@(private)
destroy_audio :: proc(world: ^World) {
rl.CloseAudioDevice()
for i := 0; i < audio_count; i += 1 {
if audio_pool[i].is_music {
rl.UnloadMusicStream(audio_pool[i].music)
} else {
rl.UnloadSound(audio_pool[i].sound)
}
}
delete(audio_pool)
delete(path_to_handle)
}
// process Audio components on entities: plays sounds, updates music
// streams, handles looping, and fires AudioStarted/AudioFinished signals.
// see Audio and AudioState in audio.odin for the component definition,
// and the signals above.
@(private)
process_audio_components :: proc(world: ^World) {
components := slice(world, Audio)
entities := slice_entity(world, Audio)
for &v, i in components {
entity := entities[i]
switch v.state {
case AudioState.Playing:
// one-shot sound (frameCount == 0 means no sound loaded)
if v.sound.frameCount != 0 {
rl.PlaySound(v.sound)
rl.SetSoundVolume(v.sound, v.volume)
rl.SetSoundPitch(v.sound, v.pitch)
rl.SetSoundPan(v.sound, v.pan)
write(AudioStarted{entity = entity, is_music = false})
// check if a very short sound already finished this frame
if !rl.IsSoundPlaying(v.sound) {
v.state = AudioState.Stopped
write(AudioFinished{entity = entity, is_music = false})
}
} else if v.music.ctxType != 0 {
// background music (ctxType == 0 means no music loaded)
rl.PlayMusicStream(v.music)
rl.SetMusicVolume(v.music, v.volume)
rl.SetMusicPitch(v.music, v.pitch)
rl.SetMusicPan(v.music, v.pan)
write(AudioStarted{entity = entity, is_music = true})
// update music stream buffers every frame
rl.UpdateMusicStream(v.music)
// loop handling: if looping and reached end, seek to start
if v.looping && rl.GetMusicTimePlayed(v.music) >=
rl.GetMusicTimeLength(v.music) {
rl.SeekMusicStream(v.music, 0)
}
}
case AudioState.Paused:
// do nothing this frame (paused)
case AudioState.Stopped:
// nothing to do; component can be removed by a cleanup system
case AudioState.None:
// no-op
}
}
}
// unload sounds and music from the global pool that have been marked as
// not playing anymore. called every UPDATE by AudioPlugin, runs after
// process_audio_components so finished sounds from this frame get cleaned
// up before the next frame. see audio_pool and audio_count above.
@(private)
cleanup_finished_sounds :: proc() {
for i := 0; i < audio_count; i += 1 {
if !audio_pool[i].playing {
if audio_pool[i].is_music {
rl.UnloadMusicStream(audio_pool[i].music)
} else {
rl.UnloadSound(audio_pool[i].sound)
}
audio_pool[i] = AudioEntry{}
}
}
}
+6 -53
View File
@@ -2,68 +2,21 @@ package shoal
import rl "vendor:raylib"
// 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
Window :: struct {
WindowComponent :: struct {
width: i32,
height: i32,
title: string,
background: rl.Color,
}
Window: Storage(WindowComponent)
// the mouse position, refreshed every UPDATE by DefaultPlugin
Cursor :: struct {
position: rl.Vector2,
}
// a texture DefaultPlugin draws at DRAW, placed by the same entity's
// TransformComponent (so every sprite entity needs a Transform component
// too). extended fields:
// source: crop to a region of the texture (sprite sheets)
// scale: scale the sprite (default 1.0, 1.0)
// tint: color tint (default WHITE)
// flip_h/v: flip the sprite horizontally/vertically
Sprite :: struct {
SpriteComponent :: struct {
texture: rl.Texture2D,
source: rl.Rectangle, // zero = whole texture
scale: rl.Vector2, // default 1.0, 1.0
tint: rl.Color, // default WHITE
flip_h: bool,
flip_v: bool,
}
Sprite: Storage(SpriteComponent)
// 2D translation + rotation, read back by draw_sprite when it places the
// entity's SpriteComponent
Transform :: struct {
TransformComponent :: struct {
translation: rl.Vector2,
rotation: f32,
}
// audio state: what an entity's Audio component is doing this frame.
AudioState :: enum u8 {
None,
Playing,
Paused,
Stopped,
}
// an entity-level audio component: add this to any entity that should
// play sounds or music. the AudioSystem processes it every UPDATE.
// one-shot sounds go in `sound`, background music in `music`.
// both can coexist on the same entity (e.g. a radio playing ambient
// music with click sounds).
Audio :: struct {
sound: rl.Sound, // one-shot effect (frameCount == 0 means none)
music: rl.Music, // background music (ctxType == 0 means none)
state: AudioState, // what the system should do this frame
volume: f32, // 0.0 .. 1.0 (default 1.0)
pitch: f32, // > 1.0 = higher, < 1.0 = lower (default 1.0)
pan: f32, // -1.0 = left, 0.0 = center, 1.0 = right (default 0.0)
looping: bool, // loop music when it reaches the end
}
// 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
Transform: Storage(TransformComponent)
-28
View File
@@ -1,28 +0,0 @@
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
// LATE_DRAW / (LATE_DRAW runs after every DRAW system, for overlays)
// POST_DRAW once per frame, after all drawing
// 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.
// renderers that flush accumulated draw state (like the ui plugin replaying
// microui's command list) go on LATE_DRAW, so they run after all game DRAW
// systems but still inside the raylib frame.
STARTUP :: 0
PRE_UPDATE :: 1
UPDATE :: 2
POST_UPDATE :: 3
PRE_DRAW :: 4
DRAW :: 5
LATE_DRAW :: 6
POST_DRAW :: 7
DESTROY :: 8
+21 -94
View File
@@ -1,115 +1,42 @@
package shoal
import "core:strings"
import rl "vendor:raylib"
// 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)
STARTUP :: 0
UPDATE :: 1
DRAW :: 2
App :: struct {
systems: map[int][dynamic]System,
systems: map[int][dynamic]proc(),
plugins: [dynamic]proc(app: ^App),
world: World,
entities: [dynamic]Entity,
}
// 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),
add_system :: proc(app: ^App, schedule: int, system: proc()) {
append(&app.systems[schedule], system)
}
// 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. 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,
}
@(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. 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]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 -> LATE_DRAW -> POST_DRAW, over and over until rl.WindowShouldClose()
// says stop,
// 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, entity_limit: int = 1000) {
for p in app.plugins {
p(app)
}
for s in get_system(app, STARTUP) {run_system(app, s)}
for i in app.systems[STARTUP] {i()}
_, window := single(&Window)
rl.InitWindow(window.width, window.height, strings.clone_to_cstring(window.title))
defer rl.CloseWindow()
rl.SetTargetFPS(60)
for !rl.WindowShouldClose() {
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, LATE_DRAW) {run_system(app, s)}
for s in get_system(app, POST_DRAW) {run_system(app, s)}
}
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)
rl.ClearBackground(window.background)
for i in app.systems[UPDATE] {i()}
rl.BeginDrawing()
for i in app.systems[DRAW] {i()}
rl.EndDrawing()
}
}
+18 -155
View File
@@ -1,184 +1,47 @@
package shoal
import "core:fmt"
import "core:mem"
// 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.
// 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,
data: map[Entity]T,
}
// 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(world: ^World) -> Entity {
entity_num := len(world.entities)
spawn_entity :: proc(app: ^App) -> Entity {
entity_num := len(&app.entities)
entity: Entity = Entity(entity_num)
append(&world.entities, entity)
append(&app.entities, entity)
return entity
}
// 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 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
storage.sparse[entity] = i
add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) {
storage.data[entity] = T{}
}
// same, but starts from a copy of the component you pass in
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
storage.sparse[entity] = i
add_component_with_data :: proc(entity: Entity, storage: ^Storage($T), component: T) {
storage.data[entity] = component
}
// 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
slice :: proc(storage: ^Storage($T)) -> map[Entity]T {
return storage.data
}
// the entity ids backing the dense array, index-aligned with slice().
// same invalidation caveat: adds can reallocate it
slice_entity :: proc(world: ^World, $T: typeid) -> [dynamic]Entity {
return get_storage(world, T).entities
}
// ^T of the world's first entry of that component plus the entity that owns
// it, handy for singletons like WindowComponent. panics with a readable
// message if no entity has the component yet
single :: proc(world: ^World, $T: typeid) -> (^T, Entity) {
storage := get_storage(world, T)
if len(storage.dense) == 0 {
panic(fmt.tprintf("single: no entity has component {}", typeid_of(T)))
single :: proc(storage: ^Storage($T)) -> (Entity, ^T) {
for i, &v in slice(storage) {
return i, &v
}
return &storage.dense[0], storage.entities[0]
return Entity{}, new(T)
}
// a pointer to that entity's component, looked up through the sparse map.
// panics with a readable message if this specific entity was never added to
// the storage. use has_component or try_get_component when you aren't sure
get_component :: proc(world: ^World, entity: Entity, $T: typeid) -> ^T {
storage := get_storage(world, T)
if i, ok := storage.sparse[entity]; ok {
return &storage.dense[i]
get_component :: proc(entity: Entity, storage: ^Storage($T)) -> ^T {
if c, exists := &storage.data[entity]; exists {
return c
}
panic(fmt.tprintf("get_component: entity {} has no component {}", entity, typeid_of(T)))
}
// true iff this specific entity has the component. probing a component the
// entity lacks (or a type nothing has added yet) is safe: it never panics
// and never reports a different entity's entry. fine to call every frame,
// though the first probe of a never-seen type lazily creates its storage
has_component :: proc(world: ^World, entity: Entity, $T: typeid) -> bool {
storage := get_storage(world, T)
_, ok := storage.sparse[entity]
return ok
}
// the entity's component and a flag saying whether it's there: (nil, false)
// when this entity never got the component, no panic. the pointer-flavored
// alternative to has_component + get_component for plugins and systems that
// probe components they don't own
try_get_component :: proc(world: ^World, entity: Entity, $T: typeid) -> (component: ^T, ok: bool) {
storage := get_storage(world, T)
if i, exists := storage.sparse[entity]; exists {
return &storage.dense[i], true
}
return nil, false
return new(T)
}
-85
View File
@@ -1,85 +0,0 @@
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 and signal_system below
Bus :: struct($T: typeid) {
pending: [dynamic]T,
}
@(private)
Bus_Entry :: struct {
bus: rawptr,
clear: proc(b: rawptr),
}
@(private)
buses: map[typeid]Bus_Entry
@(private)
get_bus :: proc($T: typeid) -> ^Bus(T) {
if entry, exists := buses[T]; exists {
return (^Bus(T))(entry.bus)
}
bus := new(Bus(T))
buses[T] = {
bus = bus,
clear = clear_bus(T),
}
return bus
}
// called once per payload type, hands signal_system a rawptr clear for that bus
@(private)
clear_bus :: proc($T: typeid) -> proc(b: rawptr) {
return proc(b: rawptr) {
bus := (^Bus(T))(b)
clear(&bus.pending)
}
}
// queue a signal, readable until signal_system clears it at POST_UPDATE.
// 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)
// queue an empty payload by passing the bare type: write(MySignal)
write :: proc {
write_empty,
write_with_data,
}
write_empty :: proc($T: typeid) {
bus := get_bus(T)
append(&bus.pending, T{})
}
write_with_data :: proc(data: $T) {
bus := get_bus(T)
append(&bus.pending, data)
}
// 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. 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}
result := make([]T, len(bus.pending), context.temp_allocator)
copy(result, bus.pending[:])
return result
}
// 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. the next frame's DRAW
// never does though, POST_UPDATE gets there first
signal_system :: proc() {
for _, e in buses {
e.clear(e.bus)
}
}
+10 -186
View File
@@ -1,203 +1,27 @@
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 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 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
// 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(world: ^World) {rl.BeginDrawing()
window, _ := single(world, Window)
rl.ClearBackground(window.background)})
add_system(app, POST_DRAW, proc() {rl.EndDrawing()})
add_system(app, DESTROY, proc() {rl.CloseWindow()})
// 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)
display := spawn_entity(app)
add_component(
world,
display,
Window{width = 800, height = 600, title = "shoal window", background = rl.BLACK},
&Window,
WindowComponent{width = 800, height = 600, title = "shoal window"},
)
cursor := spawn_entity(world)
add_component(world, cursor, Cursor)
}
@(private)
update_cursor_position :: proc(world: ^World) {
cursor, _ := single(world, Cursor)
cursor.position = rl.GetMousePosition()
}
// draw a sprite using DrawTexturePro, respecting source rect, scale,
// tint, flip, and rotation from TransformComponent. uses zero values
// (empty Rectangle, empty Color) as defaults: empty source means the
// full texture, empty tint means white (no color change). backward
// compatible: a sprite with no extended fields draws exactly as before.
@(private)
draw_sprite :: proc(world: ^World) {
zero_rect := rl.Rectangle{}
zero_color := rl.Color{}
components := slice(world, Sprite)
entities := slice_entity(world, Sprite)
for &v, i in components {
transform := get_component(world, entities[i], Transform)
// source rect: zero means the whole texture
source := v.source
if source == zero_rect {
source = rl.Rectangle{
x = 0,
y = 0,
width = f32(v.texture.width),
height = f32(v.texture.height),
}
}
// destination size: source scaled
dest_w := source.width * v.scale.x
dest_h := source.height * v.scale.y
// flip by negating destination size (DrawTexturePro handles this)
if v.flip_h {
dest_w = -dest_w
}
if v.flip_v {
dest_h = -dest_h
}
// origin at center of source (rotation pivot) — Vector2 is positional
origin := rl.Vector2{
source.width * 0.5,
source.height * 0.5,
}
// destination rectangle (top-left from Transform, size from scale+flip)
dest := rl.Rectangle{
x = transform.translation.x,
y = transform.translation.y,
width = dest_w,
height = dest_h,
}
// tint: default WHITE means no color change
tint := v.tint
if tint == zero_color {
tint = rl.WHITE
}
rl.DrawTexturePro(
draw_sprite :: proc() {
for i, &v in slice(&Sprite) {
sprite_transform := get_component(i, &Transform)
rl.DrawTexture(
v.texture,
source,
dest,
origin,
transform.rotation,
tint,
i32(sprite_transform.translation.x),
i32(sprite_transform.translation.y),
rl.WHITE,
)
}
}
// load a sprite from a file path. returns a Sprite with the loaded texture
// and default extended fields (full source, 1.0 scale, white tint).
// see load_sprite_sheet below for splitting a texture into a grid.
//
// sprite := load_sprite("images/player.png")
// ecs.add_component(world, entity, sprite)
load_sprite :: proc(path: string) -> Sprite {
cpath := strings.clone_to_cstring(path)
texture := rl.LoadTexture(cpath)
return Sprite{
texture = texture,
source = rl.Rectangle{},
scale = rl.Vector2{1.0, 1.0},
tint = rl.WHITE,
}
}
// load a sprite sheet and split it into individual sprites.
// cols and rows define the grid. returns a slice of Sprites, one per cell.
// all returned sprites share the same underlying texture, so calling
// unload_sprite or unload_sprites on any one entry frees the texture for
// all of them (see unload_sprite and unload_sprites below).
//
// sprites := load_sprite_sheet("images/sprites.png", 4, 3)
// // sprites[0] is top-left, sprites[11] is bottom-right
load_sprite_sheet :: proc(path: string, cols, rows: int) -> [dynamic]Sprite {
cpath := strings.clone_to_cstring(path)
texture := rl.LoadTexture(cpath)
cell_w := f32(texture.width) / f32(cols)
cell_h := f32(texture.height) / f32(rows)
sprites := [dynamic]Sprite{}
for row := 0; row < rows; row += 1 {
for col := 0; col < cols; col += 1 {
append(&sprites, Sprite{
texture = texture,
source = rl.Rectangle{
x = f32(col) * cell_w,
y = f32(row) * cell_h,
width = cell_w,
height = cell_h,
},
scale = rl.Vector2{1.0, 1.0},
tint = rl.WHITE,
})
}
}
return sprites
}
// free the texture loaded by load_sprite or an entry from load_sprite_sheet.
// call this when you no longer need the sprite to avoid GPU memory leaks.
// note: for sprites from load_sprite_sheet, all entries share the same
// underlying texture. calling unload_sprite on any one entry unloads that
// texture for all entries — subsequent use of any other entry from the same
// sheet will result in undefined behavior (crash or garbage).
//
// sprite := ecs.load_sprite("images/player.png")
// // ... use it ...
// ecs.unload_sprite(sprite)
unload_sprite :: proc(sprite: Sprite) {
rl.UnloadTexture(sprite.texture)
}
// free all sprites from a sprite sheet. since all entries share one texture,
// only one call is needed, but this helper unloads all entries for convenience.
// see unload_sprite above for the shared-texture caveat.
//
// sprites := ecs.load_sprite_sheet("images/sprites.png", 4, 3)
// // ... use them ...
// ecs.unload_sprites(sprites)
unload_sprites :: proc(sprites: [dynamic]Sprite) {
if len(sprites) > 0 {
rl.UnloadTexture(sprites[0].texture)
}
}
@(private)
startup_window :: proc(world: ^World) {
window, _ := single(world, Window)
rl.InitWindow(window.width, window.height, strings.clone_to_cstring(window.title))
rl.SetTargetFPS(60)
}
-75
View File
@@ -1,75 +0,0 @@
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 in their
// own file
@(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,
signal = signal,
}
timers[signal] = new_timer
}
// remove the timer stored under this name entirely, freeing its map entry.
// safe to call for a name that isn't running (no-op). this is for releasing
// timers you created, not for reading them out -- use get_timer for that
stop_timer :: proc(signal: string) {
delete_key(&timers, signal)
}
// 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]
}
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(TimerFired{name = t.signal})
}
}
}
}
-334
View File
@@ -1,334 +0,0 @@
package ui
import "base:runtime"
import "core:fmt"
import "core:strings"
import "core:unicode/utf8"
import mu "vendor:microui"
import rl "vendor:raylib"
@(private)
Color32 :: [4]u8
RLMU_State :: struct {
atlas: rl.Texture2D,
ctx: mu.Context,
}
global_state: ^RLMU_State
@(init)
rlmu_init :: proc "contextless" () {
context = runtime.default_context()
global_state = new(RLMU_State)
}
// bake the default microui atlas into a raylib texture and set up the mui
// context (text metrics + clipboard callbacks). UIPlugin runs it at STARTUP
init :: proc(state := global_state) {
// Allocate an array of 32 bit pixel colors for the atlas used by mui
// An atlas is a big image packed with multiple smaller images. Like a sprite sheet!
pixels := make([]Color32, mu.DEFAULT_ATLAS_WIDTH * mu.DEFAULT_ATLAS_HEIGHT)
// mui has its default atlas already "baked" into a pixel array like ours
// However it only stores the alpha of each pixel since there are not colors in the atlas
// We can just copy the alpha values into the alpha channel of our pixels array, leaving the rgb as white
for alpha, i in mu.default_atlas_alpha do pixels[i] = {255, 255, 255, alpha}
// Create a raylib Image whose data is the pixels we just allocated
image := rl.Image {
data = raw_data(pixels),
width = mu.DEFAULT_ATLAS_WIDTH,
height = mu.DEFAULT_ATLAS_HEIGHT,
format = .UNCOMPRESSED_R8G8B8A8,
mipmaps = 1,
}
// The image we just created lives on the CPU side of things
// If we want to actually draw stuff on the GPU we need to create a *Texture*
// This will actually send the pixel data to the GPU so it can be rendered
state.atlas = rl.LoadTextureFromImage(image)
// Now that the pixel data is stored in video memory, we can delete the pixel array in system memory
delete(pixels)
image = {}
// Initialize mui with our mui context
mu.init(ctx = &state.ctx)
// and point the text_width/height callback functions of our context to the default ones since we're using the default atlas
state.ctx.text_width = mu.default_atlas_text_width
state.ctx.text_height = mu.default_atlas_text_height
// These variables are actually pointers to `(font: Font, str: string) -> i32` functions,
// which mui uses to calculate the pixel width/height of a string when rendered with a certain font
// Let's also assign the clipboard callbacks for copy/paste support. We have our own functions for handling these events.
state.ctx.textbox_state.set_clipboard = set_clipboard
state.ctx.textbox_state.get_clipboard = get_clipboard
}
// unload the atlas texture. UIPlugin runs it at DESTROY
destroy :: proc(state := global_state) {
// Free atlas texture
rl.UnloadTexture(state.atlas)
}
// forward this frame's raylib input (text, mouse, keys) to microui and start
// the frame. returns the context to build ui against. UIPlugin runs it at
// PRE_DRAW and hands the result to get_ctx()
begin :: proc(state: ^RLMU_State = global_state) -> ^mu.Context {
// Forward all input from raylib to mui
// We need to tell mui what keys are pressed, where the mouse is etc so the ui can react accordingly
forward_text_input(state)
forward_mouse_input(state)
forward_keyboard_input(state)
// Now we can tell mui that we're ready to tell it what UI we want to draw
mu.begin(&state.ctx)
return &state.ctx
}
// tell microui the frame is finished, then replay its command list as raylib
// draw calls (and close any lingering scissor). UIPlugin runs it at LATE_DRAW
end :: proc(state := global_state) {
// Tell mui that we're done drawing ui
mu.end(&state.ctx)
// We've "declared" the ui, but it's just a list of commands
// Now we need to render the UI
// mui transforms our high-level UI calls into a list of primitive render commands: text, rectangle, icon, clip (masking)
current_command: ^mu.Command
for cmd_variant in mu.next_command_iterator(&state.ctx, &current_command) {
#partial switch cmd in cmd_variant {
// Draw a (single-line) string
case ^mu.Command_Text:
// This is the top-left position of the first character in the string we need to draw
// We will move it to the right as we draw each character
draw_position := [2]i32{cmd.pos.x, cmd.pos.y}
// Loop over each character in the text. This "do if" condition ensures that we only process single-byte ASCII characters.
for char in cmd.str do if !is_utf8_continuation_byte(char) {
// We need to convert the UTF8 character to an plain ASCII integer so that we can use it to index
// into the mui default atlas.
ascii := char_to_ascii(char)
// "mu.default_atlas" is an array of rects for every character and icon in the default atlas texture
// "mu.DEFAULT_ATLAS_FONT" stores the index of the atlas rect for the first ASCII character texture
// By adding our ascii int to the base index we can get the rect for our char's texture
rect := mu.default_atlas[mu.DEFAULT_ATLAS_FONT + ascii]
// Now that we have the atlas rect for the current char we can draw it to the screen
draw_mu_atlas_texture(state.atlas, rect, draw_position, cmd.color)
// Finally, we need to offset our draw position before drawing the next char
draw_position.x += rect.w
}
// Draw a rectangle
case ^mu.Command_Rect:
rl.DrawRectangle(
cmd.rect.x,
cmd.rect.y,
cmd.rect.w,
cmd.rect.h,
transmute(rl.Color)cmd.color,
)
// Draw an icon
case ^mu.Command_Icon:
// cmd.id stores the index into the default_atlas array of rects which we can use to get the icons atlas rect
rect := mu.default_atlas[cmd.id]
x := cmd.rect.x + (cmd.rect.w - rect.w) / 2
y := cmd.rect.y + (cmd.rect.h - rect.h) / 2
draw_mu_atlas_texture(state.atlas, rect, {x, y}, cmd.color)
// Start masking what we draw
case ^mu.Command_Clip:
// End any previous masking
rl.EndScissorMode()
// Begin a mask using the current commands rect
rl.BeginScissorMode(cmd.rect.x, cmd.rect.y, cmd.rect.w, cmd.rect.h)
}
}
// Make sure we end any lingering scissor mode.
// Without this precaution it's possible for clipping to persist into the next frame
// and obstruct stuff until the next clip command from microui is handled!
rl.EndScissorMode()
}
// init() with destroy() deferred to scope exit: set the ui up and tear it
// down around a block
@(deferred_in = destroy)
init_scope :: proc(state := global_state) -> ^mu.Context {init(state); return &state.ctx}
// begin() with end() deferred to scope exit: a whole frame of ui in one block
@(deferred_in = end)
begin_scope :: proc(state := global_state) -> ^mu.Context {begin(state); return &state.ctx}
// Sends the current text (typing) input from raylib to mui
@(private)
forward_text_input :: proc(state: ^RLMU_State) {
// Create a buffer to hold UTF8 text input
// UTF8 is a "variable width encoding" so some characters may be 1 byte whereas other may be up to 4
text_input: [512]byte = ---
// This will track the index into the text_input buffer where characters bytes will be copied to
text_input_offset := 0
// This loops reads the characters currently pressed until we've read all the pressed characters or we hit the
// limit of our text input buffer. We'd only hit the limit if someone was holding a LOT of characters
for text_input_offset < len(text_input) {
// Get the pressed UTF8 character rune
// Called multiple times since multiple keys may be pressed
pressed_rune := rl.GetCharPressed()
// If the pressed rune (Unicode character) is 0 there are no more keys pressed
if pressed_rune == 0 do break
// UTF8 characters are stored using "variable width encoding"
// This means a character could be represented by 1-4 bytes
// Encoding the rune into UTF8 always returns 4 bytes, but the count indicates how many runes the character actually is
bytes, count := utf8.encode_rune(pressed_rune)
// We'll copy from the start of the bytes array, up to the number of bytes used to represent the character,
// into our text input buffer at the current text input offset
copy(text_input[text_input_offset:], bytes[:count])
// Finally we can offset the text_input_offset by the number of bytes we copied into the buffer
text_input_offset += count
}
// Now we can send our text_input buffer to mui so it knows the currently pressed characters
// We'll just send a slice of the full text_input buffer, up to the latest input offset since it probably wasn't filled
mu.input_text(&state.ctx, string(text_input[:text_input_offset]))
}
// Sends the current mouse input from raylib to mui
@(private)
forward_mouse_input :: proc(state: ^RLMU_State) {
// Get the current mouse position/scroll from raylib
mouse_position := [2]i32{rl.GetMouseX(), rl.GetMouseY()}
mouse_scroll := rl.GetMouseWheelMove() * -30
// Send the mouse position/scroll to mui
mu.input_mouse_move(&state.ctx, mouse_position.x, mouse_position.y)
mu.input_scroll(&state.ctx, 0, i32(mouse_scroll))
// This struct stores a mapping from a raylib mouse button enum to the equivalent mui enum
MouseButtonMapping :: struct {
rl: rl.MouseButton,
mu: mu.Mouse,
}
// We'll create an array of mappings using the struct above
@(static) MouseButtonMappings := [?]MouseButtonMapping {
{.LEFT, .LEFT},
{.RIGHT, .RIGHT},
{.MIDDLE, .MIDDLE},
}
// Check each if each mouse button is down or released with Raylib and forward the event to mui if so
for button in MouseButtonMappings {
if rl.IsMouseButtonPressed(button.rl) {
mu.input_mouse_down(&state.ctx, mouse_position.x, mouse_position.y, button.mu)
} else if rl.IsMouseButtonReleased(button.rl) {
mu.input_mouse_up(&state.ctx, mouse_position.x, mouse_position.y, button.mu)
}
}
}
// Sends the current keyboard input from raylib to mui
// Not quite the same as forward_text_input_to_mui() which sends any pressed *characters*
// whereas this sends specific "modifier" keys like Shift, Enter and Backspace
@(private)
forward_keyboard_input :: proc(state: ^RLMU_State) {
// This struct stores a mapping from a raylib key enum to the equivalent mui enum
KeyMapping :: struct {
rl: rl.KeyboardKey,
mu: mu.Key,
}
// We'll create an array of mappings using the struct above
// We don't need to map every raylib key - just the ones mui needs for ui stuff
@(static) KeyMappings := [?]KeyMapping {
{.LEFT_SHIFT, .SHIFT},
{.RIGHT_SHIFT, .SHIFT},
{.LEFT_CONTROL, .CTRL},
{.RIGHT_CONTROL, .CTRL},
{.LEFT_ALT, .ALT},
{.RIGHT_ALT, .ALT},
{.ENTER, .RETURN},
{.KP_ENTER, .RETURN},
{.BACKSPACE, .BACKSPACE},
{.DELETE, .DELETE},
{.END, .END},
{.HOME, .HOME},
{.LEFT, .LEFT},
{.RIGHT, .RIGHT},
{.A, .A},
{.C, .C},
{.V, .V},
{.X, .X},
}
for key in KeyMappings {
if rl.IsKeyPressed(key.rl) {
mu.input_key_down(&state.ctx, key.mu)
} else if rl.IsKeyReleased(key.rl) {
mu.input_key_up(&state.ctx, key.mu)
}
}
}
// Draws a section of the mui atlas to the screen with a tint
// The target_position it draws to will be the top left of the drawn texture
@(private)
draw_mu_atlas_texture :: proc(
atlas: rl.Texture2D,
atlas_source: mu.Rect,
target_position: [2]i32,
color: mu.Color,
) {
// Create a raylib version of the source rect and target position.
// We don't have to do this for the color since its memory layout is identical
rl_target_position := rl.Vector2{f32(target_position.x), f32(target_position.y)}
rl_atlas_source := rl.Rectangle {
f32(atlas_source.x),
f32(atlas_source.y),
f32(atlas_source.w),
f32(atlas_source.h),
}
// Draw the source rect part of the atlas (sprite) to the screen
// We can transmute the mu.Color to a rl.Color since the memory layout is the same. This is like casting without type-checking
rl.DrawTextureRec(atlas, rl_atlas_source, rl_target_position, transmute(rl.Color)color)
}
@(private)
set_clipboard :: proc(user_data: rawptr, text: string) -> (ok: bool) {
// Try and convert the text string handed to us from mui to a cstring, which raylib expects
ctext, err := strings.clone_to_cstring(text, context.temp_allocator)
if err == .None {
rl.SetClipboardText(ctext)
return true
}
return false
}
@(private)
get_clipboard :: proc(user_data: rawptr) -> (text: string, ok: bool) {
// Try and convert the cstring handed to us from raylib to a string, which mui expects
ctext := rl.GetClipboardText()
if ctext == nil || len(ctext) == 0 {
ok = false
return
}
clipboard_text, err := strings.clone_from_cstring(ctext, context.temp_allocator)
text = clipboard_text
ok = err == .None
return
}
// Checks if the character is a continuation byte in UTF-8 encoding.
// In UTF-8, any byte where the top two bits are 10 (binary 0x80 in hexadecimal) is a continuation byte.
@(private)
is_utf8_continuation_byte :: proc(char: rune) -> bool {
return char & 0xc0 == 0x80
}
@(private)
char_to_ascii :: proc(char: rune) -> int {
return min(int(char), 127)
}
-29
View File
@@ -1,29 +0,0 @@
package ui
import ecs "../src"
import ml "vendor:microui"
// the microui context UIPlugin refreshes every frame, nil until the first
// PRE_DRAW. use get_ctx() instead of touching it directly
ui_ctx: ^ml.Context
// wires the ui package into the app, in frame order:
//
// STARTUP init() bakes the microui atlas into a texture
// PRE_DRAW begin() forwards raylib input, starts the frame, stashes ui_ctx
// LATE_DRAW end() renders the ui after all game drawing, still in-frame
// POST_DRAW the frame's temp arena is freed
// DESTROY destroy() unloads the atlas texture
UIPlugin :: proc(app: ^ecs.App) {
ecs.add_system(app, ecs.STARTUP, proc() {init()})
ecs.add_system(app, ecs.POST_DRAW, proc() {free_all(context.temp_allocator)})
ecs.add_system(app, ecs.PRE_DRAW, proc() {ui_ctx = begin()})
ecs.add_system(app, ecs.LATE_DRAW, proc() {end()})
ecs.add_system(app, ecs.DESTROY, proc() {destroy()})
}
// the microui context for this frame. pass it to every microui call. valid
// from PRE_DRAW onwards (nil before the first frame)
get_ctx :: proc() -> ^ml.Context {
return ui_ctx
}