sprite and audio abstraction
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
+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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-2
@@ -19,9 +19,18 @@ Cursor :: struct {
|
|||||||
|
|
||||||
// a texture DefaultPlugin draws at DRAW, placed by the same entity's
|
// a texture DefaultPlugin draws at DRAW, placed by the same entity's
|
||||||
// TransformComponent (so every sprite entity needs a Transform component
|
// TransformComponent (so every sprite entity needs a Transform component
|
||||||
// too)
|
// 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 {
|
Sprite :: struct {
|
||||||
texture: rl.Texture2D,
|
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
|
// 2D translation + rotation, read back by draw_sprite when it places the
|
||||||
@@ -31,6 +40,29 @@ Transform :: struct {
|
|||||||
rotation: f32,
|
rotation: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// audio state: what an entity's Audio component is doing this frame.
|
||||||
|
AudioState :: enum u8 {
|
||||||
|
None,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
// an entity-level audio component: add this to any entity that should
|
||||||
|
// play sounds or music. the AudioSystem processes it every UPDATE.
|
||||||
|
// one-shot sounds go in `sound`, background music in `music`.
|
||||||
|
// both can coexist on the same entity (e.g. a radio playing ambient
|
||||||
|
// music with click sounds).
|
||||||
|
Audio :: struct {
|
||||||
|
sound: rl.Sound, // one-shot effect (frameCount == 0 means none)
|
||||||
|
music: rl.Music, // background music (ctxType == 0 means none)
|
||||||
|
state: AudioState, // what the system should do this frame
|
||||||
|
volume: f32, // 0.0 .. 1.0 (default 1.0)
|
||||||
|
pitch: f32, // > 1.0 = higher, < 1.0 = lower (default 1.0)
|
||||||
|
pan: f32, // -1.0 = left, 0.0 = center, 1.0 = right (default 0.0)
|
||||||
|
looping: bool, // loop music when it reaches the end
|
||||||
|
}
|
||||||
|
|
||||||
// components are plain struct declarations. the world holds every storage
|
// components are plain struct declarations. the world holds every storage
|
||||||
// internally, keyed by the component's typeid and created on first add (see
|
// 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
|
// get_storage in entities.odin), so there's nothing to declare next to a
|
||||||
|
|||||||
@@ -26,9 +26,3 @@ DRAW :: 5
|
|||||||
LATE_DRAW :: 6
|
LATE_DRAW :: 6
|
||||||
POST_DRAW :: 7
|
POST_DRAW :: 7
|
||||||
DESTROY :: 8
|
DESTROY :: 8
|
||||||
|
|
||||||
// easing ids for the (wip) Tween.ease field. see tween.odin, nothing reads
|
|
||||||
// them yet
|
|
||||||
EASE_IN :: 0
|
|
||||||
EASE_OUT :: 1
|
|
||||||
EASE_IN_OUT :: 2
|
|
||||||
|
|||||||
+136
-5
@@ -49,21 +49,152 @@ update_cursor_position :: proc(world: ^World) {
|
|||||||
cursor.position = rl.GetMousePosition()
|
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)
|
@(private)
|
||||||
draw_sprite :: proc(world: ^World) {
|
draw_sprite :: proc(world: ^World) {
|
||||||
|
zero_rect := rl.Rectangle{}
|
||||||
|
zero_color := rl.Color{}
|
||||||
components := slice(world, Sprite)
|
components := slice(world, Sprite)
|
||||||
entities := slice_entity(world, Sprite)
|
entities := slice_entity(world, Sprite)
|
||||||
for &v, i in components {
|
for &v, i in components {
|
||||||
sprite_transform := get_component(world, 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)
|
@(private)
|
||||||
startup_window :: proc(world: ^World) {
|
startup_window :: proc(world: ^World) {
|
||||||
window, _ := single(world, Window)
|
window, _ := single(world, Window)
|
||||||
|
|||||||
Reference in New Issue
Block a user