diff --git a/examples/audio_sprite_demo.odin b/examples/audio_sprite_demo.odin new file mode 100644 index 0000000..5b45ec0 --- /dev/null +++ b/examples/audio_sprite_demo.odin @@ -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" +} diff --git a/src/audio.odin b/src/audio.odin new file mode 100644 index 0000000..0bc3ce9 --- /dev/null +++ b/src/audio.odin @@ -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{} + } + } +} diff --git a/src/componenets.odin b/src/componenets.odin index 536783e..2963c78 100644 --- a/src/componenets.odin +++ b/src/componenets.odin @@ -19,9 +19,18 @@ Cursor :: struct { // a texture DefaultPlugin draws at DRAW, placed by the same entity's // 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 { - 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 @@ -31,6 +40,29 @@ Transform :: struct { rotation: f32, } +// audio state: what an entity's Audio component is doing this frame. +AudioState :: enum u8 { + None, + Playing, + Paused, + Stopped, +} + +// an entity-level audio component: add this to any entity that should +// play sounds or music. the AudioSystem processes it every UPDATE. +// one-shot sounds go in `sound`, background music in `music`. +// both can coexist on the same entity (e.g. a radio playing ambient +// music with click sounds). +Audio :: struct { + sound: rl.Sound, // one-shot effect (frameCount == 0 means none) + music: rl.Music, // background music (ctxType == 0 means none) + state: AudioState, // what the system should do this frame + volume: f32, // 0.0 .. 1.0 (default 1.0) + pitch: f32, // > 1.0 = higher, < 1.0 = lower (default 1.0) + pan: f32, // -1.0 = left, 0.0 = center, 1.0 = right (default 0.0) + looping: bool, // loop music when it reaches the end +} + // components are plain struct declarations. the world holds every storage // internally, keyed by the component's typeid and created on first add (see // get_storage in entities.odin), so there's nothing to declare next to a diff --git a/src/constants.odin b/src/constants.odin index 2195745..df6a6a4 100644 --- a/src/constants.odin +++ b/src/constants.odin @@ -26,9 +26,3 @@ DRAW :: 5 LATE_DRAW :: 6 POST_DRAW :: 7 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 diff --git a/src/systems.odin b/src/systems.odin index ab627c5..27ab737 100644 --- a/src/systems.odin +++ b/src/systems.odin @@ -49,21 +49,152 @@ update_cursor_position :: proc(world: ^World) { 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 { - sprite_transform := get_component(world, entities[i], Transform) - rl.DrawTexture( + transform := get_component(world, entities[i], Transform) + + // source rect: zero means the whole texture + source := v.source + if source == zero_rect { + source = rl.Rectangle{ + x = 0, + y = 0, + width = f32(v.texture.width), + height = f32(v.texture.height), + } + } + + // destination size: source scaled + dest_w := source.width * v.scale.x + dest_h := source.height * v.scale.y + + // flip by negating destination size (DrawTexturePro handles this) + if v.flip_h { + dest_w = -dest_w + } + if v.flip_v { + dest_h = -dest_h + } + + // origin at center of source (rotation pivot) — Vector2 is positional + origin := rl.Vector2{ + source.width * 0.5, + source.height * 0.5, + } + + // destination rectangle (top-left from Transform, size from scale+flip) + dest := rl.Rectangle{ + x = transform.translation.x, + y = transform.translation.y, + width = dest_w, + height = dest_h, + } + + // tint: default WHITE means no color change + tint := v.tint + if tint == zero_color { + tint = rl.WHITE + } + + rl.DrawTexturePro( v.texture, - i32(sprite_transform.translation.x), - i32(sprite_transform.translation.y), - rl.WHITE, + source, + dest, + 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)