Compare commits
19
Commits
94197df1bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2344005d86 | ||
|
|
24f19171fd | ||
|
|
7209032b0f | ||
|
|
b522a28069 | ||
|
|
939841d143 | ||
|
|
942970b864 | ||
|
|
3804ce3bd7 | ||
|
|
dddffcec43 | ||
|
|
0d35d4b0c3 | ||
|
|
405cfed061 | ||
|
|
16bf55f093 | ||
|
|
c45e6b1dbb | ||
|
|
f2654d8f8a | ||
|
|
56adba8ea5 | ||
|
|
c69bf5cabf | ||
|
|
65970091b4 | ||
|
|
78a3951d82 | ||
|
|
4dfb1c5e28 | ||
|
|
9f719e71cf |
@@ -7,11 +7,22 @@ my silly little ecs engine for odin and raylib :3
|
|||||||
## contents
|
## contents
|
||||||
- [install](#install)
|
- [install](#install)
|
||||||
- [usage](#usage)
|
- [usage](#usage)
|
||||||
|
- [systems](#systems)
|
||||||
|
- [components](#components)
|
||||||
|
- [spawn entities](#spawn-entities)
|
||||||
|
- [query](#query)
|
||||||
|
- [plugins](#plugins)
|
||||||
|
- [signals](#signals)
|
||||||
|
- [timers](#timers)
|
||||||
|
- [ui](#ui)
|
||||||
|
- [tween](#tween)
|
||||||
|
|
||||||
## install
|
## install
|
||||||
clone this repo in your project directory
|
clone this repo in your project directory
|
||||||
|
|
||||||
`git clone https://git.egfk.net/harper/shoal`
|
```sh
|
||||||
|
git clone https://git.egfk.net/harper/shoal
|
||||||
|
```
|
||||||
|
|
||||||
and import the package
|
and import the package
|
||||||
|
|
||||||
@@ -20,15 +31,19 @@ import ecs "shoal/src"
|
|||||||
```
|
```
|
||||||
|
|
||||||
## usage
|
## usage
|
||||||
first create a new app struct. this is where your systems and plugins live.
|
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
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
import ecs "shoal/src"
|
import ecs "shoal/src"
|
||||||
|
|
||||||
|
main :: proc() {
|
||||||
app: ecs.App
|
app: ecs.App
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
you can run your app with `run()`. run takes a reference to your app to run it's systems.
|
you can run your app with `run()`. run takes a pointer to your app and drives its systems.
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
main :: proc() {
|
main :: proc() {
|
||||||
@@ -39,15 +54,15 @@ main :: proc() {
|
|||||||
### systems
|
### systems
|
||||||
systems are procedures that run on set schedules
|
systems are procedures that run on set schedules
|
||||||
|
|
||||||
a schedule just determines when a producure runs\
|
a schedule determines when a procedure runs\
|
||||||
schedules are:
|
there are 4 of them, and `run()` walks them in this order:
|
||||||
- STARTUP
|
|
||||||
- UPDATE
|
|
||||||
- DRAW
|
|
||||||
|
|
||||||
`STARTUP` runs once at the start of an app\
|
- `STARTUP`: once, before the first frame
|
||||||
`UPDATE` runs every frame\
|
- `UPDATE`: every frame, your main logic
|
||||||
`DRAW` runs every frame *after* update, and makes draw calls to raylib\
|
- `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.
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
main :: proc() {
|
main :: proc() {
|
||||||
@@ -66,26 +81,36 @@ 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
|
||||||
components are just structs with data, and kept with other components of that type in `Storage`.\
|
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
|
||||||
create a new component
|
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
ExampleComponent :: struct {}
|
ExampleComponent :: struct {}
|
||||||
```
|
```
|
||||||
|
|
||||||
and create it's storage
|
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.
|
||||||
|
|
||||||
```odin
|
|
||||||
Example: ecs.Storage(ExampleComponent)
|
|
||||||
```
|
|
||||||
|
|
||||||
### spawn entities
|
### spawn entities
|
||||||
create a new entity with `spawn_entity()`
|
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)`)
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
main :: proc() {
|
example_spawn :: proc(world: ^ecs.World) {
|
||||||
entity := ecs.spawn_entity(&app)
|
entity := ecs.spawn_entity(world)
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -93,37 +118,82 @@ main :: proc() {
|
|||||||
and give it new components with `add_component()`
|
and give it new components with `add_component()`
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
main :: proc() {
|
example_spawn :: proc(world: ^ecs.World) {
|
||||||
ecs.add_component(entity, &Example) // make sure to pass a reference to the storage, not the component type
|
entity := ecs.spawn_entity(world)
|
||||||
|
ecs.add_component(world, entity, ExampleComponent) // just the type: a zero-initialized component
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### query
|
or fill it in on the spot by passing data instead
|
||||||
since components are saved in storage next to their entity, you can loop through a Storage and pick out what you like
|
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
main :: proc() {
|
example_spawn :: proc(world: ^ecs.World) {
|
||||||
components := ecs.slice(&Example)
|
entity := ecs.spawn_entity(world)
|
||||||
entities := ecs.slice_entity(&Example)
|
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]`
|
||||||
|
|
||||||
|
```odin
|
||||||
|
example_system :: proc(world: ^ecs.World) {
|
||||||
|
components := ecs.slice(world, ExampleComponent)
|
||||||
|
entities := ecs.slice_entity(world, ExampleComponent)
|
||||||
for v, i in components {
|
for v, i in components {
|
||||||
e := entities[i] // get entity that owns the component
|
e := entities[i] // get entity that owns the component
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
or if you know your storage only has one entry use `single()`
|
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()`
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
main :: proc() {
|
example_system :: proc(world: ^ecs.World) {
|
||||||
component, entity_id := ecs.single(&Example)
|
component, entity_id := ecs.single(world, ExampleComponent)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
plugins are just a procedure that takes `App` as a parameter, meaning it can make changes to `app`, just like main()
|
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)`
|
||||||
|
|
||||||
```odin
|
```odin
|
||||||
ExamplePlugin :: proc(app: ^App) {
|
ExamplePlugin :: proc(app: ^App) {
|
||||||
ecs.add_system(app, ecs.STARTUP, some_plugin_system)
|
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{...})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -134,4 +204,76 @@ 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 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import "core:math/rand"
|
import "core:math/rand"
|
||||||
import "core:strconv"
|
import "core:strconv"
|
||||||
import "core:strings"
|
import "core:strings"
|
||||||
import ecs "shoal/src"
|
import ecs "../src"
|
||||||
import rl "vendor:raylib"
|
import rl "vendor:raylib"
|
||||||
|
|
||||||
app: ecs.App
|
app: ecs.App
|
||||||
@@ -13,7 +13,6 @@ BallComponent :: struct {
|
|||||||
radius: f32,
|
radius: f32,
|
||||||
speed: f32,
|
speed: f32,
|
||||||
}
|
}
|
||||||
Ball: ecs.Storage(BallComponent)
|
|
||||||
|
|
||||||
main :: proc() {
|
main :: proc() {
|
||||||
ecs.add_plugin(&app, ecs.DefaultPlugin)
|
ecs.add_plugin(&app, ecs.DefaultPlugin)
|
||||||
@@ -25,18 +24,22 @@ main :: proc() {
|
|||||||
ecs.run(&app)
|
ecs.run(&app)
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_window :: proc() {
|
// STARTUP system taking the world: the window lives in it, so adjusting the
|
||||||
window, _ := ecs.single(&ecs.Window)
|
// default WindowComponent is a world lookup now
|
||||||
|
setup_window :: proc(world: ^ecs.World) {
|
||||||
|
window, _ := ecs.single(world, ecs.WindowComponent)
|
||||||
window.background = rl.BLACK
|
window.background = rl.BLACK
|
||||||
}
|
}
|
||||||
|
|
||||||
spawn_balls :: proc() {
|
// STARTUP system taking the world: spawn_entity wants it, and the balls'
|
||||||
|
// components live inside it, no global storage to declare
|
||||||
|
spawn_balls :: proc(world: ^ecs.World) {
|
||||||
for i in 0 ..< ball_count {
|
for i in 0 ..< ball_count {
|
||||||
ball := ecs.spawn_entity(&app)
|
ball := ecs.spawn_entity(world)
|
||||||
ecs.add_component(ball, &Ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)})
|
ecs.add_component(world, ball, BallComponent{radius = 5, speed = rand.float32_range(4, 6)})
|
||||||
ecs.add_component(
|
ecs.add_component(
|
||||||
|
world,
|
||||||
ball,
|
ball,
|
||||||
&ecs.Transform,
|
|
||||||
ecs.TransformComponent {
|
ecs.TransformComponent {
|
||||||
translation = {400, 300},
|
translation = {400, 300},
|
||||||
rotation = rand.float32_range(0, 360),
|
rotation = rand.float32_range(0, 360),
|
||||||
@@ -45,13 +48,13 @@ spawn_balls :: proc() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_balls :: proc() {
|
update_balls :: proc(world: ^ecs.World) {
|
||||||
window, _ := ecs.single(&ecs.Window)
|
window, _ := ecs.single(world, ecs.WindowComponent)
|
||||||
|
|
||||||
balls := ecs.slice(&Ball)
|
balls := ecs.slice(world, BallComponent)
|
||||||
entities := ecs.slice_entity(&Ball)
|
entities := ecs.slice_entity(world, BallComponent)
|
||||||
for ball, i in balls {
|
for ball, i in balls {
|
||||||
transform := ecs.get_component(entities[i], &ecs.Transform)
|
transform := ecs.get_component(world, entities[i], ecs.TransformComponent)
|
||||||
pos := transform.translation
|
pos := transform.translation
|
||||||
|
|
||||||
if pos.x <= 0 || pos.x >= f32(window.width) || pos.y <= 0 || pos.y >= f32(window.height) {
|
if pos.x <= 0 || pos.x >= f32(window.width) || pos.y <= 0 || pos.y >= f32(window.height) {
|
||||||
@@ -63,11 +66,11 @@ update_balls :: proc() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_balls :: proc() {
|
draw_balls :: proc(world: ^ecs.World) {
|
||||||
balls := ecs.slice(&Ball)
|
balls := ecs.slice(world, BallComponent)
|
||||||
entities := ecs.slice_entity(&Ball)
|
entities := ecs.slice_entity(world, BallComponent)
|
||||||
for ball, i in balls {
|
for ball, i in balls {
|
||||||
transform := ecs.get_component(entities[i], &ecs.Transform)
|
transform := ecs.get_component(world, entities[i], ecs.TransformComponent)
|
||||||
rl.DrawCircle(
|
rl.DrawCircle(
|
||||||
i32(transform.translation.x),
|
i32(transform.translation.x),
|
||||||
i32(transform.translation.y),
|
i32(transform.translation.y),
|
||||||
@@ -81,6 +84,6 @@ show_framerate :: proc() {
|
|||||||
fps_buf: [19]byte
|
fps_buf: [19]byte
|
||||||
framerate := strconv.write_int(fps_buf[:], i64(1 / rl.GetFrameTime()), 10)
|
framerate := strconv.write_int(fps_buf[:], i64(1 / rl.GetFrameTime()), 10)
|
||||||
rl.DrawText(strings.clone_to_cstring(framerate), 0, 0, 24, rl.WHITE)
|
rl.DrawText(strings.clone_to_cstring(framerate), 0, 0, 24, rl.WHITE)
|
||||||
rl.DrawText(" fps", 0, 0, 24, rl.WHITE)
|
rl.DrawText(" fps", 0, 24, 24, rl.WHITE)
|
||||||
rl.DrawText("10000 balls", 0, 24, 24, rl.WHITE)
|
rl.DrawText("10000 balls", 0, 24, 24, rl.WHITE)
|
||||||
}
|
}
|
||||||
|
|||||||
+327
@@ -0,0 +1,327 @@
|
|||||||
|
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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-7
@@ -2,21 +2,68 @@ package shoal
|
|||||||
|
|
||||||
import rl "vendor:raylib"
|
import rl "vendor:raylib"
|
||||||
|
|
||||||
WindowComponent :: struct {
|
// 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 {
|
||||||
width: i32,
|
width: i32,
|
||||||
height: i32,
|
height: i32,
|
||||||
title: string,
|
title: string,
|
||||||
background: rl.Color,
|
background: rl.Color,
|
||||||
}
|
}
|
||||||
Window: Storage(WindowComponent)
|
|
||||||
|
|
||||||
SpriteComponent :: struct {
|
// the mouse position, refreshed every UPDATE by DefaultPlugin
|
||||||
texture: rl.Texture2D,
|
Cursor :: struct {
|
||||||
|
position: rl.Vector2,
|
||||||
}
|
}
|
||||||
Sprite: Storage(SpriteComponent)
|
|
||||||
|
|
||||||
TransformComponent :: struct {
|
// 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 {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2D translation + rotation, read back by draw_sprite when it places the
|
||||||
|
// entity's SpriteComponent
|
||||||
|
Transform :: struct {
|
||||||
translation: rl.Vector2,
|
translation: rl.Vector2,
|
||||||
rotation: f32,
|
rotation: f32,
|
||||||
}
|
}
|
||||||
Transform: Storage(TransformComponent)
|
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
+94
-35
@@ -1,56 +1,115 @@
|
|||||||
package shoal
|
package shoal
|
||||||
|
|
||||||
import "core:strings"
|
|
||||||
import rl "vendor:raylib"
|
import rl "vendor:raylib"
|
||||||
|
|
||||||
STARTUP :: 0
|
// the engine backend: systems grouped by schedule id (see the STARTUP..DESTROY
|
||||||
UPDATE :: 1
|
// constants), the plugins that run before everything else, and the world,
|
||||||
DRAW :: 2
|
// holding every entity and component at a lower scope than the app itself. only
|
||||||
|
// main() and plugins ever see an ^App; systems are handed ^World and so
|
||||||
|
// structurally can't reach the registration machinery (add_system, plugins,
|
||||||
|
// run)
|
||||||
|
App :: struct {
|
||||||
|
systems: map[int][dynamic]System,
|
||||||
|
plugins: [dynamic]proc(app: ^App),
|
||||||
|
world: World,
|
||||||
|
}
|
||||||
|
|
||||||
|
// a system in either shape add_system accepts: a plain proc(), or one that
|
||||||
|
// takes the world as proc(world: ^World). stored uniformly so run() can give
|
||||||
|
// every system the same dispatch. more parameter shapes can go here later
|
||||||
|
System :: union {
|
||||||
|
proc(),
|
||||||
|
proc(world: ^World),
|
||||||
|
}
|
||||||
|
|
||||||
|
// register a system on a schedule (one of the STARTUP..DESTROY constants).
|
||||||
|
// systems on the same schedule run in registration order, every time that
|
||||||
|
// schedule comes around. 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)
|
@(private)
|
||||||
sparse_entity_limit: int
|
add_plain_system :: proc(app: ^App, schedule: int, system: proc()) {
|
||||||
|
|
||||||
get_entity_limit :: proc() -> int {
|
|
||||||
return sparse_entity_limit
|
|
||||||
}
|
|
||||||
|
|
||||||
App :: struct {
|
|
||||||
systems: map[int][dynamic]proc(),
|
|
||||||
plugins: [dynamic]proc(app: ^App),
|
|
||||||
entities: [dynamic]Entity,
|
|
||||||
}
|
|
||||||
|
|
||||||
// add a new system to the app that will run based on the provided schedule
|
|
||||||
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]System{}
|
||||||
}
|
}
|
||||||
append(&app.systems[schedule], system)
|
append(&app.systems[schedule], System(system))
|
||||||
}
|
}
|
||||||
|
|
||||||
// add a new plugin to the app. a modular way to add new functionality from beyond the main procedure
|
@(private)
|
||||||
|
add_world_system :: proc(app: ^App, schedule: int, system: proc(world: ^World)) {
|
||||||
|
if _, ok := app.systems[schedule]; !ok {
|
||||||
|
app.systems[schedule] = [dynamic]System{}
|
||||||
|
}
|
||||||
|
append(&app.systems[schedule], System(system))
|
||||||
|
}
|
||||||
|
|
||||||
|
// register a plugin: a proc that gets ^App, the one privilege over regular
|
||||||
|
// systems, which only ever see ^World. a plugin can add_system/add_plugin
|
||||||
|
// the same way main() would, and reach the game data through get_world. run()
|
||||||
|
// executes plugins in registration order, once, before the STARTUP systems.
|
||||||
|
// the systems a plugin adds can be plain proc() or take the world, same as
|
||||||
|
// any other add_system call
|
||||||
add_plugin :: proc(app: ^App, plugin: proc(app: ^App)) {
|
add_plugin :: proc(app: ^App, plugin: proc(app: ^App)) {
|
||||||
append(&app.plugins, plugin)
|
append(&app.plugins, plugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// run the app
|
// everything registered on a schedule, in registration order, or an empty
|
||||||
run :: proc(app: ^App, entity_limit: int = 1000) {
|
// list if nothing was added. each entry is a System union: switch on it to
|
||||||
sparse_entity_limit = entity_limit
|
// 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) {
|
||||||
for p in app.plugins {
|
for p in app.plugins {
|
||||||
p(app)
|
p(app)
|
||||||
}
|
}
|
||||||
for i in app.systems[STARTUP] {i()}
|
for s in get_system(app, STARTUP) {run_system(app, s)}
|
||||||
|
|
||||||
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 !rl.WindowShouldClose() {
|
||||||
for i in app.systems[UPDATE] {i()}
|
for s in get_system(app, PRE_UPDATE) {run_system(app, s)}
|
||||||
rl.BeginDrawing()
|
for s in get_system(app, UPDATE) {run_system(app, s)}
|
||||||
rl.ClearBackground(window.background)
|
for s in get_system(app, POST_UPDATE) {run_system(app, s)}
|
||||||
for i in app.systems[DRAW] {i()}
|
for s in get_system(app, PRE_DRAW) {run_system(app, s)}
|
||||||
rl.EndDrawing()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-23
@@ -1,62 +1,184 @@
|
|||||||
package shoal
|
package shoal
|
||||||
|
|
||||||
import "core:fmt"
|
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
|
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) {
|
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
|
// the storage for component type T inside this world, created on first
|
||||||
spawn_entity :: proc(app: ^App) -> Entity {
|
// access, the same lazy pattern as the signal buses in signals.odin. every
|
||||||
entity_num := len(&app.entities)
|
// 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)
|
||||||
entity: Entity = Entity(entity_num)
|
entity: Entity = Entity(entity_num)
|
||||||
append(&app.entities, entity)
|
append(&world.entities, entity)
|
||||||
return entity
|
return entity
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a new component of type and add to Storage
|
// 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_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 entity's storage and note where it landed in the
|
||||||
add_fresh_component :: proc(entity: Entity, storage: ^Storage($T)) {
|
// sparse map
|
||||||
|
add_fresh_component :: proc(world: ^World, entity: Entity, $T: typeid) {
|
||||||
|
storage := get_storage(world, T)
|
||||||
append(&storage.dense, T{})
|
append(&storage.dense, T{})
|
||||||
append(&storage.entities, entity)
|
append(&storage.entities, entity)
|
||||||
i := len(storage.dense) - 1
|
i := len(storage.dense) - 1
|
||||||
defer storage.sparse[entity] = i
|
storage.sparse[entity] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(world: ^World, entity: Entity, component: $T) {
|
||||||
|
storage := get_storage(world, T)
|
||||||
append(&storage.dense, component)
|
append(&storage.dense, component)
|
||||||
append(&storage.entities, entity)
|
append(&storage.entities, entity)
|
||||||
i := len(storage.dense) - 1
|
i := len(storage.dense) - 1
|
||||||
defer storage.sparse[entity] = i
|
storage.sparse[entity] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
// get components saved in Storage
|
// every component of type T in the world: the live backing array, not a
|
||||||
// returns storage.data
|
// copy of the data, so index i lines up with slice_entity()[i]. a later
|
||||||
slice :: proc(storage: ^Storage($T)) -> [dynamic]T {
|
// add_component that grows past capacity reallocates it, so don't hold the
|
||||||
return storage.dense
|
// result across adds
|
||||||
|
slice :: proc(world: ^World, $T: typeid) -> [dynamic]T {
|
||||||
|
return get_storage(world, T).dense
|
||||||
}
|
}
|
||||||
|
|
||||||
slice_entity :: proc(storage: ^Storage($T)) -> [dynamic]Entity {
|
// the entity ids backing the dense array, index-aligned with slice().
|
||||||
return storage.entities
|
// same invalidation caveat: adds can reallocate it
|
||||||
|
slice_entity :: proc(world: ^World, $T: typeid) -> [dynamic]Entity {
|
||||||
|
return get_storage(world, T).entities
|
||||||
}
|
}
|
||||||
|
|
||||||
// get component saved in Storage
|
// ^T of the world's first entry of that component plus the entity that owns
|
||||||
// returns Entity and component of type
|
// it, handy for singletons like WindowComponent. panics with a readable
|
||||||
single :: proc(storage: ^Storage($T)) -> (^T, Entity) {
|
// 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)))
|
||||||
|
}
|
||||||
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.
|
||||||
get_component :: proc(entity: Entity, storage: ^Storage($T)) -> ^T {
|
// panics with a readable message if this specific entity was never added to
|
||||||
return &storage.dense[storage.sparse[entity]]
|
// 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]
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-13
@@ -1,25 +1,85 @@
|
|||||||
package shoal
|
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)
|
@(private)
|
||||||
signal_buffer_b: [dynamic]string
|
Bus_Entry :: struct {
|
||||||
|
bus: rawptr,
|
||||||
|
clear: proc(b: rawptr),
|
||||||
|
}
|
||||||
|
|
||||||
@(private)
|
@(private)
|
||||||
signal_buffer_a: [dynamic]string
|
buses: map[typeid]Bus_Entry
|
||||||
|
|
||||||
write :: proc(name: string) {
|
@(private)
|
||||||
append(&signal_buffer_b, name)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
read :: proc(name: string) -> bool {
|
// called once per payload type, hands signal_system a rawptr clear for that bus
|
||||||
for s in signal_buffer_a {
|
@(private)
|
||||||
if s == name {return true}
|
clear_bus :: proc($T: typeid) -> proc(b: rawptr) {
|
||||||
|
return proc(b: rawptr) {
|
||||||
|
bus := (^Bus(T))(b)
|
||||||
|
clear(&bus.pending)
|
||||||
}
|
}
|
||||||
for s in signal_buffer_b {
|
|
||||||
if s == name {return true}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
signal_system :: proc() {
|
||||||
signal_buffer_a = signal_buffer_b
|
for _, e in buses {
|
||||||
clear(&signal_buffer_b)
|
e.clear(e.bus)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-12
@@ -1,31 +1,203 @@
|
|||||||
package shoal
|
package shoal
|
||||||
|
|
||||||
|
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 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) {
|
DefaultPlugin :: proc(app: ^App) {
|
||||||
|
add_system(app, STARTUP, startup_window)
|
||||||
add_system(app, DRAW, draw_sprite)
|
add_system(app, DRAW, draw_sprite)
|
||||||
add_system(app, UPDATE, signal_system)
|
add_system(app, POST_UPDATE, signal_system)
|
||||||
add_system(app, UPDATE, run_timers)
|
add_system(app, UPDATE, run_timers)
|
||||||
|
add_system(app, UPDATE, update_cursor_position)
|
||||||
|
add_system(app, 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()})
|
||||||
|
|
||||||
display := spawn_entity(app)
|
// a plugin is one of the few callers that holds an ^App, so it can reach
|
||||||
|
// the world through it and set up entities before the frame loop starts
|
||||||
|
world := get_world(app)
|
||||||
|
display := spawn_entity(world)
|
||||||
add_component(
|
add_component(
|
||||||
|
world,
|
||||||
display,
|
display,
|
||||||
&Window,
|
Window{width = 800, height = 600, title = "shoal window", background = rl.BLACK},
|
||||||
WindowComponent{width = 800, height = 600, title = "shoal window", background = rl.BLACK},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cursor := spawn_entity(world)
|
||||||
|
add_component(world, cursor, Cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@(private)
|
@(private)
|
||||||
draw_sprite :: proc() {
|
update_cursor_position :: proc(world: ^World) {
|
||||||
components := slice(&Sprite)
|
cursor, _ := single(world, Cursor)
|
||||||
entities := slice_entity(&Sprite)
|
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 {
|
for &v, i in components {
|
||||||
sprite_transform := get_component(entities[i], &Transform)
|
transform := get_component(world, entities[i], Transform)
|
||||||
rl.DrawTexture(
|
|
||||||
|
// 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(
|
||||||
v.texture,
|
v.texture,
|
||||||
i32(sprite_transform.translation.x),
|
source,
|
||||||
i32(sprite_transform.translation.y),
|
dest,
|
||||||
rl.WHITE,
|
origin,
|
||||||
|
transform.rotation,
|
||||||
|
tint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
+43
-6
@@ -3,21 +3,47 @@ package shoal
|
|||||||
import "core:fmt"
|
import "core:fmt"
|
||||||
import rl "vendor:raylib"
|
import rl "vendor:raylib"
|
||||||
|
|
||||||
|
// a named countdown: how many seconds are left (lifetime) and the name it
|
||||||
|
// fires under (also the key it's stored under)
|
||||||
Timer :: struct {
|
Timer :: struct {
|
||||||
lifetime: f32,
|
lifetime: f32,
|
||||||
signal: string,
|
signal: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queued when a timer runs out, for systems to pick up with
|
||||||
|
// read(TimerFired). run_timers below has the current caveat
|
||||||
|
TimerFired :: struct {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
// timers stay in a package global for now, deliberately outside the
|
||||||
|
// World: they're keyed by name, not owned by an entity, so there's nothing
|
||||||
|
// per-world to hang them on yet. same goes for the signal buses in their
|
||||||
|
// own file
|
||||||
@(private)
|
@(private)
|
||||||
timers: map[string]Timer
|
timers: map[string]Timer
|
||||||
|
|
||||||
start_timer :: proc(time: f32, signal: string) -> int {
|
// start (or restart) a timer under this name. a name that's already running
|
||||||
new_timer: Timer
|
// gets overwritten. it starts ticking on the next UPDATE, where run_timers
|
||||||
new_timer.lifetime = time
|
// counts it down
|
||||||
|
start_timer :: proc(time: f32, signal: string) {
|
||||||
|
new_timer := Timer {
|
||||||
|
lifetime = time,
|
||||||
|
signal = signal,
|
||||||
|
}
|
||||||
timers[signal] = new_timer
|
timers[signal] = new_timer
|
||||||
return len(timers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
get_timer :: proc(signal: string) -> Timer {
|
||||||
if timer, exists := timers[signal]; exists {
|
if timer, exists := timers[signal]; exists {
|
||||||
return timers[signal]
|
return timers[signal]
|
||||||
@@ -25,13 +51,24 @@ get_timer :: proc(signal: string) -> Timer {
|
|||||||
return Timer{}
|
return Timer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registered at UPDATE by DefaultPlugin: ticks every timer down by the frame
|
||||||
|
// time, and when the remaining lifetime drops to roughly one frame's delta it
|
||||||
|
// resets its entry to Timer{} so get_timer reads it back zeroed. it compares
|
||||||
|
// against a fresh GetFrameTime() call, so it can reset a frame or two
|
||||||
|
// early. it's meant to then queue TimerFired{name = <timer name>} for
|
||||||
|
// read(TimerFired). like any signal, it's readable only until signal_system
|
||||||
|
// clears the buses at POST_UPDATE, see signals.odin for how that lands per
|
||||||
|
// schedule.
|
||||||
|
// known bug: the reset writes the same map slot t points at, so t.signal is
|
||||||
|
// already "" when it's checked just after and nothing gets queued, so
|
||||||
|
// TimerFired doesn't actually fire yet (the timers themselves work fine)
|
||||||
run_timers :: proc() {
|
run_timers :: proc() {
|
||||||
for i, &t in timers {
|
for i, &t in timers {
|
||||||
t.lifetime -= rl.GetFrameTime()
|
t.lifetime -= rl.GetFrameTime()
|
||||||
if t.lifetime <= 0 {
|
if t.lifetime <= rl.GetFrameTime() {
|
||||||
timers[i] = Timer{}
|
timers[i] = Timer{}
|
||||||
if t.signal != "" {
|
if t.signal != "" {
|
||||||
write(t.signal)
|
write(TimerFired{name = t.signal})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+334
@@ -0,0 +1,334 @@
|
|||||||
|
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, ¤t_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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user