Compare commits
52
Commits
No files matched your search
@@ -32,7 +32,7 @@ jobs:
|
|||||||
git \
|
git \
|
||||||
libssl-dev
|
libssl-dev
|
||||||
- name: Build Linux
|
- name: Build Linux
|
||||||
run: ./scripts/build-linux.sh
|
run: ./scripts/linux/build-linux.sh
|
||||||
- name: Upload Linux binary
|
- name: Upload Linux binary
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: Setup pspdev
|
- name: Setup pspdev
|
||||||
uses: ./.github/actions/setup-pspdev
|
uses: ./.github/actions/setup-pspdev
|
||||||
- name: Build PSP
|
- name: Build PSP
|
||||||
run: ./scripts/build-psp.sh
|
run: ./scripts/psp/build-psp.sh
|
||||||
- name: Move EBOOT.PBP to Dusk subfolder
|
- name: Move EBOOT.PBP to Dusk subfolder
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./git-artifcats/Dusk/PSP/GAME/Dusk
|
mkdir -p ./git-artifcats/Dusk/PSP/GAME/Dusk
|
||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
libgles2:arm64 \
|
libgles2:arm64 \
|
||||||
libgl1-mesa-dev:arm64
|
libgl1-mesa-dev:arm64
|
||||||
- name: Build Knulli
|
- name: Build Knulli
|
||||||
run: ./scripts/build-knulli.sh
|
run: ./scripts/knulli/build-knulli.sh
|
||||||
- name: Move output to Dusk subfolder
|
- name: Move output to Dusk subfolder
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./git-artifcats/Dusk
|
mkdir -p ./git-artifcats/Dusk
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
||||||
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
||||||
- name: Build GameCube
|
- name: Build GameCube
|
||||||
run: ./scripts/build-gamecube.sh
|
run: ./scripts/gamecube/build-gamecube.sh
|
||||||
- name: Copy output files
|
- name: Copy output files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./git-artifcats/Dusk
|
mkdir -p ./git-artifcats/Dusk
|
||||||
@@ -166,7 +166,7 @@ jobs:
|
|||||||
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
||||||
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
||||||
- name: Build GameCube ISO
|
- name: Build GameCube ISO
|
||||||
run: ./scripts/build-gamecube-iso.sh
|
run: ./scripts/gamecube/build-gamecube-iso.sh
|
||||||
- name: Copy output files
|
- name: Copy output files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./git-artifcats/Dusk
|
mkdir -p ./git-artifcats/Dusk
|
||||||
@@ -199,7 +199,7 @@ jobs:
|
|||||||
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
||||||
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
||||||
- name: Build Wii
|
- name: Build Wii
|
||||||
run: ./scripts/build-wii.sh
|
run: ./scripts/wii/build-wii.sh
|
||||||
- name: Copy output files
|
- name: Copy output files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./git-artifcats/Dusk/apps/Dusk
|
mkdir -p ./git-artifcats/Dusk/apps/Dusk
|
||||||
@@ -232,7 +232,7 @@ jobs:
|
|||||||
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
gamecube-sdl2 ppc-liblzma ppc-libzip \
|
||||||
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
|
||||||
- name: Build Wii ISO
|
- name: Build Wii ISO
|
||||||
run: ./scripts/build-wii-iso.sh
|
run: ./scripts/wii/build-wii-iso.sh
|
||||||
- name: Copy output files
|
- name: Copy output files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ./git-artifcats/Dusk
|
mkdir -p ./git-artifcats/Dusk
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ jobs:
|
|||||||
git \
|
git \
|
||||||
libssl-dev
|
libssl-dev
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: ./scripts/test-linux.sh
|
run: ./scripts/linux/test-linux.sh
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"moves": {
|
||||||
|
"fire": {
|
||||||
|
// Target flags
|
||||||
|
"enemies": true,
|
||||||
|
"allies": true,
|
||||||
|
"multiple": false,
|
||||||
|
"self": true,
|
||||||
|
"default": "enemy",
|
||||||
|
"target": "SINGLE_ENEMY"
|
||||||
|
},
|
||||||
|
|
||||||
|
"slash": {
|
||||||
|
"enemies": true,
|
||||||
|
"allies": true,
|
||||||
|
"self": false,
|
||||||
|
"default": "enemy",
|
||||||
|
"target": "SINGLE_ENEMY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "MODAL",
|
||||||
|
"title": "Fire",
|
||||||
|
"message": "A burst of flame scorches the target."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "WAIT",
|
||||||
|
"seconds": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BATTLE_FIGHTER_DAMAGE",
|
||||||
|
"fighter": "target",
|
||||||
|
"amount": 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "MODAL",
|
||||||
|
"title": "Slash",
|
||||||
|
"message": "A quick blade strike cuts across the target."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "WAIT",
|
||||||
|
"seconds": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "MODAL_CLOSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BATTLE_FIGHTER_DAMAGE",
|
||||||
|
"fighter": "target",
|
||||||
|
"amount": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "BATTLE_START",
|
||||||
|
"enemies": [
|
||||||
|
{
|
||||||
|
"attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2,
|
||||||
|
"healthMax": 30, "mpMax": 0,
|
||||||
|
"abilities": ["fire"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2,
|
||||||
|
"healthMax": 20, "mpMax": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "CUTSCENE",
|
"type": "CUTSCENE",
|
||||||
"name": "main_menu"
|
"name": "cutscenes/main_menu.jsonc"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
// Placeholder for the new-game intro flow - just shows a modal for now.
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "MODAL",
|
||||||
|
"title": "New Game",
|
||||||
|
"message": "This is a placeholder for the new game intro.",
|
||||||
|
"blocking": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"potion": { "type": "MEDICINE" },
|
||||||
|
"potato": { "type": "FOOD" },
|
||||||
|
"apple": { "type": "FOOD" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,12 @@ msgstr "Select Save"
|
|||||||
msgid "ui.select_save.empty"
|
msgid "ui.select_save.empty"
|
||||||
msgstr "Empty Slot"
|
msgstr "Empty Slot"
|
||||||
|
|
||||||
|
msgid "ui.select_save.corrupt"
|
||||||
|
msgstr "Corrupted Save"
|
||||||
|
|
||||||
|
msgid "ui.select_save.corrupt_warning"
|
||||||
|
msgstr "This save file is corrupted and could not be loaded. Please contact support if this issue persists."
|
||||||
|
|
||||||
msgid "ui.select_save.slot_format"
|
msgid "ui.select_save.slot_format"
|
||||||
msgstr "%s Lv.%d %s"
|
msgstr "%s Lv.%d %s"
|
||||||
|
|
||||||
@@ -260,6 +266,10 @@ msgstr "loading"
|
|||||||
msgid "ui.autosave.saving"
|
msgid "ui.autosave.saving"
|
||||||
msgstr "SAVING"
|
msgstr "SAVING"
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Items
|
||||||
|
#
|
||||||
msgid "item.potion.name"
|
msgid "item.potion.name"
|
||||||
msgstr "Potion"
|
msgstr "Potion"
|
||||||
|
|
||||||
@@ -268,3 +278,12 @@ msgstr "Potato"
|
|||||||
|
|
||||||
msgid "item.apple.name"
|
msgid "item.apple.name"
|
||||||
msgstr "Apple"
|
msgstr "Apple"
|
||||||
|
|
||||||
|
#
|
||||||
|
# Moves
|
||||||
|
#
|
||||||
|
msgid "move.fire.name"
|
||||||
|
msgstr "Fire"
|
||||||
|
|
||||||
|
msgid "move.slash.name"
|
||||||
|
msgstr "Slash"
|
||||||
@@ -1 +1 @@
|
|||||||
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"item","itemId":1,"quantity":1,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
|
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"global","globalId":4,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"members": {
|
||||||
|
"hero": {
|
||||||
|
"attack": 10,
|
||||||
|
"defense": 5,
|
||||||
|
"magic": 0,
|
||||||
|
"speed": 10,
|
||||||
|
"luck": 0,
|
||||||
|
"healthMax": 30,
|
||||||
|
"mpMax": 10
|
||||||
|
},
|
||||||
|
"companion": {
|
||||||
|
"attack": 8,
|
||||||
|
"defense": 6,
|
||||||
|
"magic": 4,
|
||||||
|
"speed": 8,
|
||||||
|
"luck": 1,
|
||||||
|
"healthMax": 25,
|
||||||
|
"mpMax": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"flags": {
|
||||||
|
"test": {
|
||||||
|
"initial": true
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
"initial": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
|||||||
)
|
)
|
||||||
|
|
||||||
set(DUSK_BACKTRACE ON CACHE BOOL "Enable backtrace support for assert failures.")
|
set(DUSK_BACKTRACE ON CACHE BOOL "Enable backtrace support for assert failures.")
|
||||||
|
set(DUSK_TRACK_MEMORY ON CACHE BOOL "Track memoryAllocate() call sites to help find memory leaks.")
|
||||||
|
|
||||||
# Define platform-specific macros.
|
# Define platform-specific macros.
|
||||||
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// ChunkTerrain - JS port of the terrain-quad generation performed by
|
|
||||||
// tools/asset/chunk/__main__.py (build_terrain_verts / _tile_quad /
|
|
||||||
// _RAMP_CORNERS) so the editor's 3D preview can show the same geometry the
|
|
||||||
// Python compiler will bake into the chunk's terrain mesh, without waiting
|
|
||||||
// for a compile pass. Vertex layout matches DMF: interleaved [u, v, x, y, z].
|
|
||||||
|
|
||||||
const ChunkTerrain = (() => {
|
|
||||||
const CHUNK_WIDTH = 16;
|
|
||||||
const CHUNK_HEIGHT = 16;
|
|
||||||
const CHUNK_DEPTH = 4;
|
|
||||||
const TILE_COUNT = CHUNK_WIDTH * CHUNK_HEIGHT * CHUNK_DEPTH;
|
|
||||||
|
|
||||||
// World-space Z distance of one Z-layer/story, in world-float space.
|
|
||||||
// Must match WORLD_LAYER_HEIGHT in src/dusk/rpg/overworld/worldpos.h.
|
|
||||||
const WORLD_LAYER_HEIGHT = Math.SQRT1_2;
|
|
||||||
|
|
||||||
const TILE_SHAPE_NULL = 0;
|
|
||||||
const TILE_SHAPE_GROUND = 1;
|
|
||||||
const TILE_SHAPE_RAMP_NORTH = 2;
|
|
||||||
const TILE_SHAPE_RAMP_EAST = 3;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTH = 4;
|
|
||||||
const TILE_SHAPE_RAMP_WEST = 5;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHEAST = 6;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHWEST = 7;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHEAST = 8;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHWEST = 9;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHEAST_INNER = 10;
|
|
||||||
const TILE_SHAPE_RAMP_NORTHWEST_INNER = 11;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHEAST_INNER = 12;
|
|
||||||
const TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13;
|
|
||||||
|
|
||||||
// Per-shape corner heights as [sw, se, ne, nw] offsets from tile base Z.
|
|
||||||
// NORTH=+Y, EAST=+X. Cardinal ramps: low face at 0, high face at 1.
|
|
||||||
// Diagonal outer-corner ramps: only the named corner raised, rest at 0.
|
|
||||||
// Diagonal inner-corner ramps: only the corner opposite the named one is
|
|
||||||
// lowered to 0, the rest (including the named corner) stay raised at 1.
|
|
||||||
const RAMP_CORNERS = {
|
|
||||||
[TILE_SHAPE_GROUND]: [0.0, 0.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTH]: [0.0, 0.0, 1.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTH]: [1.0, 1.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_EAST]: [0.0, 1.0, 1.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_WEST]: [1.0, 0.0, 0.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHEAST]: [0.0, 0.0, 1.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHWEST]: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHEAST]: [0.0, 1.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHWEST]: [1.0, 0.0, 0.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHEAST_INNER]: [0.0, 1.0, 1.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_NORTHWEST_INNER]: [1.0, 0.0, 1.0, 1.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHEAST_INNER]: [1.0, 1.0, 1.0, 0.0],
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHWEST_INNER]: [1.0, 1.0, 0.0, 1.0],
|
|
||||||
};
|
|
||||||
|
|
||||||
function tileIndex(x, y, z) {
|
|
||||||
return x + y * CHUNK_WIDTH + z * CHUNK_WIDTH * CHUNK_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Six vertices (2 CCW triangles) for a quad with per-corner Z heights.
|
|
||||||
// Corner order: SW=(fx,fy), SE=(fx+1,fy), NE=(fx+1,fy+1), NW=(fx,fy+1).
|
|
||||||
function pushTileQuad(out, u0, u1, v0, v1, fx, fy, swZ, seZ, neZ, nwZ) {
|
|
||||||
out.push(
|
|
||||||
u0, v0, fx, fy, swZ,
|
|
||||||
u1, v0, fx + 1, fy, seZ,
|
|
||||||
u1, v1, fx + 1, fy + 1, neZ,
|
|
||||||
u0, v0, fx, fy, swZ,
|
|
||||||
u1, v1, fx + 1, fy + 1, neZ,
|
|
||||||
u0, v1, fx, fy + 1, nwZ,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate terrain quads for every non-null tile in a flat tiles array
|
|
||||||
// (length TILE_COUNT, indexed via tileIndex). Returns a Float32Array of
|
|
||||||
// interleaved [u, v, x, y, z] vertices, matching DMF.VERTEX_FLOATS.
|
|
||||||
function buildTerrainVerts(tiles) {
|
|
||||||
const out = [];
|
|
||||||
for(let z = 0; z < CHUNK_DEPTH; z++) {
|
|
||||||
for(let y = 0; y < CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
|
|
||||||
const corners = RAMP_CORNERS[type];
|
|
||||||
if(!corners) continue;
|
|
||||||
|
|
||||||
const u0 = x / CHUNK_WIDTH;
|
|
||||||
const u1 = (x + 1) / CHUNK_WIDTH;
|
|
||||||
const v0 = y / CHUNK_HEIGHT;
|
|
||||||
const v1 = (y + 1) / CHUNK_HEIGHT;
|
|
||||||
const [sw, se, ne, nw] = corners;
|
|
||||||
|
|
||||||
pushTileQuad(
|
|
||||||
out, u0, u1, v0, v1, x, y,
|
|
||||||
(z + sw) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + se) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + ne) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + nw) * WORLD_LAYER_HEIGHT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Float32Array(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Four edges (SW-SE, SE-NE, NE-NW, NW-SW) of a tile's quad as line
|
|
||||||
// segments, lifted slightly above the terrain surface to avoid
|
|
||||||
// z-fighting - same corner-height convention as pushTileQuad().
|
|
||||||
function pushTileGridLines(out, fx, fy, swZ, seZ, neZ, nwZ) {
|
|
||||||
const eps = 0.01;
|
|
||||||
const sw = [fx, fy, swZ + eps];
|
|
||||||
const se = [fx + 1, fy, seZ + eps];
|
|
||||||
const ne = [fx + 1, fy + 1, neZ + eps];
|
|
||||||
const nw = [fx, fy + 1, nwZ + eps];
|
|
||||||
const edges = [sw, se, se, ne, ne, nw, nw, sw];
|
|
||||||
for(const [x, y, z] of edges) out.push(0, 0, x, y, z);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate grid-outline line segments for every non-null tile, following
|
|
||||||
// the same per-corner heights as buildTerrainVerts() so lines hug ramps
|
|
||||||
// instead of cutting through them. Returns a Float32Array of interleaved
|
|
||||||
// [u, v, x, y, z] vertices meant to be drawn with gl.LINES (pairs).
|
|
||||||
function buildGridLines(tiles) {
|
|
||||||
const out = [];
|
|
||||||
for(let z = 0; z < CHUNK_DEPTH; z++) {
|
|
||||||
for(let y = 0; y < CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[tileIndex(x, y, z)] || TILE_SHAPE_NULL;
|
|
||||||
const corners = RAMP_CORNERS[type];
|
|
||||||
if(!corners) continue;
|
|
||||||
|
|
||||||
const [sw, se, ne, nw] = corners;
|
|
||||||
pushTileGridLines(
|
|
||||||
out, x, y,
|
|
||||||
(z + sw) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + se) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + ne) * WORLD_LAYER_HEIGHT,
|
|
||||||
(z + nw) * WORLD_LAYER_HEIGHT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Float32Array(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
CHUNK_WIDTH, CHUNK_HEIGHT, CHUNK_DEPTH, TILE_COUNT, WORLD_LAYER_HEIGHT,
|
|
||||||
TILE_SHAPE_NULL, TILE_SHAPE_GROUND,
|
|
||||||
TILE_SHAPE_RAMP_NORTH, TILE_SHAPE_RAMP_EAST,
|
|
||||||
TILE_SHAPE_RAMP_SOUTH, TILE_SHAPE_RAMP_WEST,
|
|
||||||
TILE_SHAPE_RAMP_NORTHEAST, TILE_SHAPE_RAMP_NORTHWEST,
|
|
||||||
TILE_SHAPE_RAMP_SOUTHEAST, TILE_SHAPE_RAMP_SOUTHWEST,
|
|
||||||
TILE_SHAPE_RAMP_NORTHEAST_INNER, TILE_SHAPE_RAMP_NORTHWEST_INNER,
|
|
||||||
TILE_SHAPE_RAMP_SOUTHEAST_INNER, TILE_SHAPE_RAMP_SOUTHWEST_INNER,
|
|
||||||
RAMP_CORNERS, tileIndex, buildTerrainVerts, buildGridLines,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// DMF - Dusk Mesh Format
|
|
||||||
//
|
|
||||||
// Header (12 bytes):
|
|
||||||
// [0-3] "DMF\0" magic
|
|
||||||
// [4-7] uint32 version (little-endian)
|
|
||||||
// [8-11] uint32 vertCount (little-endian)
|
|
||||||
//
|
|
||||||
// Followed by vertCount vertices, each 20 bytes:
|
|
||||||
// [0-7] float32[2] uv (little-endian)
|
|
||||||
// [8-19] float32[3] pos (little-endian)
|
|
||||||
|
|
||||||
const DMF = (() => {
|
|
||||||
const MAGIC = [0x44, 0x4d, 0x46, 0x00]; // "DMF\0"
|
|
||||||
const VERSION = 1;
|
|
||||||
const HEADER_SIZE = 12;
|
|
||||||
const VERTEX_FLOATS = 5; // uv[2] + pos[3]
|
|
||||||
const VERTEX_SIZE = VERTEX_FLOATS * 4;
|
|
||||||
|
|
||||||
// Decode a DMF ArrayBuffer into { version, vertexCount, floats }, where
|
|
||||||
// floats is a Float32Array of tightly interleaved [u, v, x, y, z] per
|
|
||||||
// vertex - ready to hand straight to gl.bufferData().
|
|
||||||
function decode(buffer) {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
|
|
||||||
if(bytes.length < HEADER_SIZE) throw new Error("File too small to be a valid DMF");
|
|
||||||
if(
|
|
||||||
bytes[0] !== MAGIC[0] || bytes[1] !== MAGIC[1] ||
|
|
||||||
bytes[2] !== MAGIC[2] || bytes[3] !== MAGIC[3]
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid DMF magic bytes - not a DMF file");
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = view.getUint32(4, true);
|
|
||||||
if(version !== VERSION) {
|
|
||||||
throw new Error(`Unsupported DMF version: ${version}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const vertexCount = view.getUint32(8, true);
|
|
||||||
const expected = HEADER_SIZE + vertexCount * VERTEX_SIZE;
|
|
||||||
if(bytes.length < expected) {
|
|
||||||
throw new Error(`DMF vertex data truncated (expected ${expected} bytes, got ${bytes.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const floats = new Float32Array(vertexCount * VERTEX_FLOATS);
|
|
||||||
for(let i = 0; i < floats.length; i++) {
|
|
||||||
floats[i] = view.getFloat32(HEADER_SIZE + i * 4, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { version, vertexCount, floats };
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
decode,
|
|
||||||
VERSION, HEADER_SIZE, VERTEX_FLOATS, VERTEX_SIZE,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h1>Dusk Map Editor</h1>
|
|
||||||
<p class="text-muted">Browse and edit project assets.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<link rel="stylesheet" href="/map/map.css">
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex flex-wrap align-items-end gap-2 mb-2">
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">X</label>
|
|
||||||
<input type="number" id="coordX" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Y</label>
|
|
||||||
<input type="number" id="coordY" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Z</label>
|
|
||||||
<input type="number" id="coordZ" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<button id="btnLoad" type="button" class="btn btn-sm btn-secondary">Load</button>
|
|
||||||
|
|
||||||
<div class="dropdown">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
|
||||||
type="button" data-bs-toggle="dropdown"
|
|
||||||
>Browse</button>
|
|
||||||
<ul class="dropdown-menu" id="chunkBrowseList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dpad" title="Navigate to the neighboring chunk">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-n" id="navN" title="North (+Y)">N</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-w" id="navW" title="West (-X)">W</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-e" id="navE" title="East (+X)">E</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-s" id="navS" title="South (-Y)">S</button>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-column gap-1">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="navUp" title="Up (+Z)">Up</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="navDown" title="Down (-Z)">Down</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="btnSave" type="button" class="btn btn-sm btn-primary ms-auto">
|
|
||||||
Save & Compile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="statusLine" class="small text-muted mb-2"> </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-2">
|
|
||||||
<label class="form-label mb-0">Z-Level</label>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelDown">-</button>
|
|
||||||
<input type="range" id="zLevel" class="form-range" style="width:150px" min="0" max="3" value="0">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelUp">+</button>
|
|
||||||
<span id="zLevelLabel">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<div id="toolPalette" class="tile-palette"></div>
|
|
||||||
<div id="tilePalette" class="tile-palette"></div>
|
|
||||||
<canvas id="gridCanvas" class="border tile-grid" width="512" height="512"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5 class="mt-3">Meshes</h5>
|
|
||||||
<ul id="meshList" class="list-group mb-2"></ul>
|
|
||||||
<div class="d-flex gap-2 align-items-end flex-wrap">
|
|
||||||
<div style="min-width:220px">
|
|
||||||
<label class="form-label mb-0">Model</label>
|
|
||||||
<select id="meshPicker" class="form-select form-select-sm"></select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">X</label>
|
|
||||||
<input id="meshX" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Y</label>
|
|
||||||
<input id="meshY" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="form-label mb-0">Z</label>
|
|
||||||
<input id="meshZ" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
|
||||||
</div>
|
|
||||||
<button id="btnAddMesh" type="button" class="btn btn-sm btn-outline-primary">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-lg-6">
|
|
||||||
<p class="small text-muted mb-2">Preview (scroll to zoom)</p>
|
|
||||||
<canvas id="previewCanvas" class="border" width="512" height="512"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/common/dmf.js"></script>
|
|
||||||
<script src="/common/chunkterrain.js"></script>
|
|
||||||
<script src="/map/renderer.js"></script>
|
|
||||||
<script src="/map/map.js"></script>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
.tile-palette {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-swatch {
|
|
||||||
position: relative;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-swatch.active {
|
|
||||||
border-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-swatch-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dpad {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 2rem);
|
|
||||||
grid-template-rows: repeat(3, 2rem);
|
|
||||||
grid-template-areas:
|
|
||||||
". n ."
|
|
||||||
"w . e"
|
|
||||||
". s .";
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dpad-n { grid-area: n; }
|
|
||||||
.dpad-w { grid-area: w; }
|
|
||||||
.dpad-e { grid-area: e; }
|
|
||||||
.dpad-s { grid-area: s; }
|
|
||||||
.dpad button { padding: 0; }
|
|
||||||
|
|
||||||
.tile-grid {
|
|
||||||
image-rendering: pixelated;
|
|
||||||
cursor: crosshair;
|
|
||||||
touch-action: none;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#previewCanvas {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
@@ -1,920 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
(() => {
|
|
||||||
const SHAPES = [
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_NULL, name: "Erase", color: "#20232a" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_GROUND, name: "Ground", color: "#4caf50" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTH, name: "Ramp North", color: "#2196f3" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_EAST, name: "Ramp East", color: "#03a9f4" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTH, name: "Ramp South", color: "#00bcd4" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_WEST, name: "Ramp West", color: "#009688" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST, name: "Ramp NE", color: "#ff9800" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST, name: "Ramp NW", color: "#ff5722" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST, name: "Ramp SE", color: "#9c27b0" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST, name: "Ramp SW", color: "#e91e63" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER, name: "Ramp NE Inner", color: "#795548" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER, name: "Ramp NW Inner", color: "#607d8b" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER, name: "Ramp SE Inner", color: "#8bc34a" },
|
|
||||||
{ type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER, name: "Ramp SW Inner", color: "#ffc107" },
|
|
||||||
];
|
|
||||||
const SHAPE_COLORS = Object.fromEntries(SHAPES.map((s) => [s.type, s.color]));
|
|
||||||
|
|
||||||
// Arrow direction per ramp, in canvas/screen space: north is up (dy < 0),
|
|
||||||
// east is right (dx > 0) - matches renderGrid()'s north-is-up convention.
|
|
||||||
const SHAPE_DIRECTIONS = {
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTH]: { dx: 0, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTH]: { dx: 0, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_EAST]: { dx: 1, dy: 0 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_WEST]: { dx: -1, dy: 0 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST]: { dx: 1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST]: { dx: -1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST]: { dx: 1, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST]: { dx: -1, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER]: { dx: 1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER]: { dx: -1, dy: -1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER]: { dx: 1, dy: 1 },
|
|
||||||
[ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER]: { dx: -1, dy: 1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const INNER_CORNER_SHAPES = new Set([
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER,
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER,
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER,
|
|
||||||
ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Draws a single-headed arrow from (tailX, tailY) to (tipX, tipY).
|
|
||||||
function drawArrowSegment(ctx, tailX, tailY, tipX, tipY, size) {
|
|
||||||
ctx.strokeStyle = "#ffffff";
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.lineWidth = Math.max(1, size * 0.08);
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(tailX, tailY);
|
|
||||||
ctx.lineTo(tipX, tipY);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
const headLen = size * 0.18;
|
|
||||||
const angle = Math.atan2(tipY - tailY, tipX - tailX);
|
|
||||||
const leftAngle = angle + Math.PI * 0.8;
|
|
||||||
const rightAngle = angle - Math.PI * 0.8;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(tipX, tipY);
|
|
||||||
ctx.lineTo(tipX + Math.cos(leftAngle) * headLen, tipY + Math.sin(leftAngle) * headLen);
|
|
||||||
ctx.lineTo(tipX + Math.cos(rightAngle) * headLen, tipY + Math.sin(rightAngle) * headLen);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws an arrow centered at (cx, cy) pointing toward the ramp's high
|
|
||||||
// side, so ramp direction is visible at a glance on both the palette
|
|
||||||
// swatches and the tile grid. Used for cardinal ramps and outer-corner
|
|
||||||
// ramps (a single raised corner).
|
|
||||||
function drawArrow(ctx, cx, cy, size, dx, dy) {
|
|
||||||
const mag = Math.hypot(dx, dy) || 1;
|
|
||||||
const ux = dx / mag, uy = dy / mag;
|
|
||||||
const len = size * 0.32;
|
|
||||||
drawArrowSegment(ctx, cx - ux * len, cy - uy * len, cx + ux * len, cy + uy * len, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws a plain right-angle bracket - two line segments meeting at 90
|
|
||||||
// degrees at the tile's corner, one running along each of the two edges
|
|
||||||
// adjacent to it - so inner-corner ramps (three corners raised, one
|
|
||||||
// dropped) read as "the adjacent sides meeting in a 90-degree corner",
|
|
||||||
// distinct from the single diagonal arrow used for outer-corner ramps.
|
|
||||||
function drawCornerBracket(ctx, cx, cy, size, dx, dy) {
|
|
||||||
const cornerDist = size * 0.42;
|
|
||||||
const armLen = size * 0.34;
|
|
||||||
const cornerX = cx + dx * cornerDist;
|
|
||||||
const cornerY = cy + dy * cornerDist;
|
|
||||||
|
|
||||||
ctx.strokeStyle = "#ffffff";
|
|
||||||
ctx.lineWidth = Math.max(1, size * 0.08);
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.lineJoin = "round";
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(cornerX - dx * armLen, cornerY);
|
|
||||||
ctx.lineTo(cornerX, cornerY);
|
|
||||||
ctx.lineTo(cornerX, cornerY - dy * armLen);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws the direction icon for a ramp tile `type` in a `size`x`size` area
|
|
||||||
// centered at (cx, cy): a diagonal arrow for cardinal/outer-corner ramps,
|
|
||||||
// or a right-angle bracket for inner-corner ramps. No-op for shapes with
|
|
||||||
// no direction (ground, erase).
|
|
||||||
function drawShapeIcon(ctx, cx, cy, size, type) {
|
|
||||||
const dir = SHAPE_DIRECTIONS[type];
|
|
||||||
if(!dir) return;
|
|
||||||
if(INNER_CORNER_SHAPES.has(type)) {
|
|
||||||
drawCornerBracket(ctx, cx, cy, size, dir.dx, dir.dy);
|
|
||||||
} else {
|
|
||||||
drawArrow(ctx, cx, cy, size, dir.dx, dir.dy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws a small pencil icon centered in a `size`x`size` canvas.
|
|
||||||
function drawPencilIcon(ctx, size) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(size / 2, size / 2);
|
|
||||||
ctx.rotate(-Math.PI / 4);
|
|
||||||
|
|
||||||
const shaftW = size * 0.18;
|
|
||||||
const shaftH = size * 0.62;
|
|
||||||
|
|
||||||
ctx.fillStyle = "#e0e0e0";
|
|
||||||
ctx.fillRect(-shaftW / 2, shaftH * 0.28, shaftW, shaftH * 0.22);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#f0c419";
|
|
||||||
ctx.fillRect(-shaftW / 2, -shaftH * 0.5, shaftW, shaftH * 0.78);
|
|
||||||
|
|
||||||
ctx.fillStyle = "#8d6e63";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-shaftW / 2, -shaftH * 0.5);
|
|
||||||
ctx.lineTo(shaftW / 2, -shaftH * 0.5);
|
|
||||||
ctx.lineTo(0, -shaftH * 0.5 - shaftW);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws a small paint bucket icon centered in a `size`x`size` canvas.
|
|
||||||
function drawBucketIcon(ctx, size) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(size / 2, size / 2);
|
|
||||||
|
|
||||||
const w = size * 0.5, h = size * 0.34;
|
|
||||||
|
|
||||||
ctx.strokeStyle = "#e0e0e0";
|
|
||||||
ctx.lineWidth = Math.max(1, size * 0.06);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, -h * 0.4, w * 0.4, Math.PI, 0);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = "#03a9f4";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(-w / 2, -h * 0.2);
|
|
||||||
ctx.lineTo(w / 2, -h * 0.2);
|
|
||||||
ctx.lineTo(w * 0.32, h * 0.6);
|
|
||||||
ctx.lineTo(-w * 0.32, h * 0.6);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(-w * 0.55, h * 0.75, size * 0.06, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOLS = [
|
|
||||||
{ id: "pencil", name: "Pencil", draw: drawPencilIcon },
|
|
||||||
{ id: "bucket", name: "Paint Bucket", draw: drawBucketIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
let coord = { x: 0, y: 0, z: 0 };
|
|
||||||
let tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
|
|
||||||
let meshes = [];
|
|
||||||
let chunkExists = false;
|
|
||||||
let currentZLevel = 0;
|
|
||||||
let selectedShape = ChunkTerrain.TILE_SHAPE_GROUND;
|
|
||||||
let zoomWorldH = 20;
|
|
||||||
let dirty = false;
|
|
||||||
let hoverTile = null;
|
|
||||||
let activeTool = "pencil";
|
|
||||||
|
|
||||||
let mapRenderer = null;
|
|
||||||
let terrainMesh = null;
|
|
||||||
let gridLinesMesh = null;
|
|
||||||
let terrainTexturePromise = null;
|
|
||||||
let modelIndex = null;
|
|
||||||
let neighborChunks = [];
|
|
||||||
const modelCache = new Map();
|
|
||||||
|
|
||||||
const UNDO_LIMIT = 100;
|
|
||||||
let undoStack = [];
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotState() {
|
|
||||||
return {
|
|
||||||
tiles: tiles.slice(),
|
|
||||||
meshes: meshes.map((m) => ({ file: m.file, pos: m.pos.slice() })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushUndo() {
|
|
||||||
undoStack.push(snapshotState());
|
|
||||||
if(undoStack.length > UNDO_LIMIT) undoStack.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
function undo() {
|
|
||||||
if(!undoStack.length) return;
|
|
||||||
const snap = undoStack.pop();
|
|
||||||
tiles = snap.tiles;
|
|
||||||
meshes = snap.meshes;
|
|
||||||
dirty = true;
|
|
||||||
renderGrid();
|
|
||||||
renderMeshList();
|
|
||||||
renderPreview();
|
|
||||||
updateStatus("Undid last change.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if it's OK to proceed (no unsaved changes, or the user
|
|
||||||
// confirmed discarding them).
|
|
||||||
function confirmDiscardIfDirty() {
|
|
||||||
if(!dirty) return true;
|
|
||||||
return window.confirm("You have unsaved changes to this chunk. Discard them?");
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLine() {
|
|
||||||
return document.getElementById("statusLine");
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(msg, isError) {
|
|
||||||
const el = statusLine();
|
|
||||||
el.textContent = msg;
|
|
||||||
el.classList.toggle("text-danger", !!isError);
|
|
||||||
el.classList.toggle("text-muted", !isError);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiReadJson(path) {
|
|
||||||
const res = await fetch(`/api/read?path=${encodeURIComponent(path)}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return { ok: res.ok, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiWrite(path, content) {
|
|
||||||
return fetch(`/api/write?path=${encodeURIComponent(path)}`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiDelete(path) {
|
|
||||||
return fetch(`/api/delete?path=${encodeURIComponent(path)}`, { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiCompileChunk(x, y, z) {
|
|
||||||
const res = await fetch(`/api/compile-chunk?x=${x}&y=${y}&z=${z}`, { method: "POST" });
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiLs(path) {
|
|
||||||
const res = await fetch(`/api/ls?path=${encodeURIComponent(path)}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFind(path, ext) {
|
|
||||||
const res = await fetch(`/api/find?path=${encodeURIComponent(path)}&ext=${encodeURIComponent(ext)}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64ToBuffer(base64) {
|
|
||||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImageTexture(assetPath) {
|
|
||||||
const { ok, data } = await apiReadJson(assetPath);
|
|
||||||
if(!ok || data.error) return null;
|
|
||||||
const blob = new Blob([base64ToBuffer(data.content)], { type: "image/png" });
|
|
||||||
const bitmap = await createImageBitmap(blob);
|
|
||||||
return mapRenderer.createTextureFromImage(bitmap);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readDmfVertices(assetPath) {
|
|
||||||
const { ok, data } = await apiReadJson(assetPath);
|
|
||||||
if(!ok || data.error) throw new Error(`Failed to read ${assetPath}`);
|
|
||||||
return DMF.decode(base64ToBuffer(data.content)).floats;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureModelIndex() {
|
|
||||||
if(modelIndex) return modelIndex;
|
|
||||||
const res = await apiFind("assets/models", ".json");
|
|
||||||
modelIndex = new Map();
|
|
||||||
for(const file of res.files || []) {
|
|
||||||
const base = file.split("/").pop().replace(/\.json$/, "");
|
|
||||||
modelIndex.set(base, file);
|
|
||||||
}
|
|
||||||
return modelIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scales a mesh's stored [x, y, z] offset for preview, matching the
|
|
||||||
// Z scaling mapChunkLoaded() applies at runtime (chunk.meshOffsets[m][2]
|
|
||||||
// * WORLD_LAYER_HEIGHT) - x/y stay 1:1 since only Z is height-scaled.
|
|
||||||
function scaledMeshOffset(pos) {
|
|
||||||
return [pos[0], pos[1], pos[2] * ChunkTerrain.WORLD_LAYER_HEIGHT];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to
|
|
||||||
// its model JSON by basename, mirroring find_model() in
|
|
||||||
// tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare
|
|
||||||
// filename, not a path.
|
|
||||||
async function resolveModelForMeshFile(file) {
|
|
||||||
await ensureModelIndex();
|
|
||||||
const base = file.replace(/\.[^.]+$/, "");
|
|
||||||
const rel = modelIndex.get(base);
|
|
||||||
if(!rel) return null;
|
|
||||||
|
|
||||||
const modelPath = `assets/models/${rel}`;
|
|
||||||
if(modelCache.has(modelPath)) return modelCache.get(modelPath);
|
|
||||||
|
|
||||||
const promise = (async () => {
|
|
||||||
const { ok, data } = await apiReadJson(modelPath);
|
|
||||||
if(!ok || data.error) throw new Error(`Failed to read model ${modelPath}`);
|
|
||||||
const modelDef = JSON.parse(data.content);
|
|
||||||
const meshFloats = await readDmfVertices(`assets/${modelDef.mesh}`);
|
|
||||||
const mesh = mapRenderer.createMesh(meshFloats);
|
|
||||||
const texture = modelDef.texture ? await loadImageTexture(`assets/${modelDef.texture}`) : null;
|
|
||||||
const colorBytes = modelDef.color || [255, 255, 255, 255];
|
|
||||||
const color = colorBytes.map((c) => c / 255);
|
|
||||||
return { mesh, texture, color };
|
|
||||||
})();
|
|
||||||
modelCache.set(modelPath, promise);
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAP_NAME = "overworld";
|
|
||||||
|
|
||||||
function chunkPaths(x, y, z) {
|
|
||||||
return {
|
|
||||||
raw: `assetsraw/maps/${MAP_NAME}/chunks/chunk_${x}_${y}_${z}.json`,
|
|
||||||
dcf: `assets/maps/${MAP_NAME}/chunks/${x}_${y}_${z}.dcf`,
|
|
||||||
terrainDmf: `assets/maps/${MAP_NAME}/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`,
|
|
||||||
terrainModel: `assets/maps/${MAP_NAME}/models/chunks/chunk_${x}_${y}_${z}_0.json`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loads the 8 same-Z neighbors around `coord` (read-only, for context in
|
|
||||||
// the 3D preview) and pre-builds their render data so renderPreview()
|
|
||||||
// doesn't have to rebuild GL buffers on every paint stroke.
|
|
||||||
async function loadNeighborChunks() {
|
|
||||||
const offsets = [];
|
|
||||||
for(let dy = -1; dy <= 1; dy++) {
|
|
||||||
for(let dx = -1; dx <= 1; dx++) {
|
|
||||||
if(dx || dy) offsets.push({ dx, dy });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(offsets.map(async ({ dx, dy }) => {
|
|
||||||
const nx = coord.x + dx, ny = coord.y + dy, nz = coord.z;
|
|
||||||
const { ok, data } = await apiReadJson(chunkPaths(nx, ny, nz).raw);
|
|
||||||
if(!ok || data.error) return null;
|
|
||||||
|
|
||||||
const json = JSON.parse(data.content);
|
|
||||||
const neighborTiles = new Int32Array(ChunkTerrain.TILE_COUNT);
|
|
||||||
for(const t of json.tiles || []) {
|
|
||||||
const [tx, ty, tz] = t.pos;
|
|
||||||
neighborTiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
|
|
||||||
}
|
|
||||||
const neighborMeshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
|
|
||||||
|
|
||||||
const floats = ChunkTerrain.buildTerrainVerts(neighborTiles);
|
|
||||||
const terrain = floats.length ? { mesh: mapRenderer.createMesh(floats) } : null;
|
|
||||||
|
|
||||||
const models = [];
|
|
||||||
for(const m of neighborMeshes) {
|
|
||||||
try {
|
|
||||||
const resolved = await resolveModelForMeshFile(m.file);
|
|
||||||
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
|
|
||||||
} catch(e) {
|
|
||||||
console.warn("Failed to load neighbor mesh preview", m.file, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
offset: [dx * ChunkTerrain.CHUNK_WIDTH, dy * ChunkTerrain.CHUNK_HEIGHT, 0],
|
|
||||||
terrain,
|
|
||||||
models,
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
|
|
||||||
neighborChunks = results.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadChunk(x, y, z) {
|
|
||||||
coord = { x, y, z };
|
|
||||||
tiles = new Int32Array(ChunkTerrain.TILE_COUNT);
|
|
||||||
meshes = [];
|
|
||||||
chunkExists = false;
|
|
||||||
|
|
||||||
const { ok, data } = await apiReadJson(chunkPaths(x, y, z).raw);
|
|
||||||
if(ok && !data.error) {
|
|
||||||
chunkExists = true;
|
|
||||||
const json = JSON.parse(data.content);
|
|
||||||
for(const t of json.tiles || []) {
|
|
||||||
const [tx, ty, tz] = t.pos;
|
|
||||||
tiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type;
|
|
||||||
}
|
|
||||||
meshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadNeighborChunks();
|
|
||||||
dirty = false;
|
|
||||||
undoStack = [];
|
|
||||||
|
|
||||||
renderGrid();
|
|
||||||
renderMeshList();
|
|
||||||
renderPreview();
|
|
||||||
updateStatus(chunkExists
|
|
||||||
? `Loaded chunk_${x}_${y}_${z}.json`
|
|
||||||
: `New (empty) chunk at ${x}, ${y}, ${z} - paint a tile and save to create it.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveChunk() {
|
|
||||||
const tilesArr = [];
|
|
||||||
for(let z = 0; z < ChunkTerrain.CHUNK_DEPTH; z++) {
|
|
||||||
for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[ChunkTerrain.tileIndex(x, y, z)];
|
|
||||||
if(type && type !== ChunkTerrain.TILE_SHAPE_NULL) {
|
|
||||||
tilesArr.push({ pos: [x, y, z], type, tile: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = chunkPaths(coord.x, coord.y, coord.z);
|
|
||||||
|
|
||||||
if(tilesArr.length === 0 && meshes.length === 0) {
|
|
||||||
await Promise.all(Object.values(paths).map((p) => apiDelete(p)));
|
|
||||||
chunkExists = false;
|
|
||||||
dirty = false;
|
|
||||||
updateStatus(`Chunk ${coord.x}, ${coord.y}, ${coord.z} is empty - deleted.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = { tiles: tilesArr, meshes: meshes.map((m) => ({ file: m.file, pos: m.pos })) };
|
|
||||||
await apiWrite(paths.raw, JSON.stringify(json, null, 2));
|
|
||||||
chunkExists = true;
|
|
||||||
dirty = false;
|
|
||||||
updateStatus("Saved. Compiling...");
|
|
||||||
|
|
||||||
const result = await apiCompileChunk(coord.x, coord.y, coord.z);
|
|
||||||
if(result.ok) {
|
|
||||||
updateStatus(`Compiled OK.\n${result.stdout}`);
|
|
||||||
} else {
|
|
||||||
updateStatus(`Compile FAILED (code ${result.returncode}):\n${result.stderr}`, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPalette() {
|
|
||||||
const el = document.getElementById("tilePalette");
|
|
||||||
el.innerHTML = "";
|
|
||||||
for(const s of SHAPES) {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "tile-swatch" + (s.type === selectedShape ? " active" : "");
|
|
||||||
btn.style.background = s.color;
|
|
||||||
btn.title = s.name;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
selectedShape = s.type;
|
|
||||||
buildPalette();
|
|
||||||
});
|
|
||||||
|
|
||||||
if(SHAPE_DIRECTIONS[s.type]) {
|
|
||||||
const icon = document.createElement("canvas");
|
|
||||||
icon.width = 32;
|
|
||||||
icon.height = 32;
|
|
||||||
icon.className = "tile-swatch-icon";
|
|
||||||
drawShapeIcon(icon.getContext("2d"), 16, 16, 32, s.type);
|
|
||||||
btn.appendChild(icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildToolPalette() {
|
|
||||||
const el = document.getElementById("toolPalette");
|
|
||||||
el.innerHTML = "";
|
|
||||||
for(const tool of TOOLS) {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "tile-swatch" + (tool.id === activeTool ? " active" : "");
|
|
||||||
btn.style.background = "#2a2d33";
|
|
||||||
btn.title = tool.name;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
activeTool = tool.id;
|
|
||||||
buildToolPalette();
|
|
||||||
});
|
|
||||||
|
|
||||||
const icon = document.createElement("canvas");
|
|
||||||
icon.width = 32;
|
|
||||||
icon.height = 32;
|
|
||||||
icon.className = "tile-swatch-icon";
|
|
||||||
tool.draw(icon.getContext("2d"), 32);
|
|
||||||
btn.appendChild(icon);
|
|
||||||
|
|
||||||
el.appendChild(btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasToTile(e, canvas) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
|
|
||||||
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
||||||
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
||||||
const x = Math.floor(px / cell);
|
|
||||||
const yFromTop = Math.floor(py / cell);
|
|
||||||
const y = ChunkTerrain.CHUNK_HEIGHT - 1 - yFromTop;
|
|
||||||
if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) return null;
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGrid() {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) {
|
|
||||||
for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) {
|
|
||||||
const type = tiles[ChunkTerrain.tileIndex(x, y, currentZLevel)];
|
|
||||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell;
|
|
||||||
ctx.fillStyle = SHAPE_COLORS[type] || "#15171b";
|
|
||||||
ctx.fillRect(x * cell, sy, cell, cell);
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
|
|
||||||
ctx.strokeRect(x * cell, sy, cell, cell);
|
|
||||||
|
|
||||||
drawShapeIcon(ctx, x * cell + cell / 2, sy + cell / 2, cell, type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
for(const m of meshes) {
|
|
||||||
const [mx, my, mz] = m.pos;
|
|
||||||
if(Math.round(mz) !== currentZLevel) continue;
|
|
||||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(mx * cell + cell / 2, sy + cell / 2, cell * 0.3, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(hoverTile) {
|
|
||||||
const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - hoverTile.y) * cell;
|
|
||||||
ctx.strokeStyle = "#ffeb3b";
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.strokeRect(hoverTile.x * cell + 1, sy + 1, cell - 2, cell - 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hitTestMesh(e, canvas) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH;
|
|
||||||
const px = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
||||||
const py = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
||||||
|
|
||||||
let closest = -1;
|
|
||||||
let closestDist = cell * 0.5;
|
|
||||||
meshes.forEach((m, i) => {
|
|
||||||
const [mx, my, mz] = m.pos;
|
|
||||||
if(Math.round(mz) !== currentZLevel) return;
|
|
||||||
const cx = mx * cell + cell / 2;
|
|
||||||
const cy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell + cell / 2;
|
|
||||||
const dist = Math.hypot(px - cx, py - cy);
|
|
||||||
if(dist < closestDist) { closestDist = dist; closest = i; }
|
|
||||||
});
|
|
||||||
return closest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveMeshTo(e, index) {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const t = canvasToTile(e, canvas);
|
|
||||||
if(!t) return;
|
|
||||||
meshes[index].pos[0] = t.x;
|
|
||||||
meshes[index].pos[1] = t.y;
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderMeshList();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintAt(e) {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const t = canvasToTile(e, canvas);
|
|
||||||
if(!t) return;
|
|
||||||
tiles[ChunkTerrain.tileIndex(t.x, t.y, currentZLevel)] = selectedShape;
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4-connected flood fill of same-type tiles at the current Z-level,
|
|
||||||
// starting from (startX, startY), replacing them with selectedShape.
|
|
||||||
function floodFill(startX, startY) {
|
|
||||||
const targetType = tiles[ChunkTerrain.tileIndex(startX, startY, currentZLevel)];
|
|
||||||
if(targetType === selectedShape) return;
|
|
||||||
|
|
||||||
const stack = [[startX, startY]];
|
|
||||||
const visited = new Set();
|
|
||||||
while(stack.length) {
|
|
||||||
const [x, y] = stack.pop();
|
|
||||||
const key = `${x},${y}`;
|
|
||||||
if(visited.has(key)) continue;
|
|
||||||
if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) continue;
|
|
||||||
visited.add(key);
|
|
||||||
|
|
||||||
const idx = ChunkTerrain.tileIndex(x, y, currentZLevel);
|
|
||||||
if(tiles[idx] !== targetType) continue;
|
|
||||||
tiles[idx] = selectedShape;
|
|
||||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillAt(e) {
|
|
||||||
const canvas = document.getElementById("gridCanvas");
|
|
||||||
const t = canvasToTile(e, canvas);
|
|
||||||
if(!t) return;
|
|
||||||
pushUndo();
|
|
||||||
floodFill(t.x, t.y);
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebuildTerrainMesh() {
|
|
||||||
const floats = ChunkTerrain.buildTerrainVerts(tiles);
|
|
||||||
terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null;
|
|
||||||
|
|
||||||
const lineFloats = ChunkTerrain.buildGridLines(tiles);
|
|
||||||
gridLinesMesh = lineFloats.length ? mapRenderer.createMesh(lineFloats) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keeps the preview canvas's backing-store resolution matched to its
|
|
||||||
// actual displayed CSS size (times devicePixelRatio) - the canvas
|
|
||||||
// otherwise stays at its fixed width/height attributes (512x512) while
|
|
||||||
// CSS stretches it to fill the column, which upscales and blurs the
|
|
||||||
// WebGL output on wider or high-DPI screens.
|
|
||||||
function resizePreviewCanvas() {
|
|
||||||
const canvas = document.getElementById("previewCanvas");
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const displayWidth = Math.max(1, Math.round(canvas.clientWidth * dpr));
|
|
||||||
const displayHeight = Math.max(1, Math.round(canvas.clientHeight * dpr));
|
|
||||||
if(canvas.width !== displayWidth || canvas.height !== displayHeight) {
|
|
||||||
canvas.width = displayWidth;
|
|
||||||
canvas.height = displayHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderPreview() {
|
|
||||||
if(!mapRenderer) return;
|
|
||||||
resizePreviewCanvas();
|
|
||||||
rebuildTerrainMesh();
|
|
||||||
|
|
||||||
const models = [];
|
|
||||||
for(const m of meshes) {
|
|
||||||
try {
|
|
||||||
const resolved = await resolveModelForMeshFile(m.file);
|
|
||||||
if(resolved) models.push({ ...resolved, offset: scaledMeshOffset(m.pos) });
|
|
||||||
} catch(e) {
|
|
||||||
console.warn("Failed to load mesh preview", m.file, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let terrainTexture = null;
|
|
||||||
if(terrainMesh || neighborChunks.some((n) => n.terrain)) {
|
|
||||||
if(!terrainTexturePromise) terrainTexturePromise = loadImageTexture("assets/tiles.png");
|
|
||||||
terrainTexture = await terrainTexturePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const neighbors = neighborChunks.map((n) => ({
|
|
||||||
offset: n.offset,
|
|
||||||
terrain: n.terrain ? { mesh: n.terrain.mesh, texture: terrainTexture } : null,
|
|
||||||
models: n.models,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const zLevelHeight = currentZLevel * ChunkTerrain.WORLD_LAYER_HEIGHT;
|
|
||||||
mapRenderer.render({
|
|
||||||
target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, zLevelHeight],
|
|
||||||
worldH: zoomWorldH,
|
|
||||||
terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null,
|
|
||||||
gridLines: gridLinesMesh ? { mesh: gridLinesMesh } : null,
|
|
||||||
models,
|
|
||||||
neighbors,
|
|
||||||
highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: zLevelHeight } : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMeshList() {
|
|
||||||
const ul = document.getElementById("meshList");
|
|
||||||
ul.innerHTML = "";
|
|
||||||
meshes.forEach((m, i) => {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "list-group-item d-flex align-items-center gap-2 flex-wrap";
|
|
||||||
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.className = "me-auto";
|
|
||||||
label.textContent = m.file;
|
|
||||||
li.appendChild(label);
|
|
||||||
|
|
||||||
["X", "Y", "Z"].forEach((axis, axisIndex) => {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "number";
|
|
||||||
input.className = "form-control form-control-sm";
|
|
||||||
input.style.width = "5rem";
|
|
||||||
input.title = axis;
|
|
||||||
input.value = m.pos[axisIndex];
|
|
||||||
input.addEventListener("change", () => {
|
|
||||||
pushUndo();
|
|
||||||
m.pos[axisIndex] = parseFloat(input.value) || 0;
|
|
||||||
markDirty();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
li.appendChild(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "btn btn-sm btn-outline-danger";
|
|
||||||
btn.textContent = "Remove";
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
pushUndo();
|
|
||||||
meshes.splice(i, 1);
|
|
||||||
markDirty();
|
|
||||||
renderMeshList();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
li.appendChild(btn);
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildMeshPicker() {
|
|
||||||
await ensureModelIndex();
|
|
||||||
const select = document.getElementById("meshPicker");
|
|
||||||
select.innerHTML = "";
|
|
||||||
for(const [base, rel] of [...modelIndex.entries()].sort()) {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = base;
|
|
||||||
opt.textContent = rel;
|
|
||||||
select.appendChild(opt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildChunkBrowseList() {
|
|
||||||
const list = document.getElementById("chunkBrowseList");
|
|
||||||
list.innerHTML = "";
|
|
||||||
const res = await apiLs(`assetsraw/maps/${MAP_NAME}/chunks`);
|
|
||||||
for(const entry of res.entries || []) {
|
|
||||||
const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/);
|
|
||||||
if(!m) continue;
|
|
||||||
const [, x, y, z] = m;
|
|
||||||
|
|
||||||
const li = document.createElement("li");
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.className = "dropdown-item";
|
|
||||||
a.href = "#";
|
|
||||||
a.textContent = `${x}, ${y}, ${z}`;
|
|
||||||
a.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
goToChunk(parseInt(x, 10), parseInt(y, 10), parseInt(z, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
li.appendChild(a);
|
|
||||||
list.appendChild(li);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCoordInputs(x, y, z) {
|
|
||||||
document.getElementById("coordX").value = x;
|
|
||||||
document.getElementById("coordY").value = y;
|
|
||||||
document.getElementById("coordZ").value = z;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToChunk(x, y, z) {
|
|
||||||
if(!confirmDiscardIfDirty()) return;
|
|
||||||
setCoordInputs(x, y, z);
|
|
||||||
loadChunk(x, y, z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
const gridCanvas = document.getElementById("gridCanvas");
|
|
||||||
let painting = false;
|
|
||||||
let draggingMeshIndex = -1;
|
|
||||||
|
|
||||||
gridCanvas.addEventListener("mousedown", (e) => {
|
|
||||||
if(activeTool === "bucket") {
|
|
||||||
fillAt(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hit = hitTestMesh(e, gridCanvas);
|
|
||||||
pushUndo();
|
|
||||||
if(hit !== -1) {
|
|
||||||
draggingMeshIndex = hit;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
painting = true;
|
|
||||||
paintAt(e);
|
|
||||||
});
|
|
||||||
window.addEventListener("mouseup", () => { painting = false; draggingMeshIndex = -1; });
|
|
||||||
gridCanvas.addEventListener("mousemove", (e) => {
|
|
||||||
hoverTile = canvasToTile(e, gridCanvas);
|
|
||||||
if(draggingMeshIndex !== -1) { moveMeshTo(e, draggingMeshIndex); return; }
|
|
||||||
if(painting) { paintAt(e); return; }
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
gridCanvas.addEventListener("mouseleave", () => {
|
|
||||||
hoverTile = null;
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
function setZLevel(z) {
|
|
||||||
currentZLevel = Math.max(0, Math.min(ChunkTerrain.CHUNK_DEPTH - 1, z));
|
|
||||||
document.getElementById("zLevel").value = currentZLevel;
|
|
||||||
document.getElementById("zLevelLabel").textContent = currentZLevel;
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("zLevel").addEventListener("input", (e) => {
|
|
||||||
setZLevel(parseInt(e.target.value, 10));
|
|
||||||
});
|
|
||||||
document.getElementById("zLevelDown").addEventListener("click", () => setZLevel(currentZLevel - 1));
|
|
||||||
document.getElementById("zLevelUp").addEventListener("click", () => setZLevel(currentZLevel + 1));
|
|
||||||
|
|
||||||
document.getElementById("btnLoad").addEventListener("click", () => {
|
|
||||||
const x = parseInt(document.getElementById("coordX").value, 10) || 0;
|
|
||||||
const y = parseInt(document.getElementById("coordY").value, 10) || 0;
|
|
||||||
const z = parseInt(document.getElementById("coordZ").value, 10) || 0;
|
|
||||||
goToChunk(x, y, z);
|
|
||||||
});
|
|
||||||
|
|
||||||
// NORTH=+Y, EAST=+X (matches tile-shape convention used by the tile
|
|
||||||
// grid and tools/asset/chunk/__main__.py).
|
|
||||||
document.getElementById("navN").addEventListener("click", () => goToChunk(coord.x, coord.y + 1, coord.z));
|
|
||||||
document.getElementById("navS").addEventListener("click", () => goToChunk(coord.x, coord.y - 1, coord.z));
|
|
||||||
document.getElementById("navE").addEventListener("click", () => goToChunk(coord.x + 1, coord.y, coord.z));
|
|
||||||
document.getElementById("navW").addEventListener("click", () => goToChunk(coord.x - 1, coord.y, coord.z));
|
|
||||||
document.getElementById("navUp").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z + 1));
|
|
||||||
document.getElementById("navDown").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z - 1));
|
|
||||||
|
|
||||||
function runSave() {
|
|
||||||
saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("btnSave").addEventListener("click", runSave);
|
|
||||||
|
|
||||||
document.getElementById("btnAddMesh").addEventListener("click", () => {
|
|
||||||
const base = document.getElementById("meshPicker").value;
|
|
||||||
if(!base) return;
|
|
||||||
const pos = [
|
|
||||||
parseFloat(document.getElementById("meshX").value) || 0,
|
|
||||||
parseFloat(document.getElementById("meshY").value) || 0,
|
|
||||||
parseFloat(document.getElementById("meshZ").value) || 0,
|
|
||||||
];
|
|
||||||
pushUndo();
|
|
||||||
meshes.push({ file: `${base}.dmf`, pos });
|
|
||||||
markDirty();
|
|
||||||
renderMeshList();
|
|
||||||
renderGrid();
|
|
||||||
renderPreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("previewCanvas").addEventListener("wheel", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
zoomWorldH = Math.min(60, Math.max(4, zoomWorldH + Math.sign(e.deltaY)));
|
|
||||||
renderPreview();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => renderPreview());
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (e) => {
|
|
||||||
if(!(e.ctrlKey || e.metaKey)) return;
|
|
||||||
const key = e.key.toLowerCase();
|
|
||||||
if(key === "z") {
|
|
||||||
e.preventDefault();
|
|
||||||
undo();
|
|
||||||
} else if(key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
runSave();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
buildPalette();
|
|
||||||
buildToolPalette();
|
|
||||||
mapRenderer = MapRenderer.create(document.getElementById("previewCanvas"));
|
|
||||||
bindEvents();
|
|
||||||
await Promise.all([buildChunkBrowseList(), buildMeshPicker()]);
|
|
||||||
await loadChunk(coord.x, coord.y, coord.z);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// MapRenderer - a minimal, dependency-free WebGL renderer for the chunk
|
|
||||||
// preview pane. The camera formula is ported 1:1 from
|
|
||||||
// src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview
|
|
||||||
// approximates the game's own fixed-angle 3rd-person camera: the eye sits
|
|
||||||
// worldH units "behind" the target along Y and a proportional distance
|
|
||||||
// "above" along Z, always looking at the target, up = (0, 1, 0).
|
|
||||||
//
|
|
||||||
// The material model mirrors SHADER_UNLIT: a texture sampler multiplied by
|
|
||||||
// a flat RGBA tint, with a 1x1 white fallback texture standing in for
|
|
||||||
// untextured models.
|
|
||||||
|
|
||||||
const MapRenderer = (() => {
|
|
||||||
const VERT_SRC = `
|
|
||||||
attribute vec2 aUV;
|
|
||||||
attribute vec3 aPos;
|
|
||||||
uniform mat4 uModel;
|
|
||||||
uniform mat4 uView;
|
|
||||||
uniform mat4 uProjection;
|
|
||||||
varying vec2 vUV;
|
|
||||||
void main() {
|
|
||||||
vUV = aUV;
|
|
||||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FRAG_SRC = `
|
|
||||||
precision mediump float;
|
|
||||||
varying vec2 vUV;
|
|
||||||
uniform sampler2D uTexture;
|
|
||||||
uniform vec4 uColor;
|
|
||||||
void main() {
|
|
||||||
gl_FragColor = texture2D(uTexture, vUV) * uColor;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function mat4Identity(out) {
|
|
||||||
out.fill(0);
|
|
||||||
out[0] = out[5] = out[10] = out[15] = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mat4Translation(out, v) {
|
|
||||||
mat4Identity(out);
|
|
||||||
out[12] = v[0];
|
|
||||||
out[13] = v[1];
|
|
||||||
out[14] = v[2];
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mat4Perspective(out, fovy, aspect, near, far) {
|
|
||||||
const f = 1.0 / Math.tan(fovy / 2);
|
|
||||||
out.fill(0);
|
|
||||||
out[0] = f / aspect;
|
|
||||||
out[5] = f;
|
|
||||||
out[10] = (far + near) / (near - far);
|
|
||||||
out[11] = -1;
|
|
||||||
out[14] = (2 * far * near) / (near - far);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function vec3Sub(a, b) {
|
|
||||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
||||||
}
|
|
||||||
|
|
||||||
function vec3Cross(a, b) {
|
|
||||||
return [
|
|
||||||
a[1] * b[2] - a[2] * b[1],
|
|
||||||
a[2] * b[0] - a[0] * b[2],
|
|
||||||
a[0] * b[1] - a[1] * b[0],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function vec3Normalize(a) {
|
|
||||||
const len = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
||||||
return [a[0] / len, a[1] / len, a[2] / len];
|
|
||||||
}
|
|
||||||
|
|
||||||
function mat4LookAt(out, eye, center, up) {
|
|
||||||
const zAxis = vec3Normalize(vec3Sub(eye, center));
|
|
||||||
const xAxis = vec3Normalize(vec3Cross(up, zAxis));
|
|
||||||
const yAxis = vec3Cross(zAxis, xAxis);
|
|
||||||
|
|
||||||
out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0;
|
|
||||||
out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0;
|
|
||||||
out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0;
|
|
||||||
out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]);
|
|
||||||
out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]);
|
|
||||||
out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]);
|
|
||||||
out[15] = 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ported from sceneoverworld.c:62-81. Returns { eye, view }.
|
|
||||||
function computeCamera(target, worldH) {
|
|
||||||
const fov = Math.PI / 4; // glm_rad(45.0f)
|
|
||||||
const zDist = (worldH * 0.5) / Math.tan(fov * 0.5);
|
|
||||||
const offset = -worldH;
|
|
||||||
|
|
||||||
const eye = [target[0], target[1] + offset, target[2] + zDist];
|
|
||||||
const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]);
|
|
||||||
return { eye, view, fov };
|
|
||||||
}
|
|
||||||
|
|
||||||
function compileShader(gl, type, src) {
|
|
||||||
const shader = gl.createShader(type);
|
|
||||||
gl.shaderSource(shader, src);
|
|
||||||
gl.compileShader(shader);
|
|
||||||
if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
||||||
const info = gl.getShaderInfoLog(shader);
|
|
||||||
gl.deleteShader(shader);
|
|
||||||
throw new Error(`Shader compile failed: ${info}`);
|
|
||||||
}
|
|
||||||
return shader;
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(canvas) {
|
|
||||||
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
||||||
if(!gl) throw new Error("WebGL is not available in this browser");
|
|
||||||
|
|
||||||
const program = gl.createProgram();
|
|
||||||
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC));
|
|
||||||
gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC));
|
|
||||||
gl.linkProgram(program);
|
|
||||||
if(!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
||||||
throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const attribs = {
|
|
||||||
aUV: gl.getAttribLocation(program, "aUV"),
|
|
||||||
aPos: gl.getAttribLocation(program, "aPos"),
|
|
||||||
};
|
|
||||||
const uniforms = {
|
|
||||||
uModel: gl.getUniformLocation(program, "uModel"),
|
|
||||||
uView: gl.getUniformLocation(program, "uView"),
|
|
||||||
uProjection: gl.getUniformLocation(program, "uProjection"),
|
|
||||||
uTexture: gl.getUniformLocation(program, "uTexture"),
|
|
||||||
uColor: gl.getUniformLocation(program, "uColor"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const whiteTexture = gl.createTexture();
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture);
|
|
||||||
gl.texImage2D(
|
|
||||||
gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
|
|
||||||
new Uint8Array([255, 255, 255, 255]),
|
|
||||||
);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
||||||
|
|
||||||
gl.enable(gl.DEPTH_TEST);
|
|
||||||
gl.enable(gl.BLEND);
|
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
// Terrain/mesh winding isn't guaranteed consistent relative to this
|
|
||||||
// preview's fixed camera angle, so backface culling stays off - this
|
|
||||||
// is an authoring preview, not a performance-sensitive renderer, and
|
|
||||||
// an invisible mesh is a worse failure mode than a visible backface.
|
|
||||||
|
|
||||||
// Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout)
|
|
||||||
// into a GL buffer. Returns { buffer, vertexCount }.
|
|
||||||
function createMesh(floats) {
|
|
||||||
const buffer = gl.createBuffer();
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW);
|
|
||||||
return { buffer, vertexCount: floats.length / 5 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTextureFromImage(image) {
|
|
||||||
const texture = gl.createTexture();
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
||||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
||||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMesh(mesh, model, texture, color, depthWrite, mode) {
|
|
||||||
gl.depthMask(depthWrite !== false);
|
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
|
|
||||||
gl.enableVertexAttribArray(attribs.aUV);
|
|
||||||
gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0);
|
|
||||||
gl.enableVertexAttribArray(attribs.aPos);
|
|
||||||
gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8);
|
|
||||||
|
|
||||||
gl.uniformMatrix4fv(uniforms.uModel, false, model);
|
|
||||||
gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]);
|
|
||||||
|
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture);
|
|
||||||
gl.uniform1i(uniforms.uTexture, 0);
|
|
||||||
|
|
||||||
gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly
|
|
||||||
// to avoid z-fighting with the terrain surface beneath it.
|
|
||||||
function buildHighlightMesh(x, y, z) {
|
|
||||||
const eps = 0.02;
|
|
||||||
return createMesh(new Float32Array([
|
|
||||||
0, 0, x, y, z + eps,
|
|
||||||
0, 0, x + 1, y, z + eps,
|
|
||||||
0, 0, x + 1, y + 1, z + eps,
|
|
||||||
0, 0, x, y + 1, z + eps,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neighboring chunks are drawn dimmer and semi-transparent, so they
|
|
||||||
// read as context rather than part of the chunk being edited.
|
|
||||||
const NEIGHBOR_ALPHA = 0.35;
|
|
||||||
const NEIGHBOR_DARKEN = 0.5;
|
|
||||||
|
|
||||||
// scene = {
|
|
||||||
// target: [x, y, z], worldH: number,
|
|
||||||
// terrain: { mesh, texture } | null,
|
|
||||||
// gridLines: { mesh } | null,
|
|
||||||
// models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }],
|
|
||||||
// neighbors: [{
|
|
||||||
// offset: [x,y,z],
|
|
||||||
// terrain: { mesh, texture } | null,
|
|
||||||
// models: [{ mesh, texture, color, offset }],
|
|
||||||
// }],
|
|
||||||
// highlight: { x, y, z } | null,
|
|
||||||
// }
|
|
||||||
function render(scene) {
|
|
||||||
const width = canvas.width, height = canvas.height;
|
|
||||||
gl.viewport(0, 0, width, height);
|
|
||||||
gl.clearColor(0.16, 0.18, 0.22, 1);
|
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
||||||
gl.useProgram(program);
|
|
||||||
gl.depthMask(true);
|
|
||||||
|
|
||||||
const { view, fov } = computeCamera(scene.target, scene.worldH);
|
|
||||||
const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000);
|
|
||||||
gl.uniformMatrix4fv(uniforms.uProjection, false, projection);
|
|
||||||
gl.uniformMatrix4fv(uniforms.uView, false, view);
|
|
||||||
|
|
||||||
const identity = mat4Identity(new Float32Array(16));
|
|
||||||
|
|
||||||
if(scene.terrain) {
|
|
||||||
drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(scene.gridLines) {
|
|
||||||
drawMesh(scene.gridLines.mesh, identity, null, [0, 0, 0, 0.35], false, gl.LINES);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const model of scene.models) {
|
|
||||||
const modelMatrix = mat4Translation(new Float32Array(16), model.offset);
|
|
||||||
drawMesh(model.mesh, modelMatrix, model.texture, model.color, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const neighbor of scene.neighbors || []) {
|
|
||||||
if(neighbor.terrain) {
|
|
||||||
const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset);
|
|
||||||
drawMesh(
|
|
||||||
neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture,
|
|
||||||
[NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const model of neighbor.models) {
|
|
||||||
const worldOffset = [
|
|
||||||
neighbor.offset[0] + model.offset[0],
|
|
||||||
neighbor.offset[1] + model.offset[1],
|
|
||||||
neighbor.offset[2] + model.offset[2],
|
|
||||||
];
|
|
||||||
const modelMatrix = mat4Translation(new Float32Array(16), worldOffset);
|
|
||||||
const color = [
|
|
||||||
model.color[0] * NEIGHBOR_DARKEN,
|
|
||||||
model.color[1] * NEIGHBOR_DARKEN,
|
|
||||||
model.color[2] * NEIGHBOR_DARKEN,
|
|
||||||
model.color[3] * NEIGHBOR_ALPHA,
|
|
||||||
];
|
|
||||||
drawMesh(model.mesh, modelMatrix, model.texture, color, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(scene.highlight) {
|
|
||||||
const { x, y, z } = scene.highlight;
|
|
||||||
const highlightMesh = buildHighlightMesh(x, y, z);
|
|
||||||
drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { gl, createMesh, createTextureFromImage, render };
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({ create, computeCamera });
|
|
||||||
})();
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dusk Map Editor</title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand navbar-dark bg-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="/">Dusk Map Editor</a>
|
|
||||||
<ul class="navbar-nav flex-row">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/map">Map Editor</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div id="editor" class="container-fluid py-3"></div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
"""
|
|
||||||
Map Editor HTTP Server
|
|
||||||
|
|
||||||
Serves editor/client/public/ as static files with smart routing:
|
|
||||||
/ -> index.html
|
|
||||||
/page -> page.html or page/index.html
|
|
||||||
/index -> index.html
|
|
||||||
|
|
||||||
API endpoints (all paths relative to project root):
|
|
||||||
GET /api/ls?path=<dir> directory listing
|
|
||||||
GET /api/read?path=<file> file contents
|
|
||||||
GET /api/find?path=<dir>&ext=<.json> recursive file listing by extension
|
|
||||||
POST /api/write?path=<file> write file (JSON body: {"content":"..."})
|
|
||||||
POST /api/mkdir?path=<dir> create directory
|
|
||||||
POST /api/delete?path=<file> delete a file
|
|
||||||
POST /api/compile-chunk?x=&y=&z= recompile a chunk JSON to .dcf
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
import mimetypes
|
|
||||||
import subprocess
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from urllib.parse import urlparse, parse_qs, unquote
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
CHUNK_COORD_RE = re.compile(r"^-?\d+$")
|
|
||||||
|
|
||||||
PORT = 3000
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
||||||
PUBLIC_DIR = SCRIPT_DIR.parent / "client" / "public"
|
|
||||||
PROJECT_ROOT = SCRIPT_DIR.parent.parent.resolve()
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
path = unquote(parsed.path)
|
|
||||||
if path.startswith("/api/"):
|
|
||||||
params = parse_qs(parsed.query)
|
|
||||||
if path == "/api/ls":
|
|
||||||
self.api_ls(params)
|
|
||||||
elif path == "/api/read":
|
|
||||||
self.api_read(params)
|
|
||||||
elif path == "/api/find":
|
|
||||||
self.api_find(params)
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
else:
|
|
||||||
self.serve_static(path)
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
params = parse_qs(parsed.query)
|
|
||||||
if parsed.path == "/api/write":
|
|
||||||
self.api_write(params)
|
|
||||||
elif parsed.path == "/api/mkdir":
|
|
||||||
self.api_mkdir(params)
|
|
||||||
elif parsed.path == "/api/delete":
|
|
||||||
self.api_delete(params)
|
|
||||||
elif parsed.path == "/api/compile-chunk":
|
|
||||||
self.api_compile_chunk(params)
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def resolve_project_path(self, rel):
|
|
||||||
rel = rel.lstrip("/")
|
|
||||||
target = (PROJECT_ROOT / rel).resolve()
|
|
||||||
if not str(target).startswith(str(PROJECT_ROOT)):
|
|
||||||
return None
|
|
||||||
return target
|
|
||||||
|
|
||||||
def send_json(self, data, code=200):
|
|
||||||
body = json.dumps(data, indent=2).encode("utf-8")
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", len(body))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def read_body(self):
|
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
|
||||||
return self.rfile.read(length)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# API handlers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def api_ls(self, params):
|
|
||||||
rel = params.get("path", [""])[0]
|
|
||||||
target = self.resolve_project_path(rel)
|
|
||||||
if target is None:
|
|
||||||
self.send_json({"error": "invalid path"}, 400)
|
|
||||||
return
|
|
||||||
if not target.exists():
|
|
||||||
self.send_json({"error": "path not found"}, 404)
|
|
||||||
return
|
|
||||||
if not target.is_dir():
|
|
||||||
self.send_json({"error": "not a directory"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
entries = []
|
|
||||||
for entry in sorted(target.iterdir(), key=lambda e: (e.is_file(), e.name)):
|
|
||||||
entries.append({
|
|
||||||
"name": entry.name,
|
|
||||||
"type": "dir" if entry.is_dir() else "file",
|
|
||||||
"size": entry.stat().st_size if entry.is_file() else None,
|
|
||||||
})
|
|
||||||
self.send_json({
|
|
||||||
"path": str(target.relative_to(PROJECT_ROOT)),
|
|
||||||
"entries": entries,
|
|
||||||
})
|
|
||||||
|
|
||||||
def api_read(self, params):
|
|
||||||
rel = params.get("path", [""])[0]
|
|
||||||
target = self.resolve_project_path(rel)
|
|
||||||
if target is None:
|
|
||||||
self.send_json({"error": "invalid path"}, 400)
|
|
||||||
return
|
|
||||||
if not target.exists():
|
|
||||||
self.send_json({"error": "file not found"}, 404)
|
|
||||||
return
|
|
||||||
if not target.is_file():
|
|
||||||
self.send_json({"error": "not a file"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = target.read_text(encoding="utf-8")
|
|
||||||
self.send_json({
|
|
||||||
"path": str(target.relative_to(PROJECT_ROOT)),
|
|
||||||
"content": content,
|
|
||||||
})
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
import base64
|
|
||||||
content = base64.b64encode(target.read_bytes()).decode("ascii")
|
|
||||||
self.send_json({
|
|
||||||
"path": str(target.relative_to(PROJECT_ROOT)),
|
|
||||||
"content": content,
|
|
||||||
"encoding": "base64",
|
|
||||||
})
|
|
||||||
|
|
||||||
def api_write(self, params):
|
|
||||||
rel = params.get("path", [""])[0]
|
|
||||||
target = self.resolve_project_path(rel)
|
|
||||||
if target is None:
|
|
||||||
self.send_json({"error": "invalid path"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
body = self.read_body()
|
|
||||||
try:
|
|
||||||
data = json.loads(body)
|
|
||||||
content = data.get("content", "")
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
content = body.decode("utf-8")
|
|
||||||
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
target.write_text(content, encoding="utf-8")
|
|
||||||
self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))})
|
|
||||||
|
|
||||||
def api_mkdir(self, params):
|
|
||||||
rel = params.get("path", [""])[0]
|
|
||||||
target = self.resolve_project_path(rel)
|
|
||||||
if target is None:
|
|
||||||
self.send_json({"error": "invalid path"}, 400)
|
|
||||||
return
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))})
|
|
||||||
|
|
||||||
def api_delete(self, params):
|
|
||||||
rel = params.get("path", [""])[0]
|
|
||||||
target = self.resolve_project_path(rel)
|
|
||||||
if target is None:
|
|
||||||
self.send_json({"error": "invalid path"}, 400)
|
|
||||||
return
|
|
||||||
if target.is_file():
|
|
||||||
target.unlink()
|
|
||||||
self.send_json({"ok": True, "path": str(target.relative_to(PROJECT_ROOT))})
|
|
||||||
|
|
||||||
def api_find(self, params):
|
|
||||||
rel = params.get("path", [""])[0]
|
|
||||||
ext = params.get("ext", [""])[0]
|
|
||||||
target = self.resolve_project_path(rel)
|
|
||||||
if target is None:
|
|
||||||
self.send_json({"error": "invalid path"}, 400)
|
|
||||||
return
|
|
||||||
if not target.is_dir():
|
|
||||||
self.send_json({"error": "not a directory"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
pattern = f"*{ext}" if ext else "*"
|
|
||||||
paths = sorted(
|
|
||||||
str(p.relative_to(target)).replace(os.sep, "/")
|
|
||||||
for p in target.rglob(pattern)
|
|
||||||
if p.is_file()
|
|
||||||
)
|
|
||||||
self.send_json({"path": str(target.relative_to(PROJECT_ROOT)), "files": paths})
|
|
||||||
|
|
||||||
def api_compile_chunk(self, params):
|
|
||||||
x = params.get("x", [""])[0]
|
|
||||||
y = params.get("y", [""])[0]
|
|
||||||
z = params.get("z", [""])[0]
|
|
||||||
if not (CHUNK_COORD_RE.match(x) and CHUNK_COORD_RE.match(y) and CHUNK_COORD_RE.match(z)):
|
|
||||||
self.send_json({"error": "invalid chunk coordinates"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
json_path = (
|
|
||||||
PROJECT_ROOT / "assetsraw" / "maps" / "overworld" / "chunks"
|
|
||||||
/ f"chunk_{x}_{y}_{z}.json"
|
|
||||||
)
|
|
||||||
if not json_path.is_file():
|
|
||||||
self.send_json({"error": "chunk json not found"}, 404)
|
|
||||||
return
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "tools.asset.chunk", str(json_path)],
|
|
||||||
cwd=str(PROJECT_ROOT),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
self.send_json({
|
|
||||||
"ok": result.returncode == 0,
|
|
||||||
"returncode": result.returncode,
|
|
||||||
"stdout": result.stdout,
|
|
||||||
"stderr": result.stderr,
|
|
||||||
})
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Static file serving
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def serve_static(self, path):
|
|
||||||
clean = path.lstrip("/")
|
|
||||||
candidates = []
|
|
||||||
|
|
||||||
if clean == "" or clean == "index" or clean == "index.html":
|
|
||||||
candidates = ["index.html"]
|
|
||||||
else:
|
|
||||||
candidates.append(clean)
|
|
||||||
if "." not in Path(clean).name:
|
|
||||||
candidates.append(clean + ".html")
|
|
||||||
candidates.append(clean + "/index.html")
|
|
||||||
|
|
||||||
for candidate in candidates:
|
|
||||||
file_path = PUBLIC_DIR / candidate
|
|
||||||
if file_path.is_file():
|
|
||||||
self.serve_file(file_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
def serve_file(self, file_path):
|
|
||||||
mime, _ = mimetypes.guess_type(str(file_path))
|
|
||||||
mime = mime or "application/octet-stream"
|
|
||||||
|
|
||||||
template = PUBLIC_DIR / "template.html"
|
|
||||||
if (
|
|
||||||
mime == "text/html"
|
|
||||||
and file_path.name != "template.html"
|
|
||||||
and template.is_file()
|
|
||||||
):
|
|
||||||
fragment = file_path.read_text(encoding="utf-8")
|
|
||||||
shell = template.read_text(encoding="utf-8")
|
|
||||||
marker = shell.find('id="editor"')
|
|
||||||
if marker == -1:
|
|
||||||
content = shell.encode("utf-8")
|
|
||||||
else:
|
|
||||||
tag_end = shell.index(">", marker) + 1
|
|
||||||
close_idx = shell.index("</div>", tag_end)
|
|
||||||
content = (shell[:tag_end] + fragment + shell[close_idx:]).encode("utf-8")
|
|
||||||
else:
|
|
||||||
content = file_path.read_bytes()
|
|
||||||
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", mime)
|
|
||||||
self.send_header("Content-Length", len(content))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(content)
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
print(f" {self.address_string()} {fmt % args}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print(f"Map Editor")
|
|
||||||
print(f" http://localhost:{PORT}")
|
|
||||||
print(f" public : {PUBLIC_DIR}")
|
|
||||||
print(f" project: {PROJECT_ROOT}")
|
|
||||||
server = HTTPServer(("", PORT), Handler)
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nStopped.")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
python3 server/server.py
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-gamecube.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/gamecube/build-gamecube.sh"
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-gamecube-iso.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/gamecube/build-gamecube-iso.sh"
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-knulli -f docker/knulli/Dockerfile .
|
docker build -t dusk-knulli -f docker/knulli/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-knulli /bin/bash -c "./scripts/build-knulli.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-knulli /bin/bash -c "./scripts/knulli/build-knulli.sh"
|
||||||
File renamed without changes.
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-linux -f docker/linux/Dockerfile .
|
docker build -t dusk-linux -f docker/linux/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/build-linux.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-linux /bin/bash -c "./scripts/linux/build-linux.sh"
|
||||||
File renamed without changes.
@@ -4,4 +4,4 @@ docker run \
|
|||||||
--rm \
|
--rm \
|
||||||
-v "${GITHUB_WORKSPACE}:/workdir" \
|
-v "${GITHUB_WORKSPACE}:/workdir" \
|
||||||
dusk-linux \
|
dusk-linux \
|
||||||
/bin/bash -c "./scripts/test-linux.sh"
|
/bin/bash -c "./scripts/linux/test-linux.sh"
|
||||||
File renamed without changes.
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-psp -f docker/psp/Dockerfile .
|
docker build -t dusk-psp -f docker/psp/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-psp /bin/bash -c "./scripts/build-psp.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-psp /bin/bash -c "./scripts/psp/build-psp.sh"
|
||||||
File renamed without changes.
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-vita -f docker/vita/Dockerfile .
|
docker build -t dusk-vita -f docker/vita/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-vita /bin/bash -c "./scripts/build-vita.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-vita /bin/bash -c "./scripts/vita/build-vita.sh"
|
||||||
File renamed without changes.
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-wii.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/wii/build-wii.sh"
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
docker build -t dusk-dolphin -f docker/dolphin/Dockerfile .
|
||||||
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/build-wii-iso.sh"
|
docker run --rm -v "$(pwd):/workdir" dusk-dolphin /bin/bash -c "./scripts/wii/build-wii-iso.sh"
|
||||||
File renamed without changes.
File renamed without changes.
@@ -50,6 +50,12 @@ if(DUSK_BACKTRACE)
|
|||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(DUSK_TRACK_MEMORY)
|
||||||
|
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
|
||||||
|
DUSK_TRACK_MEMORY
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Includes
|
# Includes
|
||||||
target_include_directories(${DUSK_LIBRARY_TARGET_NAME}
|
target_include_directories(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
PUBLIC
|
PUBLIC
|
||||||
@@ -75,11 +81,9 @@ add_subdirectory(error)
|
|||||||
add_subdirectory(input)
|
add_subdirectory(input)
|
||||||
add_subdirectory(locale)
|
add_subdirectory(locale)
|
||||||
add_subdirectory(perf)
|
add_subdirectory(perf)
|
||||||
add_subdirectory(rpg)
|
|
||||||
add_subdirectory(scene)
|
add_subdirectory(scene)
|
||||||
add_subdirectory(system)
|
add_subdirectory(system)
|
||||||
add_subdirectory(time)
|
add_subdirectory(time)
|
||||||
add_subdirectory(ui)
|
|
||||||
add_subdirectory(save)
|
add_subdirectory(save)
|
||||||
add_subdirectory(util)
|
add_subdirectory(util)
|
||||||
add_subdirectory(thread)
|
add_subdirectory(thread)
|
||||||
@@ -6,6 +6,4 @@
|
|||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
||||||
PUBLIC
|
PUBLIC
|
||||||
easing.c
|
easing.c
|
||||||
animation.c
|
|
||||||
keyframe.c
|
|
||||||
)
|
)
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
#include "animation.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/math.h"
|
|
||||||
|
|
||||||
void animationInit(
|
|
||||||
animation_t *anim,
|
|
||||||
keyframe_t *keyframes,
|
|
||||||
uint16_t *keyframeCounts,
|
|
||||||
const uint16_t layerCount
|
|
||||||
) {
|
|
||||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
|
||||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
|
||||||
assertNotNull(keyframeCounts, "Keyframe counts pointer cannot be null.");
|
|
||||||
assertTrue(layerCount > 0, "Layer count must be greater than zero.");
|
|
||||||
|
|
||||||
memoryZero(anim, sizeof(animation_t));
|
|
||||||
anim->keyframes = keyframes;
|
|
||||||
anim->keyframeCounts = keyframeCounts;
|
|
||||||
anim->layerCount = layerCount;
|
|
||||||
|
|
||||||
// Determine duration
|
|
||||||
float_t duration = 0.0f;
|
|
||||||
for(uint16_t layer = 0; layer < layerCount; layer++) {
|
|
||||||
uint16_t keyframeCount = keyframeCounts[layer];
|
|
||||||
assertTrue(keyframeCount > 0, "Keyframe count invalid.");
|
|
||||||
keyframe_t *layerKeyframes = keyframes + layer * keyframeCount;
|
|
||||||
|
|
||||||
#ifdef DUSK_ASSERTIONS
|
|
||||||
// Check that the keyframes are sorted by time.
|
|
||||||
for(uint16_t i = 1; i < keyframeCount; i++) {
|
|
||||||
assertTrue(
|
|
||||||
layerKeyframes[i].time >= layerKeyframes[i - 1].time,
|
|
||||||
"Keyframes must be sorted by time."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
keyframe_t *lastKeyframe = layerKeyframes + keyframeCount - 1;
|
|
||||||
duration = mathMax(duration, lastKeyframe->time);
|
|
||||||
}
|
|
||||||
assertTrue(duration > 0, "Animation duration must be greater than 0.");
|
|
||||||
anim->duration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer) {
|
|
||||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
|
||||||
assertTrue(layer < anim->layerCount, "Layer index out of bounds.");
|
|
||||||
|
|
||||||
uint16_t keyframeCount = anim->keyframeCounts[layer];
|
|
||||||
keyframe_t *layerKeyframes = anim->keyframes + layer * keyframeCount;
|
|
||||||
return keyframeGetValue(layerKeyframes, keyframeCount, anim->time);
|
|
||||||
}
|
|
||||||
|
|
||||||
void animationUpdate(
|
|
||||||
animation_t *anim,
|
|
||||||
const float_t deltaTime
|
|
||||||
) {
|
|
||||||
assertNotNull(anim, "Animation pointer cannot be null.");
|
|
||||||
assertTrue(deltaTime >= 0, "Delta time must be non-negative.");
|
|
||||||
|
|
||||||
bool_t justCompleted = false;
|
|
||||||
|
|
||||||
if(!(anim->flags & ANIMATION_FLAG_INTERNAL_COMPLETED)) {
|
|
||||||
bool_t loop = (anim->flags & ANIMATION_FLAG_LOOP) != 0;
|
|
||||||
bool_t pingpong = (anim->flags & ANIMATION_FLAG_PINGPONG) != 0;
|
|
||||||
assertFalse(
|
|
||||||
loop && pingpong,
|
|
||||||
"Cannot set both ANIMATION_FLAG_LOOP and ANIMATION_FLAG_PINGPONG."
|
|
||||||
);
|
|
||||||
|
|
||||||
bool_t backward;
|
|
||||||
if(pingpong) {
|
|
||||||
backward = (anim->flags & ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD) != 0;
|
|
||||||
} else {
|
|
||||||
backward = (anim->flags & ANIMATION_FLAG_REVERSE) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve boundary crossings one at a time, so a single large deltaTime
|
|
||||||
// can correctly loop/pingpong across multiple boundaries in one call.
|
|
||||||
float_t remaining = deltaTime;
|
|
||||||
while(remaining > 0.0f) {
|
|
||||||
float_t toBoundary = (
|
|
||||||
backward ? anim->time : (anim->duration - anim->time)
|
|
||||||
);
|
|
||||||
if(remaining < toBoundary) {
|
|
||||||
anim->time += backward ? -remaining : remaining;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining -= toBoundary;
|
|
||||||
anim->time = backward ? 0.0f : anim->duration;
|
|
||||||
|
|
||||||
bool_t stopHere;
|
|
||||||
if(backward) {
|
|
||||||
stopHere = (anim->flags & ANIMATION_FLAG_STOP_BEGINNING) != 0;
|
|
||||||
} else {
|
|
||||||
stopHere = (anim->flags & ANIMATION_FLAG_STOP_END) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(stopHere) {
|
|
||||||
justCompleted = true;
|
|
||||||
break;
|
|
||||||
} else if(pingpong) {
|
|
||||||
backward = !backward;
|
|
||||||
if(backward) anim->flags |= ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
|
|
||||||
else anim->flags &= ~ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD;
|
|
||||||
} else if(loop) {
|
|
||||||
anim->time = backward ? anim->duration : 0.0f;
|
|
||||||
if(anim->onLoop) anim->onLoop(anim->user);
|
|
||||||
} else {
|
|
||||||
justCompleted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(justCompleted) anim->flags |= ANIMATION_FLAG_INTERNAL_COMPLETED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call onUpdate for each layer.
|
|
||||||
for(uint16_t layer = 0; layer < anim->layerCount; layer++) {
|
|
||||||
float_t value = animationGetLayerValue(anim, layer);
|
|
||||||
if(anim->onUpdate) anim->onUpdate(layer, value, anim->user);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(justCompleted && anim->onComplete) anim->onComplete(anim->user);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "keyframe.h"
|
|
||||||
|
|
||||||
#define ANIMATION_FLAG_LOOP (1 << 0)
|
|
||||||
#define ANIMATION_FLAG_REVERSE (1 << 1)
|
|
||||||
#define ANIMATION_FLAG_PINGPONG (1 << 2)
|
|
||||||
#define ANIMATION_FLAG_STOP_BEGINNING (1 << 3)
|
|
||||||
#define ANIMATION_FLAG_STOP_END (1 << 4)
|
|
||||||
|
|
||||||
// Internal - tracks which direction a pingponging animation is currently
|
|
||||||
// travelling. Do not set this manually, it is managed by animationUpdate().
|
|
||||||
#define ANIMATION_FLAG_INTERNAL_PINGPONG_BACKWARD (1 << 7)
|
|
||||||
|
|
||||||
// Internal - set once the animation has stopped advancing (see
|
|
||||||
// animationUpdate()). Do not set this manually. There is currently no way to
|
|
||||||
// restart a completed animation short of clearing this bit and resetting
|
|
||||||
// anim->time by hand.
|
|
||||||
#define ANIMATION_FLAG_INTERNAL_COMPLETED (1 << 6)
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
keyframe_t *keyframes;
|
|
||||||
uint16_t *keyframeCounts;
|
|
||||||
uint16_t layerCount;
|
|
||||||
float_t time;
|
|
||||||
float_t duration;
|
|
||||||
uint8_t flags;
|
|
||||||
|
|
||||||
void *user;
|
|
||||||
void (*onUpdate)(const uint16_t layer, const float_t value, void *user);
|
|
||||||
void (*onComplete)(void *user);
|
|
||||||
void (*onLoop)(void *user);
|
|
||||||
} animation_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes an animation with the given keyframes and layer count.
|
|
||||||
*
|
|
||||||
* @param anim Pointer to the animation to initialize.
|
|
||||||
* @param keyframes Pointer to the array of keyframes for each layer.
|
|
||||||
* @param keyframeCount Number of keyframes in each layer.
|
|
||||||
* @param layerCount Number of layers in the animation.
|
|
||||||
*/
|
|
||||||
void animationInit(
|
|
||||||
animation_t *anim,
|
|
||||||
keyframe_t *keyframes,
|
|
||||||
uint16_t *keyframeCounts,
|
|
||||||
const uint16_t layerCount
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the current time of the animation, clamping it to the valid range.
|
|
||||||
* This will call the onUpdate callback but none of the other callbacks.
|
|
||||||
*
|
|
||||||
* @param anim Pointer to the animation to set the time for.
|
|
||||||
* @param time The new time to set for the animation.
|
|
||||||
*/
|
|
||||||
void animationSetTime(animation_t *anim, const float_t time);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current value of a specific layer in the animation based on the
|
|
||||||
* current animation time.
|
|
||||||
*/
|
|
||||||
float_t animationGetLayerValue(const animation_t *anim, const uint16_t layer);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the animation state based on the elapsed time. Advances anim->time
|
|
||||||
* by deltaTime (or against it, if ANIMATION_FLAG_REVERSE is set), then
|
|
||||||
* resolves whatever happens when it reaches the 0 or duration boundary:
|
|
||||||
*
|
|
||||||
* - ANIMATION_FLAG_PINGPONG: reflects off the boundary and continues playing
|
|
||||||
* in the opposite direction, forever, unless stopped (see below).
|
|
||||||
* - ANIMATION_FLAG_LOOP: wraps back around to the other boundary and keeps
|
|
||||||
* playing in the same direction, forever, unless stopped (see below).
|
|
||||||
* - ANIMATION_FLAG_STOP_BEGINNING / ANIMATION_FLAG_STOP_END: when the
|
|
||||||
* animation reaches that specific boundary, it clamps there and stops
|
|
||||||
* (firing onComplete) instead of looping/pingponging past it.
|
|
||||||
* - If none of the above apply at a boundary, the animation clamps there and
|
|
||||||
* stops, firing onComplete.
|
|
||||||
*
|
|
||||||
* onUpdate is called for every layer on every call. onLoop is called each
|
|
||||||
* time a loop wraps around. onComplete is called at most once, the moment
|
|
||||||
* the animation stops advancing.
|
|
||||||
*
|
|
||||||
* @param anim Pointer to the animation to update.
|
|
||||||
* @param deltaTime Time elapsed since the last update (in seconds).
|
|
||||||
*/
|
|
||||||
void animationUpdate(
|
|
||||||
animation_t *anim,
|
|
||||||
const float_t deltaTime
|
|
||||||
);
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "keyframe.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/math.h"
|
|
||||||
|
|
||||||
float_t keyframeGetValue(
|
|
||||||
const keyframe_t *keyframes,
|
|
||||||
const uint32_t keyframeCount,
|
|
||||||
const float_t time
|
|
||||||
) {
|
|
||||||
assertNotNull(keyframes, "Keyframes pointer cannot be null.");
|
|
||||||
assertTrue(keyframeCount > 0, "Keyframe count must be more than 0.");
|
|
||||||
assertTrue(time >= 0, "Time must be non-negative.");
|
|
||||||
#ifdef DUSK_ASSERTIONS
|
|
||||||
// Checks that the keyframes are sorted by time.
|
|
||||||
for(uint32_t i = 1; i < keyframeCount; i++) {
|
|
||||||
assertTrue(
|
|
||||||
keyframes[i].time >= keyframes[i - 1].time,
|
|
||||||
"Keyframes must be sorted by time."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
keyframe_t *last = (keyframe_t *)(keyframes + keyframeCount - 1);
|
|
||||||
if(time >= last->time) return last->value;
|
|
||||||
|
|
||||||
// Since time < last->time (checked above), current is guaranteed to stop
|
|
||||||
// at or before reaching last, so no separate end-of-array check is needed.
|
|
||||||
keyframe_t *current = (keyframe_t *)keyframes;
|
|
||||||
keyframe_t *start = current;
|
|
||||||
while(current->time <= time) {
|
|
||||||
start = current;
|
|
||||||
current++;
|
|
||||||
}
|
|
||||||
keyframe_t *end = current;
|
|
||||||
|
|
||||||
float_t t = (time - start->time) / (end->time - start->time);
|
|
||||||
return mathLerp(start->value, end->value, easingApply(start->easing, t));
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright (c) 2026 Dominic Masters
|
|
||||||
//
|
|
||||||
// This software is released under the MIT License.
|
|
||||||
// https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "easing.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
float_t time;
|
|
||||||
float_t value;
|
|
||||||
easingtype_t easing;
|
|
||||||
} keyframe_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the value of a keyframe at a given time.
|
|
||||||
*
|
|
||||||
* @param keyframes The keyframes to get the value from.
|
|
||||||
* @param keyframeCount The number of keyframes in the array.
|
|
||||||
* @param time The time at which to get the value, in seconds.
|
|
||||||
* @return The value of the keyframe at the given time.
|
|
||||||
*/
|
|
||||||
float_t keyframeGetValue(
|
|
||||||
const keyframe_t *keyframes,
|
|
||||||
const uint32_t keyframeCount,
|
|
||||||
const float_t time
|
|
||||||
);
|
|
||||||
@@ -12,13 +12,16 @@
|
|||||||
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
|
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
|
||||||
// never gets misread as a valid dusk.dsk.
|
// never gets misread as a valid dusk.dsk.
|
||||||
#define ASSET_DSK_MAGIC_SIZE 4
|
#define ASSET_DSK_MAGIC_SIZE 4
|
||||||
#define ASSET_DSK_MAGIC "DSK2"
|
#define ASSET_DSK_MAGIC "DSK"
|
||||||
#define ASSET_DSK_VERSION 2
|
#define ASSET_DSK_VERSION 2
|
||||||
|
|
||||||
// magic(4) + version(4) + size(4) + checksum(4), all little-endian
|
// Magic + version + size + checksum
|
||||||
// regardless of host - see assetDskParseHeader. The zip archive itself
|
#define ASSET_DSK_HEADER_SIZE ( \
|
||||||
// starts immediately after, at ASSET_DSK_HEADER_SIZE.
|
ASSET_DSK_MAGIC_SIZE + \
|
||||||
#define ASSET_DSK_HEADER_SIZE 16
|
sizeof(uint32_t) + \
|
||||||
|
sizeof(uint32_t) + \
|
||||||
|
sizeof(uint32_t) \
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is a single uncompressed
|
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is a single uncompressed
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|||||||
add_subdirectory(display)
|
add_subdirectory(display)
|
||||||
add_subdirectory(locale)
|
add_subdirectory(locale)
|
||||||
add_subdirectory(json)
|
add_subdirectory(json)
|
||||||
add_subdirectory(chunk)
|
|
||||||
add_subdirectory(dmf)
|
add_subdirectory(dmf)
|
||||||
add_subdirectory(cutscene)
|
|
||||||
add_subdirectory(wav)
|
add_subdirectory(wav)
|
||||||
add_subdirectory(mp3)
|
add_subdirectory(mp3)
|
||||||
@@ -46,18 +46,6 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
|
|||||||
.dispose = assetJsonDispose
|
.dispose = assetJsonDispose
|
||||||
},
|
},
|
||||||
|
|
||||||
[ASSET_LOADER_TYPE_CHUNK] = {
|
|
||||||
.loadSync = assetChunkLoaderSync,
|
|
||||||
.loadAsync = assetChunkLoaderAsync,
|
|
||||||
.dispose = assetChunkDispose
|
|
||||||
},
|
|
||||||
|
|
||||||
[ASSET_LOADER_TYPE_CUTSCENE] = {
|
|
||||||
.loadSync = assetCutsceneLoaderSync,
|
|
||||||
.loadAsync = assetCutsceneLoaderAsync,
|
|
||||||
.dispose = assetCutsceneDispose
|
|
||||||
},
|
|
||||||
|
|
||||||
[ASSET_LOADER_TYPE_WAV] = {
|
[ASSET_LOADER_TYPE_WAV] = {
|
||||||
.loadSync = assetWavLoaderSync,
|
.loadSync = assetWavLoaderSync,
|
||||||
.loadAsync = assetWavLoaderAsync,
|
.loadAsync = assetWavLoaderAsync,
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
#include "asset/loader/display/assettilesetloader.h"
|
#include "asset/loader/display/assettilesetloader.h"
|
||||||
#include "asset/loader/locale/assetlocaleloader.h"
|
#include "asset/loader/locale/assetlocaleloader.h"
|
||||||
#include "asset/loader/json/assetjsonloader.h"
|
#include "asset/loader/json/assetjsonloader.h"
|
||||||
#include "asset/loader/chunk/assetchunkloader.h"
|
|
||||||
#include "asset/loader/cutscene/assetcutsceneloader.h"
|
|
||||||
#include "asset/loader/wav/assetwavloader.h"
|
#include "asset/loader/wav/assetwavloader.h"
|
||||||
#include "asset/loader/mp3/assetmp3loader.h"
|
#include "asset/loader/mp3/assetmp3loader.h"
|
||||||
|
|
||||||
@@ -26,8 +24,6 @@ typedef enum {
|
|||||||
ASSET_LOADER_TYPE_TILESET,
|
ASSET_LOADER_TYPE_TILESET,
|
||||||
ASSET_LOADER_TYPE_LOCALE,
|
ASSET_LOADER_TYPE_LOCALE,
|
||||||
ASSET_LOADER_TYPE_JSON,
|
ASSET_LOADER_TYPE_JSON,
|
||||||
ASSET_LOADER_TYPE_CHUNK,
|
|
||||||
ASSET_LOADER_TYPE_CUTSCENE,
|
|
||||||
ASSET_LOADER_TYPE_WAV,
|
ASSET_LOADER_TYPE_WAV,
|
||||||
ASSET_LOADER_TYPE_MP3,
|
ASSET_LOADER_TYPE_MP3,
|
||||||
|
|
||||||
@@ -41,8 +37,6 @@ typedef union {
|
|||||||
assettilesetloaderloading_t tileset;
|
assettilesetloaderloading_t tileset;
|
||||||
assetlocaleloaderloading_t locale;
|
assetlocaleloaderloading_t locale;
|
||||||
assetjsonloaderloading_t json;
|
assetjsonloaderloading_t json;
|
||||||
assetchunkloaderloading_t chunk;
|
|
||||||
assetcutsceneloaderloading_t cutscene;
|
|
||||||
assetwavloaderloading_t wav;
|
assetwavloaderloading_t wav;
|
||||||
assetmp3loaderloading_t mp3;
|
assetmp3loaderloading_t mp3;
|
||||||
} assetloaderloading_t;
|
} assetloaderloading_t;
|
||||||
@@ -54,8 +48,6 @@ typedef union {
|
|||||||
assettilesetoutput_t tileset;
|
assettilesetoutput_t tileset;
|
||||||
assetlocaleoutput_t locale;
|
assetlocaleoutput_t locale;
|
||||||
assetjsonoutput_t json;
|
assetjsonoutput_t json;
|
||||||
assetchunkoutput_t chunk;
|
|
||||||
assetcutsceneoutput_t cutscene;
|
|
||||||
assetwavoutput_t wav;
|
assetwavoutput_t wav;
|
||||||
assetmp3output_t mp3;
|
assetmp3output_t mp3;
|
||||||
} assetloaderoutput_t;
|
} assetloaderoutput_t;
|
||||||
@@ -65,8 +57,6 @@ typedef union {
|
|||||||
assettilesetloaderinput_t tileset;
|
assettilesetloaderinput_t tileset;
|
||||||
assetlocaleloaderinput_t locale;
|
assetlocaleloaderinput_t locale;
|
||||||
assetjsonloaderinput_t json;
|
assetjsonloaderinput_t json;
|
||||||
assetchunkloaderinput_t chunk;
|
|
||||||
assetcutsceneloaderinput_t cutscene;
|
|
||||||
assetwavloaderinput_t wav;
|
assetwavloaderinput_t wav;
|
||||||
assetmp3loaderinput_t mp3;
|
assetmp3loaderinput_t mp3;
|
||||||
} assetloaderinput_t;
|
} assetloaderinput_t;
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
# Sources
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
assetchunkloader.c
|
|
||||||
)
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "assetchunkloader.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "asset/asset.h"
|
|
||||||
#include "asset/loader/assetloading.h"
|
|
||||||
#include "asset/loader/assetentry.h"
|
|
||||||
#include "asset/loader/assetloader.h"
|
|
||||||
#include "asset/loader/json/assetjsonloader.h"
|
|
||||||
#include "display/color.h"
|
|
||||||
#include "display/texture/texture.h"
|
|
||||||
#include "rpg/overworld/worldpos.h"
|
|
||||||
#include "yyjson.h"
|
|
||||||
|
|
||||||
#define ASSET_CHUNK_TERRAIN_TEXTURE "tiles.png"
|
|
||||||
|
|
||||||
// Per-shape corner Z offsets (sw, se, ne, nw) as offsets from a tile's own
|
|
||||||
// base Z, ported 1:1 from tools/asset/chunk/__main__.py's _RAMP_CORNERS.
|
|
||||||
// TILE_SHAPE_NULL's row is never read - assetChunkTilesHaveTerrain/
|
|
||||||
// assetChunkBuildTerrain skip those tiles outright.
|
|
||||||
static const float_t CHUNK_RAMP_CORNERS[TILE_SHAPE_COUNT][4] = {
|
|
||||||
[TILE_SHAPE_GROUND] = {0.0f, 0.0f, 0.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_NORTH] = {0.0f, 0.0f, 1.0f, 1.0f},
|
|
||||||
[TILE_SHAPE_RAMP_SOUTH] = {1.0f, 1.0f, 0.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_EAST] = {0.0f, 1.0f, 1.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_WEST] = {1.0f, 0.0f, 0.0f, 1.0f},
|
|
||||||
[TILE_SHAPE_RAMP_NORTHEAST] = {0.0f, 0.0f, 1.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_NORTHWEST] = {0.0f, 0.0f, 0.0f, 1.0f},
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHEAST] = {0.0f, 1.0f, 0.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHWEST] = {1.0f, 0.0f, 0.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_NORTHEAST_INNER] = {0.0f, 1.0f, 1.0f, 1.0f},
|
|
||||||
[TILE_SHAPE_RAMP_NORTHWEST_INNER] = {1.0f, 0.0f, 1.0f, 1.0f},
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHEAST_INNER] = {1.0f, 1.0f, 1.0f, 0.0f},
|
|
||||||
[TILE_SHAPE_RAMP_SOUTHWEST_INNER] = {1.0f, 1.0f, 0.0f, 1.0f},
|
|
||||||
};
|
|
||||||
|
|
||||||
errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
|
|
||||||
assertNotNull(loading, "Loading cannot be NULL");
|
|
||||||
assertNotMainThread("Should be called from an async thread.");
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool_t assetChunkTilesHaveTerrain(const tile_t *tiles) {
|
|
||||||
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
|
|
||||||
if(tiles[i].shape != TILE_SHAPE_NULL) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads the "tiles" array - a dense, fixed-size CHUNK_TILE_COUNT grid,
|
|
||||||
// one slot per (x, y) column at index x + y * CHUNK_WIDTH (position is
|
|
||||||
// implicit in the index, not stored). Each slot is either `null` (empty
|
|
||||||
// column, already zeroed = TILE_SHAPE_NULL) or a 2-element [type, z]
|
|
||||||
// array.
|
|
||||||
static void assetChunkParseTiles(yyjson_val *root, tile_t *tiles) {
|
|
||||||
memoryZero(tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
|
|
||||||
|
|
||||||
yyjson_val *tilesArr = yyjson_obj_get(root, "tiles");
|
|
||||||
if(!yyjson_is_arr(tilesArr)) return;
|
|
||||||
|
|
||||||
size_t idx, len;
|
|
||||||
yyjson_val *tileVal;
|
|
||||||
yyjson_arr_foreach(tilesArr, idx, len, tileVal) {
|
|
||||||
if(idx >= CHUNK_TILE_COUNT) break;
|
|
||||||
if(!yyjson_is_arr(tileVal) || yyjson_arr_size(tileVal) < 2) continue;
|
|
||||||
|
|
||||||
int32_t type = (int32_t)yyjson_get_int(yyjson_arr_get(tileVal, 0));
|
|
||||||
int32_t z = (int32_t)yyjson_get_int(yyjson_arr_get(tileVal, 1));
|
|
||||||
tiles[idx] = (tile_t){ .shape = (tileshape_t)type, .z = (uint8_t)z };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolves "meshes" (external building references, by bare filename) into
|
|
||||||
// out->modelNames/meshOffsets starting at *ioIndex, advancing it as
|
|
||||||
// entries are written. Filenames are resolved as
|
|
||||||
// "models/buildings/<basename>.json" - a hardcoded stand-in for
|
|
||||||
// tools/asset/chunk/__main__.py's find_model() recursive search, valid
|
|
||||||
// as long as every building model lives flat under that one directory
|
|
||||||
// (true today - see the raw-json-maps plan notes for the tradeoff).
|
|
||||||
static void assetChunkParseMeshes(
|
|
||||||
yyjson_val *root, assetchunkoutput_t *out, uint8_t *ioIndex
|
|
||||||
) {
|
|
||||||
yyjson_val *meshesArr = yyjson_obj_get(root, "meshes");
|
|
||||||
if(!yyjson_is_arr(meshesArr)) return;
|
|
||||||
|
|
||||||
size_t idx, len;
|
|
||||||
yyjson_val *meshVal;
|
|
||||||
yyjson_arr_foreach(meshesArr, idx, len, meshVal) {
|
|
||||||
if(*ioIndex >= CHUNK_MESH_COUNT_MAX) break;
|
|
||||||
|
|
||||||
yyjson_val *fileVal = yyjson_obj_get(meshVal, "file");
|
|
||||||
if(!fileVal || !yyjson_is_str(fileVal)) continue;
|
|
||||||
|
|
||||||
char_t baseName[CHUNK_MESH_NAME_MAX];
|
|
||||||
stringCopy(baseName, yyjson_get_str(fileVal), sizeof(baseName));
|
|
||||||
char_t *dot = stringFindLastChar(baseName, '.');
|
|
||||||
if(dot != NULL) *dot = '\0';
|
|
||||||
|
|
||||||
uint8_t m = *ioIndex;
|
|
||||||
stringFormat(
|
|
||||||
out->modelNames[m], CHUNK_MESH_NAME_MAX,
|
|
||||||
"models/buildings/%s.json", baseName
|
|
||||||
);
|
|
||||||
|
|
||||||
vec3 offset = {0.0f, 0.0f, 0.0f};
|
|
||||||
yyjson_val *posArr = yyjson_obj_get(meshVal, "pos");
|
|
||||||
if(yyjson_is_arr(posArr) && yyjson_arr_size(posArr) >= 3) {
|
|
||||||
offset[0] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 0));
|
|
||||||
offset[1] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 1));
|
|
||||||
offset[2] = (float_t)yyjson_get_num(yyjson_arr_get(posArr, 2));
|
|
||||||
}
|
|
||||||
memoryCopy(out->meshOffsets[m], offset, sizeof(vec3));
|
|
||||||
|
|
||||||
(*ioIndex)++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void assetChunkParseEntities(yyjson_val *root, assetchunkoutput_t *out) {
|
|
||||||
out->entitySpawnCount = 0;
|
|
||||||
|
|
||||||
yyjson_val *arr = yyjson_obj_get(root, "entities");
|
|
||||||
if(!yyjson_is_arr(arr)) return;
|
|
||||||
|
|
||||||
size_t idx, len;
|
|
||||||
yyjson_val *val;
|
|
||||||
yyjson_arr_foreach(arr, idx, len, val) {
|
|
||||||
if(out->entitySpawnCount >= CHUNK_ENTITY_SPAWN_COUNT_MAX) break;
|
|
||||||
|
|
||||||
const char_t *type = yyjson_get_str(yyjson_obj_get(val, "type"));
|
|
||||||
yyjson_val *posArr = yyjson_obj_get(val, "pos");
|
|
||||||
if(type == NULL || !yyjson_is_arr(posArr) || yyjson_arr_size(posArr) < 3) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
worldpos_t pos = {
|
|
||||||
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 0)),
|
|
||||||
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 1)),
|
|
||||||
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(posArr, 2)),
|
|
||||||
};
|
|
||||||
|
|
||||||
chunkentityspawn_t *spawn = &out->entitySpawns[out->entitySpawnCount];
|
|
||||||
if(stringEquals(type, "global")) {
|
|
||||||
spawn->kind = CHUNK_ENTITY_SPAWN_KIND_GLOBAL;
|
|
||||||
spawn->globalId =
|
|
||||||
(uint16_t)yyjson_get_int(yyjson_obj_get(val, "globalId"));
|
|
||||||
spawn->itemId = 0;
|
|
||||||
spawn->itemQuantity = 0;
|
|
||||||
} else if(stringEquals(type, "item")) {
|
|
||||||
spawn->kind = CHUNK_ENTITY_SPAWN_KIND_ITEM;
|
|
||||||
spawn->globalId = 0;
|
|
||||||
spawn->itemId = (uint16_t)yyjson_get_int(yyjson_obj_get(val, "itemId"));
|
|
||||||
spawn->itemQuantity =
|
|
||||||
(uint8_t)yyjson_get_int(yyjson_obj_get(val, "quantity"));
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
spawn->position = pos;
|
|
||||||
out->entitySpawnCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void assetChunkParseAreas(yyjson_val *root, assetchunkoutput_t *out) {
|
|
||||||
out->areaSpawnCount = 0;
|
|
||||||
|
|
||||||
yyjson_val *arr = yyjson_obj_get(root, "areas");
|
|
||||||
if(!yyjson_is_arr(arr)) return;
|
|
||||||
|
|
||||||
size_t idx, len;
|
|
||||||
yyjson_val *val;
|
|
||||||
yyjson_arr_foreach(arr, idx, len, val) {
|
|
||||||
if(out->areaSpawnCount >= CHUNK_AREA_COUNT_MAX) break;
|
|
||||||
|
|
||||||
yyjson_val *minArr = yyjson_obj_get(val, "min");
|
|
||||||
yyjson_val *maxArr = yyjson_obj_get(val, "max");
|
|
||||||
if(
|
|
||||||
!yyjson_is_arr(minArr) || yyjson_arr_size(minArr) < 3 ||
|
|
||||||
!yyjson_is_arr(maxArr) || yyjson_arr_size(maxArr) < 3
|
|
||||||
) continue;
|
|
||||||
|
|
||||||
chunkareaspawn_t *area = &out->areaSpawns[out->areaSpawnCount];
|
|
||||||
area->min = (worldpos_t){
|
|
||||||
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 0)),
|
|
||||||
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 1)),
|
|
||||||
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(minArr, 2)),
|
|
||||||
};
|
|
||||||
area->max = (worldpos_t){
|
|
||||||
.x = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 0)),
|
|
||||||
.y = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 1)),
|
|
||||||
.z = (worldunit_t)yyjson_get_int(yyjson_arr_get(maxArr, 2)),
|
|
||||||
};
|
|
||||||
area->callbackId =
|
|
||||||
(uint16_t)yyjson_get_int(yyjson_obj_get(val, "callbackId"));
|
|
||||||
area->notify = (uint8_t)yyjson_get_int(yyjson_obj_get(val, "notify"));
|
|
||||||
area->trigger = (uint8_t)yyjson_get_int(yyjson_obj_get(val, "trigger"));
|
|
||||||
out->areaSpawnCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Six vertices (2 CCW triangles) for one tile's quad, matching
|
|
||||||
// tools/asset/chunk/__main__.py's _tile_quad. Corner order: SW=(x,y),
|
|
||||||
// SE=(x+1,y), NE=(x+1,y+1), NW=(x,y+1). NORTH=+Y, EAST=+X.
|
|
||||||
static void assetChunkAppendTileQuad(
|
|
||||||
meshvertex_t *verts, uint32_t *vertCount,
|
|
||||||
const uint32_t x, const uint32_t y,
|
|
||||||
const float_t sw, const float_t se, const float_t ne, const float_t nw
|
|
||||||
) {
|
|
||||||
const float_t u0 = (float_t)x / (float_t)CHUNK_WIDTH;
|
|
||||||
const float_t u1 = (float_t)(x + 1) / (float_t)CHUNK_WIDTH;
|
|
||||||
const float_t v0 = (float_t)y / (float_t)CHUNK_HEIGHT;
|
|
||||||
const float_t v1 = (float_t)(y + 1) / (float_t)CHUNK_HEIGHT;
|
|
||||||
const float_t fx = (float_t)x;
|
|
||||||
const float_t fy = (float_t)y;
|
|
||||||
|
|
||||||
meshvertex_t *v = verts + *vertCount;
|
|
||||||
v[0] = (meshvertex_t){ .uv = {u0, v0}, .pos = {fx, fy, sw} };
|
|
||||||
v[1] = (meshvertex_t){ .uv = {u1, v0}, .pos = {fx + 1, fy, se} };
|
|
||||||
v[2] = (meshvertex_t){ .uv = {u1, v1}, .pos = {fx + 1, fy + 1, ne} };
|
|
||||||
v[3] = (meshvertex_t){ .uv = {u0, v0}, .pos = {fx, fy, sw} };
|
|
||||||
v[4] = (meshvertex_t){ .uv = {u1, v1}, .pos = {fx + 1, fy + 1, ne} };
|
|
||||||
v[5] = (meshvertex_t){ .uv = {u0, v1}, .pos = {fx, fy + 1, nw} };
|
|
||||||
*vertCount += 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds a mesh_t straight from the tile grid - one quad per non-null
|
|
||||||
// tile column, shaped by CHUNK_RAMP_CORNERS - the runtime equivalent of
|
|
||||||
// tools/asset/chunk/__main__.py's build_terrain_verts. Only called once
|
|
||||||
// assetChunkTilesHaveTerrain has confirmed at least one real tile exists.
|
|
||||||
// Mirrors assetMeshLoaderSync's meshInit/meshFlush/free-after-upload
|
|
||||||
// sequence (assetmeshloader.c) since this produces the same
|
|
||||||
// assetmeshoutput_t shape, just from generated verts instead of a file.
|
|
||||||
static errorret_t assetChunkBuildTerrain(
|
|
||||||
const tile_t *tiles, assetmeshoutput_t *out
|
|
||||||
) {
|
|
||||||
meshvertex_t *verts = memoryAllocate(
|
|
||||||
CHUNK_TILE_COUNT * 6 * sizeof(meshvertex_t)
|
|
||||||
);
|
|
||||||
uint32_t vertCount = 0;
|
|
||||||
|
|
||||||
for(uint32_t y = 0; y < CHUNK_HEIGHT; y++) {
|
|
||||||
for(uint32_t x = 0; x < CHUNK_WIDTH; x++) {
|
|
||||||
const tile_t *tile = &tiles[x + y * CHUNK_WIDTH];
|
|
||||||
if(tile->shape == TILE_SHAPE_NULL) continue;
|
|
||||||
|
|
||||||
const float_t *corners = CHUNK_RAMP_CORNERS[tile->shape];
|
|
||||||
const float_t fz = (float_t)tile->z;
|
|
||||||
assetChunkAppendTileQuad(
|
|
||||||
verts, &vertCount, x, y,
|
|
||||||
(fz + corners[0]) * WORLD_LAYER_HEIGHT,
|
|
||||||
(fz + corners[1]) * WORLD_LAYER_HEIGHT,
|
|
||||||
(fz + corners[2]) * WORLD_LAYER_HEIGHT,
|
|
||||||
(fz + corners[3]) * WORLD_LAYER_HEIGHT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(vertCount > 0, "Terrain build called with no terrain tiles.");
|
|
||||||
|
|
||||||
out->vertices = verts;
|
|
||||||
errorret_t ret = meshInit(
|
|
||||||
&out->mesh, MESH_PRIMITIVE_TYPE_TRIANGLES, (int32_t)vertCount, verts
|
|
||||||
);
|
|
||||||
if(errorIsNotOk(ret)) {
|
|
||||||
memoryFree(out->vertices);
|
|
||||||
out->vertices = NULL;
|
|
||||||
errorChain(ret);
|
|
||||||
}
|
|
||||||
out->meshInitialized = true;
|
|
||||||
|
|
||||||
ret = meshFlush(&out->mesh, 0, (int32_t)vertCount);
|
|
||||||
if(errorIsNotOk(ret)) {
|
|
||||||
meshDispose(&out->mesh);
|
|
||||||
out->meshInitialized = false;
|
|
||||||
memoryFree(out->vertices);
|
|
||||||
out->vertices = NULL;
|
|
||||||
errorChain(ret);
|
|
||||||
}
|
|
||||||
|
|
||||||
#if defined(DUSK_OPENGL) && !defined(DUSK_OPENGL_LEGACY)
|
|
||||||
memoryFree(out->vertices);
|
|
||||||
out->vertices = NULL;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t assetChunkLoaderSync(assetloading_t *loading) {
|
|
||||||
assertNotNull(loading, "Loading cannot be NULL");
|
|
||||||
assertTrue(loading->type == ASSET_LOADER_TYPE_CHUNK, "Invalid type.");
|
|
||||||
assertIsMainThread("Must be called from the main thread.");
|
|
||||||
|
|
||||||
assetchunkoutput_t *out = &loading->entry->data.chunk;
|
|
||||||
|
|
||||||
switch(loading->loading.chunk.state) {
|
|
||||||
case ASSET_CHUNK_LOADING_STATE_INITIAL:
|
|
||||||
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
|
||||||
errorOk();
|
|
||||||
|
|
||||||
case ASSET_CHUNK_LOADING_STATE_PARSE:
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ASSET_CHUNK_LOADING_STATE_LOAD_MODELS:
|
|
||||||
while(loading->loading.chunk.modelIndex < out->meshCount) {
|
|
||||||
uint8_t m = loading->loading.chunk.modelIndex;
|
|
||||||
if(out->modelEntries[m] == NULL) {
|
|
||||||
out->modelEntries[m] = assetLock(
|
|
||||||
out->modelNames[m], ASSET_LOADER_TYPE_MODEL, NULL
|
|
||||||
);
|
|
||||||
assertNotNull(out->modelEntries[m], "Failed to lock model.");
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
if(out->modelEntries[m]->state == ASSET_ENTRY_STATE_ERROR) {
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Model failed to load: %s", out->modelNames[m]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if(out->modelEntries[m]->state != ASSET_ENTRY_STATE_LOADED) {
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
loading->loading.chunk.modelIndex++;
|
|
||||||
}
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
|
||||||
errorOk();
|
|
||||||
|
|
||||||
default:
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PARSE: lock+require the chunk's own JSON as a sub-asset, exactly
|
|
||||||
// like assetmodelloader.c's ASSET_MODEL_LOADING_STATE_LOCK_ASSETS does
|
|
||||||
// for its model descriptor, then read everything out of it before
|
|
||||||
// unlocking (the yyjson_doc/vals only stay valid while jsonEntry does).
|
|
||||||
char_t jsonKey[ASSET_FILE_NAME_MAX];
|
|
||||||
stringFormat(jsonKey, sizeof(jsonKey), "json:%s", loading->entry->name);
|
|
||||||
assetloaderinput_t jsonInput;
|
|
||||||
memoryZero(&jsonInput, sizeof(jsonInput));
|
|
||||||
stringCopy(
|
|
||||||
jsonInput.json.path, loading->entry->name, ASSET_FILE_NAME_MAX
|
|
||||||
);
|
|
||||||
assetentry_t *jsonEntry =
|
|
||||||
assetLock(jsonKey, ASSET_LOADER_TYPE_JSON, &jsonInput);
|
|
||||||
errorret_t jsonRet = assetRequireLoaded(jsonEntry);
|
|
||||||
if(errorIsNotOk(jsonRet)) {
|
|
||||||
assetUnlockEntry(jsonEntry);
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Failed to load chunk JSON: %s", loading->entry->name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
yyjson_val *root = yyjson_doc_get_root(jsonEntry->data.json);
|
|
||||||
|
|
||||||
tile_t tiles[CHUNK_TILE_COUNT];
|
|
||||||
assetChunkParseTiles(root, tiles);
|
|
||||||
out->hasTerrain = assetChunkTilesHaveTerrain(tiles);
|
|
||||||
|
|
||||||
uint8_t meshIndex = out->hasTerrain ? 1 : 0;
|
|
||||||
assetChunkParseMeshes(root, out, &meshIndex);
|
|
||||||
out->meshCount = meshIndex;
|
|
||||||
|
|
||||||
assetChunkParseEntities(root, out);
|
|
||||||
assetChunkParseAreas(root, out);
|
|
||||||
|
|
||||||
assetUnlockEntry(jsonEntry);
|
|
||||||
|
|
||||||
out->tiles = memoryAllocate(CHUNK_TILE_COUNT * sizeof(tile_t));
|
|
||||||
memoryCopy(out->tiles, tiles, CHUNK_TILE_COUNT * sizeof(tile_t));
|
|
||||||
|
|
||||||
// Terrain: build the mesh, then wrap it in synthetic (non-cache,
|
|
||||||
// non-refcounted) mesh/model entries so modelEntries[0] behaves exactly
|
|
||||||
// like a real file-backed model entry for every downstream consumer -
|
|
||||||
// see the header's doc comment on hasTerrain/terrainMeshEntry.
|
|
||||||
if(out->hasTerrain) {
|
|
||||||
assetmeshoutput_t terrainMesh;
|
|
||||||
memoryZero(&terrainMesh, sizeof(terrainMesh));
|
|
||||||
errorret_t terrainRet = assetChunkBuildTerrain(out->tiles, &terrainMesh);
|
|
||||||
if(errorIsNotOk(terrainRet)) {
|
|
||||||
memoryFree(out->tiles);
|
|
||||||
out->tiles = NULL;
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Failed to build chunk terrain mesh: %s",
|
|
||||||
loading->entry->name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assetloaderinput_t texInput = { .texture = TEXTURE_FORMAT_RGBA };
|
|
||||||
assetentry_t *texEntry = assetLock(
|
|
||||||
ASSET_CHUNK_TERRAIN_TEXTURE, ASSET_LOADER_TYPE_TEXTURE, &texInput
|
|
||||||
);
|
|
||||||
errorret_t texRet = assetRequireLoaded(texEntry);
|
|
||||||
if(errorIsNotOk(texRet)) {
|
|
||||||
assetUnlockEntry(texEntry);
|
|
||||||
meshDispose(&terrainMesh.mesh);
|
|
||||||
memoryFree(out->tiles);
|
|
||||||
out->tiles = NULL;
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Failed to load chunk terrain texture: %s",
|
|
||||||
loading->entry->name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
out->terrainTexEntry = texEntry;
|
|
||||||
|
|
||||||
out->terrainMeshEntry = memoryAllocate(sizeof(assetentry_t));
|
|
||||||
memoryZero(out->terrainMeshEntry, sizeof(assetentry_t));
|
|
||||||
out->terrainMeshEntry->type = ASSET_LOADER_TYPE_MESH;
|
|
||||||
out->terrainMeshEntry->state = ASSET_ENTRY_STATE_LOADED;
|
|
||||||
out->terrainMeshEntry->data.mesh = terrainMesh;
|
|
||||||
|
|
||||||
out->terrainModelEntry = memoryAllocate(sizeof(assetentry_t));
|
|
||||||
memoryZero(out->terrainModelEntry, sizeof(assetentry_t));
|
|
||||||
out->terrainModelEntry->type = ASSET_LOADER_TYPE_MODEL;
|
|
||||||
out->terrainModelEntry->state = ASSET_ENTRY_STATE_LOADED;
|
|
||||||
out->terrainModelEntry->data.model.meshEntry = out->terrainMeshEntry;
|
|
||||||
out->terrainModelEntry->data.model.texEntry = out->terrainTexEntry;
|
|
||||||
out->terrainModelEntry->data.model.color = COLOR_WHITE;
|
|
||||||
|
|
||||||
out->modelEntries[0] = out->terrainModelEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(out->meshCount == 0) {
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_MODELS;
|
|
||||||
loading->loading.chunk.modelIndex = out->hasTerrain ? 1 : 0;
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t assetChunkDispose(assetentry_t *entry) {
|
|
||||||
assertNotNull(entry, "Entry cannot be NULL");
|
|
||||||
assertTrue(entry->type == ASSET_LOADER_TYPE_CHUNK, "Invalid type.");
|
|
||||||
assertIsMainThread("Must be called from the main thread.");
|
|
||||||
|
|
||||||
assetchunkoutput_t *out = &entry->data.chunk;
|
|
||||||
|
|
||||||
if(out->tiles != NULL) {
|
|
||||||
memoryFree(out->tiles);
|
|
||||||
out->tiles = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
for(uint8_t m = 0; m < out->meshCount; m++) {
|
|
||||||
if(out->modelEntries[m] == NULL) continue;
|
|
||||||
if(out->hasTerrain && m == 0) continue; // synthetic - freed below.
|
|
||||||
assetUnlockEntry(out->modelEntries[m]);
|
|
||||||
out->modelEntries[m] = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(out->hasTerrain) {
|
|
||||||
if(out->terrainMeshEntry != NULL) {
|
|
||||||
assetmeshoutput_t *meshOut = &out->terrainMeshEntry->data.mesh;
|
|
||||||
if(meshOut->meshInitialized) {
|
|
||||||
meshDispose(&meshOut->mesh);
|
|
||||||
meshOut->meshInitialized = false;
|
|
||||||
}
|
|
||||||
if(meshOut->vertices != NULL) {
|
|
||||||
memoryFree(meshOut->vertices);
|
|
||||||
meshOut->vertices = NULL;
|
|
||||||
}
|
|
||||||
memoryFree(out->terrainMeshEntry);
|
|
||||||
out->terrainMeshEntry = NULL;
|
|
||||||
}
|
|
||||||
if(out->terrainModelEntry != NULL) {
|
|
||||||
memoryFree(out->terrainModelEntry);
|
|
||||||
out->terrainModelEntry = NULL;
|
|
||||||
}
|
|
||||||
if(out->terrainTexEntry != NULL) {
|
|
||||||
assetUnlockEntry(out->terrainTexEntry);
|
|
||||||
out->terrainTexEntry = NULL;
|
|
||||||
}
|
|
||||||
out->modelEntries[0] = NULL;
|
|
||||||
out->hasTerrain = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "asset/assetfile.h"
|
|
||||||
#include "rpg/overworld/chunk.h"
|
|
||||||
|
|
||||||
typedef struct assetloading_s assetloading_t;
|
|
||||||
typedef struct assetentry_s assetentry_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
void *nothing;
|
|
||||||
} assetchunkloaderinput_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
ASSET_CHUNK_LOADING_STATE_INITIAL,
|
|
||||||
ASSET_CHUNK_LOADING_STATE_PARSE,
|
|
||||||
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
|
|
||||||
ASSET_CHUNK_LOADING_STATE_DONE
|
|
||||||
} assetchunkloadingstate_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
assetchunkloadingstate_t state;
|
|
||||||
|
|
||||||
// Index into assetchunkoutput_t.modelNames/modelEntries the external
|
|
||||||
// (non-terrain) model polling loop has reached - see
|
|
||||||
// ASSET_CHUNK_LOADING_STATE_LOAD_MODELS.
|
|
||||||
uint8_t modelIndex;
|
|
||||||
} assetchunkloaderloading_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
CHUNK_ENTITY_SPAWN_KIND_GLOBAL,
|
|
||||||
CHUNK_ENTITY_SPAWN_KIND_ITEM
|
|
||||||
} chunkentityspawnkind_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
chunkentityspawnkind_t kind;
|
|
||||||
uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL.
|
|
||||||
uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
|
|
||||||
uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM.
|
|
||||||
worldpos_t position;
|
|
||||||
} chunkentityspawn_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
worldpos_t min;
|
|
||||||
worldpos_t max;
|
|
||||||
uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST.
|
|
||||||
uint8_t notify;
|
|
||||||
uint8_t trigger;
|
|
||||||
} chunkareaspawn_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
tile_t *tiles;
|
|
||||||
uint8_t meshCount;
|
|
||||||
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
|
|
||||||
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
|
|
||||||
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
|
|
||||||
|
|
||||||
// Terrain mesh generated at load time from the tile grid (no baked
|
|
||||||
// .dmf/.json - see assetChunkBuildTerrain in assetchunkloader.c). NULL
|
|
||||||
// (with hasTerrain false) for a chunk with no non-null tiles, in which
|
|
||||||
// case modelEntries[0] (if meshCount > 0) is the first external mesh
|
|
||||||
// instead - matches the old baked pipeline's "only write a terrain
|
|
||||||
// model if it has vertices" behavior.
|
|
||||||
//
|
|
||||||
// terrainMeshEntry/terrainModelEntry are heap-allocated, plain
|
|
||||||
// assetentry_t values that are never passed to assetLock/assetGetEntry/
|
|
||||||
// assetUnlockEntry (only a real, name-keyed, ref-counted entry may be)
|
|
||||||
// - they exist purely so modelEntries[0] can point at something with
|
|
||||||
// the same ->state/->data.model/->data.mesh.mesh shape every other
|
|
||||||
// model/mesh entry has, so chunk.c/map.c/sceneoverworld.c don't need to
|
|
||||||
// know terrain isn't file-backed. Freed directly (not assetUnlockEntry)
|
|
||||||
// in assetChunkDispose. terrainTexEntry is a real, ref-counted texture
|
|
||||||
// entry (the shared "tiles.png") and must be assetUnlockEntry'd there.
|
|
||||||
bool_t hasTerrain;
|
|
||||||
assetentry_t *terrainMeshEntry;
|
|
||||||
assetentry_t *terrainModelEntry;
|
|
||||||
assetentry_t *terrainTexEntry;
|
|
||||||
|
|
||||||
uint8_t entitySpawnCount;
|
|
||||||
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
|
|
||||||
|
|
||||||
uint8_t areaSpawnCount;
|
|
||||||
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
|
|
||||||
} assetchunkoutput_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async loader stub for chunk assets. Chunk loading is performed entirely
|
|
||||||
* on the main thread (it delegates to the JSON asset loader and to
|
|
||||||
* meshInit/meshFlush, both main-thread-only); this callback is never
|
|
||||||
* reached.
|
|
||||||
*
|
|
||||||
* @param loading Loading information for the asset being loaded.
|
|
||||||
* @return Error code indicating success or failure of the load operation.
|
|
||||||
*/
|
|
||||||
errorret_t assetChunkLoaderAsync(assetloading_t *loading);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous loader for chunk assets. Locks the chunk's JSON descriptor
|
|
||||||
* as a sub-asset, parses tiles/external mesh references/entity spawns/
|
|
||||||
* area spawns, generates the terrain mesh directly from the tile grid,
|
|
||||||
* and locks/waits for every referenced model (and the terrain's shared
|
|
||||||
* tileset texture) to finish loading.
|
|
||||||
*
|
|
||||||
* @param loading Loading information for the asset being loaded.
|
|
||||||
* @return Error code indicating success or failure of the load operation.
|
|
||||||
*/
|
|
||||||
errorret_t assetChunkLoaderSync(assetloading_t *loading);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposer for chunk assets.
|
|
||||||
*
|
|
||||||
* @param entry Asset entry containing the chunk data to dispose.
|
|
||||||
* @return Error code indicating success or failure of the dispose operation.
|
|
||||||
*/
|
|
||||||
errorret_t assetChunkDispose(assetentry_t *entry);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
assetcutsceneloader.c
|
|
||||||
)
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "assetcutsceneloader.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "asset/loader/assetloading.h"
|
|
||||||
#include "asset/loader/assetentry.h"
|
|
||||||
#include "asset/loader/assetloader.h"
|
|
||||||
#include "asset/asset.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonpauseflags.h"
|
|
||||||
|
|
||||||
errorret_t assetCutsceneParseItem(
|
|
||||||
assetloading_t *loading,
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset,
|
|
||||||
const uint8_t index
|
|
||||||
) {
|
|
||||||
if(!yyjson_is_obj(itemObj)) {
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading,
|
|
||||||
"Cutscene item %u is not an object",
|
|
||||||
(uint32_t)index
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemObj, "type"));
|
|
||||||
if(!cutsceneItemLookupType(typeName, &item->type)) {
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading,
|
|
||||||
"Cutscene item %u: missing/unsupported item type %s",
|
|
||||||
(uint32_t)index,
|
|
||||||
typeName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
cutsceneitemloadcallback_t *load = CUTSCENE_ITEM_CALLBACKS[item->type].load;
|
|
||||||
if(load == NULL) {
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Cutscene item %u: unhandled item type %u", (uint32_t)index,
|
|
||||||
(uint32_t)item->type
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t result = load(itemObj, item, pool, poolOffset);
|
|
||||||
if(errorIsNotOk(result)) {
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
|
|
||||||
errorChain(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) {
|
|
||||||
size_t size = 0;
|
|
||||||
yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr);
|
|
||||||
yyjson_val *itemVal;
|
|
||||||
while((itemVal = yyjson_arr_iter_next(&iter)) != NULL) {
|
|
||||||
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemVal, "type"));
|
|
||||||
cutsceneitemtype_t type;
|
|
||||||
if(!cutsceneItemLookupType(typeName, &type)) continue;
|
|
||||||
|
|
||||||
cutsceneitempoolsizecallback_t *poolSize =
|
|
||||||
CUTSCENE_ITEM_CALLBACKS[type].poolSize;
|
|
||||||
if(poolSize == NULL) continue;
|
|
||||||
|
|
||||||
size = cutsceneJsonPoolAlign(size) + poolSize(itemVal);
|
|
||||||
}
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out) {
|
|
||||||
if(out->items != NULL) {
|
|
||||||
memoryFree(out->items);
|
|
||||||
out->items = NULL;
|
|
||||||
}
|
|
||||||
if(out->pool != NULL) {
|
|
||||||
memoryFree(out->pool);
|
|
||||||
out->pool = NULL;
|
|
||||||
}
|
|
||||||
if(out->doc != NULL) {
|
|
||||||
yyjson_doc_free(out->doc);
|
|
||||||
out->doc = NULL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
|
|
||||||
assertNotNull(loading, "Loading cannot be NULL");
|
|
||||||
assertNotMainThread("Should be called from an async thread.");
|
|
||||||
|
|
||||||
if(
|
|
||||||
loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE
|
|
||||||
) {
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNull(loading->loading.cutscene.buffer, "Buffer already defined?");
|
|
||||||
|
|
||||||
assetfile_t *file = &loading->loading.cutscene.file;
|
|
||||||
assetLoaderErrorChain(loading,
|
|
||||||
assetFileInit(file, loading->entry->name, NULL, NULL)
|
|
||||||
);
|
|
||||||
|
|
||||||
if(file->size > ASSET_CUTSCENE_FILE_SIZE_MAX) {
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Cutscene JSON exceeds maximum allowed size"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t fileSize = (size_t)file->size;
|
|
||||||
uint8_t *buffer = memoryAllocate(fileSize);
|
|
||||||
assetLoaderErrorChain(loading, assetFileOpen(file));
|
|
||||||
assetLoaderErrorChain(loading, assetFileRead(file, buffer, fileSize));
|
|
||||||
assertTrue(
|
|
||||||
file->lastRead == file->size,
|
|
||||||
"Failed to read entire cutscene file."
|
|
||||||
);
|
|
||||||
assetLoaderErrorChain(loading, assetFileClose(file));
|
|
||||||
assetLoaderErrorChain(loading, assetFileDispose(file));
|
|
||||||
|
|
||||||
loading->loading.cutscene.buffer = buffer;
|
|
||||||
loading->loading.cutscene.size = fileSize;
|
|
||||||
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
|
|
||||||
assertNotNull(loading, "Loading cannot be NULL");
|
|
||||||
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
|
|
||||||
assertIsMainThread("Must be called from the main thread.");
|
|
||||||
|
|
||||||
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
|
|
||||||
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
|
|
||||||
uint8_t *buffer = loading->loading.cutscene.buffer;
|
|
||||||
assertNotNull(buffer, "Cutscene data should have been loaded by now.");
|
|
||||||
size_t bufferSize = loading->loading.cutscene.size;
|
|
||||||
|
|
||||||
yyjson_doc *doc = yyjson_read(
|
|
||||||
(char *)buffer, bufferSize,
|
|
||||||
YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS
|
|
||||||
);
|
|
||||||
memoryFree(buffer);
|
|
||||||
loading->loading.cutscene.buffer = NULL;
|
|
||||||
|
|
||||||
if(doc == NULL) {
|
|
||||||
assetLoaderErrorThrow(loading, "Failed to parse cutscene JSON");
|
|
||||||
}
|
|
||||||
out->doc = doc;
|
|
||||||
|
|
||||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
||||||
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
|
|
||||||
if(!yyjson_is_arr(itemsArr)) {
|
|
||||||
assetCutsceneFreeParsed(out);
|
|
||||||
assetLoaderErrorThrow(loading, "Cutscene JSON is missing an 'items' array");
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t itemCount = yyjson_arr_size(itemsArr);
|
|
||||||
if(itemCount > UINT8_MAX) {
|
|
||||||
assetCutsceneFreeParsed(out);
|
|
||||||
assetLoaderErrorThrow(loading, "Cutscene has more than 255 items");
|
|
||||||
}
|
|
||||||
|
|
||||||
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
|
|
||||||
yyjson_val *pauseArr = yyjson_obj_get(root, "pause");
|
|
||||||
if(yyjson_is_arr(pauseArr)) {
|
|
||||||
if(!cutsceneJsonParsePauseFlags(pauseArr, &pause)) {
|
|
||||||
assetCutsceneFreeParsed(out);
|
|
||||||
assetLoaderErrorThrow(
|
|
||||||
loading, "Cutscene JSON has an invalid 'pause' flag"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
|
|
||||||
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
|
|
||||||
|
|
||||||
size_t poolSize = assetCutsceneComputePoolSize(itemsArr);
|
|
||||||
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
|
|
||||||
|
|
||||||
size_t poolOffset = 0;
|
|
||||||
yyjson_arr_iter itemIter = yyjson_arr_iter_with(itemsArr);
|
|
||||||
yyjson_val *itemVal;
|
|
||||||
uint8_t index = 0;
|
|
||||||
while((itemVal = yyjson_arr_iter_next(&itemIter)) != NULL) {
|
|
||||||
errorret_t itemResult = assetCutsceneParseItem(
|
|
||||||
loading, itemVal, &out->items[index], out->pool, &poolOffset, index
|
|
||||||
);
|
|
||||||
if(errorIsNotOk(itemResult)) {
|
|
||||||
assetCutsceneFreeParsed(out);
|
|
||||||
errorChain(itemResult);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
out->cutscene.items = out->items;
|
|
||||||
out->cutscene.itemCount = (uint8_t)itemCount;
|
|
||||||
out->cutscene.pause = pause;
|
|
||||||
out->cutscene.dataSize = 0;
|
|
||||||
|
|
||||||
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t assetCutsceneDispose(assetentry_t *entry) {
|
|
||||||
assertNotNull(entry, "Entry cannot be NULL");
|
|
||||||
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
|
|
||||||
assertIsMainThread("Must be called from the main thread.");
|
|
||||||
|
|
||||||
assetCutsceneFreeParsed(&entry->data.cutscene);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "asset/assetfile.h"
|
|
||||||
#include "rpg/cutscene/cutscene.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonpool.h"
|
|
||||||
#include "yyjson.h"
|
|
||||||
|
|
||||||
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
|
|
||||||
|
|
||||||
typedef struct assetloading_s assetloading_t;
|
|
||||||
typedef struct assetentry_s assetentry_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
void *nothing;
|
|
||||||
} assetcutsceneloaderinput_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
|
|
||||||
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
|
|
||||||
ASSET_CUTSCENE_LOADING_STATE_PARSE
|
|
||||||
} assetcutsceneloadingstate_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
assetfile_t file;
|
|
||||||
assetcutsceneloadingstate_t state;
|
|
||||||
uint8_t *buffer;
|
|
||||||
size_t size;
|
|
||||||
} assetcutsceneloaderloading_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
cutscene_t cutscene;
|
|
||||||
cutsceneitem_t *items;
|
|
||||||
uint8_t *pool;
|
|
||||||
yyjson_doc *doc;
|
|
||||||
} assetcutsceneoutput_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses one cutscene item object into item, writing any pool-backed
|
|
||||||
* array data (entityWalkTo positions, mapAreaWait areaIds) into pool at
|
|
||||||
* *poolOffset and advancing it. Every field/enum name is validated as
|
|
||||||
* real (untrusted, authored) file content - failures are real errors, not
|
|
||||||
* asserts. Dispatches to the matching type's Load callback (see
|
|
||||||
* CUTSCENE_ITEM_CALLBACKS in rpg/cutscene/item/cutsceneitem.h) - the
|
|
||||||
* per-type parsing logic itself lives there now, colocated with that
|
|
||||||
* type's runtime behavior.
|
|
||||||
*
|
|
||||||
* @param loading Loading information for the asset being loaded.
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Base pointer of the entry's pool allocation (may be NULL if
|
|
||||||
* assetCutsceneComputePoolSize returned 0).
|
|
||||||
* @param poolOffset In/out cursor into pool.
|
|
||||||
* @param index Index of this item within the cutscene, for error messages.
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t assetCutsceneParseItem(
|
|
||||||
assetloading_t *loading,
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset,
|
|
||||||
const uint8_t index
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frees whatever of out->items/out->pool/out->doc is currently non-NULL
|
|
||||||
* and clears them - shared by both the normal disposer and every parse
|
|
||||||
* failure path's cleanup.
|
|
||||||
*
|
|
||||||
* @param out The output to free.
|
|
||||||
*/
|
|
||||||
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asynchronous loader for cutscene assets. Reads the raw JSONC file bytes
|
|
||||||
* into the loading buffer so the sync phase can parse without blocking the
|
|
||||||
* main thread on I/O.
|
|
||||||
*
|
|
||||||
* @param loading Loading information for the asset being loaded.
|
|
||||||
* @return Error code indicating success or failure of the load operation.
|
|
||||||
*/
|
|
||||||
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous loader for cutscene assets. Parses the JSONC previously read
|
|
||||||
* by the async phase into a heap-allocated cutsceneitem_t array + pool.
|
|
||||||
*
|
|
||||||
* @param loading Loading information for the asset being loaded.
|
|
||||||
* @return Error code indicating success or failure of the load operation.
|
|
||||||
*/
|
|
||||||
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposer for cutscene assets.
|
|
||||||
*
|
|
||||||
* @param entry Asset entry containing the cutscene data to dispose.
|
|
||||||
* @return Error code indicating success or failure of the dispose operation.
|
|
||||||
*/
|
|
||||||
errorret_t assetCutsceneDispose(assetentry_t *entry);
|
|
||||||
@@ -12,11 +12,10 @@
|
|||||||
|
|
||||||
// Forward-declared rather than including asset/asset.h - this header only
|
// Forward-declared rather than including asset/asset.h - this header only
|
||||||
// ever needs a pointer to it, and pulling in the full asset module here
|
// ever needs a pointer to it, and pulling in the full asset module here
|
||||||
// creates a real include cycle for anything that reaches this header via
|
// risks an include cycle for anything that reaches this header via a
|
||||||
// asset/loader/cutscene/assetcutsceneloader.h (asset.h -> ... ->
|
// cutscene item (cutscene.h -> cutsceneitem.h -> the audio cutscene items
|
||||||
// assetloader.h -> assetcutsceneloader.h -> cutscene.h -> cutsceneitem.h
|
// -> this header -> asset/asset.h, mid-parse). audiomixerchannel.c itself
|
||||||
// -> the audio cutscene items -> this header -> asset/asset.h again,
|
// still includes the full header.
|
||||||
// mid-parse). audiomixerchannel.c itself still includes the full header.
|
|
||||||
typedef struct assetentry_s assetentry_t;
|
typedef struct assetentry_s assetentry_t;
|
||||||
|
|
||||||
typedef struct audiostream_s audiostream_t;
|
typedef struct audiostream_s audiostream_t;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
#include "display/mesh/capsule.h"
|
#include "display/mesh/capsule.h"
|
||||||
#include "display/mesh/triprism.h"
|
#include "display/mesh/triprism.h"
|
||||||
#include "display/screen/screen.h"
|
#include "display/screen/screen.h"
|
||||||
#include "ui/ui.h"
|
|
||||||
#include "display/text/text.h"
|
#include "display/text/text.h"
|
||||||
#include "assert/assert.h"
|
#include "assert/assert.h"
|
||||||
#include "util/memory.h"
|
#include "util/memory.h"
|
||||||
|
|||||||
@@ -11,11 +11,9 @@
|
|||||||
#include "time/time.h"
|
#include "time/time.h"
|
||||||
#include "input/input.h"
|
#include "input/input.h"
|
||||||
#include "locale/localemanager.h"
|
#include "locale/localemanager.h"
|
||||||
#include "rpg/rpg.h"
|
|
||||||
#include "display/display.h"
|
#include "display/display.h"
|
||||||
#include "scene/scene.h"
|
#include "scene/scene.h"
|
||||||
#include "asset/asset.h"
|
#include "asset/asset.h"
|
||||||
#include "ui/ui.h"
|
|
||||||
#include "assert/assert.h"
|
#include "assert/assert.h"
|
||||||
#ifdef DUSK_NETWORK
|
#ifdef DUSK_NETWORK
|
||||||
#include "network/network.h"
|
#include "network/network.h"
|
||||||
@@ -47,8 +45,6 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
|||||||
errorChain(localeManagerInit());
|
errorChain(localeManagerInit());
|
||||||
errorChain(displayInit());
|
errorChain(displayInit());
|
||||||
errorChain(audioInit());
|
errorChain(audioInit());
|
||||||
errorChain(uiInit());
|
|
||||||
errorChain(rpgInit());
|
|
||||||
#ifdef DUSK_NETWORK
|
#ifdef DUSK_NETWORK
|
||||||
errorChain(networkInit());
|
errorChain(networkInit());
|
||||||
#endif
|
#endif
|
||||||
@@ -62,7 +58,6 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
|
|||||||
consolePrint("Assertions real");
|
consolePrint("Assertions real");
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
sceneSet(SCENE_TYPE_INITIAL);
|
|
||||||
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
@@ -77,10 +72,8 @@ errorret_t engineUpdate(void) {
|
|||||||
inputUpdate();
|
inputUpdate();
|
||||||
consoleUpdate();
|
consoleUpdate();
|
||||||
errorChain(audioUpdateEarly());
|
errorChain(audioUpdateEarly());
|
||||||
errorChain(rpgUpdate());
|
|
||||||
errorChain(sceneUpdate());
|
errorChain(sceneUpdate());
|
||||||
errorChain(assetUpdate());
|
errorChain(assetUpdate());
|
||||||
errorChain(uiUpdate());
|
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
errorChain(displayUpdate());
|
errorChain(displayUpdate());
|
||||||
@@ -98,9 +91,7 @@ errorret_t engineDispose(void) {
|
|||||||
#ifdef DUSK_NETWORK
|
#ifdef DUSK_NETWORK
|
||||||
errorChain(networkDispose());
|
errorChain(networkDispose());
|
||||||
#endif
|
#endif
|
||||||
errorChain(rpgDispose());
|
|
||||||
localeManagerDispose();
|
localeManagerDispose();
|
||||||
errorChain(uiDispose());
|
|
||||||
errorChain(audioDispose());
|
errorChain(audioDispose());
|
||||||
consoleDispose();
|
consoleDispose();
|
||||||
errorChain(displayDispose());
|
errorChain(displayDispose());
|
||||||
@@ -108,5 +99,7 @@ errorret_t engineDispose(void) {
|
|||||||
errorChain(assetDispose());
|
errorChain(assetDispose());
|
||||||
perfDispose();
|
perfDispose();
|
||||||
|
|
||||||
|
memoryCheckUnfreed();
|
||||||
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
#include "util/memory.h"
|
#include "util/memory.h"
|
||||||
#include "util/string.h"
|
#include "util/string.h"
|
||||||
#include "assert/assert.h"
|
#include "assert/assert.h"
|
||||||
#include "ui/ui.h"
|
|
||||||
#include "system/system.h"
|
#include "system/system.h"
|
||||||
#include "console/console.h"
|
#include "console/console.h"
|
||||||
|
|
||||||
@@ -39,9 +38,6 @@ errorret_t localeManagerSetLocale(const localeinfo_t *locale) {
|
|||||||
assetEntryLock(LOCALE.entry);
|
assetEntryLock(LOCALE.entry);
|
||||||
errorChain(assetRequireLoaded(LOCALE.entry));
|
errorChain(assetRequireLoaded(LOCALE.entry));
|
||||||
|
|
||||||
// TODO : Trigger UI update.
|
|
||||||
errorChain(uiUpdateTranslations());
|
|
||||||
|
|
||||||
errorOk();
|
errorOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Copyright (c) 2025 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
# Sources
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
rpg.c
|
|
||||||
rpgcamera.c
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subdirs
|
|
||||||
add_subdirectory(battle)
|
|
||||||
add_subdirectory(cutscene)
|
|
||||||
add_subdirectory(entity)
|
|
||||||
add_subdirectory(overworld)
|
|
||||||
|
|
||||||
add_subdirectory(story)
|
|
||||||
add_subdirectory(item)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
# Sources
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
battle.c
|
|
||||||
battlefighter.c
|
|
||||||
party.c
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subdirs
|
|
||||||
add_subdirectory(testbattle)
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "battle.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "rpg/cutscene/cutscenesystem.h"
|
|
||||||
|
|
||||||
battle_t BATTLE;
|
|
||||||
|
|
||||||
void battleInit(void) {
|
|
||||||
memoryZero(&BATTLE, sizeof(battle_t));
|
|
||||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
|
||||||
BATTLE.fighters[i].id = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t battleGetAvailableFighter(void) {
|
|
||||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
|
||||||
if(BATTLE.fighters[i].status == BATTLE_FIGHTER_STATUS_NULL) return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0xFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
battlefighter_t *battleAddFighter(
|
|
||||||
const battlefighterteam_t team,
|
|
||||||
const battlefightercontroller_t controller,
|
|
||||||
const battlefighterstats_t stats,
|
|
||||||
const uint16_t healthMax,
|
|
||||||
const uint16_t mpMax
|
|
||||||
) {
|
|
||||||
const uint8_t index = battleGetAvailableFighter();
|
|
||||||
if(index == 0xFF) return NULL;
|
|
||||||
|
|
||||||
battlefighter_t *fighter = &BATTLE.fighters[index];
|
|
||||||
battleFighterInit(fighter, team, controller, stats, healthMax, mpMax);
|
|
||||||
return fighter;
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleStart(
|
|
||||||
const battleencountertype_t encounterType,
|
|
||||||
const bool_t fleeAvailable
|
|
||||||
) {
|
|
||||||
assertTrue(encounterType < BATTLE_ENCOUNTER_COUNT, "Invalid encounter type");
|
|
||||||
|
|
||||||
BATTLE.encounterType = encounterType;
|
|
||||||
BATTLE.fleeAvailable = fleeAvailable;
|
|
||||||
BATTLE.result = BATTLE_RESULT_NONE;
|
|
||||||
BATTLE.round = 1;
|
|
||||||
|
|
||||||
battleSetState(BATTLE_STATE_OPENING);
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleDispose(void) {
|
|
||||||
battleInit();
|
|
||||||
}
|
|
||||||
|
|
||||||
battlefighter_t *battleGetCurrentFighter(void) {
|
|
||||||
if(BATTLE.state != BATTLE_STATE_PLAYER_SELECTION) return NULL;
|
|
||||||
if(BATTLE.selectionIndex >= BATTLE.executionCount) return NULL;
|
|
||||||
return &BATTLE.fighters[BATTLE.executionOrder[BATTLE.selectionIndex]];
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t battleGetAliveCount(const battlefighterteam_t team) {
|
|
||||||
uint8_t count = 0;
|
|
||||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
|
||||||
if(BATTLE.fighters[i].team != team) continue;
|
|
||||||
if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue;
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleResolveAttack(
|
|
||||||
battlefighter_t *attacker,
|
|
||||||
battlefighter_t *defender
|
|
||||||
) {
|
|
||||||
assertNotNull(attacker, "Attacker cannot be NULL");
|
|
||||||
assertNotNull(defender, "Defender cannot be NULL");
|
|
||||||
|
|
||||||
const int32_t rawDamage =
|
|
||||||
(int32_t)attacker->stats.attack - (int32_t)defender->stats.defense;
|
|
||||||
const uint16_t damage = rawDamage > 0 ? (uint16_t)rawDamage : 1;
|
|
||||||
|
|
||||||
defender->health = damage >= defender->health ? 0 : defender->health - damage;
|
|
||||||
if(defender->health == 0) defender->status = BATTLE_FIGHTER_STATUS_DEAD;
|
|
||||||
}
|
|
||||||
|
|
||||||
battleresult_t battleCheckResult(void) {
|
|
||||||
if(BATTLE.result != BATTLE_RESULT_NONE) return BATTLE.result;
|
|
||||||
|
|
||||||
if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ALLY) == 0) {
|
|
||||||
battleSetResult(BATTLE_RESULT_LOSS);
|
|
||||||
} else if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ENEMY) == 0) {
|
|
||||||
battleSetResult(BATTLE_RESULT_WIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BATTLE.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleQueueAction(
|
|
||||||
const uint8_t fighterIndex,
|
|
||||||
const battleactiontype_t type,
|
|
||||||
const uint8_t targetIndex
|
|
||||||
) {
|
|
||||||
battleaction_t *action = &BATTLE.actions[fighterIndex];
|
|
||||||
action->type = type;
|
|
||||||
action->targetIndex = targetIndex;
|
|
||||||
|
|
||||||
if(BATTLE.onActionDecided != NULL) {
|
|
||||||
BATTLE.onActionDecided(&BATTLE.fighters[fighterIndex], action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void battlePlayerAttack(const uint8_t targetIndex) {
|
|
||||||
battlefighter_t *fighter = battleGetCurrentFighter();
|
|
||||||
if(fighter == NULL) return;
|
|
||||||
if(targetIndex >= BATTLE_FIGHTER_COUNT_MAX) return;
|
|
||||||
if(!battleFighterIsAlive(&BATTLE.fighters[targetIndex])) return;
|
|
||||||
|
|
||||||
battleQueueAction(fighter->id, BATTLE_ACTION_ATTACK, targetIndex);
|
|
||||||
BATTLE.selectionIndex++;
|
|
||||||
battleAdvanceSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
void battlePlayerFlee(void) {
|
|
||||||
battlefighter_t *fighter = battleGetCurrentFighter();
|
|
||||||
if(fighter == NULL) return;
|
|
||||||
if(!BATTLE.fleeAvailable) return;
|
|
||||||
|
|
||||||
battleSetResult(BATTLE_RESULT_FLED);
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleUpdate(void) {
|
|
||||||
if(BATTLE.state == BATTLE_STATE_NONE) return;
|
|
||||||
if(BATTLE.state == BATTLE_STATE_ENDED) return;
|
|
||||||
if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE) return;
|
|
||||||
|
|
||||||
switch(BATTLE.state) {
|
|
||||||
case BATTLE_STATE_OPENING:
|
|
||||||
battleSetState(BATTLE_STATE_PRE_ROUND);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case BATTLE_STATE_PRE_ROUND:
|
|
||||||
battleUpdatePreRound();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case BATTLE_STATE_AI_SELECTION:
|
|
||||||
battleUpdateAiSelection();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case BATTLE_STATE_MOVES_EXECUTING:
|
|
||||||
battleUpdateMovesExecuting();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case BATTLE_STATE_POST_ROUND:
|
|
||||||
battleUpdatePostRound();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// BATTLE_STATE_PLAYER_SELECTION: waits on battlePlayerAttack/Flee.
|
|
||||||
// BATTLE_STATE_NONE/ENDED: handled above.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleBuildExecutionOrder(const bool_t applyEncounterBias) {
|
|
||||||
BATTLE.executionCount = 0;
|
|
||||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
|
||||||
if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue;
|
|
||||||
BATTLE.executionOrder[BATTLE.executionCount++] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insertion sort by speed descending -- fine for BATTLE_FIGHTER_COUNT_MAX.
|
|
||||||
for(uint8_t i = 1; i < BATTLE.executionCount; i++) {
|
|
||||||
const uint8_t key = BATTLE.executionOrder[i];
|
|
||||||
const uint16_t keySpeed = BATTLE.fighters[key].stats.speed;
|
|
||||||
|
|
||||||
int8_t j = (int8_t)i - 1;
|
|
||||||
while(
|
|
||||||
j >= 0 &&
|
|
||||||
BATTLE.fighters[BATTLE.executionOrder[j]].stats.speed < keySpeed
|
|
||||||
) {
|
|
||||||
BATTLE.executionOrder[j + 1] = BATTLE.executionOrder[j];
|
|
||||||
j--;
|
|
||||||
}
|
|
||||||
BATTLE.executionOrder[j + 1] = key;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!applyEncounterBias) return;
|
|
||||||
|
|
||||||
if(BATTLE.encounterType == BATTLE_ENCOUNTER_PLAYER_ADVANTAGE) {
|
|
||||||
battleMoveTeamFirst(BATTLE_FIGHTER_TEAM_ALLY);
|
|
||||||
} else if(BATTLE.encounterType == BATTLE_ENCOUNTER_BACK_ATTACK) {
|
|
||||||
battleMoveTeamFirst(BATTLE_FIGHTER_TEAM_ENEMY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleMoveTeamFirst(const battlefighterteam_t team) {
|
|
||||||
uint8_t sorted[BATTLE_FIGHTER_COUNT_MAX];
|
|
||||||
uint8_t count = 0;
|
|
||||||
|
|
||||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
|
||||||
if(BATTLE.fighters[BATTLE.executionOrder[i]].team != team) continue;
|
|
||||||
sorted[count++] = BATTLE.executionOrder[i];
|
|
||||||
}
|
|
||||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
|
||||||
if(BATTLE.fighters[BATTLE.executionOrder[i]].team == team) continue;
|
|
||||||
sorted[count++] = BATTLE.executionOrder[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
memoryCopy(
|
|
||||||
BATTLE.executionOrder, sorted, sizeof(uint8_t) * BATTLE.executionCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) {
|
|
||||||
const battlefighterteam_t enemyTeam =
|
|
||||||
fighter->team == BATTLE_FIGHTER_TEAM_ALLY ?
|
|
||||||
BATTLE_FIGHTER_TEAM_ENEMY : BATTLE_FIGHTER_TEAM_ALLY;
|
|
||||||
|
|
||||||
battlefighter_t *weakest = NULL;
|
|
||||||
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
|
|
||||||
battlefighter_t *candidate = &BATTLE.fighters[i];
|
|
||||||
if(candidate->team != enemyTeam) continue;
|
|
||||||
if(!battleFighterIsAlive(candidate)) continue;
|
|
||||||
if(weakest == NULL || candidate->health < weakest->health) {
|
|
||||||
weakest = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return weakest;
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleSetState(const battlestate_t next) {
|
|
||||||
const battlestate_t previous = BATTLE.state;
|
|
||||||
BATTLE.state = next;
|
|
||||||
if(BATTLE.onStateChanged != NULL) BATTLE.onStateChanged(previous, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleSetResult(const battleresult_t result) {
|
|
||||||
BATTLE.result = result;
|
|
||||||
battleSetState(BATTLE_STATE_ENDED);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t battleFighterNeedsDecision(
|
|
||||||
const uint8_t fighterIndex,
|
|
||||||
const battlefightercontroller_t controller
|
|
||||||
) {
|
|
||||||
return battleFighterIsAlive(&BATTLE.fighters[fighterIndex])
|
|
||||||
&& BATTLE.fighters[fighterIndex].controller == controller
|
|
||||||
&& BATTLE.actions[fighterIndex].type == BATTLE_ACTION_NONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleAdvanceSelection(void) {
|
|
||||||
while(BATTLE.selectionIndex < BATTLE.executionCount) {
|
|
||||||
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.selectionIndex];
|
|
||||||
if(
|
|
||||||
battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_PLAYER)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BATTLE.selectionIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
battleSetState(BATTLE_STATE_AI_SELECTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleUpdatePreRound(void) {
|
|
||||||
// No need to clear BATTLE.actions here: every living fighter's action is
|
|
||||||
// unconditionally reset to BATTLE_ACTION_NONE as it's processed in
|
|
||||||
// battleUpdateMovesExecuting, and round 1 starts pre-zeroed by
|
|
||||||
// battleInit(). Clearing it here would also wipe out any action a
|
|
||||||
// cutscene force-queued while parked at BATTLE_STATE_PRE_ROUND.
|
|
||||||
battleBuildExecutionOrder(BATTLE.round == 1);
|
|
||||||
|
|
||||||
BATTLE.selectionIndex = 0;
|
|
||||||
battleSetState(BATTLE_STATE_PLAYER_SELECTION);
|
|
||||||
battleAdvanceSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleUpdateAiSelection(void) {
|
|
||||||
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
|
|
||||||
const uint8_t fighterIndex = BATTLE.executionOrder[i];
|
|
||||||
if(
|
|
||||||
!battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_AI)
|
|
||||||
) continue;
|
|
||||||
|
|
||||||
battlefighter_t *target =
|
|
||||||
battleAIChooseTarget(&BATTLE.fighters[fighterIndex]);
|
|
||||||
if(target == NULL) continue;
|
|
||||||
|
|
||||||
battleQueueAction(fighterIndex, BATTLE_ACTION_ATTACK, target->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
BATTLE.executionIndex = 0;
|
|
||||||
battleSetState(BATTLE_STATE_MOVES_EXECUTING);
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleUpdateMovesExecuting(void) {
|
|
||||||
if(BATTLE.executionIndex >= BATTLE.executionCount) {
|
|
||||||
battleSetState(BATTLE_STATE_POST_ROUND);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.executionIndex++];
|
|
||||||
battlefighter_t *fighter = &BATTLE.fighters[fighterIndex];
|
|
||||||
if(!battleFighterIsAlive(fighter)) return;
|
|
||||||
|
|
||||||
battleaction_t *action = &BATTLE.actions[fighterIndex];
|
|
||||||
if(action->type == BATTLE_ACTION_ATTACK) {
|
|
||||||
battlefighter_t *target = &BATTLE.fighters[action->targetIndex];
|
|
||||||
if(battleFighterIsAlive(target)) battleResolveAttack(fighter, target);
|
|
||||||
}
|
|
||||||
action->type = BATTLE_ACTION_NONE;
|
|
||||||
|
|
||||||
battleCheckResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
void battleUpdatePostRound(void) {
|
|
||||||
BATTLE.round++;
|
|
||||||
battleSetState(BATTLE_STATE_PRE_ROUND);
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "battlefighter.h"
|
|
||||||
|
|
||||||
#define BATTLE_FIGHTER_COUNT_MAX 8
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_ENCOUNTER_REGULAR,
|
|
||||||
BATTLE_ENCOUNTER_PLAYER_ADVANTAGE,
|
|
||||||
BATTLE_ENCOUNTER_BACK_ATTACK,
|
|
||||||
|
|
||||||
BATTLE_ENCOUNTER_COUNT
|
|
||||||
} battleencountertype_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_RESULT_NONE,
|
|
||||||
BATTLE_RESULT_WIN,
|
|
||||||
BATTLE_RESULT_LOSS,
|
|
||||||
BATTLE_RESULT_FLED,
|
|
||||||
|
|
||||||
BATTLE_RESULT_COUNT
|
|
||||||
} battleresult_t;
|
|
||||||
|
|
||||||
// Where BATTLE currently is within a round. A cutscene can pause progression
|
|
||||||
// (CUTSCENE_PAUSE_BATTLE) and use CUTSCENE_BATTLE_WAIT_STATE to synchronize
|
|
||||||
// with any of these, or CUTSCENE_BATTLE_FORCE_ACTION to decide a fighter's
|
|
||||||
// action ahead of PLAYER_SELECTION/AI_SELECTION reaching them.
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_STATE_NONE, // Battle inactive.
|
|
||||||
|
|
||||||
BATTLE_STATE_OPENING, // Entered once by battleStart().
|
|
||||||
BATTLE_STATE_PRE_ROUND, // Rebuilds execution order, clears the action queue.
|
|
||||||
BATTLE_STATE_PLAYER_SELECTION, // Waits on battlePlayerAttack/Flee.
|
|
||||||
BATTLE_STATE_AI_SELECTION, // Auto-queues every undecided AI fighter.
|
|
||||||
BATTLE_STATE_MOVES_EXECUTING, // Resolves one queued action per update.
|
|
||||||
BATTLE_STATE_POST_ROUND, // Round wrap-up; loops back to PRE_ROUND.
|
|
||||||
|
|
||||||
BATTLE_STATE_ENDED, // Terminal for WIN/LOSS/FLED alike -- see BATTLE.result.
|
|
||||||
|
|
||||||
BATTLE_STATE_COUNT
|
|
||||||
} battlestate_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_ACTION_NONE, // No action decided yet for this fighter this round.
|
|
||||||
BATTLE_ACTION_ATTACK,
|
|
||||||
|
|
||||||
BATTLE_ACTION_COUNT
|
|
||||||
} battleactiontype_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
battleactiontype_t type;
|
|
||||||
uint8_t targetIndex; // Meaningful for BATTLE_ACTION_ATTACK.
|
|
||||||
} battleaction_t;
|
|
||||||
|
|
||||||
typedef void (*battlestatechangedcallback_t)(
|
|
||||||
const battlestate_t previous,
|
|
||||||
const battlestate_t next
|
|
||||||
);
|
|
||||||
|
|
||||||
typedef void (*battleactiondecidedcallback_t)(
|
|
||||||
const battlefighter_t *fighter,
|
|
||||||
const battleaction_t *action
|
|
||||||
);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
battlestate_t state;
|
|
||||||
battlefighter_t fighters[BATTLE_FIGHTER_COUNT_MAX];
|
|
||||||
battleaction_t actions[BATTLE_FIGHTER_COUNT_MAX];
|
|
||||||
|
|
||||||
battleencountertype_t encounterType;
|
|
||||||
bool_t fleeAvailable;
|
|
||||||
battleresult_t result;
|
|
||||||
|
|
||||||
// Fighter indices (into fighters[]), sorted for the current round.
|
|
||||||
uint8_t executionOrder[BATTLE_FIGHTER_COUNT_MAX];
|
|
||||||
uint8_t executionCount;
|
|
||||||
uint8_t executionIndex;
|
|
||||||
uint16_t round;
|
|
||||||
|
|
||||||
// Cursor into executionOrder used by BATTLE_STATE_PLAYER_SELECTION to find
|
|
||||||
// the next player-controlled fighter that still needs a decision.
|
|
||||||
uint8_t selectionIndex;
|
|
||||||
|
|
||||||
battlestatechangedcallback_t onStateChanged;
|
|
||||||
battleactiondecidedcallback_t onActionDecided;
|
|
||||||
} battle_t;
|
|
||||||
|
|
||||||
extern battle_t BATTLE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the battle system. Marks it as inactive with no fighters.
|
|
||||||
*/
|
|
||||||
void battleInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets an available (unused) fighter slot index.
|
|
||||||
*
|
|
||||||
* @return The index of an available fighter slot, or 0xFF if none are
|
|
||||||
* available.
|
|
||||||
*/
|
|
||||||
uint8_t battleGetAvailableFighter(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a fighter to the battle in the next available slot.
|
|
||||||
*
|
|
||||||
* @param team The team the fighter belongs to.
|
|
||||||
* @param controller Who makes decisions for the fighter.
|
|
||||||
* @param stats The fighter's base combat stats.
|
|
||||||
* @param healthMax The fighter's maximum health.
|
|
||||||
* @param mpMax The fighter's maximum mp.
|
|
||||||
* @return Pointer to the newly added fighter, or NULL if the battle is
|
|
||||||
* already full.
|
|
||||||
*/
|
|
||||||
battlefighter_t *battleAddFighter(
|
|
||||||
const battlefighterteam_t team,
|
|
||||||
const battlefightercontroller_t controller,
|
|
||||||
const battlefighterstats_t stats,
|
|
||||||
const uint16_t healthMax,
|
|
||||||
const uint16_t mpMax
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the battle: enters BATTLE_STATE_OPENING and marks the battle
|
|
||||||
* active. Call once every fighter has been added via battleAddFighter.
|
|
||||||
*
|
|
||||||
* @param encounterType Determines the opening round's execution order.
|
|
||||||
* @param fleeAvailable Whether the party may attempt to flee this battle.
|
|
||||||
*/
|
|
||||||
void battleStart(
|
|
||||||
const battleencountertype_t encounterType,
|
|
||||||
const bool_t fleeAvailable
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of the battle, clearing all fighters and marking it inactive.
|
|
||||||
*/
|
|
||||||
void battleDispose(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the fighter currently awaiting a player decision.
|
|
||||||
*
|
|
||||||
* @return Pointer to the fighter awaiting a decision, or NULL if the battle
|
|
||||||
* isn't in BATTLE_STATE_PLAYER_SELECTION or every player-controlled
|
|
||||||
* fighter has already decided.
|
|
||||||
*/
|
|
||||||
battlefighter_t *battleGetCurrentFighter(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the number of living fighters on a team.
|
|
||||||
*
|
|
||||||
* @param team The team to count.
|
|
||||||
* @return Count of living fighters on that team.
|
|
||||||
*/
|
|
||||||
uint8_t battleGetAliveCount(const battlefighterteam_t team);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a physical attack from attacker onto defender: damage is the
|
|
||||||
* attacker's attack stat minus the defender's defense stat (minimum 1),
|
|
||||||
* subtracted from the defender's health. The defender is marked dead
|
|
||||||
* once health reaches 0.
|
|
||||||
*
|
|
||||||
* @param attacker The attacking fighter.
|
|
||||||
* @param defender The defending fighter.
|
|
||||||
*/
|
|
||||||
void battleResolveAttack(
|
|
||||||
battlefighter_t *attacker,
|
|
||||||
battlefighter_t *defender
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the battle has been won or lost, transitioning to
|
|
||||||
* BATTLE_STATE_ENDED and updating BATTLE.result if so. Does nothing if a
|
|
||||||
* result has already been set (e.g. by a successful flee).
|
|
||||||
*
|
|
||||||
* @return The battle's current result.
|
|
||||||
*/
|
|
||||||
battleresult_t battleCheckResult(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queues an action for a fighter to perform once BATTLE_STATE_MOVES_EXECUTING
|
|
||||||
* reaches them this round, overwriting any action already queued for that
|
|
||||||
* fighter. Fires BATTLE.onActionDecided.
|
|
||||||
*
|
|
||||||
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
|
|
||||||
* @param type The type of action to perform.
|
|
||||||
* @param targetIndex Index into BATTLE.fighters of the target, meaningful
|
|
||||||
* for BATTLE_ACTION_ATTACK.
|
|
||||||
*/
|
|
||||||
void battleQueueAction(
|
|
||||||
const uint8_t fighterIndex,
|
|
||||||
const battleactiontype_t type,
|
|
||||||
const uint8_t targetIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submits the currently-selecting fighter's attack against a target, if the
|
|
||||||
* battle is in BATTLE_STATE_PLAYER_SELECTION and awaiting a decision.
|
|
||||||
* Queues the action and advances the selection cursor.
|
|
||||||
*
|
|
||||||
* @param targetIndex Index into BATTLE.fighters of the target.
|
|
||||||
*/
|
|
||||||
void battlePlayerAttack(const uint8_t targetIndex);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submits a flee attempt for the currently-selecting fighter, if the battle
|
|
||||||
* is in BATTLE_STATE_PLAYER_SELECTION and fleeing is available for this
|
|
||||||
* battle. Always succeeds, ending the battle with BATTLE_RESULT_FLED.
|
|
||||||
*/
|
|
||||||
void battlePlayerFlee(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the battle simulation for one frame, dispatching on BATTLE.state.
|
|
||||||
* No-op if the battle isn't active, has already ended, or
|
|
||||||
* CUTSCENE_PAUSE_BATTLE is set.
|
|
||||||
*/
|
|
||||||
void battleUpdate(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds BATTLE.executionOrder/executionCount from every currently living
|
|
||||||
* fighter, sorted by speed descending.
|
|
||||||
*
|
|
||||||
* @param applyEncounterBias If true, reorders the freshly speed-sorted
|
|
||||||
* queue so BATTLE.encounterType's favoured team goes first (used only
|
|
||||||
* for the opening round).
|
|
||||||
*/
|
|
||||||
void battleBuildExecutionOrder(const bool_t applyEncounterBias);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stably partitions BATTLE.executionOrder so every fighter on the given team
|
|
||||||
* comes first, preserving each side's relative (speed-sorted) order.
|
|
||||||
*
|
|
||||||
* @param team The team to move to the front of the execution order.
|
|
||||||
*/
|
|
||||||
void battleMoveTeamFirst(const battlefighterteam_t team);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks an AI target for fighter: the lowest-health living fighter on
|
|
||||||
* the opposing team.
|
|
||||||
*
|
|
||||||
* @param fighter The AI-controlled fighter choosing a target.
|
|
||||||
* @return The chosen target, or NULL if the opposing team has no
|
|
||||||
* living fighters.
|
|
||||||
*/
|
|
||||||
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets BATTLE.state and fires BATTLE.onStateChanged with the previous and
|
|
||||||
* new state.
|
|
||||||
*
|
|
||||||
* @param next The state to transition to.
|
|
||||||
*/
|
|
||||||
void battleSetState(const battlestate_t next);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets BATTLE.result and transitions to BATTLE_STATE_ENDED.
|
|
||||||
*
|
|
||||||
* @param result The result to end the battle with.
|
|
||||||
*/
|
|
||||||
void battleSetResult(const battleresult_t result);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a fighter is a live, undecided candidate for the given
|
|
||||||
* controller -- i.e. whether PLAYER_SELECTION or AI_SELECTION should still
|
|
||||||
* be deciding an action for it this round.
|
|
||||||
*
|
|
||||||
* @param fighterIndex Index into BATTLE.fighters to check.
|
|
||||||
* @param controller The controller PLAYER_SELECTION/AI_SELECTION is
|
|
||||||
* currently deciding for.
|
|
||||||
* @return True if the fighter is alive, matches controller, and has no
|
|
||||||
* action queued yet.
|
|
||||||
*/
|
|
||||||
bool_t battleFighterNeedsDecision(
|
|
||||||
const uint8_t fighterIndex,
|
|
||||||
const battlefightercontroller_t controller
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances BATTLE.selectionIndex to the next player-controlled fighter that
|
|
||||||
* still needs a decision, or transitions to BATTLE_STATE_AI_SELECTION once
|
|
||||||
* none remain.
|
|
||||||
*/
|
|
||||||
void battleAdvanceSelection(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles BATTLE_STATE_PRE_ROUND: rebuilds the execution order and moves on
|
|
||||||
* to BATTLE_STATE_PLAYER_SELECTION, positioning the selection cursor.
|
|
||||||
*/
|
|
||||||
void battleUpdatePreRound(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles BATTLE_STATE_AI_SELECTION: queues an attack for every undecided
|
|
||||||
* AI-controlled fighter, then moves on to BATTLE_STATE_MOVES_EXECUTING.
|
|
||||||
*/
|
|
||||||
void battleUpdateAiSelection(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles BATTLE_STATE_MOVES_EXECUTING: resolves one queued action from
|
|
||||||
* BATTLE.executionOrder per call, or transitions to BATTLE_STATE_POST_ROUND
|
|
||||||
* once every fighter this round has been processed.
|
|
||||||
*/
|
|
||||||
void battleUpdateMovesExecuting(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles BATTLE_STATE_POST_ROUND: advances BATTLE.round and transitions
|
|
||||||
* back to BATTLE_STATE_PRE_ROUND.
|
|
||||||
*/
|
|
||||||
void battleUpdatePostRound(void);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "battlefighter.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
void battleFighterInit(
|
|
||||||
battlefighter_t *fighter,
|
|
||||||
const battlefighterteam_t team,
|
|
||||||
const battlefightercontroller_t controller,
|
|
||||||
const battlefighterstats_t stats,
|
|
||||||
const uint16_t healthMax,
|
|
||||||
const uint16_t mpMax
|
|
||||||
) {
|
|
||||||
assertNotNull(fighter, "Fighter pointer cannot be NULL");
|
|
||||||
assertTrue(team < BATTLE_FIGHTER_TEAM_COUNT, "Invalid fighter team");
|
|
||||||
assertTrue(
|
|
||||||
controller < BATTLE_FIGHTER_CONTROLLER_COUNT,
|
|
||||||
"Invalid fighter controller"
|
|
||||||
);
|
|
||||||
|
|
||||||
const uint8_t id = fighter->id;
|
|
||||||
memoryZero(fighter, sizeof(battlefighter_t));
|
|
||||||
fighter->id = id;
|
|
||||||
fighter->status = BATTLE_FIGHTER_STATUS_NORMAL;
|
|
||||||
fighter->team = team;
|
|
||||||
fighter->controller = controller;
|
|
||||||
fighter->stats = stats;
|
|
||||||
fighter->healthMax = healthMax;
|
|
||||||
fighter->health = healthMax;
|
|
||||||
fighter->mpMax = mpMax;
|
|
||||||
fighter->mp = mpMax;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t battleFighterIsAlive(const battlefighter_t *fighter) {
|
|
||||||
assertNotNull(fighter, "Fighter pointer cannot be NULL");
|
|
||||||
return fighter->status == BATTLE_FIGHTER_STATUS_NORMAL;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
|
|
||||||
// An empty status means the slot in BATTLE.fighters is unused.
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_FIGHTER_STATUS_NULL,
|
|
||||||
|
|
||||||
BATTLE_FIGHTER_STATUS_NORMAL,
|
|
||||||
BATTLE_FIGHTER_STATUS_DEAD,
|
|
||||||
|
|
||||||
BATTLE_FIGHTER_STATUS_COUNT
|
|
||||||
} battlefighterstatus_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_FIGHTER_TEAM_ALLY,
|
|
||||||
BATTLE_FIGHTER_TEAM_ENEMY,
|
|
||||||
|
|
||||||
BATTLE_FIGHTER_TEAM_COUNT
|
|
||||||
} battlefighterteam_t;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
|
||||||
BATTLE_FIGHTER_CONTROLLER_AI,
|
|
||||||
|
|
||||||
BATTLE_FIGHTER_CONTROLLER_COUNT
|
|
||||||
} battlefightercontroller_t;
|
|
||||||
|
|
||||||
// Base combat stats, kept separate from the resource pools (health/mp) on
|
|
||||||
// battlefighter_t so that equipment/buffs can later modify them without
|
|
||||||
// touching current health/mp state.
|
|
||||||
typedef struct {
|
|
||||||
uint16_t attack;
|
|
||||||
uint16_t defense;
|
|
||||||
uint16_t magic;
|
|
||||||
uint16_t speed;
|
|
||||||
uint16_t luck;
|
|
||||||
} battlefighterstats_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint8_t id;
|
|
||||||
battlefighterstatus_t status;
|
|
||||||
battlefighterteam_t team;
|
|
||||||
battlefightercontroller_t controller;
|
|
||||||
|
|
||||||
uint16_t health;
|
|
||||||
uint16_t healthMax;
|
|
||||||
uint16_t mp;
|
|
||||||
uint16_t mpMax;
|
|
||||||
|
|
||||||
battlefighterstats_t stats;
|
|
||||||
} battlefighter_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a battle fighter in place, filling health/mp to their maximum
|
|
||||||
* values and setting its status to normal.
|
|
||||||
*
|
|
||||||
* @param fighter Pointer to the fighter to initialize.
|
|
||||||
* @param team The team the fighter belongs to.
|
|
||||||
* @param controller Who makes decisions for the fighter.
|
|
||||||
* @param stats The fighter's base combat stats.
|
|
||||||
* @param healthMax The fighter's maximum health.
|
|
||||||
* @param mpMax The fighter's maximum mp.
|
|
||||||
*/
|
|
||||||
void battleFighterInit(
|
|
||||||
battlefighter_t *fighter,
|
|
||||||
const battlefighterteam_t team,
|
|
||||||
const battlefightercontroller_t controller,
|
|
||||||
const battlefighterstats_t stats,
|
|
||||||
const uint16_t healthMax,
|
|
||||||
const uint16_t mpMax
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the fighter is in a state where it can still act (i.e.
|
|
||||||
* is not dead).
|
|
||||||
*
|
|
||||||
* @param fighter Pointer to the fighter to check.
|
|
||||||
* @returns True if the fighter can act.
|
|
||||||
*/
|
|
||||||
bool_t battleFighterIsAlive(const battlefighter_t *fighter);
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "party.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
|
|
||||||
party_t PARTY;
|
|
||||||
|
|
||||||
void partyInit(void) {
|
|
||||||
memoryZero(&PARTY, sizeof(party_t));
|
|
||||||
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
|
|
||||||
PARTY.members[i].id = i;
|
|
||||||
}
|
|
||||||
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
|
|
||||||
PARTY.order[i] = PARTY_ORDER_EMPTY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t partyGetAvailableMember(void) {
|
|
||||||
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
|
|
||||||
if(PARTY.members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0xFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
battlefighter_t *partyAddMember(
|
|
||||||
const battlefighterstats_t stats,
|
|
||||||
const uint16_t healthMax,
|
|
||||||
const uint16_t mpMax
|
|
||||||
) {
|
|
||||||
const uint8_t index = partyGetAvailableMember();
|
|
||||||
if(index == 0xFF) return NULL;
|
|
||||||
|
|
||||||
battlefighter_t *member = &PARTY.members[index];
|
|
||||||
battleFighterInit(
|
|
||||||
member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
|
||||||
stats, healthMax, mpMax
|
|
||||||
);
|
|
||||||
|
|
||||||
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
|
|
||||||
if(PARTY.order[i] != PARTY_ORDER_EMPTY) continue;
|
|
||||||
PARTY.order[i] = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
|
|
||||||
battlefighter_t *partyGetOrderMember(const uint8_t slot) {
|
|
||||||
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
|
|
||||||
|
|
||||||
const uint8_t index = PARTY.order[slot];
|
|
||||||
if(index == PARTY_ORDER_EMPTY) return NULL;
|
|
||||||
return &PARTY.members[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
void partySetOrder(const uint8_t slot, const uint8_t memberIndex) {
|
|
||||||
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
|
|
||||||
assertTrue(
|
|
||||||
memberIndex == PARTY_ORDER_EMPTY || memberIndex < PARTY_MEMBER_COUNT_MAX,
|
|
||||||
"Invalid party member index"
|
|
||||||
);
|
|
||||||
|
|
||||||
PARTY.order[slot] = memberIndex;
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "battlefighter.h"
|
|
||||||
|
|
||||||
#define PARTY_MEMBER_COUNT_MAX 4
|
|
||||||
#define PARTY_ACTIVE_SIZE_MAX 3
|
|
||||||
#define PARTY_ORDER_EMPTY 0xFF
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
battlefighter_t members[PARTY_MEMBER_COUNT_MAX];
|
|
||||||
|
|
||||||
// Maps an active battle slot to the roster member filling it, or
|
|
||||||
// PARTY_ORDER_EMPTY if the slot is unfilled. Only the first
|
|
||||||
// PARTY_ACTIVE_SIZE_MAX of the PARTY_MEMBER_COUNT_MAX roster members
|
|
||||||
// can be in the active lineup at once.
|
|
||||||
uint8_t order[PARTY_ACTIVE_SIZE_MAX];
|
|
||||||
} party_t;
|
|
||||||
|
|
||||||
extern party_t PARTY;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the party system with an empty roster and order.
|
|
||||||
*/
|
|
||||||
void partyInit(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets an available (unused) party member slot index.
|
|
||||||
*
|
|
||||||
* @return The index of an available slot, or 0xFF if the party is full.
|
|
||||||
*/
|
|
||||||
uint8_t partyGetAvailableMember(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a member to the party roster in the next available slot. Party
|
|
||||||
* members are always allies controlled by the player. If there is a
|
|
||||||
* free active order slot, the new member is placed into it.
|
|
||||||
*
|
|
||||||
* @param stats The member's base combat stats.
|
|
||||||
* @param healthMax The member's maximum health.
|
|
||||||
* @param mpMax The member's maximum mp.
|
|
||||||
* @return Pointer to the newly added party member, or NULL if the party is
|
|
||||||
* already full.
|
|
||||||
*/
|
|
||||||
battlefighter_t *partyAddMember(
|
|
||||||
const battlefighterstats_t stats,
|
|
||||||
const uint16_t healthMax,
|
|
||||||
const uint16_t mpMax
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the roster member currently occupying an active order slot.
|
|
||||||
*
|
|
||||||
* @param slot The active order slot to query.
|
|
||||||
* @return Pointer to the member in that slot, or NULL if the slot is
|
|
||||||
* empty.
|
|
||||||
*/
|
|
||||||
battlefighter_t *partyGetOrderMember(const uint8_t slot);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigns a roster member to an active order slot, replacing whatever
|
|
||||||
* was there. Use PARTY_ORDER_EMPTY to clear a slot.
|
|
||||||
*
|
|
||||||
* @param slot The active order slot to assign.
|
|
||||||
* @param memberIndex The roster member index to place there, or
|
|
||||||
* PARTY_ORDER_EMPTY to clear the slot.
|
|
||||||
*/
|
|
||||||
void partySetOrder(const uint8_t slot, const uint8_t memberIndex);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
# Sources
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
testbattle.c
|
|
||||||
)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "testbattle.h"
|
|
||||||
#include "rpg/battle/battle.h"
|
|
||||||
#include "scene/scene.h"
|
|
||||||
|
|
||||||
void testBattleStart(void) {
|
|
||||||
battleInit();
|
|
||||||
|
|
||||||
const battlefighterstats_t allyOneStats =
|
|
||||||
{ .attack = 10, .defense = 5, .magic = 0, .speed = 10, .luck = 0 };
|
|
||||||
const battlefighterstats_t allyTwoStats =
|
|
||||||
{ .attack = 8, .defense = 4, .magic = 0, .speed = 8, .luck = 0 };
|
|
||||||
const battlefighterstats_t enemyOneStats =
|
|
||||||
{ .attack = 6, .defense = 3, .magic = 0, .speed = 6, .luck = 0 };
|
|
||||||
const battlefighterstats_t enemyTwoStats =
|
|
||||||
{ .attack = 7, .defense = 3, .magic = 0, .speed = 5, .luck = 0 };
|
|
||||||
|
|
||||||
battleAddFighter(
|
|
||||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
|
||||||
allyOneStats, 30, 10
|
|
||||||
);
|
|
||||||
battleAddFighter(
|
|
||||||
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
|
|
||||||
allyTwoStats, 25, 10
|
|
||||||
);
|
|
||||||
battleAddFighter(
|
|
||||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
|
|
||||||
enemyOneStats, 20, 5
|
|
||||||
);
|
|
||||||
battleAddFighter(
|
|
||||||
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
|
|
||||||
enemyTwoStats, 20, 5
|
|
||||||
);
|
|
||||||
|
|
||||||
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
|
|
||||||
sceneSet(SCENE_TYPE_BATTLE);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "error/error.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TEMPORARY test hook: sets up a hardcoded mock battle and switches to the
|
|
||||||
* battle scene, so the battle scene (camera/fighters/HUD) can be seen and
|
|
||||||
* played without a real encounter trigger yet.
|
|
||||||
*/
|
|
||||||
void testBattleStart(void);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Copyright (c) 2025 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
# Sources
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
cutscenesystem.c
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subdirs
|
|
||||||
add_subdirectory(item)
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2025 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "rpg/cutscene/cutscenepause.h"
|
|
||||||
|
|
||||||
typedef struct cutscene_s {
|
|
||||||
const cutsceneitem_t *items;
|
|
||||||
uint8_t itemCount;
|
|
||||||
cutscenepause_t pause;
|
|
||||||
|
|
||||||
// Size in bytes of this cutscene's custom user data, carved out of
|
|
||||||
// CUTSCENE_SYSTEM.data while the cutscene is running.
|
|
||||||
size_t dataSize;
|
|
||||||
} cutscene_t;
|
|
||||||
|
|
||||||
#define CUTSCENE(NAME, SIZE, PAUSE_TYPE, ...) \
|
|
||||||
static const cutsceneitem_t CUTSCENE_##NAME##_ITEMS[] = { __VA_ARGS__ }; \
|
|
||||||
static const cutscene_t CUTSCENE_##NAME = { \
|
|
||||||
.items = CUTSCENE_##NAME##_ITEMS, \
|
|
||||||
.itemCount = sizeof(CUTSCENE_##NAME##_ITEMS) / sizeof(cutsceneitem_t), \
|
|
||||||
.pause = CUTSCENE_PAUSE_##PAUSE_TYPE, \
|
|
||||||
.dataSize = SIZE \
|
|
||||||
};
|
|
||||||
|
|
||||||
#define CUTSCENE_REFERENCE(CUTSCENE) \
|
|
||||||
&CUTSCENE_##CUTSCENE
|
|
||||||
|
|
||||||
#define CUTSCENE_TEXT(TEXT) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_TEXT, .text = { .text = TEXT } }
|
|
||||||
|
|
||||||
#define CUTSCENE_TEXT_MINI(TEXT, X, Y, Z, DURATION) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_TEXT_MINI, \
|
|
||||||
.textMini = { \
|
|
||||||
.text = TEXT, \
|
|
||||||
.position = { X, Y, Z }, \
|
|
||||||
.duration = DURATION \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_TEXT_MINI_HIDE(INDEX) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE, \
|
|
||||||
.textMiniHide = { .index = INDEX } \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_WAIT(WAIT) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_WAIT, .wait = WAIT }
|
|
||||||
|
|
||||||
// A named, otherwise no-op position in the item list that cutsceneGoTo
|
|
||||||
// can jump execution straight to. NAME is matched with stringEquals,
|
|
||||||
// not pointer identity, so it's safe to use separate string literals
|
|
||||||
// with the same contents at the marker and at each call site.
|
|
||||||
#define CUTSCENE_MARKER(NAME) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_MARKER, .marker = { .name = NAME } }
|
|
||||||
|
|
||||||
// Restarts the currently running cutscene from its first item,
|
|
||||||
// preserving whatever interact/interacted entities triggered it.
|
|
||||||
#define CUTSCENE_RESTART() \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_RESTART }
|
|
||||||
|
|
||||||
// Blocks the cutscene here indefinitely - it never completes on its own,
|
|
||||||
// only cutsceneGoTo (called externally, e.g. from a UI callback) can move
|
|
||||||
// execution past it.
|
|
||||||
#define CUTSCENE_IDLE() \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_IDLE }
|
|
||||||
|
|
||||||
// Opens a full-screen UI panel (e.g. the main menu) declaratively, then
|
|
||||||
// immediately continues on to whatever follows this item.
|
|
||||||
#define CUTSCENE_UI_SHOW(SCREEN) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_UI_SHOW, .uiShow = { .screen = SCREEN } }
|
|
||||||
|
|
||||||
// Requests a switch to a different SCENE_TYPE via sceneSet, then
|
|
||||||
// immediately continues on to whatever follows this item - the switch
|
|
||||||
// itself doesn't happen until the next sceneUpdate() tick, so it does
|
|
||||||
// not take effect this frame.
|
|
||||||
#define CUTSCENE_SCENE(TYPE) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_SCENE, .sceneChange = { .type = TYPE } }
|
|
||||||
|
|
||||||
#define CUTSCENE_CUTSCENE(CUTSCENE) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_CUTSCENE, \
|
|
||||||
.cutscene = CUTSCENE_REFERENCE(CUTSCENE) \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_CALLBACK(CALLBACK) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = CALLBACK }
|
|
||||||
|
|
||||||
#define CUTSCENE_PRINT(TEXT) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_PRINT, .print = { .text = TEXT } }
|
|
||||||
|
|
||||||
// Shows a message-only modal (no option buttons) and immediately
|
|
||||||
// continues on to whatever follows this item - it does not wait for
|
|
||||||
// the dialog to be dismissed. Script the rest of the interaction (e.g.
|
|
||||||
// CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then
|
|
||||||
// CUTSCENE_MODAL_CLOSE) as later items in the same cutscene.
|
|
||||||
// TITLE and MESSAGE are each displayed as-is unless they match a
|
|
||||||
// locale message ID, in which case the translated string is shown
|
|
||||||
// instead - see uiModalLocalize.
|
|
||||||
#define CUTSCENE_MODAL(TITLE, MESSAGE) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MODAL, \
|
|
||||||
.modal = { .title = TITLE, .message = MESSAGE } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shows a modal with option buttons and immediately continues on, same
|
|
||||||
// as CUTSCENE_MODAL - it does not block waiting for a selection.
|
|
||||||
// Back/cancel input is disabled while it's open (see
|
|
||||||
// uiMenuSetDisableBack), so it must be dismissed by picking one; CALLBACK
|
|
||||||
// then fires with the selected option index once the dialog closes.
|
|
||||||
// Option labels are passed as trailing arguments, e.g.
|
|
||||||
// CUTSCENE_MODAL_OPTIONS(title, message, callback, "Retry", "Cancel") -
|
|
||||||
// their strings are not copied by this item, so they must stay valid
|
|
||||||
// until the modal opens (string literals are fine). Like TITLE and
|
|
||||||
// MESSAGE, each option is translated if it matches a locale message ID.
|
|
||||||
#define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MODAL, \
|
|
||||||
.modal = { \
|
|
||||||
.title = TITLE, .message = MESSAGE, \
|
|
||||||
.options = (const char_t *[]){ __VA_ARGS__ }, \
|
|
||||||
.optionCount = (uint8_t)( \
|
|
||||||
sizeof((const char_t *[]){ __VA_ARGS__ }) / sizeof(const char_t *) \
|
|
||||||
), \
|
|
||||||
.callback = CALLBACK \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly one option and
|
|
||||||
// MARKER1 to jump straight to via cutsceneGoTo once it's selected - no
|
|
||||||
// callback function to write. Does not fall through to whatever follows
|
|
||||||
// this item, so MARKER1 must be scripted elsewhere in the same cutscene.
|
|
||||||
#define CUTSCENE_MODAL_OPTIONS_ONE(TITLE, MESSAGE, OPTION1, MARKER1) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
|
|
||||||
.modalOptionsMarkers = { \
|
|
||||||
.title = TITLE, .message = MESSAGE, \
|
|
||||||
.options = { OPTION1 }, \
|
|
||||||
.markers = { MARKER1 }, \
|
|
||||||
.optionCount = 1 \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly two options,
|
|
||||||
// jumping straight to OPTION1_MARKER or OPTION2_MARKER via cutsceneGoTo
|
|
||||||
// once the corresponding option is selected - no callback function to
|
|
||||||
// write. Does not fall through to whatever follows this item, so both
|
|
||||||
// markers must be scripted elsewhere in the same cutscene.
|
|
||||||
#define CUTSCENE_MODAL_OPTIONS_TWO( \
|
|
||||||
TITLE, MESSAGE, OPTION1, OPTION1_MARKER, OPTION2, OPTION2_MARKER \
|
|
||||||
) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
|
|
||||||
.modalOptionsMarkers = { \
|
|
||||||
.title = TITLE, .message = MESSAGE, \
|
|
||||||
.options = { OPTION1, OPTION2 }, \
|
|
||||||
.markers = { OPTION1_MARKER, OPTION2_MARKER }, \
|
|
||||||
.optionCount = 2 \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closes the currently open modal (if any). Useful when a modal was
|
|
||||||
// opened outside of a blocking CUTSCENE_MODAL item (e.g. directly via
|
|
||||||
// uiModalOpen) and this cutscene just needs to dismiss it and continue
|
|
||||||
// on to whatever follows this item in the sequence.
|
|
||||||
#define CUTSCENE_MODAL_CLOSE() \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_MODAL_CLOSE }
|
|
||||||
|
|
||||||
// Opens the on-screen keyboard and blocks the cutscene until it closes,
|
|
||||||
// then caches whatever was typed - see cutsceneSystemGetTextCache to
|
|
||||||
// read it back afterwards. __VA_ARGS__ are uikeyboardopen_t designated
|
|
||||||
// initializers, e.g. CUTSCENE_KEYBOARD(.cancel = true, .maxLength = 8).
|
|
||||||
#define CUTSCENE_KEYBOARD(...) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_KEYBOARD, \
|
|
||||||
.keyboard = { .open = { __VA_ARGS__ } } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Re)requests an available save device and jumps straight to
|
|
||||||
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same as a
|
|
||||||
// CUTSCENE_MODAL_OPTIONS callback - it does not fall through to
|
|
||||||
// whatever follows this item, so both markers must be scripted
|
|
||||||
// elsewhere in the same cutscene.
|
|
||||||
#define CUTSCENE_SAVE_DEVICE_CHECK(SUCCESS_MARKER, FAILURE_MARKER) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK, \
|
|
||||||
.saveDeviceCheck = { \
|
|
||||||
.successMarker = SUCCESS_MARKER, \
|
|
||||||
.failureMarker = FAILURE_MARKER \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Re)loads every save slot via saveLoadAllSlots() and jumps straight to
|
|
||||||
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same shape as
|
|
||||||
// CUTSCENE_SAVE_DEVICE_CHECK - it does not fall through to whatever
|
|
||||||
// follows this item, so both markers must be scripted elsewhere in the
|
|
||||||
// same cutscene.
|
|
||||||
#define CUTSCENE_SAVE_LOAD_ALL_SLOTS(SUCCESS_MARKER, FAILURE_MARKER) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS, \
|
|
||||||
.saveLoadAllSlots = { \
|
|
||||||
.successMarker = SUCCESS_MARKER, \
|
|
||||||
.failureMarker = FAILURE_MARKER \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_WALK_TO(ENTITY_INDEX, X, Y, Z) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \
|
|
||||||
.entityWalkTo = { \
|
|
||||||
.entityIndex = ENTITY_INDEX, \
|
|
||||||
.positions = (const worldpos_t[]){ { X, Y, Z } }, \
|
|
||||||
.count = 1, \
|
|
||||||
.walkAround = true \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_WALK_PATH(NAME, ENTITY_INDEX, ...) \
|
|
||||||
static const worldpos_t CUTSCENE_##NAME##_POSITIONS[] = { __VA_ARGS__ }; \
|
|
||||||
static const cutsceneitem_t CUTSCENE_##NAME = { \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \
|
|
||||||
.entityWalkTo = { \
|
|
||||||
.entityIndex = ENTITY_INDEX, \
|
|
||||||
.positions = CUTSCENE_##NAME##_POSITIONS, \
|
|
||||||
.count = sizeof(CUTSCENE_##NAME##_POSITIONS) / sizeof(worldpos_t), \
|
|
||||||
.walkAround = true \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_REMOVE(ENTITY_INDEX) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_REMOVE, \
|
|
||||||
.entityRemove = { .entityIndex = ENTITY_INDEX } \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_ADD(TYPE, X, Y, Z) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_ADD, \
|
|
||||||
.entityAdd = { .entityType = TYPE, .position = { X, Y, Z } } \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_TURN(ENTITY_INDEX, DIRECTION) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_TURN, \
|
|
||||||
.entityTurn = { .entityIndex = ENTITY_INDEX, .direction = DIRECTION } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walks ENTITY_INDEX to stand beside TARGET_ENTITY_INDEX, offset by
|
|
||||||
// (OFFSET_X, OFFSET_Y) on the 2D plane. The destination Z is resolved
|
|
||||||
// from nearby terrain each frame, so ramps between the two entities are
|
|
||||||
// accounted for automatically.
|
|
||||||
#define CUTSCENE_ENTITY_WALK_TO_ENTITY( \
|
|
||||||
ENTITY_INDEX, TARGET_ENTITY_INDEX, OFFSET_X, OFFSET_Y \
|
|
||||||
) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY, \
|
|
||||||
.entityWalkToEntity = { \
|
|
||||||
.entityIndex = ENTITY_INDEX, \
|
|
||||||
.targetEntityIndex = TARGET_ENTITY_INDEX, \
|
|
||||||
.offsetX = OFFSET_X, \
|
|
||||||
.offsetY = OFFSET_Y \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_TELEPORT(ENTITY_INDEX, X, Y, Z) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT, \
|
|
||||||
.entityTeleport = { .entityIndex = ENTITY_INDEX, .target = { X, Y, Z } } \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_FADE(FROM, TO, DURATION, EASING) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_FADE, \
|
|
||||||
.fade = { .from = FROM, .to = TO, .duration = DURATION, .easing = EASING } \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_FADE_TO_BLACK(DURATION) \
|
|
||||||
CUTSCENE_FADE(COLOR_TRANSPARENT_BLACK, COLOR_BLACK, DURATION, EASING_LINEAR)
|
|
||||||
|
|
||||||
#define CUTSCENE_FADE_FROM_BLACK(DURATION) \
|
|
||||||
CUTSCENE_FADE(COLOR_BLACK, COLOR_TRANSPARENT_BLACK, DURATION, EASING_LINEAR)
|
|
||||||
|
|
||||||
#define CUTSCENE_FADE_TO_WHITE(DURATION) \
|
|
||||||
CUTSCENE_FADE(COLOR_TRANSPARENT_WHITE, COLOR_WHITE, DURATION, EASING_LINEAR)
|
|
||||||
|
|
||||||
#define CUTSCENE_FADE_FROM_WHITE(DURATION) \
|
|
||||||
CUTSCENE_FADE(COLOR_WHITE, COLOR_TRANSPARENT_WHITE, DURATION, EASING_LINEAR)
|
|
||||||
|
|
||||||
#define CUTSCENE_EMOJI(ENTITY_INDEX, EMOJI_TYPE, DURATION) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_EMOJI, \
|
|
||||||
.emoji = { \
|
|
||||||
.entityIndex = ENTITY_INDEX, \
|
|
||||||
.emojiType = EMOJI_TYPE, \
|
|
||||||
.duration = DURATION \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// AMOUNT ranges 0 (no shake) to 4 (three tiles): 1 is half a tile, 2 is
|
|
||||||
// a full tile, 3 is two tiles, and 4 is three tiles.
|
|
||||||
#define CUTSCENE_SHAKE(AMOUNT, DURATION) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_SHAKE, \
|
|
||||||
.shake = { .amount = AMOUNT, .duration = DURATION } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Waits until BATTLE.state reaches STATE. Put this BEFORE
|
|
||||||
// CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE), not after -- pausing first
|
|
||||||
// freezes BATTLE.state wherever it already is, so it would never reach
|
|
||||||
// STATE on its own to satisfy the wait. Waiting unpaused, then pausing the
|
|
||||||
// moment it's satisfied, catches the battle right at STATE before it can
|
|
||||||
// advance further.
|
|
||||||
#define CUTSCENE_BATTLE_WAIT_STATE(STATE) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, \
|
|
||||||
.battleWaitState = { .state = STATE } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediately queues an attack for FIGHTER_INDEX against TARGET_INDEX,
|
|
||||||
// bypassing normal player/AI selection for that fighter this round.
|
|
||||||
#define CUTSCENE_BATTLE_FORCE_ACTION(FIGHTER_INDEX, TARGET_INDEX) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, \
|
|
||||||
.battleForceAction = { \
|
|
||||||
.fighterIndex = FIGHTER_INDEX, .targetIndex = TARGET_INDEX \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_SET_PAUSE(FLAGS) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) }
|
|
||||||
|
|
||||||
#define CUTSCENE_ITEM_GIVE(ITEM_ID, QUANTITY) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_ITEM_GIVE, \
|
|
||||||
.itemGive = { .item = ITEM_ID, .quantity = QUANTITY } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs all listed items simultaneously and waits until all are done.
|
|
||||||
// Concurrent items cannot be nested inside another CUTSCENE_CONCURRENT.
|
|
||||||
#define CUTSCENE_CONCURRENT(...) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_CONCURRENT, \
|
|
||||||
.concurrent = { \
|
|
||||||
.items = (const cutsceneitem_t[]){ __VA_ARGS__ }, \
|
|
||||||
.count = (uint8_t)( \
|
|
||||||
sizeof((cutsceneitem_t[]){ __VA_ARGS__ }) / \
|
|
||||||
sizeof(cutsceneitem_t) \
|
|
||||||
) \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_MAP_AREA_ADD( \
|
|
||||||
MIN_X, MIN_Y, MIN_Z, MAX_X, MAX_Y, MAX_Z, CALLBACK, NOTIFY, TRIGGER \
|
|
||||||
) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MAP_AREA_ADD, \
|
|
||||||
.mapAreaAdd = { \
|
|
||||||
.min = { MIN_X, MIN_Y, MIN_Z }, \
|
|
||||||
.max = { MAX_X, MAX_Y, MAX_Z }, \
|
|
||||||
.callback = CALLBACK, \
|
|
||||||
.notify = NOTIFY, \
|
|
||||||
.trigger = TRIGGER \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_MAP_AREA_REMOVE(AREA_ID) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE, \
|
|
||||||
.mapAreaRemove = { .areaId = AREA_ID } \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Waits until any one of the given map area IDs has its callback invoked.
|
|
||||||
// Accepts CUTSCENE_AREA_LAST_CREATED in place of a literal area ID.
|
|
||||||
#define CUTSCENE_MAP_AREA_WAIT(...) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT, \
|
|
||||||
.mapAreaWait = { \
|
|
||||||
.areaIds = (const uint8_t[]){ __VA_ARGS__ }, \
|
|
||||||
.count = (uint8_t)( \
|
|
||||||
sizeof((const uint8_t[]){ __VA_ARGS__ }) / sizeof(uint8_t) \
|
|
||||||
) \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds a map area, waits for it to be triggered once, then removes it
|
|
||||||
// before the cutscene continues. Uses a no-op callback since the wait is
|
|
||||||
// driven by the area's trigger count rather than callback logic.
|
|
||||||
#define CUTSCENE_MAP_AREA_TRIGGER_ONCE( \
|
|
||||||
MIN_X, MIN_Y, MIN_Z, MAX_X, MAX_Y, MAX_Z, NOTIFY, TRIGGER \
|
|
||||||
) \
|
|
||||||
CUTSCENE_MAP_AREA_ADD( \
|
|
||||||
MIN_X, MIN_Y, MIN_Z, MAX_X, MAX_Y, MAX_Z, \
|
|
||||||
mapAreaNoopCallback, NOTIFY, TRIGGER \
|
|
||||||
), \
|
|
||||||
CUTSCENE_MAP_AREA_WAIT(CUTSCENE_AREA_LAST_CREATED), \
|
|
||||||
CUTSCENE_MAP_AREA_REMOVE(CUTSCENE_AREA_LAST_CREATED)
|
|
||||||
|
|
||||||
// Longhand - every audioMixerQueuePlay() parameter. See
|
|
||||||
// CUTSCENE_AUDIO_PLAY_SIMPLE/CUTSCENE_AUDIO_PLAY_LOOPED below for the
|
|
||||||
// shorthands.
|
|
||||||
#define CUTSCENE_AUDIO_PLAY( \
|
|
||||||
FILE, CHANNEL, VOLUME, PAN, LOOPING, LOOP_COUNT, LOOP_START, LOOP_TO \
|
|
||||||
) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_AUDIO_PLAY, \
|
|
||||||
.audioPlay = { \
|
|
||||||
.file = FILE, \
|
|
||||||
.channel = CHANNEL, \
|
|
||||||
.volume = VOLUME, \
|
|
||||||
.pan = PAN, \
|
|
||||||
.looping = LOOPING, \
|
|
||||||
.loopCount = LOOP_COUNT, \
|
|
||||||
.loopStart = LOOP_START, \
|
|
||||||
.loopTo = LOOP_TO \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shorthand: play FILE on CHANNEL once, full volume, centered (0.0f, same
|
|
||||||
// as AUDIO_STREAM_CENTER), no loop.
|
|
||||||
#define CUTSCENE_AUDIO_PLAY_SIMPLE(FILE, CHANNEL) \
|
|
||||||
CUTSCENE_AUDIO_PLAY(FILE, CHANNEL, 1.0f, 0.0f, false, 0, -1.0f, 0.0f)
|
|
||||||
|
|
||||||
// Shorthand: play FILE on CHANNEL looping the whole clip forever, full
|
|
||||||
// volume, centered (0.0f, same as AUDIO_STREAM_CENTER).
|
|
||||||
#define CUTSCENE_AUDIO_PLAY_LOOPED(FILE, CHANNEL) \
|
|
||||||
CUTSCENE_AUDIO_PLAY(FILE, CHANNEL, 1.0f, 0.0f, true, 0, -1.0f, 0.0f)
|
|
||||||
|
|
||||||
#define CUTSCENE_AUDIO_STOP(CHANNEL) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_AUDIO_STOP, .audioStopChannel = CHANNEL }
|
|
||||||
|
|
||||||
#define CUTSCENE_AUDIO_PAUSE(CHANNEL) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_AUDIO_PAUSE, .audioPauseChannel = CHANNEL }
|
|
||||||
|
|
||||||
#define CUTSCENE_AUDIO_RESUME(CHANNEL) \
|
|
||||||
{ .type = CUTSCENE_ITEM_TYPE_AUDIO_RESUME, .audioResumeChannel = CHANNEL }
|
|
||||||
|
|
||||||
// Longhand - starts CHANNEL fading from FROM to TO over DURATION seconds,
|
|
||||||
// eased by EASING. Doesn't wait for it - pair with
|
|
||||||
// CUTSCENE_AUDIO_FADE_WAIT(CHANNEL) to block until it finishes. See
|
|
||||||
// CUTSCENE_AUDIO_FADE_OUT/CUTSCENE_AUDIO_FADE_IN below for the shorthands;
|
|
||||||
// use this directly for a custom range, e.g.
|
|
||||||
// CUTSCENE_AUDIO_FADE(CHANNEL, 1.0f, 0.3f, 5.0f, EASING_LINEAR) to fade
|
|
||||||
// down to "playing quietly" rather than silent.
|
|
||||||
#define CUTSCENE_AUDIO_FADE(CHANNEL, FROM, TO, DURATION, EASING) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_AUDIO_FADE, \
|
|
||||||
.audioFade = { \
|
|
||||||
.channel = CHANNEL, \
|
|
||||||
.from = FROM, \
|
|
||||||
.to = TO, \
|
|
||||||
.duration = DURATION, \
|
|
||||||
.easing = EASING \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shorthand: fade CHANNEL from full volume to silent over DURATION seconds.
|
|
||||||
#define CUTSCENE_AUDIO_FADE_OUT(CHANNEL, DURATION) \
|
|
||||||
CUTSCENE_AUDIO_FADE(CHANNEL, 1.0f, 0.0f, DURATION, EASING_LINEAR)
|
|
||||||
|
|
||||||
// Shorthand: fade CHANNEL from silent to full volume over DURATION seconds.
|
|
||||||
#define CUTSCENE_AUDIO_FADE_IN(CHANNEL, DURATION) \
|
|
||||||
CUTSCENE_AUDIO_FADE(CHANNEL, 0.0f, 1.0f, DURATION, EASING_LINEAR)
|
|
||||||
|
|
||||||
// Waits until CHANNEL's fade (started elsewhere by CUTSCENE_AUDIO_FADE)
|
|
||||||
// has finished - a no-op wait if CHANNEL isn't fading.
|
|
||||||
#define CUTSCENE_AUDIO_FADE_WAIT(CHANNEL) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_AUDIO_FADE_WAIT, \
|
|
||||||
.audioFadeWaitChannel = CHANNEL \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_AUDIO_SET_PAN(CHANNEL, PAN) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN, \
|
|
||||||
.audioSetPan = { .channel = CHANNEL, .pan = PAN } \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define CUTSCENE_AUDIO_SET_LOOP( \
|
|
||||||
CHANNEL, LOOPING, LOOP_COUNT, LOOP_START, LOOP_TO \
|
|
||||||
) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP, \
|
|
||||||
.audioSetLoop = { \
|
|
||||||
.channel = CHANNEL, \
|
|
||||||
.looping = LOOPING, \
|
|
||||||
.loopCount = LOOP_COUNT, \
|
|
||||||
.loopStart = LOOP_START, \
|
|
||||||
.loopTo = LOOP_TO \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combined form of CUTSCENE_AUDIO_SET_PAN + CUTSCENE_AUDIO_SET_LOOP, for
|
|
||||||
// setting both at once rather than as two separate items.
|
|
||||||
#define CUTSCENE_AUDIO_SET( \
|
|
||||||
CHANNEL, PAN, LOOPING, LOOP_COUNT, LOOP_START, LOOP_TO \
|
|
||||||
) \
|
|
||||||
{ \
|
|
||||||
.type = CUTSCENE_ITEM_TYPE_AUDIO_SET, \
|
|
||||||
.audioSet = { \
|
|
||||||
.channel = CHANNEL, \
|
|
||||||
.pan = PAN, \
|
|
||||||
.looping = LOOPING, \
|
|
||||||
.loopCount = LOOP_COUNT, \
|
|
||||||
.loopStart = LOOP_START, \
|
|
||||||
.loopTo = LOOP_TO \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "dusk.h"
|
|
||||||
|
|
||||||
typedef uint8_t cutscenepause_t;
|
|
||||||
|
|
||||||
#define CUTSCENE_PAUSE_NONE ((cutscenepause_t)0)
|
|
||||||
#define CUTSCENE_PAUSE_NPC ((cutscenepause_t)(1 << 0))
|
|
||||||
#define CUTSCENE_PAUSE_PLAYER ((cutscenepause_t)(1 << 1))
|
|
||||||
#define CUTSCENE_PAUSE_WORLD ((cutscenepause_t)(1 << 2))
|
|
||||||
#define CUTSCENE_PAUSE_BATTLE ((cutscenepause_t)(1 << 3))
|
|
||||||
|
|
||||||
#define CUTSCENE_PAUSE_DEFAULT ((cutscenepause_t)( \
|
|
||||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER \
|
|
||||||
))
|
|
||||||
|
|
||||||
#define CUTSCENE_PAUSE_ALL ((cutscenepause_t)( \
|
|
||||||
CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD | \
|
|
||||||
CUTSCENE_PAUSE_BATTLE \
|
|
||||||
))
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2025 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "cutscenesystem.h"
|
|
||||||
#include "rpg/entity/entity.h"
|
|
||||||
#include "util/memory.h"
|
|
||||||
#include "util/string.h"
|
|
||||||
#include "assert/assert.h"
|
|
||||||
#include "asset/asset.h"
|
|
||||||
#include "ui/overlay/uifatalerror.h"
|
|
||||||
|
|
||||||
cutscenesystem_t CUTSCENE_SYSTEM;
|
|
||||||
|
|
||||||
void cutsceneSystemInit() {
|
|
||||||
memoryZero(&CUTSCENE_SYSTEM, sizeof(cutscenesystem_t));
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemPrepare(
|
|
||||||
const cutscene_t *cutscene,
|
|
||||||
entity_t *interact,
|
|
||||||
entity_t *interacted
|
|
||||||
) {
|
|
||||||
assertTrue(
|
|
||||||
cutscene->dataSize < CUTSCENE_SYSTEM_SIZE_MAX,
|
|
||||||
"Cutscene data size exceeds CUTSCENE_SYSTEM_SIZE_MAX"
|
|
||||||
);
|
|
||||||
|
|
||||||
CUTSCENE_SYSTEM.scene = cutscene;
|
|
||||||
CUTSCENE_SYSTEM.pause = cutscene->pause;
|
|
||||||
CUTSCENE_SYSTEM.entityInteract = interact;
|
|
||||||
CUTSCENE_SYSTEM.entityInteracted = interacted;
|
|
||||||
CUTSCENE_SYSTEM.entityLastCreated = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityLastRef = NULL;
|
|
||||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
|
||||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
|
||||||
CUTSCENE_SYSTEM.textCache[0] = '\0';
|
|
||||||
CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0.
|
|
||||||
CUTSCENE_SYSTEM.onComplete = NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
|
|
||||||
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemStartCutsceneWith(
|
|
||||||
const cutscene_t *cutscene,
|
|
||||||
entity_t *interact,
|
|
||||||
entity_t *interacted
|
|
||||||
) {
|
|
||||||
cutsceneSystemPrepare(cutscene, interact, interacted);
|
|
||||||
cutsceneSystemNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemStartCutsceneAndGoToMarker(
|
|
||||||
const cutscene_t *cutscene,
|
|
||||||
const char_t *marker
|
|
||||||
) {
|
|
||||||
cutsceneSystemPrepare(cutscene, NULL, NULL);
|
|
||||||
cutsceneGoTo(marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemLoad(const char_t *file) {
|
|
||||||
char_t path[ASSET_FILE_NAME_MAX];
|
|
||||||
stringFormat(path, sizeof(path), "cutscenes/%s", file);
|
|
||||||
|
|
||||||
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_CUTSCENE, NULL);
|
|
||||||
errorret_t result = assetRequireLoaded(entry);
|
|
||||||
if(errorIsNotOk(result)) {
|
|
||||||
errorCatch(errorPrint(result));
|
|
||||||
uiFatalErrorOpen(NULL);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cutsceneSystemStartCutscene(&entry->data.cutscene.cutscene);
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneRestart(void) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A restart is the same logical run trying again (e.g. retrying a failed
|
|
||||||
// save-device check), not a fresh unrelated start, so it should not
|
|
||||||
// silently drop a completion callback the caller already armed.
|
|
||||||
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
|
|
||||||
cutsceneSystemStartCutsceneWith(
|
|
||||||
CUTSCENE_SYSTEM.scene,
|
|
||||||
CUTSCENE_SYSTEM.entityInteract,
|
|
||||||
CUTSCENE_SYSTEM.entityInteracted
|
|
||||||
);
|
|
||||||
CUTSCENE_SYSTEM.onComplete = onComplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemUpdate() {
|
|
||||||
if(CUTSCENE_SYSTEM.scene == NULL) return;
|
|
||||||
|
|
||||||
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
|
|
||||||
if(cutsceneItemUpdate(item, &CUTSCENE_SYSTEM.data)) cutsceneSystemNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemNext() {
|
|
||||||
if(CUTSCENE_SYSTEM.scene == NULL) return;
|
|
||||||
|
|
||||||
CUTSCENE_SYSTEM.currentItem++;
|
|
||||||
|
|
||||||
// End of the cutscene?
|
|
||||||
if(
|
|
||||||
CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount
|
|
||||||
) {
|
|
||||||
// Saved and cleared before firing so a callback that immediately
|
|
||||||
// starts another cutscene (or sets its own onComplete) isn't clobbered
|
|
||||||
// by this function's own cleanup running after it - same reentrancy
|
|
||||||
// hazard as uiFocusPop, see src/dusk/ui/focus/uifocus.c.
|
|
||||||
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
|
|
||||||
void *userData = CUTSCENE_SYSTEM.userData;
|
|
||||||
|
|
||||||
CUTSCENE_SYSTEM.scene = NULL;
|
|
||||||
CUTSCENE_SYSTEM.currentItem = 0xFF;
|
|
||||||
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
|
|
||||||
CUTSCENE_SYSTEM.entityInteract = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityInteracted = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityLastCreated = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityLastRef = NULL;
|
|
||||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
|
||||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
|
||||||
CUTSCENE_SYSTEM.textCache[0] = '\0';
|
|
||||||
CUTSCENE_SYSTEM.onComplete = NULL;
|
|
||||||
|
|
||||||
if(onComplete != NULL) onComplete(userData);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start item.
|
|
||||||
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
|
|
||||||
memset(&CUTSCENE_SYSTEM.data, 0, sizeof(CUTSCENE_SYSTEM.data));
|
|
||||||
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.scene,
|
|
||||||
"cutsceneSystemSetOnComplete called with no cutscene running"
|
|
||||||
);
|
|
||||||
CUTSCENE_SYSTEM.onComplete = onComplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneGoTo(const char_t *name) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running"
|
|
||||||
);
|
|
||||||
|
|
||||||
for(uint8_t i = 0; i < CUTSCENE_SYSTEM.scene->itemCount; i++) {
|
|
||||||
const cutsceneitem_t *item = &CUTSCENE_SYSTEM.scene->items[i];
|
|
||||||
if(
|
|
||||||
item->type == CUTSCENE_ITEM_TYPE_MARKER &&
|
|
||||||
stringEquals(item->marker.name, name)
|
|
||||||
) {
|
|
||||||
CUTSCENE_SYSTEM.currentItem = i;
|
|
||||||
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data));
|
|
||||||
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(false, "cutsceneGoTo: no marker found with that name");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cutsceneitem_t * cutsceneSystemGetCurrentItem() {
|
|
||||||
if(CUTSCENE_SYSTEM.scene == NULL) return NULL;
|
|
||||||
|
|
||||||
return &CUTSCENE_SYSTEM.scene->items[CUTSCENE_SYSTEM.currentItem];
|
|
||||||
}
|
|
||||||
|
|
||||||
entity_t * cutsceneSystemGetEntity(const uint8_t entityIndex) {
|
|
||||||
entity_t *entity;
|
|
||||||
|
|
||||||
if(entityIndex == CUTSCENE_ENTITY_INTERACT) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.entityInteract,
|
|
||||||
"CUTSCENE_ENTITY_INTERACT used but no interact entity is set"
|
|
||||||
);
|
|
||||||
entity = CUTSCENE_SYSTEM.entityInteract;
|
|
||||||
} else if(entityIndex == CUTSCENE_ENTITY_INTERACTED) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.entityInteracted,
|
|
||||||
"CUTSCENE_ENTITY_INTERACTED used but no interacted entity is set"
|
|
||||||
);
|
|
||||||
entity = CUTSCENE_SYSTEM.entityInteracted;
|
|
||||||
} else if(entityIndex == CUTSCENE_ENTITY_LAST_CREATED) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.entityLastCreated,
|
|
||||||
"CUTSCENE_ENTITY_LAST_CREATED used but no entity has been created"
|
|
||||||
);
|
|
||||||
entity = CUTSCENE_SYSTEM.entityLastCreated;
|
|
||||||
} else if(entityIndex == CUTSCENE_ENTITY_LAST_REF) {
|
|
||||||
assertNotNull(
|
|
||||||
CUTSCENE_SYSTEM.entityLastRef,
|
|
||||||
"CUTSCENE_ENTITY_LAST_REF used but no entity has been referenced"
|
|
||||||
);
|
|
||||||
entity = CUTSCENE_SYSTEM.entityLastRef;
|
|
||||||
} else {
|
|
||||||
assertTrue(
|
|
||||||
entityIndex < ENTITY_COUNT,
|
|
||||||
"Entity index is out of range"
|
|
||||||
);
|
|
||||||
entity = &ENTITIES[entityIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
CUTSCENE_SYSTEM.entityLastRef = entity;
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t cutsceneSystemGetAreaId(const uint8_t areaId) {
|
|
||||||
if(areaId == CUTSCENE_AREA_LAST_CREATED) {
|
|
||||||
assertTrue(
|
|
||||||
CUTSCENE_SYSTEM.areaLastCreated != CUTSCENE_AREA_LAST_CREATED,
|
|
||||||
"CUTSCENE_AREA_LAST_CREATED used but no map area has been created"
|
|
||||||
);
|
|
||||||
return CUTSCENE_SYSTEM.areaLastCreated;
|
|
||||||
}
|
|
||||||
return areaId;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index) {
|
|
||||||
if(index == CUTSCENE_TEXT_MINI_LAST_CREATED) {
|
|
||||||
assertTrue(
|
|
||||||
CUTSCENE_SYSTEM.textMiniLastCreated != CUTSCENE_TEXT_MINI_LAST_CREATED,
|
|
||||||
"CUTSCENE_TEXT_MINI_LAST_CREATED used but no mini textbox has been "
|
|
||||||
"shown"
|
|
||||||
);
|
|
||||||
return CUTSCENE_SYSTEM.textMiniLastCreated;
|
|
||||||
}
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
const char_t * cutsceneSystemGetTextCache(void) {
|
|
||||||
return CUTSCENE_SYSTEM.textCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemSetTextCache(const char_t *text) {
|
|
||||||
stringCopy(
|
|
||||||
CUTSCENE_SYSTEM.textCache, text, CUTSCENE_TEXT_CACHE_MAX - 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void cutsceneSystemDispose() {
|
|
||||||
CUTSCENE_SYSTEM.scene = NULL;
|
|
||||||
CUTSCENE_SYSTEM.currentItem = 0xFF;
|
|
||||||
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
|
|
||||||
CUTSCENE_SYSTEM.entityInteract = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityInteracted = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityLastCreated = NULL;
|
|
||||||
CUTSCENE_SYSTEM.entityLastRef = NULL;
|
|
||||||
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
|
|
||||||
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
|
|
||||||
CUTSCENE_SYSTEM.textCache[0] = '\0';
|
|
||||||
CUTSCENE_SYSTEM.onComplete = NULL;
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2025 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "cutscene.h"
|
|
||||||
|
|
||||||
typedef struct entity_s entity_t;
|
|
||||||
|
|
||||||
#define CUTSCENE_ENTITY_INTERACT ((uint8_t)0xFE)
|
|
||||||
#define CUTSCENE_ENTITY_INTERACTED ((uint8_t)0xFD)
|
|
||||||
#define CUTSCENE_ENTITY_LAST_CREATED ((uint8_t)0xFC)
|
|
||||||
#define CUTSCENE_ENTITY_LAST_REF ((uint8_t)0xFB)
|
|
||||||
#define CUTSCENE_AREA_LAST_CREATED ((uint8_t)0xFF)
|
|
||||||
#define CUTSCENE_TEXT_MINI_LAST_CREATED ((uint8_t)0xFA)
|
|
||||||
|
|
||||||
// Maximum number of bytes a running cutscene may request via
|
|
||||||
// cutscene_t.dataSize.
|
|
||||||
#define CUTSCENE_SYSTEM_SIZE_MAX 8192
|
|
||||||
|
|
||||||
// Size of CUTSCENE_SYSTEM.textCache - keep >= UI_KEYBOARD_TEXT_MAX (see
|
|
||||||
// ui/dialog/keyboard/uikeyboard.h) so text entered via a
|
|
||||||
// CUTSCENE_ITEM_TYPE_KEYBOARD item is never truncated caching it here.
|
|
||||||
#define CUTSCENE_TEXT_CACHE_MAX 64
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const cutscene_t *scene;
|
|
||||||
uint8_t currentItem;
|
|
||||||
cutscenepause_t pause;
|
|
||||||
entity_t *entityInteract;
|
|
||||||
entity_t *entityInteracted;
|
|
||||||
entity_t *entityLastCreated;
|
|
||||||
entity_t *entityLastRef;
|
|
||||||
uint8_t areaLastCreated;
|
|
||||||
uint8_t textMiniLastCreated;
|
|
||||||
|
|
||||||
// Free-form text cache, e.g. holding whatever was last typed via a
|
|
||||||
// CUTSCENE_ITEM_TYPE_KEYBOARD item - see cutsceneSystemGetTextCache/
|
|
||||||
// cutsceneSystemSetTextCache. Not tied to any one item type; any item
|
|
||||||
// may read or write it.
|
|
||||||
char_t textCache[CUTSCENE_TEXT_CACHE_MAX];
|
|
||||||
|
|
||||||
// Data (used by the current item).
|
|
||||||
cutsceneitemdata_t data;
|
|
||||||
|
|
||||||
// Custom user data for the running cutscene, sized per-scene by
|
|
||||||
// cutscene_t.dataSize.
|
|
||||||
uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX];
|
|
||||||
|
|
||||||
// See cutsceneSystemSetOnComplete.
|
|
||||||
cutscenecallback_t onComplete;
|
|
||||||
} cutscenesystem_t;
|
|
||||||
|
|
||||||
extern cutscenesystem_t CUTSCENE_SYSTEM;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the cutscene system.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemInit();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a cutscene with no bound entities.
|
|
||||||
*
|
|
||||||
* @param cutscene Pointer to the cutscene to start.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemStartCutscene(const cutscene_t *cutscene);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a cutscene with the two entities that triggered it.
|
|
||||||
*
|
|
||||||
* @param cutscene Pointer to the cutscene to start.
|
|
||||||
* @param interact The entity that initiated the interaction (player).
|
|
||||||
* @param interacted The entity that was interacted with (NPC).
|
|
||||||
*/
|
|
||||||
void cutsceneSystemStartCutsceneWith(
|
|
||||||
const cutscene_t *cutscene,
|
|
||||||
entity_t *interact,
|
|
||||||
entity_t *interacted
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a cutscene with no bound entities, jumping straight to the
|
|
||||||
* CUTSCENE_MARKER item with the given name instead of running from the
|
|
||||||
* first item - as if cutsceneGoTo(marker) had been called immediately
|
|
||||||
* after cutsceneSystemStartCutscene. Asserts if no marker with that
|
|
||||||
* name exists in the cutscene.
|
|
||||||
*
|
|
||||||
* @param cutscene Pointer to the cutscene to start.
|
|
||||||
* @param marker Marker name to jump to, matched with stringEquals.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemStartCutsceneAndGoToMarker(
|
|
||||||
const cutscene_t *cutscene,
|
|
||||||
const char_t *marker
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locks, loads and immediately starts a cutscene asset by file name,
|
|
||||||
* e.g. cutsceneSystemLoad("main_menu.jsonc") loads and starts
|
|
||||||
* assets/cutscenes/main_menu.jsonc - same lock/assetRequireLoaded/start
|
|
||||||
* sequence as sceneInitialInit's INITIAL_CUTSCENE_ENTRY. The entry is
|
|
||||||
* locked forever (never unlocked), same lifetime convention as
|
|
||||||
* LOCALE.entry - it's fine to call this again later for the same file,
|
|
||||||
* it'll just resolve to the same already-loaded entry. Opens the fatal
|
|
||||||
* error overlay (see uiFatalErrorOpen) instead of starting anything if
|
|
||||||
* the asset fails to load.
|
|
||||||
*
|
|
||||||
* @param file Cutscene file name (with .jsonc extension), relative to
|
|
||||||
* assets/cutscenes/.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemLoad(const char_t *file);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restarts the currently running cutscene from its first item,
|
|
||||||
* preserving whatever interact/interacted entities triggered it.
|
|
||||||
* Asserts if no cutscene is running.
|
|
||||||
*/
|
|
||||||
void cutsceneRestart(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets a native callback to fire once when the currently running cutscene
|
|
||||||
* finishes by running off the end of its item list. A fresh
|
|
||||||
* cutsceneSystemStartCutscene* call clears any previously set callback, so
|
|
||||||
* call this again after starting a new cutscene to arm it - but
|
|
||||||
* cutsceneRestart() preserves whatever was armed, since a restart (e.g.
|
|
||||||
* retrying a failed check) is the same logical run trying again, not a new
|
|
||||||
* one. Invoked with CUTSCENE_SYSTEM.userData, same as CUTSCENE_CALLBACK.
|
|
||||||
*
|
|
||||||
* Exists so a runtime-loaded cutscene file (which can't store a native
|
|
||||||
* function pointer) can still hand off to native code once it's done,
|
|
||||||
* without needing a whole name->function registry: the file just ends
|
|
||||||
* normally, and whoever started it supplies what happens next.
|
|
||||||
*
|
|
||||||
* @param onComplete Callback to fire on natural completion. May be NULL
|
|
||||||
* to clear a previously set one.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a raw entity index (or sentinel) to an entity pointer.
|
|
||||||
* Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED,
|
|
||||||
* CUTSCENE_ENTITY_LAST_CREATED and CUTSCENE_ENTITY_LAST_REF.
|
|
||||||
* Updates CUTSCENE_SYSTEM.entityLastRef to the resolved entity.
|
|
||||||
* Asserts the resolved entity is within bounds.
|
|
||||||
*
|
|
||||||
* @param entityIndex Raw entity index or sentinel value.
|
|
||||||
* @returns Pointer to the resolved entity.
|
|
||||||
*/
|
|
||||||
entity_t * cutsceneSystemGetEntity(const uint8_t entityIndex);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a raw map area ID (or CUTSCENE_AREA_LAST_CREATED sentinel) to
|
|
||||||
* a concrete map area ID.
|
|
||||||
*
|
|
||||||
* @param areaId Raw map area ID or sentinel value.
|
|
||||||
* @returns The resolved map area ID.
|
|
||||||
*/
|
|
||||||
uint8_t cutsceneSystemGetAreaId(const uint8_t areaId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a raw mini textbox slot index (or CUTSCENE_TEXT_MINI_LAST_CREATED
|
|
||||||
* sentinel) to a concrete UI_TEXTBOX_MINI_LIST slot index.
|
|
||||||
*
|
|
||||||
* @param index Raw slot index or sentinel value.
|
|
||||||
* @returns The resolved slot index.
|
|
||||||
*/
|
|
||||||
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns CUTSCENE_SYSTEM.textCache - whatever was last written there via
|
|
||||||
* cutsceneSystemSetTextCache (e.g. by a CUTSCENE_ITEM_TYPE_KEYBOARD item
|
|
||||||
* once its keyboard closes). Empty ("") if nothing has been cached yet
|
|
||||||
* for the running cutscene.
|
|
||||||
*
|
|
||||||
* @returns The cached text.
|
|
||||||
*/
|
|
||||||
const char_t * cutsceneSystemGetTextCache(void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overwrites CUTSCENE_SYSTEM.textCache with a copy of text. Any item may
|
|
||||||
* call this - it isn't tied to any one item type - so later items can
|
|
||||||
* read back whatever the caller wants to pass along, up to
|
|
||||||
* CUTSCENE_TEXT_CACHE_MAX - 1 characters.
|
|
||||||
*
|
|
||||||
* @param text The text to cache; copied internally, safe to be
|
|
||||||
* transient. Must not exceed CUTSCENE_TEXT_CACHE_MAX - 1 characters.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemSetTextCache(const char_t *text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advance to the next item in the cutscene.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemNext();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jumps the running cutscene directly to the CUTSCENE_MARKER item with
|
|
||||||
* the given name and starts it immediately, as if cutsceneSystemNext()
|
|
||||||
* had advanced straight to it. Intended to be called from within
|
|
||||||
* another item's start/update (e.g. a CUTSCENE_CALLBACK) to implement
|
|
||||||
* flow control. Asserts if no cutscene is running or no marker with
|
|
||||||
* that name exists in it.
|
|
||||||
*
|
|
||||||
* @param name Marker name to search for, matched with stringEquals.
|
|
||||||
*/
|
|
||||||
void cutsceneGoTo(const char_t *name);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the cutscene system for one frame.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemUpdate();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current cutscene item.
|
|
||||||
*
|
|
||||||
* @return Pointer to the current cutscene item.
|
|
||||||
*/
|
|
||||||
const cutsceneitem_t * cutsceneSystemGetCurrentItem();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes of the cutscene system, stopping any active cutscene.
|
|
||||||
*/
|
|
||||||
void cutsceneSystemDispose();
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Copyright (c) 2025 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
cutsceneitem.c
|
|
||||||
cutsceneitembase.c
|
|
||||||
cutscenecallback.c
|
|
||||||
cutsceneprint.c
|
|
||||||
)
|
|
||||||
|
|
||||||
add_subdirectory(control)
|
|
||||||
add_subdirectory(entity)
|
|
||||||
add_subdirectory(item)
|
|
||||||
add_subdirectory(maparea)
|
|
||||||
add_subdirectory(ui)
|
|
||||||
add_subdirectory(battle)
|
|
||||||
add_subdirectory(save)
|
|
||||||
add_subdirectory(audio)
|
|
||||||
add_subdirectory(json)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
cutsceneaudioplay.c
|
|
||||||
cutsceneaudiostop.c
|
|
||||||
cutsceneaudiopause.c
|
|
||||||
cutsceneaudioresume.c
|
|
||||||
cutsceneaudiofade.c
|
|
||||||
cutsceneaudiofadewait.c
|
|
||||||
cutsceneaudiosetpan.c
|
|
||||||
cutsceneaudiosetloop.c
|
|
||||||
cutsceneaudioset.c
|
|
||||||
)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsoneasing.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
|
|
||||||
|
|
||||||
void cutsceneAudioFadeStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerFadeTo(
|
|
||||||
item->audioFade.channel,
|
|
||||||
item->audioFade.from,
|
|
||||||
item->audioFade.to,
|
|
||||||
item->audioFade.duration,
|
|
||||||
item->audioFade.easing
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioFadeUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioFadeLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
audiomixerchannel_t channelValue;
|
|
||||||
if(!cutsceneJsonLookupAudioChannel(
|
|
||||||
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
|
|
||||||
)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_FADE): invalid 'channel'");
|
|
||||||
}
|
|
||||||
item->audioFade.channel = channelValue;
|
|
||||||
item->audioFade.from = cutsceneJsonGetFloat(itemObj, "from", 0.0f);
|
|
||||||
item->audioFade.to = cutsceneJsonGetFloat(itemObj, "to", 0.0f);
|
|
||||||
item->audioFade.duration = cutsceneJsonGetFloat(itemObj, "duration", 0.0f);
|
|
||||||
|
|
||||||
const char_t *easingName =
|
|
||||||
yyjson_get_str(yyjson_obj_get(itemObj, "easing"));
|
|
||||||
easingtype_t easingValue = EASING_LINEAR;
|
|
||||||
if(easingName != NULL &&
|
|
||||||
!cutsceneJsonLookupEasing(easingName, &easingValue)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_FADE): invalid 'easing'");
|
|
||||||
}
|
|
||||||
item->audioFade.easing = easingValue;
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "animation/easing.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
audiomixerchannel_t channel;
|
|
||||||
float_t from;
|
|
||||||
float_t to;
|
|
||||||
float_t duration;
|
|
||||||
easingtype_t easing;
|
|
||||||
} cutsceneaudiofade_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio fade step (starts the fade via audioMixerFadeTo() -
|
|
||||||
* doesn't wait for it; pair with CUTSCENE_AUDIO_FADE_WAIT to block on it).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioFadeStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio fade step (always completes immediately - the fade
|
|
||||||
* itself keeps running independently of cutscene progression).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioFadeUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_FADE cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_FADE needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_FADE needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioFadeLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
|
|
||||||
bool_t cutsceneAudioFadeWaitUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return !audioMixerIsFading(item->audioFadeWaitChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioFadeWaitLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioFadeWaitChannel);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio fade-wait step, completing once the watched channel's
|
|
||||||
* fade (started by a CUTSCENE_AUDIO_FADE elsewhere) finishes. Has no Start
|
|
||||||
* callback -- there's nothing to do until the fade is actually done.
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true once the channel is no longer fading.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioFadeWaitUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_FADE_WAIT cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_FADE_WAIT needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_FADE_WAIT needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioFadeWaitLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
|
|
||||||
void cutsceneAudioPauseStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerPause(item->audioPauseChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioPauseUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioPauseLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioPauseChannel);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio pause step (pauses the channel immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioPauseStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio pause step (always completes immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioPauseUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_PAUSE cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_PAUSE needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_PAUSE needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioPauseLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
|
|
||||||
|
|
||||||
void cutsceneAudioPlayStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerQueuePlay(
|
|
||||||
item->audioPlay.file,
|
|
||||||
item->audioPlay.channel,
|
|
||||||
item->audioPlay.volume,
|
|
||||||
item->audioPlay.pan,
|
|
||||||
item->audioPlay.looping,
|
|
||||||
item->audioPlay.loopCount,
|
|
||||||
item->audioPlay.loopStart,
|
|
||||||
item->audioPlay.loopTo
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioPlayUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioPlayLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
if(!cutsceneJsonCopyString(
|
|
||||||
itemObj, "file", NULL, item->audioPlay.file, AUDIO_PATH_MAX
|
|
||||||
)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_PLAY): missing/too-long 'file'");
|
|
||||||
}
|
|
||||||
audiomixerchannel_t channelValue;
|
|
||||||
if(!cutsceneJsonLookupAudioChannel(
|
|
||||||
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
|
|
||||||
)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_PLAY): invalid 'channel'");
|
|
||||||
}
|
|
||||||
item->audioPlay.channel = channelValue;
|
|
||||||
|
|
||||||
item->audioPlay.volume = cutsceneJsonGetFloat(itemObj, "volume", 1.0f);
|
|
||||||
item->audioPlay.pan = cutsceneJsonGetFloat(itemObj, "pan", 0.0f);
|
|
||||||
item->audioPlay.looping = cutsceneJsonGetBool(itemObj, "looping", false);
|
|
||||||
item->audioPlay.loopCount =
|
|
||||||
(uint8_t)cutsceneJsonGetInt(itemObj, "loopCount", 0);
|
|
||||||
item->audioPlay.loopStart =
|
|
||||||
cutsceneJsonGetFloat(itemObj, "loopStart", -1.0f);
|
|
||||||
item->audioPlay.loopTo = cutsceneJsonGetFloat(itemObj, "loopTo", 0.0f);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char_t file[AUDIO_PATH_MAX];
|
|
||||||
audiomixerchannel_t channel;
|
|
||||||
float_t volume;
|
|
||||||
float_t pan;
|
|
||||||
bool_t looping;
|
|
||||||
uint8_t loopCount;
|
|
||||||
float_t loopStart;
|
|
||||||
float_t loopTo;
|
|
||||||
} cutsceneaudioplay_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio play step (queues the sound via audioMixerQueuePlay() -
|
|
||||||
* see its own comment on when it actually starts).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioPlayStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio play step (always completes immediately - the sound
|
|
||||||
* keeps loading/playing independently of cutscene progression).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioPlayUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_PLAY cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_PLAY needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_PLAY needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioPlayLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
|
|
||||||
void cutsceneAudioResumeStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerResume(item->audioResumeChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioResumeUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioResumeLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioResumeChannel);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio resume step (resumes the channel immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioResumeStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio resume step (always completes immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioResumeUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_RESUME cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_RESUME needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_RESUME needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioResumeLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
|
|
||||||
|
|
||||||
void cutsceneAudioSetStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerSetPan(item->audioSet.channel, item->audioSet.pan);
|
|
||||||
audioMixerSetLoop(
|
|
||||||
item->audioSet.channel,
|
|
||||||
item->audioSet.looping,
|
|
||||||
item->audioSet.loopCount,
|
|
||||||
item->audioSet.loopStart,
|
|
||||||
item->audioSet.loopTo
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioSetUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioSetLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
audiomixerchannel_t channelValue;
|
|
||||||
if(!cutsceneJsonLookupAudioChannel(
|
|
||||||
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
|
|
||||||
)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_SET): invalid 'channel'");
|
|
||||||
}
|
|
||||||
item->audioSet.channel = channelValue;
|
|
||||||
item->audioSet.pan = cutsceneJsonGetFloat(itemObj, "pan", 0.0f);
|
|
||||||
item->audioSet.looping = cutsceneJsonGetBool(itemObj, "looping", true);
|
|
||||||
item->audioSet.loopCount =
|
|
||||||
(uint8_t)cutsceneJsonGetInt(itemObj, "loopCount", 0);
|
|
||||||
item->audioSet.loopStart =
|
|
||||||
cutsceneJsonGetFloat(itemObj, "loopStart", -1.0f);
|
|
||||||
item->audioSet.loopTo = cutsceneJsonGetFloat(itemObj, "loopTo", 0.0f);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
audiomixerchannel_t channel;
|
|
||||||
float_t pan;
|
|
||||||
bool_t looping;
|
|
||||||
uint8_t loopCount;
|
|
||||||
float_t loopStart;
|
|
||||||
float_t loopTo;
|
|
||||||
} cutsceneaudioset_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio set step - applies pan and looping behaviour together in
|
|
||||||
* one item (via audioMixerSetPan()/audioMixerSetLoop()), for when you want
|
|
||||||
* to change both at once rather than using CUTSCENE_AUDIO_SET_PAN/
|
|
||||||
* CUTSCENE_AUDIO_SET_LOOP separately.
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioSetStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio set step (always completes immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioSetUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_SET cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_SET needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_SET needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioSetLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
|
|
||||||
|
|
||||||
void cutsceneAudioSetLoopStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerSetLoop(
|
|
||||||
item->audioSetLoop.channel,
|
|
||||||
item->audioSetLoop.looping,
|
|
||||||
item->audioSetLoop.loopCount,
|
|
||||||
item->audioSetLoop.loopStart,
|
|
||||||
item->audioSetLoop.loopTo
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioSetLoopUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioSetLoopLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
audiomixerchannel_t channelValue;
|
|
||||||
if(!cutsceneJsonLookupAudioChannel(
|
|
||||||
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
|
|
||||||
)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_SET_LOOP): invalid 'channel'");
|
|
||||||
}
|
|
||||||
item->audioSetLoop.channel = channelValue;
|
|
||||||
item->audioSetLoop.looping = cutsceneJsonGetBool(itemObj, "looping", true);
|
|
||||||
item->audioSetLoop.loopCount =
|
|
||||||
(uint8_t)cutsceneJsonGetInt(itemObj, "loopCount", 0);
|
|
||||||
item->audioSetLoop.loopStart =
|
|
||||||
cutsceneJsonGetFloat(itemObj, "loopStart", -1.0f);
|
|
||||||
item->audioSetLoop.loopTo = cutsceneJsonGetFloat(itemObj, "loopTo", 0.0f);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
audiomixerchannel_t channel;
|
|
||||||
bool_t looping;
|
|
||||||
uint8_t loopCount;
|
|
||||||
float_t loopStart;
|
|
||||||
float_t loopTo;
|
|
||||||
} cutsceneaudiosetloop_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio set-loop step (applies the new looping behaviour
|
|
||||||
* immediately, via audioMixerSetLoop()).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioSetLoopStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio set-loop step (always completes immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioSetLoopUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_SET_LOOP cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_SET_LOOP needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_SET_LOOP needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioSetLoopLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
|
|
||||||
|
|
||||||
void cutsceneAudioSetPanStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerSetPan(item->audioSetPan.channel, item->audioSetPan.pan);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioSetPanUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioSetPanLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
audiomixerchannel_t channelValue;
|
|
||||||
if(!cutsceneJsonLookupAudioChannel(
|
|
||||||
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
|
|
||||||
)) {
|
|
||||||
errorThrow("Cutscene item (AUDIO_SET_PAN): invalid 'channel'");
|
|
||||||
}
|
|
||||||
item->audioSetPan.channel = channelValue;
|
|
||||||
item->audioSetPan.pan = cutsceneJsonGetFloat(itemObj, "pan", 0.0f);
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
audiomixerchannel_t channel;
|
|
||||||
float_t pan;
|
|
||||||
} cutsceneaudiosetpan_t;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio set-pan step (applies the new pan immediately, via
|
|
||||||
* audioMixerSetPan()).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioSetPanStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio set-pan step (always completes immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioSetPanUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_SET_PAN cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_SET_PAN needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_SET_PAN needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioSetPanLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
#include "audio/mixer/audiomixer.h"
|
|
||||||
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
|
|
||||||
|
|
||||||
void cutsceneAudioStopStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
audioMixerStop(item->audioStopChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneAudioStopUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneAudioStopLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioStopChannel);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
#include "audio/mixer/audiomixerchannel.h"
|
|
||||||
#include "rpg/cutscene/item/cutsceneitembase.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts an audio stop step (stops the channel immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
*/
|
|
||||||
void cutsceneAudioStopStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an audio stop step (always completes immediately).
|
|
||||||
*
|
|
||||||
* @param item The cutscene item.
|
|
||||||
* @param data Runtime data storage.
|
|
||||||
* @returns true always.
|
|
||||||
*/
|
|
||||||
bool_t cutsceneAudioStopUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an AUDIO_STOP cutscene item's JSON fields into item.
|
|
||||||
*
|
|
||||||
* @param itemObj The item's JSON object.
|
|
||||||
* @param item Destination item, already zeroed by the caller.
|
|
||||||
* @param pool Unused (AUDIO_STOP needs no pool storage).
|
|
||||||
* @param poolOffset Unused (AUDIO_STOP needs no pool storage).
|
|
||||||
* @return Error code indicating success or failure of the parse.
|
|
||||||
*/
|
|
||||||
errorret_t cutsceneAudioStopLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Copyright (c) 2026 Dominic Masters
|
|
||||||
#
|
|
||||||
# This software is released under the MIT License.
|
|
||||||
# https://opensource.org/licenses/MIT
|
|
||||||
|
|
||||||
target_sources(${DUSK_LIBRARY_TARGET_NAME}
|
|
||||||
PUBLIC
|
|
||||||
cutscenestartbattle.c
|
|
||||||
cutscenebattlewaitstate.c
|
|
||||||
cutscenebattleforceaction.c
|
|
||||||
)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2026 Dominic Masters
|
|
||||||
*
|
|
||||||
* This software is released under the MIT License.
|
|
||||||
* https://opensource.org/licenses/MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "rpg/cutscene/item/cutsceneitem.h"
|
|
||||||
|
|
||||||
void cutsceneBattleForceActionStart(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
battleQueueAction(
|
|
||||||
item->battleForceAction.fighterIndex, BATTLE_ACTION_ATTACK,
|
|
||||||
item->battleForceAction.targetIndex
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool_t cutsceneBattleForceActionUpdate(
|
|
||||||
const cutsceneitem_t *item,
|
|
||||||
cutsceneitemdata_t *data
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
errorret_t cutsceneBattleForceActionLoad(
|
|
||||||
yyjson_val *itemObj,
|
|
||||||
cutsceneitem_t *item,
|
|
||||||
uint8_t *pool,
|
|
||||||
size_t *poolOffset
|
|
||||||
) {
|
|
||||||
item->battleForceAction.fighterIndex =
|
|
||||||
(uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "fighterIndex"));
|
|
||||||
item->battleForceAction.targetIndex =
|
|
||||||
(uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "targetIndex"));
|
|
||||||
|
|
||||||
errorOk();
|
|
||||||
}
|
|
||||||
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user