diff --git a/Makefile b/Makefile index 93ac37c..b149c0a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -.PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all toad check test help +.PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all bake toad check test help help: @echo "Targets:" + @echo " bake Bake all characters under content/characters/" @echo " shaders-vulkan Compile SPIR-V into shaders/vulkan/" @echo " shaders-d3d12 Compile DXIL into shaders/d3d12/ (needs shadercross)" @echo " shaders-metal Compile MSL into shaders/metal/ (needs shadercross)" @@ -10,6 +11,9 @@ help: @echo " check Typecheck examples/toad" @echo " toad Run the toad example" +bake: + ./scripts/bake_all.sh + shaders-vulkan: ./scripts/shaders_vulkan.sh diff --git a/assets_baked/characters/toad/toad.atlas.png b/assets_baked/characters/toad/toad.atlas.png index f007cf2..f26d742 100644 Binary files a/assets_baked/characters/toad/toad.atlas.png and b/assets_baked/characters/toad/toad.atlas.png differ diff --git a/assets_baked/characters/toad/toad.char.json b/assets_baked/characters/toad/toad.char.json index 754f1f1..44ff0fb 100644 --- a/assets_baked/characters/toad/toad.char.json +++ b/assets_baked/characters/toad/toad.char.json @@ -10,7 +10,7 @@ "clips": { "walk": { "loop": true, - "fps": 10.00000000, + "fps": 15.00000000, "frames": [ { "rect": [ @@ -283,80 +283,16 @@ 0, 0 ] - }, - { - "rect": [ - 807, - 654, - 268, - 326 - ], - "source_size": [ - 268, - 326 - ], - "trim_offset": [ - 0, - 0 - ] } ] }, "idle": { "loop": true, - "fps": 10.00000000, + "fps": 15.00000000, "frames": [ { "rect": [ - 1883, - 0, - 213, - 319 - ], - "source_size": [ - 213, - 319 - ], - "trim_offset": [ - 0, - 0 - ] - }, - { - "rect": [ - 1883, - 320, - 213, - 319 - ], - "source_size": [ - 213, - 319 - ], - "trim_offset": [ - 0, - 0 - ] - }, - { - "rect": [ - 1883, - 640, - 213, - 319 - ], - "source_size": [ - 213, - 319 - ], - "trim_offset": [ - 0, - 0 - ] - }, - { - "rect": [ - 1076, + 807, 654, 213, 319 @@ -372,7 +308,7 @@ }, { "rect": [ - 1290, + 1021, 654, 213, 319 @@ -388,7 +324,7 @@ }, { "rect": [ - 1504, + 1235, 654, 213, 319 @@ -404,8 +340,8 @@ }, { "rect": [ - 1718, - 960, + 1449, + 654, 213, 319 ], @@ -420,7 +356,23 @@ }, { "rect": [ - 1076, + 1663, + 654, + 213, + 319 + ], + "source_size": [ + 213, + 319 + ], + "trim_offset": [ + 0, + 0 + ] + }, + { + "rect": [ + 807, 974, 213, 319 @@ -436,7 +388,7 @@ }, { "rect": [ - 1290, + 1021, 974, 213, 319 @@ -452,7 +404,39 @@ }, { "rect": [ - 1504, + 1235, + 974, + 213, + 319 + ], + "source_size": [ + 213, + 319 + ], + "trim_offset": [ + 0, + 0 + ] + }, + { + "rect": [ + 1449, + 974, + 213, + 319 + ], + "source_size": [ + 213, + 319 + ], + "trim_offset": [ + 0, + 0 + ] + }, + { + "rect": [ + 1663, 974, 213, 319 @@ -517,7 +501,7 @@ { "rect": [ 642, - 981, + 1294, 213, 319 ], @@ -533,23 +517,7 @@ { "rect": [ 856, - 981, - 213, - 319 - ], - "source_size": [ - 213, - 319 - ], - "trim_offset": [ - 0, - 0 - ] - }, - { - "rect": [ - 1718, - 1280, + 1294, 213, 319 ], @@ -593,6 +561,22 @@ 0, 0 ] + }, + { + "rect": [ + 1498, + 1294, + 213, + 319 + ], + "source_size": [ + 213, + 319 + ], + "trim_offset": [ + 0, + 0 + ] } ] } diff --git a/content/characters/toad/Walk/skeleton-Walk_17.png b/content/characters/toad/Walk/skeleton-Walk_17.png deleted file mode 100644 index ce4eeb8..0000000 Binary files a/content/characters/toad/Walk/skeleton-Walk_17.png and /dev/null differ diff --git a/content/characters/toad/manifest.json b/content/characters/toad/manifest.json index 27f26cd..240f575 100644 --- a/content/characters/toad/manifest.json +++ b/content/characters/toad/manifest.json @@ -5,12 +5,12 @@ "clips": { "idle": { "folder": "Idle", - "fps": 10, + "fps": 15, "loop": true }, "walk": { "folder": "Walk", - "fps": 10, + "fps": 15, "loop": true } } diff --git a/engine/animation_test.odin b/engine/animation_test.odin new file mode 100644 index 0000000..5905890 --- /dev/null +++ b/engine/animation_test.odin @@ -0,0 +1,232 @@ +package engine + +import "core:testing" + +make_test_character :: proc(allocator := context.allocator) -> Character_Data { + data: Character_Data + data.def.clips = make(map[string]Clip_Def, allocator) + + idle_frames := make([]Frame_Def, 3, allocator) + idle_frames[0] = { + rect = {0, 0, 10, 10}, + } + idle_frames[1] = { + rect = {10, 0, 10, 10}, + } + idle_frames[2] = { + rect = {20, 0, 10, 10}, + } + data.def.clips["idle"] = Clip_Def { + loop = true, + fps = 10, + frames = idle_frames, + } + + once_frames := make([]Frame_Def, 3, allocator) + once_frames[0] = { + rect = {0, 10, 10, 10}, + } + once_frames[1] = { + rect = {10, 10, 10, 10}, + } + once_frames[2] = { + rect = {20, 10, 10, 10}, + } + data.def.clips["once"] = Clip_Def { + loop = false, + fps = 10, + frames = once_frames, + } + + hold_frames := make([]Frame_Def, 2, allocator) + hold_frames[0] = { + rect = {0, 20, 10, 10}, + } + hold_frames[1] = { + rect = {10, 20, 10, 10}, + } + data.def.clips["hold"] = Clip_Def { + loop = true, + fps = 0, + frames = hold_frames, + } + + return data +} + +destroy_test_character :: proc(data: ^Character_Data) { + if data == nil do return + keys := make([dynamic]string, context.temp_allocator) + for key, clip in data.def.clips { + delete(clip.frames) + append(&keys, key) + } + for key in keys { + delete_key(&data.def.clips, key) + } + delete(data.def.clips) + data^ = {} +} + +@(test) +character_clip_found_and_missing :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + clip, ok := character_clip(&data, "idle") + testing.expect(t, ok) + testing.expect_value(t, len(clip.frames), 3) + testing.expect(t, clip.loop) + testing.expect_value(t, clip.fps, f32(10)) + + _, ok = character_clip(&data, "missing") + testing.expect(t, !ok) + + data.def.clips["empty"] = Clip_Def { + loop = true, + fps = 10, + frames = nil, + } + _, ok = character_clip(&data, "empty") + testing.expect(t, !ok) +} + +@(test) +set_sprite_clip_switch_and_guards :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + sprite := spawn_sprite(&data, {0, 0}, "idle", 0) + testing.expect_value(t, sprite.clip, "idle") + + sprite.frame = 2 + sprite.time = 0.05 + set_sprite_clip(&sprite, "once") + testing.expect_value(t, sprite.clip, "once") + testing.expect_value(t, sprite.frame, 0) + testing.expect_value(t, sprite.time, f32(0)) + + sprite.frame = 1 + sprite.time = 0.09 + set_sprite_clip(&sprite, "once") + testing.expect_value(t, sprite.frame, 1) + testing.expect_value(t, sprite.time, f32(0.09)) + + set_sprite_clip(&sprite, "nope") + testing.expect_value(t, sprite.clip, "once") + testing.expect_value(t, sprite.frame, 1) + testing.expect_value(t, sprite.time, f32(0.09)) +} + +@(test) +spawn_sprite_valid_and_invalid :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {1, 2}, "idle", 0) + testing.expect_value(t, s.clip, "idle") + testing.expect_value(t, s.frame, 0) + testing.expect_value(t, s.time, f32(0)) + testing.expect_value(t, s.position, Vec2{1, 2}) + + s2 := spawn_sprite(&data, {}, "idle", 2) + testing.expect_value(t, s2.clip, "idle") + testing.expect_value(t, s2.frame, 2) + + s3 := spawn_sprite(&data, {}, "idle", 99) + testing.expect_value(t, s3.frame, 2) + + bad := spawn_sprite(&data, {}, "missing", 0) + testing.expect_value(t, bad.clip, "") + testing.expect_value(t, bad.frame, 0) + testing.expect_value(t, bad.time, f32(0)) +} + +@(test) +update_sprite_advances_one_frame :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {}, "idle", 0) + update_sprite(&s, 0.1) + testing.expect_value(t, s.frame, 1) + testing.expect_value(t, s.time, f32(0)) +} + +@(test) +update_sprite_advances_multiple_frames :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {}, "idle", 0) + update_sprite(&s, 0.25) + testing.expect_value(t, s.frame, 2) + testing.expect(t, s.time > 0.049 && s.time < 0.051) +} + +@(test) +update_sprite_loops :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {}, "idle", 2) + update_sprite(&s, 0.1) + testing.expect_value(t, s.frame, 0) +} + +@(test) +update_sprite_non_loop_holds_last :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {}, "once", 2) + s.time = 0.05 + update_sprite(&s, 0.1) + testing.expect_value(t, s.frame, 2) + testing.expect_value(t, s.time, f32(0)) +} + +@(test) +update_sprite_fps_zero_holds :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {}, "hold", 0) + update_sprite(&s, 1.0) + testing.expect_value(t, s.frame, 0) +} + +@(test) +update_sprite_dt_non_positive_noop :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + s := spawn_sprite(&data, {}, "idle", 0) + update_sprite(&s, 0) + update_sprite(&s, -0.1) + testing.expect_value(t, s.frame, 0) + testing.expect_value(t, s.time, f32(0)) +} + +@(test) +update_sprite_nil_and_missing_clip_safe :: proc(t: ^testing.T) { + update_sprite(nil, 0.1) + + data := make_test_character() + defer destroy_test_character(&data) + + s := Sprite { + data = &data, + clip = "missing", + frame = 0, + time = 0, + } + update_sprite(&s, 0.1) + testing.expect_value(t, s.frame, 0) + + s2 := Sprite { + data = nil, + clip = "idle", + } + update_sprite(&s2, 0.1) +} diff --git a/engine/character_data.odin b/engine/character_data.odin index 6fe670e..03504fe 100644 --- a/engine/character_data.odin +++ b/engine/character_data.odin @@ -170,7 +170,10 @@ load_character_data :: proc(app: ^App, character_json_path: string) -> (Characte sdl.EndGPUCopyPass(copy_pass) fence := sdl.SubmitGPUCommandBufferAndAcquireFence(cmd) if fence == nil { - fmt.eprintfln("SubmitGPUCommandBufferAndAcquireFence (texture upload) failed: %s", sdl.GetError()) + fmt.eprintfln( + "SubmitGPUCommandBufferAndAcquireFence (texture upload) failed: %s", + sdl.GetError(), + ) sdl.ReleaseGPUTransferBuffer(app.device, tbuf) sdl.ReleaseGPUTexture(app.device, out.texture) return {}, false @@ -200,6 +203,13 @@ destroy_character_data :: proc(app: ^App, data: ^Character_Data) { data^ = {} } +character_clip :: proc(data: ^Character_Data, clip_name: string) -> (clip: Clip_Def, ok: bool) { + if data == nil do return {}, false + character, found := data.def.clips[clip_name] + if !found || len(character.frames) == 0 do return {}, false + return character, true +} + character_frame_rect :: proc( data: ^Character_Data, clip_name: string, diff --git a/engine/sprite.odin b/engine/sprite.odin index 22c11f2..fb46604 100644 --- a/engine/sprite.odin +++ b/engine/sprite.odin @@ -10,6 +10,7 @@ Sprite :: struct { position: Vec2, clip: string, frame: int, + time: f32, } spawn_sprite :: proc( @@ -18,13 +19,61 @@ spawn_sprite :: proc( clip: string = "idle", frame: int = 0, ) -> Sprite { - return Sprite{data = data, position = position, clip = clip, frame = frame} + sprite := Sprite { + data = data, + position = position, + } + set_sprite_clip(&sprite, clip) + if frame != 0 { + c, ok := character_clip(sprite.data, sprite.clip) + if ok { + if frame < 0 { + sprite.frame = 0 + } else if frame >= len(c.frames) { + sprite.frame = len(c.frames) - 1 + } else { + sprite.frame = frame + } + } + } + return sprite } // stubbed for later update_sprite :: proc(sprite: ^Sprite, dt: f32) { - _ = sprite - _ = dt + if sprite == nil || sprite.data == nil do return + if dt <= 0 do return + + clip, ok := character_clip(sprite.data, sprite.clip) + if !ok do return + + frame_count := len(clip.frames) + if frame_count <= 0 do return + + if sprite.frame < 0 do sprite.frame = 0 + if sprite.frame >= frame_count do sprite.frame = frame_count - 1 + + if clip.fps <= 0 do return + + sprite.time += dt + frame_duration := 1.0 / clip.fps + + for sprite.time >= frame_duration { + sprite.time -= frame_duration + next := sprite.frame + 1 + + if next >= frame_count { + if clip.loop { + sprite.frame = 0 + } else { + sprite.frame = frame_count - 1 + sprite.time = 0 + break + } + } else { + sprite.frame = next + } + } } to_clip :: proc(px, py, sw, sh: f32) -> [2]f32 { @@ -129,3 +178,16 @@ draw_sprite :: proc(app: ^App, sprite: ^Sprite) { sdl.DrawGPUPrimitives(app.render_pass, 6, 1, 0, 0) } + +set_sprite_clip :: proc(sprite: ^Sprite, clip: string) { + if sprite == nil || sprite.data == nil do return + + if sprite.clip == clip do return + + _, ok := character_clip(sprite.data, clip) + if !ok do return + + sprite.clip = clip + sprite.frame = 0 + sprite.time = 0 +} diff --git a/engine/time.odin b/engine/time.odin new file mode 100644 index 0000000..01273ac --- /dev/null +++ b/engine/time.odin @@ -0,0 +1,8 @@ +package engine + +import sdl "vendor:sdl3" + +// now_seconds returns seconds since SDL init (monotonic for frame timing). +now_seconds :: proc() -> f64 { + return f64(sdl.GetTicksNS()) / 1_000_000_000.0 +} diff --git a/examples/toad/main.odin b/examples/toad/main.odin index ad21f35..45fce0d 100644 --- a/examples/toad/main.odin +++ b/examples/toad/main.odin @@ -4,7 +4,7 @@ import eng "pkg:engine" main :: proc() { app: eng.App - if !eng.init(&app, "toad", 800, 600) do return + if !eng.init(&app, "toad game", 800, 600) do return defer eng.shutdown(&app) data, ok := eng.load_character_data(&app, "assets_baked/characters/toad/toad.char.json") @@ -14,10 +14,17 @@ main :: proc() { toad := eng.spawn_sprite(&data, {400, 500}, "idle", 0) + last := eng.now_seconds() + for eng.events() { + now := eng.now_seconds() + dt := f32(now - last) + last = now + + eng.update_sprite(&toad, dt) + eng.begin_frame(&app) eng.draw_sprite(&app, &toad) eng.end_frame(&app) } } - diff --git a/scripts/bake_all.sh b/scripts/bake_all.sh new file mode 100755 index 0000000..9b845f8 --- /dev/null +++ b/scripts/bake_all.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +CONTENT_ROOT="${CONTENT_ROOT:-content/characters}" +OUT_ROOT="${OUT_ROOT:-assets_baked/characters}" + +if [[ ! -d "$CONTENT_ROOT" ]]; then + echo "missing content root: $CONTENT_ROOT" >&2 + exit 1 +fi + +shopt -s nullglob +manifests=("$CONTENT_ROOT"/*/manifest.json) +if [[ ${#manifests[@]} -eq 0 ]]; then + echo "no character manifests under $CONTENT_ROOT" >&2 + exit 1 +fi + +for manifest in "${manifests[@]}"; do + char_dir="$(dirname "$manifest")" + name="$(basename "$char_dir")" + out_dir="$OUT_ROOT/$name" + mkdir -p "$out_dir" + echo "baking $name -> $out_dir" + odin run assetbake -- "$char_dir" "$out_dir" +done + +echo "baked ${#manifests[@]} character(s)"