Author SHA1 Message Date
YourWishesandClaude Sonnet 5.5 31ee7989de Remove rpg, ui, editor and scene subfolders; split scripts by platform
Delete src/dusk/rpg, src/dusk/ui, the animation/keyframe modules, the
scene subfolders, the chunk asset loader and ./editor, and strip the
dependent save slot, scene type, engine and test code so the main
binary and tests build again. Add scene/node.c/.h skeleton and move
build scripts into scripts/<platform>/.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 07:29:25 -06:00
464 changed files with 70 additions and 33009 deletions

No files matched your search

+7 -7
View File
@@ -32,7 +32,7 @@ jobs:
git \
libssl-dev
- name: Build Linux
run: ./scripts/build-linux.sh
run: ./scripts/linux/build-linux.sh
- name: Upload Linux binary
uses: actions/upload-artifact@v3
with:
@@ -48,7 +48,7 @@ jobs:
- name: Setup pspdev
uses: ./.github/actions/setup-pspdev
- name: Build PSP
run: ./scripts/build-psp.sh
run: ./scripts/psp/build-psp.sh
- name: Move EBOOT.PBP to Dusk subfolder
run: |
mkdir -p ./git-artifcats/Dusk/PSP/GAME/Dusk
@@ -103,7 +103,7 @@ jobs:
libgles2:arm64 \
libgl1-mesa-dev:arm64
- name: Build Knulli
run: ./scripts/build-knulli.sh
run: ./scripts/knulli/build-knulli.sh
- name: Move output to Dusk subfolder
run: |
mkdir -p ./git-artifcats/Dusk
@@ -134,7 +134,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube
run: ./scripts/build-gamecube.sh
run: ./scripts/gamecube/build-gamecube.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
@@ -166,7 +166,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build GameCube ISO
run: ./scripts/build-gamecube-iso.sh
run: ./scripts/gamecube/build-gamecube-iso.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
@@ -199,7 +199,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii
run: ./scripts/build-wii.sh
run: ./scripts/wii/build-wii.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk/apps/Dusk
@@ -232,7 +232,7 @@ jobs:
gamecube-sdl2 ppc-liblzma ppc-libzip \
gamecube-tools ppc-libmad ppc-zlib-ng ppc-bzip2 ppc-zstd
- name: Build Wii ISO
run: ./scripts/build-wii-iso.sh
run: ./scripts/wii/build-wii-iso.sh
- name: Copy output files
run: |
mkdir -p ./git-artifcats/Dusk
+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
git \
libssl-dev
- name: Run tests
run: ./scripts/test-linux.sh
run: ./scripts/linux/test-linux.sh
-159
View File
@@ -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,
});
})();
-64
View File
@@ -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,
});
})();
-6
View File
@@ -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>
-92
View File
@@ -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 &amp; Compile
</button>
</div>
<div id="statusLine" class="small text-muted mb-2">&nbsp;</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>
-65
View File
@@ -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;
}
-920
View File
@@ -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);
});
})();
-298
View File
@@ -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 });
})();
-23
View File
@@ -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>
-298
View File
@@ -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.")
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
python3 server/server.py
@@ -1,3 +1,3 @@
#!/bin/bash
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,3 +1,3 @@
#!/bin/bash
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
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
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 \
-v "${GITHUB_WORKSPACE}:/workdir" \
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
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
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
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
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.
-2
View File
@@ -81,11 +81,9 @@ add_subdirectory(error)
add_subdirectory(input)
add_subdirectory(locale)
add_subdirectory(perf)
add_subdirectory(rpg)
add_subdirectory(scene)
add_subdirectory(system)
add_subdirectory(time)
add_subdirectory(ui)
add_subdirectory(save)
add_subdirectory(util)
add_subdirectory(thread)
-2
View File
@@ -6,6 +6,4 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
easing.c
animation.c
keyframe.c
)
-132
View File
@@ -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);
}
-94
View File
@@ -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
);
-45
View File
@@ -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));
}
-27
View File
@@ -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
);
-1
View File
@@ -15,7 +15,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
add_subdirectory(display)
add_subdirectory(locale)
add_subdirectory(json)
add_subdirectory(chunk)
add_subdirectory(dmf)
add_subdirectory(wav)
add_subdirectory(mp3)
-6
View File
@@ -46,12 +46,6 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = {
.dispose = assetJsonDispose
},
[ASSET_LOADER_TYPE_CHUNK] = {
.loadSync = assetChunkLoaderSync,
.loadAsync = assetChunkLoaderAsync,
.dispose = assetChunkDispose
},
[ASSET_LOADER_TYPE_WAV] = {
.loadSync = assetWavLoaderSync,
.loadAsync = assetWavLoaderAsync,
-5
View File
@@ -12,7 +12,6 @@
#include "asset/loader/display/assettilesetloader.h"
#include "asset/loader/locale/assetlocaleloader.h"
#include "asset/loader/json/assetjsonloader.h"
#include "asset/loader/chunk/assetchunkloader.h"
#include "asset/loader/wav/assetwavloader.h"
#include "asset/loader/mp3/assetmp3loader.h"
@@ -25,7 +24,6 @@ typedef enum {
ASSET_LOADER_TYPE_TILESET,
ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_WAV,
ASSET_LOADER_TYPE_MP3,
@@ -39,7 +37,6 @@ typedef union {
assettilesetloaderloading_t tileset;
assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk;
assetwavloaderloading_t wav;
assetmp3loaderloading_t mp3;
} assetloaderloading_t;
@@ -51,7 +48,6 @@ typedef union {
assettilesetoutput_t tileset;
assetlocaleoutput_t locale;
assetjsonoutput_t json;
assetchunkoutput_t chunk;
assetwavoutput_t wav;
assetmp3output_t mp3;
} assetloaderoutput_t;
@@ -61,7 +57,6 @@ typedef union {
assettilesetloaderinput_t tileset;
assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk;
assetwavloaderinput_t wav;
assetmp3loaderinput_t mp3;
} 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
View File
@@ -16,7 +16,6 @@
#include "display/mesh/capsule.h"
#include "display/mesh/triprism.h"
#include "display/screen/screen.h"
#include "ui/ui.h"
#include "display/text/text.h"
#include "assert/assert.h"
#include "util/memory.h"
-9
View File
@@ -11,11 +11,9 @@
#include "time/time.h"
#include "input/input.h"
#include "locale/localemanager.h"
#include "rpg/rpg.h"
#include "display/display.h"
#include "scene/scene.h"
#include "asset/asset.h"
#include "ui/ui.h"
#include "assert/assert.h"
#ifdef DUSK_NETWORK
#include "network/network.h"
@@ -47,8 +45,6 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorChain(localeManagerInit());
errorChain(displayInit());
errorChain(audioInit());
errorChain(rpgInit());
errorChain(uiInit());
#ifdef DUSK_NETWORK
errorChain(networkInit());
#endif
@@ -62,7 +58,6 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
consolePrint("Assertions real");
#endif
sceneSet(SCENE_TYPE_INITIAL);
errorOk();
}
@@ -77,10 +72,8 @@ errorret_t engineUpdate(void) {
inputUpdate();
consoleUpdate();
errorChain(audioUpdateEarly());
errorChain(rpgUpdate());
errorChain(sceneUpdate());
errorChain(assetUpdate());
errorChain(uiUpdate());
// Render
errorChain(displayUpdate());
@@ -98,9 +91,7 @@ errorret_t engineDispose(void) {
#ifdef DUSK_NETWORK
errorChain(networkDispose());
#endif
errorChain(rpgDispose());
localeManagerDispose();
errorChain(uiDispose());
errorChain(audioDispose());
consoleDispose();
errorChain(displayDispose());
-4
View File
@@ -9,7 +9,6 @@
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
#include "ui/ui.h"
#include "system/system.h"
#include "console/console.h"
@@ -39,9 +38,6 @@ errorret_t localeManagerSetLocale(const localeinfo_t *locale) {
assetEntryLock(LOCALE.entry);
errorChain(assetRequireLoaded(LOCALE.entry));
// TODO : Trigger UI update.
errorChain(uiUpdateTranslations());
errorOk();
}
-20
View File
@@ -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)
-15
View File
@@ -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
party.c
)
add_subdirectory(fighter)
add_subdirectory(move)
add_subdirectory(state)
-79
View File
@@ -1,79 +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"
#include "scene/scene.h"
battle_t BATTLE;
void battleInit(void) {
memoryZero(&BATTLE, sizeof(battle_t));
}
void battleStart(
battlefighter_t *fighters[BATTLE_FIGHTER_POS_COUNT]
) {
assertNotNull(fighters, "Fighters cannot be NULL");
#if DUSK_ASSERTIONS
// Must be at least two non null fighter present, may need to ensure they
// are from seperate teams also.
uint8_t nonNullCount = 0;
for(uint8_t i = 0; i < BATTLE_FIGHTER_POS_COUNT; i++) {
if(fighters[i] != NULL) nonNullCount++;
}
assertTrue(nonNullCount >= 2, "Must have at least two fighters");
#endif
// TODO: I may not need to zero entire battle.
memoryZero(&BATTLE, sizeof(battle_t));
memoryCopy(
BATTLE.fighters,
fighters,
sizeof(battlefighter_t *) * BATTLE_FIGHTER_POS_COUNT
);
// Enter the opening state.
battleSetState(BATTLE_STATE_OPENING);
// Change scene
sceneSet(SCENE_TYPE_BATTLE);
}
void battleSetState(const battlestate_t next) {
assertTrue(next < BATTLE_STATE_COUNT, "Invalid battle state");
assertNotNull(BATTLE_STATE_FUNCTIONS[next].init, "Missing state init function");
assertNotNull(BATTLE_STATE_FUNCTIONS[next].update, "Missing state update function");
assertNotNull(BATTLE_STATE_FUNCTIONS[next].dispose, "Missing state dispose function");
if(BATTLE.state != BATTLE_STATE_NULL) {
BATTLE_STATE_FUNCTIONS[BATTLE.state].dispose();
}
BATTLE.state = next;
BATTLE_STATE_FUNCTIONS[BATTLE.state].init();
}
void battleUpdate(void) {
if(BATTLE.state == BATTLE_STATE_NULL) return;
assertTrue(BATTLE.state < BATTLE_STATE_COUNT, "Invalid battle state");
assertNotNull(BATTLE_STATE_FUNCTIONS[BATTLE.state].update, "Missing state update function");
BATTLE_STATE_FUNCTIONS[BATTLE.state].update();
}
void battleDispose(void) {
if(BATTLE.state == BATTLE_STATE_NULL) return;
assertTrue(BATTLE.state < BATTLE_STATE_COUNT, "Invalid battle state");
assertNotNull(BATTLE_STATE_FUNCTIONS[BATTLE.state].dispose, "Missing state dispose function");
BATTLE_STATE_FUNCTIONS[BATTLE.state].dispose();
memoryZero(&BATTLE, sizeof(battle_t));
}
-75
View File
@@ -1,75 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/battle/fighter/battlefighter.h"
#include "rpg/battle/move/battlemove.h"
#include "rpg/battle/move/battlemoveaction.h"
#include "rpg/battle/fighter/battlefighterpos.h"
#include "rpg/battle/state/battlestate.h"
typedef enum {
BATTLE_RESULT_NONE,
BATTLE_RESULT_WIN,
BATTLE_RESULT_LOSS,
BATTLE_RESULT_FLED,
BATTLE_RESULT_COUNT
} battleresult_t;
typedef struct {
battlefighter_t *fighters[BATTLE_FIGHTER_POS_COUNT];
// Order of who gets to act.
uint8_t fighterOrder[BATTLE_FIGHTER_POS_COUNT];
uint8_t fighterOrderCount;
// Actions to be executed.
battlemoveaction_t actionsPending[BATTLE_FIGHTER_POS_COUNT];
uint8_t actionsPendingCount;
battlestate_t state;
battlestatedata_t stateData;
// Set once a side has been wiped out (see cutsceneBattlePostMoveStart)
// or the player has fled (see battleStateSelectionPlayerFlee). Stays
// BATTLE_RESULT_NONE for the rest of the battle otherwise.
battleresult_t result;
} battle_t;
extern battle_t BATTLE;
/**
* Initializes the battle system. Marks it as inactive with no fighters.
*/
void battleInit(void);
/**
* Starts a battle.
*
* @param fighters Fighters in the battle.
*/
void battleStart(
battlefighter_t *fighters[BATTLE_FIGHTER_POS_COUNT]
);
/**
* Transitions the battle to a new state: disposes the current state (if
* any) and initializes next.
*
* @param next The state to transition to.
*/
void battleSetState(const battlestate_t next);
/**
* Updates the battle system.
*/
void battleUpdate(void);
/**
* Disposes of the battle, clearing all fighters and marking it inactive.
*/
void battleDispose(void);
@@ -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
battlefighter.c
)
-230
View File
@@ -1,230 +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"
errorret_t battleFighterParseStatField(
yyjson_val *obj,
const char_t *key,
uint16_t *out
) {
yyjson_val *val = yyjson_obj_get(obj, key);
if(!yyjson_is_int(val)) {
errorThrow("Fighter: missing/invalid '%s'", key);
}
const int64_t num = yyjson_get_sint(val);
if(num < 0 || num > UINT16_MAX) {
errorThrow("Fighter: '%s' must be between 0 and %u", key, UINT16_MAX);
}
*out = (uint16_t)num;
errorOk();
}
errorret_t battleFighterParseOptionalIntField(
yyjson_val *obj,
const char_t *key,
const int32_t defaultValue,
int32_t *out
) {
yyjson_val *val = yyjson_obj_get(obj, key);
if(val == NULL) {
*out = defaultValue;
errorOk();
}
if(!yyjson_is_int(val)) errorThrow("Fighter: invalid '%s'", key);
*out = (int32_t)yyjson_get_sint(val);
errorOk();
}
errorret_t battleFighterParse(battlefighter_t *fighter, yyjson_val *obj) {
assertNotNull(fighter, "Fighter cannot be NULL");
assertNotNull(obj, "JSON object cannot be NULL");
int32_t teamNum;
errorChain(battleFighterParseOptionalIntField(
obj, "team", BATTLE_FIGHTER_TEAM_ALLY, &teamNum
));
if(teamNum < 0 || teamNum >= BATTLE_FIGHTER_TEAM_COUNT) {
errorThrow("Fighter: invalid 'team'");
}
int32_t controllerNum;
errorChain(battleFighterParseOptionalIntField(
obj, "controller", BATTLE_FIGHTER_CONTROLLER_PLAYER, &controllerNum
));
if(controllerNum < 0 || controllerNum >= BATTLE_FIGHTER_CONTROLLER_COUNT) {
errorThrow("Fighter: invalid 'controller'");
}
battlefighterstats_t stats;
errorChain(battleFighterParseStatField(obj, "attack", &stats.attack));
errorChain(battleFighterParseStatField(obj, "defense", &stats.defense));
errorChain(battleFighterParseStatField(obj, "magic", &stats.magic));
errorChain(battleFighterParseStatField(obj, "speed", &stats.speed));
errorChain(battleFighterParseStatField(obj, "luck", &stats.luck));
errorChain(battleFighterParseStatField(obj, "healthMax", &stats.healthMax));
errorChain(battleFighterParseStatField(obj, "mpMax", &stats.mpMax));
battleFighterInit(
fighter, (battlefighterteam_t)teamNum,
(battlefightercontroller_t)controllerNum, stats
);
int32_t statusNum;
errorChain(battleFighterParseOptionalIntField(
obj, "status", BATTLE_FIGHTER_STATUS_NORMAL, &statusNum
));
if(statusNum < 0 || statusNum >= BATTLE_FIGHTER_STATUS_COUNT) {
errorThrow("Fighter: invalid 'status'");
}
fighter->status = (battlefighterstatus_t)statusNum;
int32_t health;
errorChain(
battleFighterParseOptionalIntField(obj, "health", stats.healthMax, &health)
);
if(health < 0 || health > stats.healthMax) {
errorThrow("Fighter: 'health' out of range");
}
fighter->stats.health = (uint16_t)health;
int32_t mp;
errorChain(battleFighterParseOptionalIntField(obj, "mp", stats.mpMax, &mp));
if(mp < 0 || mp > stats.mpMax) {
errorThrow("Fighter: 'mp' out of range");
}
fighter->stats.mp = (uint16_t)mp;
yyjson_val *abilities = yyjson_obj_get(obj, "abilities");
if(abilities != NULL) {
if(
!yyjson_is_arr(abilities) ||
yyjson_arr_size(abilities) > BATTLE_FIGHTER_ABILITY_COUNT_MAX
) {
errorThrow(
"Fighter: 'abilities' must have 0-%u entries",
BATTLE_FIGHTER_ABILITY_COUNT_MAX
);
}
yyjson_arr_iter iter = yyjson_arr_iter_with(abilities);
yyjson_val *abilityVal;
while((abilityVal = yyjson_arr_iter_next(&iter)) != NULL) {
const char_t *name = yyjson_get_str(abilityVal);
battlemoveid_t ability =
name == NULL ? BATTLE_MOVE_ID_NULL : battleMoveGetIdByName(name);
if(ability == BATTLE_MOVE_ID_NULL) {
errorThrow("Fighter: invalid entry in 'abilities'");
}
battleFighterAbilityAdd(fighter, ability);
}
}
errorOk();
}
yyjson_mut_val *battleFighterSerialize(
const battlefighter_t *fighter,
yyjson_mut_doc *doc
) {
assertNotNull(fighter, "Fighter cannot be NULL");
assertNotNull(doc, "Doc cannot be NULL");
yyjson_mut_val *obj = yyjson_mut_obj(doc);
yyjson_mut_obj_add_uint(doc, obj, "team", fighter->team);
yyjson_mut_obj_add_uint(doc, obj, "controller", fighter->controller);
yyjson_mut_obj_add_uint(doc, obj, "status", fighter->status);
yyjson_mut_obj_add_uint(doc, obj, "attack", fighter->stats.attack);
yyjson_mut_obj_add_uint(doc, obj, "defense", fighter->stats.defense);
yyjson_mut_obj_add_uint(doc, obj, "magic", fighter->stats.magic);
yyjson_mut_obj_add_uint(doc, obj, "speed", fighter->stats.speed);
yyjson_mut_obj_add_uint(doc, obj, "luck", fighter->stats.luck);
yyjson_mut_obj_add_uint(doc, obj, "healthMax", fighter->stats.healthMax);
yyjson_mut_obj_add_uint(doc, obj, "mpMax", fighter->stats.mpMax);
yyjson_mut_obj_add_uint(doc, obj, "health", fighter->stats.health);
yyjson_mut_obj_add_uint(doc, obj, "mp", fighter->stats.mp);
yyjson_mut_val *abilities = yyjson_mut_arr(doc);
for(uint8_t i = 0; i < fighter->abilityCount; i++) {
yyjson_mut_arr_add_str(doc, abilities, BATTLE_MOVES[fighter->abilities[i]].name);
}
yyjson_mut_obj_add_val(doc, obj, "abilities", abilities);
return obj;
}
void battleFighterInit(
battlefighter_t *fighter,
const battlefighterteam_t team,
const battlefightercontroller_t controller,
const battlefighterstats_t stats
) {
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->stats.health = stats.healthMax;
fighter->stats.mp = stats.mpMax;
}
bool_t battleFighterIsAlive(const battlefighter_t *fighter) {
assertNotNull(fighter, "Fighter pointer cannot be NULL");
return fighter->status == BATTLE_FIGHTER_STATUS_NORMAL;
}
void battleFighterAbilityAdd(
battlefighter_t *fighter,
const battlemoveid_t ability
) {
assertNotNull(fighter, "Fighter pointer cannot be NULL");
assertTrue(
fighter->abilityCount < BATTLE_FIGHTER_ABILITY_COUNT_MAX,
"Fighter already has the maximum number of abilities"
);
fighter->abilities[fighter->abilityCount++] = ability;
}
void battleFighterDamage(
battlefighter_t *fighter,
const uint16_t amount
) {
assertNotNull(fighter, "Fighter pointer cannot be NULL");
fighter->stats.health =
amount >= fighter->stats.health ? 0 : fighter->stats.health - amount;
if(fighter->stats.health == 0) fighter->status = BATTLE_FIGHTER_STATUS_DEAD;
}
void battleFighterHeal(
battlefighter_t *fighter,
const uint16_t amount
) {
assertNotNull(fighter, "Fighter pointer cannot be NULL");
if(!battleFighterIsAlive(fighter)) return;
const uint32_t healed = (uint32_t)fighter->stats.health + amount;
fighter->stats.health = healed > fighter->stats.healthMax
? fighter->stats.healthMax : (uint16_t)healed;
}
-127
View File
@@ -1,127 +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"
#include "yyjson.h"
#include "rpg/battle/move/battlemove.h"
#include "rpg/battle/fighter/battlefighterstatus.h"
#include "rpg/battle/fighter/battlefighterteam.h"
#include "rpg/battle/fighter/battlefightercontroller.h"
#include "rpg/battle/fighter/battlefighterstats.h"
#include "rpg/battle/fighter/battlefighterpos.h"
#define BATTLE_FIGHTER_ABILITY_COUNT_MAX 8
typedef struct battlefighter_s {
uint8_t id;
battlefighterstatus_t status;
battlefighterteam_t team;
battlefightercontroller_t controller;
battlefighterstats_t stats;
battlemoveid_t abilities[BATTLE_FIGHTER_ABILITY_COUNT_MAX];
battlemoveid_t abilityCount;
} battlefighter_t;
/**
* Initializes a battle fighter in place, filling health/mp to the maximum
* values carried on stats 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, including healthMax/mpMax.
*/
void battleFighterInit(
battlefighter_t *fighter,
const battlefighterteam_t team,
const battlefightercontroller_t controller,
const battlefighterstats_t stats
);
/**
* Generic battlefighter_t parser, shared by every place that builds a
* fighter from JSON (party.jsonc roster members, BATTLE_START enemy
* definitions, save-file party members) - see battleFighterSerialize for
* the inverse. Reads 'team'/'controller'/'status' as ints (each optional,
* defaulting to ALLY/PLAYER/NORMAL respectively - a caller that needs a
* different team/controller, e.g. BATTLE_START's enemies, sets those
* fields on fighter after this returns), 'attack'/'defense'/'magic'/
* 'speed'/'luck'/'healthMax'/'mpMax' as required ints in [0, UINT16_MAX],
* 'health'/'mp' as optional ints defaulting to healthMax/mpMax (a fresh
* fighter), and an optional 'abilities' array of move name strings. Does
* not touch fighter->id - set it (or leave it) before calling, same as
* battleFighterInit.
*
* @param fighter Pointer to the fighter to fill in.
* @param obj The JSON object to read fields from.
* @return Error if a required field is missing/invalid, or an abilities
* entry doesn't name a real move.
*/
errorret_t battleFighterParse(battlefighter_t *fighter, yyjson_val *obj);
/**
* Inverse of battleFighterParse: builds a new JSON object (not yet
* attached anywhere - the caller attaches it, e.g. via
* yyjson_mut_arr_add_val/yyjson_mut_obj_add_val) with every field
* battleFighterParse reads except id (see its doc comment for why).
*
* @param fighter The fighter to serialize.
* @param doc The mutable document to allocate the object in.
* @return The newly built object.
*/
yyjson_mut_val *battleFighterSerialize(
const battlefighter_t *fighter,
yyjson_mut_doc *doc
);
/**
* 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);
/**
* Appends an ability to the fighter's ability list.
*
* @param fighter Pointer to the fighter to add the ability to.
* @param ability The ability to add.
*/
void battleFighterAbilityAdd(
battlefighter_t *fighter,
const battlemoveid_t ability
);
/**
* Subtracts amount from the fighter's health (floored at 0), marking it
* dead once health reaches 0.
*
* @param fighter Pointer to the fighter to damage.
* @param amount The amount of damage to apply.
*/
void battleFighterDamage(
battlefighter_t *fighter,
const uint16_t amount
);
/**
* Adds amount to the fighter's health (capped at healthMax). No-op if the
* fighter isn't alive -- healing can't revive a dead fighter.
*
* @param fighter Pointer to the fighter to heal.
* @param amount The amount to heal.
*/
void battleFighterHeal(
battlefighter_t *fighter,
const uint16_t amount
);
@@ -1,18 +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"
typedef enum {
BATTLE_FIGHTER_CONTROLLER_NONE,
BATTLE_FIGHTER_CONTROLLER_PLAYER,
BATTLE_FIGHTER_CONTROLLER_AI,
BATTLE_FIGHTER_CONTROLLER_COUNT
} battlefightercontroller_t;
@@ -1,35 +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 battlefighterpos_t;
#define BATTLE_FIGHTER_POS(x) ((battlefighterpos_t)(x))
// Left (Enemy side)
#define BATTLE_FIGHTER_POS_0 BATTLE_FIGHTER_POS(0)
#define BATTLE_FIGHTER_POS_1 BATTLE_FIGHTER_POS(1)
#define BATTLE_FIGHTER_POS_2 BATTLE_FIGHTER_POS(2)
#define BATTLE_FIGHTER_POS_3 BATTLE_FIGHTER_POS(3)
// Right (Ally side)
#define BATTLE_FIGHTER_POS_4 BATTLE_FIGHTER_POS(4)
#define BATTLE_FIGHTER_POS_5 BATTLE_FIGHTER_POS(5)
#define BATTLE_FIGHTER_POS_6 BATTLE_FIGHTER_POS(6)
#define BATTLE_FIGHTER_POS_7 BATTLE_FIGHTER_POS(7)
#define BATTLE_FIGHTER_POS_COUNT (BATTLE_FIGHTER_POS_7 + 1)
// Sentinels resolved by battleStateExecutingInit against the currently
// resolving action, for cutscene items authored inside a move's own
// cutscene (e.g. cutscenes/battle/moves/slash.jsonc) that need to refer
// to "whoever is being hit"/"whoever is attacking" without knowing their
// concrete BATTLE.fighters index ahead of time.
#define BATTLE_FIGHTER_POS_TARGET BATTLE_FIGHTER_POS(0xFE)
#define BATTLE_FIGHTER_POS_USER BATTLE_FIGHTER_POS(0xFD)
@@ -1,22 +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"
#include "rpg/battle/fighter/battlefighterstats.h"
#define BATTLE_FIGHTER_SPECIES_NAME_MAX 16
#define BATTLE_FIGHTER_SPECIES_COUNT_MAX 250
typedef uint8_t battlespeciesid_t;
#define BATTLE_SPECIES_ID_NULL ((battlespeciesid_t)0)
typedef struct {
char_t name[BATTLE_FIGHTER_SPECIES_NAME_MAX];
battlefighterstats_t baseStats;
} battlefighterspecies_t;
@@ -1,22 +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 struct {
uint16_t health;
uint16_t mp;
uint16_t healthMax;
uint16_t mpMax;
uint16_t attack;
uint16_t defense;
uint16_t magic;
uint16_t speed;
uint16_t luck;
} battlefighterstats_t;
@@ -1,20 +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 enum {
BATTLE_FIGHTER_STATUS_NULL,
BATTLE_FIGHTER_STATUS_NORMAL,
BATTLE_FIGHTER_STATUS_DEAD,
// Poisoned, burned, confused, etc.
BATTLE_FIGHTER_STATUS_COUNT
} battlefighterstatus_t;
@@ -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 "dusk.h"
typedef enum {
BATTLE_FIGHTER_TEAM_ALLY,
BATTLE_FIGHTER_TEAM_ENEMY,
BATTLE_FIGHTER_TEAM_COUNT
} battlefighterteam_t;
-10
View File
@@ -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
battlemove.c
)
-139
View File
@@ -1,139 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlemove.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include <string.h>
battleability_t BATTLE_MOVES[BATTLE_MOVE_COUNT_MAX];
uint8_t BATTLE_MOVE_COUNT;
errorret_t battleMoveInit(void) {
memoryZero(BATTLE_MOVES, sizeof(BATTLE_MOVES));
BATTLE_MOVE_COUNT = BATTLE_MOVE_ID_NULL + 1;// Reserve NULL.
// Get moves JSON.
assetentry_t *entry = assetLock(
"battle/moves.jsonc", ASSET_LOADER_TYPE_JSON, NULL
);
errorret_t ret = assetRequireLoaded(entry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(entry);
errorChain(ret);
}
yyjson_val *root = yyjson_doc_get_root(entry->data.json);
yyjson_val *moves = yyjson_obj_get(root, "moves");
yyjson_obj_iter iter = yyjson_obj_iter_with(moves);
yyjson_val *key;
while((key = yyjson_obj_iter_next(&iter))) {
if(BATTLE_MOVE_COUNT >= BATTLE_MOVE_COUNT_MAX) {
assetUnlockEntry(entry);
errorThrow(
"Battle move: too many moves defined (max %u)",
(uint32_t)BATTLE_MOVE_COUNT_MAX
);
}
battlemoveid_t id = BATTLE_MOVE_COUNT++;
battleability_t *move = &BATTLE_MOVES[id];
// Name
if(!yyjson_is_str(key)) {
assetUnlockEntry(entry);
errorThrow("Battle move: keys in 'moves' must be strings");
}
const char_t *name = yyjson_get_str(key);
stringCopy(move->name, name, BATTLE_MOVE_NAME_MAX);
if(strlen(name) >= BATTLE_MOVE_NAME_MAX) {
assetUnlockEntry(entry);
errorThrow(
"Battle move '%s': name must be less than %u characters",
name, (uint32_t)BATTLE_MOVE_NAME_MAX
);
}
// Must me object
yyjson_val *val = yyjson_obj_iter_get_val(key);
if(!yyjson_is_obj(val)) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': value must be an object", name);
}
// Valid targets
yyjson_val *enemies = yyjson_obj_get(val, "enemies");
if(enemies != NULL && !yyjson_is_bool(enemies)) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': 'enemies' must be a boolean", name);
}
yyjson_val *allies = yyjson_obj_get(val, "allies");
if(allies != NULL && !yyjson_is_bool(allies)) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': 'allies' must be a boolean", name);
}
yyjson_val *multiple = yyjson_obj_get(val, "multiple");
if(multiple != NULL && !yyjson_is_bool(multiple)) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': 'multiple' must be a boolean", name);
}
yyjson_val *self = yyjson_obj_get(val, "self");
if(self != NULL && !yyjson_is_bool(self)) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': 'self' must be a boolean", name);
}
battletarget_t target = 0;
if(yyjson_get_bool(enemies)) target |= BATTLE_TARGET_ENEMIES;
if(yyjson_get_bool(allies)) target |= BATTLE_TARGET_ALLIES;
if(yyjson_get_bool(multiple)) target |= BATTLE_TARGET_MULTIPLE;
if(yyjson_get_bool(self)) target |= BATTLE_TARGET_SELF;
move->target = target;
if(move->target == 0) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': missing target type", name);
}
// Default target (optional)
yyjson_val *defaultVal = yyjson_obj_get(val, "default");
if(defaultVal != NULL && !yyjson_is_str(defaultVal)) {
assetUnlockEntry(entry);
errorThrow("Battle move '%s': 'default' must be a string", name);
}
const char_t *defaultTarget = yyjson_get_str(defaultVal);
if(defaultTarget == NULL) {
move->targetDefault = 0;
} else if(stringCompareInsensitive(defaultTarget, "enemy") == 0) {
move->targetDefault = BATTLE_TARGET_ENEMIES;
} else if(stringCompareInsensitive(defaultTarget, "ally") == 0) {
move->targetDefault = BATTLE_TARGET_ALLIES;
} else if(stringCompareInsensitive(defaultTarget, "self") == 0) {
move->targetDefault = BATTLE_TARGET_SELF;
} else {
assetUnlockEntry(entry);
errorThrow("Battle move %s has invalid default target.", name);
}
}
assetUnlockEntry(entry);
errorOk();
}
battlemoveid_t battleMoveGetIdByName(const char_t *name) {
assertNotNull(name, "name must not be NULL");
for(battlemoveid_t id = BATTLE_MOVE_ID_NULL + 1; id < BATTLE_MOVE_COUNT; id++) {
if(stringEquals(BATTLE_MOVES[id].name, name)) return id;
}
return BATTLE_MOVE_ID_NULL;
}
-42
View File
@@ -1,42 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/asset.h"
#include "battletarget.h"
typedef uint8_t battlemoveid_t;
#define BATTLE_MOVE_NAME_MAX 16
#define BATTLE_MOVE_COUNT_MAX 250
#define BATTLE_MOVE_ID_NULL ((battlemoveid_t)0)
extern uint8_t BATTLE_MOVE_COUNT;
typedef struct {
char_t name[BATTLE_MOVE_NAME_MAX];
battletarget_t target;
battletarget_t targetDefault;
} battleability_t;
extern battleability_t BATTLE_MOVES[BATTLE_MOVE_COUNT_MAX];
/**
* Initializes the battle move subsystem, loading assets/battle/moves.jsonc.
*
* @return Any error that occurs.
*/
errorret_t battleMoveInit(void);
/**
* Finds a move's id by name (exact match).
*
* @param name The move name to search for.
* @return The matching move's id, or BATTLE_MOVE_ID_NULL if not found.
*/
battlemoveid_t battleMoveGetIdByName(const char_t *name);
@@ -1,18 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/battle/move/battlemove.h"
#include "rpg/battle/fighter/battlefighterpos.h"
typedef struct battlefighter_s battlefighter_t;
typedef struct {
battlemoveid_t move;
battlefighterpos_t targets[BATTLE_FIGHTER_POS_COUNT];
battlefighter_t *user;
} battlemoveaction_t;
-16
View File
@@ -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 "dusk.h"
typedef uint8_t battletarget_t;
#define BATTLE_TARGET_ENEMIES (1 << 0)
#define BATTLE_TARGET_ALLIES (1 << 1)
#define BATTLE_TARGET_MULTIPLE (1 << 2)
#define BATTLE_TARGET_SELF (1 << 3)
-188
View File
@@ -1,188 +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"
#include "util/string.h"
#include "save/save.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
errorret_t partyInit(party_t *party) {
assertNotNull(party, "Party cannot be NULL");
memoryZero(party, sizeof(party_t));
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
party->order[i] = PARTY_ORDER_EMPTY;
}
assetentry_t *entry = assetLock("party.jsonc", ASSET_LOADER_TYPE_JSON, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorChain(result);
}
yyjson_val *root = yyjson_doc_get_root(entry->data.json);
yyjson_val *members = yyjson_obj_get(root, "members");
if(!yyjson_is_obj(members)) {
assetUnlockEntry(entry);
errorThrow("party.jsonc: missing/invalid 'members' object");
}
const size_t memberCount = yyjson_obj_size(members);
if(memberCount > PARTY_MEMBER_COUNT_MAX) {
assetUnlockEntry(entry);
errorThrow(
"party.jsonc: defines more than PARTY_MEMBER_COUNT_MAX (%u) members",
PARTY_MEMBER_COUNT_MAX
);
}
uint8_t idx = 0;
yyjson_val *key;
yyjson_val *memberObj;
yyjson_obj_iter iter = yyjson_obj_iter_with(members);
while((key = yyjson_obj_iter_next(&iter))) {
memberObj = yyjson_obj_iter_get_val(key);
if(yyjson_get_len(key) >= PARTY_KEY_LENGTH_MAX) {
assetUnlockEntry(entry);
errorThrow(
"party.jsonc: member key '%s' exceeds PARTY_KEY_LENGTH_MAX (%u)",
yyjson_get_str(key), PARTY_KEY_LENGTH_MAX
);
}
stringCopy(party->keys[idx], yyjson_get_str(key), PARTY_KEY_LENGTH_MAX - 1);
battlefighter_t *member = &party->members[idx];
member->id = idx;
errorret_t parseResult = battleFighterParse(member, memberObj);
if(errorIsNotOk(parseResult)) {
assetUnlockEntry(entry);
errorChain(parseResult);
}
idx++;
}
assetUnlockEntry(entry);
// TEST: only the first defined member starts active, in the first party
// position - replace once there's a real starting-party/character-
// creation flow. Deliberately separate from the roster seeding above:
// order (not a member's presence in the roster) is what determines who's
// currently in the party.
if(memberCount > 0) party->order[0] = 0;
errorOk();
}
uint8_t partyGetMemberIndexByKey(const char_t *key) {
assertNotNull(key, "Key cannot be NULL");
const party_t *party = &SAVE.slot.party;
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
if(stringEquals(party->keys[i], key)) return i;
}
return PARTY_MEMBER_NOT_FOUND;
}
const char_t *partyGetMemberKey(const uint8_t memberIndex) {
assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index");
return SAVE.slot.party.keys[memberIndex];
}
bool_t partyIsMemberActive(const uint8_t memberIndex) {
assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index");
const party_t *party = &SAVE.slot.party;
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
if(party->order[i] == memberIndex) return true;
}
return false;
}
void partyAddMember(const uint8_t memberIndex) {
assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index");
party_t *party = &SAVE.slot.party;
assertTrue(
party->members[memberIndex].status != BATTLE_FIGHTER_STATUS_NULL,
"Member does not exist in the roster"
);
if(partyIsMemberActive(memberIndex)) return;
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
if(party->order[i] != PARTY_ORDER_EMPTY) continue;
party->order[i] = memberIndex;
return;
}
}
battlefighter_t *partyGetOrderMember(const uint8_t slot) {
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
party_t *party = &SAVE.slot.party;
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"
);
SAVE.slot.party.order[slot] = memberIndex;
}
void partyRemoveMember(const uint8_t memberIndex) {
assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index");
party_t *party = &SAVE.slot.party;
uint8_t removedSlot = PARTY_ORDER_EMPTY;
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
if(party->order[i] != memberIndex) continue;
removedSlot = i;
break;
}
if(removedSlot == PARTY_ORDER_EMPTY) return;
for(uint8_t i = removedSlot; i < PARTY_ACTIVE_SIZE_MAX - 1; i++) {
party->order[i] = party->order[i + 1];
}
party->order[PARTY_ACTIVE_SIZE_MAX - 1] = PARTY_ORDER_EMPTY;
}
void partyHealMember(const uint8_t memberIndex) {
assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index");
battlefighter_t *member = &SAVE.slot.party.members[memberIndex];
if(member->status == BATTLE_FIGHTER_STATUS_NULL) return;
member->status = BATTLE_FIGHTER_STATUS_NORMAL;
member->stats.health = member->stats.healthMax;
member->stats.mp = member->stats.mpMax;
}
void partyHealAll(void) {
const party_t *party = &SAVE.slot.party;
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
if(party->members[i].status == BATTLE_FIGHTER_STATUS_NULL) continue;
partyHealMember(i);
}
}
-126
View File
@@ -1,126 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/battle/fighter/battlefighter.h"
#include "yyjson.h"
#define PARTY_MEMBER_COUNT_MAX 10
#define PARTY_ACTIVE_SIZE_MAX 4
#define PARTY_KEY_LENGTH_MAX 32
#define PARTY_ORDER_EMPTY 0xFF
#define PARTY_MEMBER_NOT_FOUND 0xFF
typedef struct {
battlefighter_t members[PARTY_MEMBER_COUNT_MAX];
char_t keys[PARTY_MEMBER_COUNT_MAX][PARTY_KEY_LENGTH_MAX];
uint8_t order[PARTY_ACTIVE_SIZE_MAX];
} party_t;
/**
* Initializes party with an empty order, then seeds every roster slot
* directly from party.jsonc's "members" object (each key/value pair fills
* the next members[]/keys[] slot in the object's iteration order, key
* copied into keys[] and value parsed via battleFighterParse) - unlike
* partyAddMember, this never touches order itself except for one
* placeholder default: if party.jsonc defines at least one member,
* order[0] is set to 0 (the first member starts active in the first party
* position). Replace this default once there's a real starting-party/
* character-creation flow.
*
* @param party The party to initialize.
* @return Error if party.jsonc fails to load/parse, defines more members
* than PARTY_MEMBER_COUNT_MAX has room for, or a key exceeds
* PARTY_KEY_LENGTH_MAX.
*/
errorret_t partyInit(party_t *party);
/**
* Looks up a roster member's index by its party.jsonc key (see
* party_t.keys).
*
* @param key The member's key to search for.
* @return The matching roster index, or PARTY_MEMBER_NOT_FOUND if no
* member has that key.
*/
uint8_t partyGetMemberIndexByKey(const char_t *key);
/**
* Gets a roster member's party.jsonc key by index (see party_t.keys).
*
* @param memberIndex The roster member index to query.
* @return The member's key - "" if memberIndex is past however many
* members party.jsonc actually defines.
*/
const char_t *partyGetMemberKey(const uint8_t memberIndex);
/**
* Checks whether memberIndex currently occupies a slot in the current save
* slot's party order (i.e. is actively in the party right now).
*
* @param memberIndex The roster member index to check.
* @return True if memberIndex appears anywhere in order.
*/
bool_t partyIsMemberActive(const uint8_t memberIndex);
/**
* Adds an existing roster member (see partyInit - every roster slot
* always exists once initialized) to the current save slot's active party
* order, in the first free order slot. No-op if memberIndex is already
* active, or if there is no free order slot.
*
* @param memberIndex The roster member index to activate.
*/
void partyAddMember(const uint8_t memberIndex);
/**
* Gets the roster member currently occupying an active order slot in the
* current save slot's party.
*
* @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 in the current save
* slot's party, 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);
/**
* Removes memberIndex from the current save slot's active party order -
* the roster member itself is untouched (it still exists, per partyInit;
* it's simply no longer active) - shifting every later order entry down
* one slot to close the gap, so order stays packed with no holes between
* active members. No-op if memberIndex isn't currently active.
*
* @param memberIndex The roster member index to deactivate.
*/
void partyRemoveMember(const uint8_t memberIndex);
/**
* Fully restores a roster member's health/mp to their max and clears a
* dead status back to normal - unlike battleFighterHeal, this can revive
* a dead member (there's no combat context here to keep them down). No-op
* if the slot is empty.
*
* @param memberIndex The roster member index to heal.
*/
void partyHealMember(const uint8_t memberIndex);
/**
* Calls partyHealMember for every occupied slot in the current save
* slot's party roster.
*/
void partyHealAll(void);
-19
View File
@@ -1,19 +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
battlestate.c
battlestateopening.c
battlestatepreround.c
battlestateselection.c
battlestateexecuting.c
battlestatepostround.c
battlestatelost.c
battlestatewon.c
battlestatefled.c
battlestateending.c
)
-66
View File
@@ -1,66 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestate.h"
battlestatefunctions_t BATTLE_STATE_FUNCTIONS[BATTLE_STATE_COUNT] = {
[BATTLE_STATE_NULL] = { 0 },
[BATTLE_STATE_OPENING] = {
.init = battleStateOpeningInit,
.update = battleStateOpeningUpdate,
.dispose = battleStateOpeningDispose
},
[BATTLE_STATE_PRE_ROUND] = {
.init = battleStatePreRoundInit,
.update = battleStatePreRoundUpdate,
.dispose = battleStatePreRoundDispose
},
[BATTLE_STATE_SELECTION] = {
.init = battleStateSelectionInit,
.update = battleStateSelectionUpdate,
.dispose = battleStateSelectionDispose
},
[BATTLE_STATE_EXECUTING] = {
.init = battleStateExecutingInit,
.update = battleStateExecutingUpdate,
.dispose = battleStateExecutingDispose
},
[BATTLE_STATE_POST_ROUND] = {
.init = battleStatePostRoundInit,
.update = battleStatePostRoundUpdate,
.dispose = battleStatePostRoundDispose
},
[BATTLE_STATE_LOST] = {
.init = battleStateLostInit,
.update = battleStateLostUpdate,
.dispose = battleStateLostDispose
},
[BATTLE_STATE_WON] = {
.init = battleStateWonInit,
.update = battleStateWonUpdate,
.dispose = battleStateWonDispose
},
[BATTLE_STATE_FLED] = {
.init = battleStateFledInit,
.update = battleStateFledUpdate,
.dispose = battleStateFledDispose
},
[BATTLE_STATE_ENDING] = {
.init = battleStateEndingInit,
.update = battleStateEndingUpdate,
.dispose = battleStateEndingDispose
}
};
-64
View File
@@ -1,64 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "battlestateopening.h"
#include "battlestatepreround.h"
#include "battlestateselection.h"
#include "battlestateexecuting.h"
#include "battlestatepostround.h"
#include "battlestatelost.h"
#include "battlestatewon.h"
#include "battlestatefled.h"
#include "battlestateending.h"
typedef enum {
// No active battle.
BATTLE_STATE_NULL,
// Battle hasn't actually started yet, equiv to trainer intro in Pokemon.
BATTLE_STATE_OPENING,
// Pre round, anything that happens before AI or Player can act.
BATTLE_STATE_PRE_ROUND,
// Player is deciding what to do, waiting for their input
BATTLE_STATE_SELECTION,
// Currently executing the moves.
BATTLE_STATE_EXECUTING,
// Post round, anything that happens after AI or Player has acted, loops back
// after this point
BATTLE_STATE_POST_ROUND,
// Battle has ended with a player loss
BATTLE_STATE_LOST,
// Battle has ended with a player win
BATTLE_STATE_WON,
// Battle has ended with a player fleeing
BATTLE_STATE_FLED,
// Battle state is done, this is fade out or whatever.
BATTLE_STATE_ENDING,
BATTLE_STATE_COUNT
} battlestate_t;
typedef union {
battlestateopening_t opening;
battlestatepreround_t preRound;
battlestateselection_t selection;
battlestateexecuting_t executing;
battlestatepostround_t postRound;
battlestatelost_t lost;
battlestatewon_t won;
battlestatefled_t fled;
battlestateending_t ending;
} battlestatedata_t;
typedef struct {
void (*init)();
void (*update)();
void (*dispose)();
} battlestatefunctions_t;
extern battlestatefunctions_t BATTLE_STATE_FUNCTIONS[BATTLE_STATE_COUNT];
@@ -1,28 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestateending.h"
#include "rpg/battle/battle.h"
#include "scene/scene.h"
void battleStateEndingInit(void) {
// Tear down the battle and hand control back to the overworld. The
// overworld scene keeps no state of its own in scenedata_t (player/map
// state lives in the separate global entity/map systems, untouched by
// switching to/from SCENE_TYPE_BATTLE), so there's nothing else to
// restore here.
battleDispose();
sceneSet(SCENE_TYPE_OVERWORLD);
}
void battleStateEndingUpdate(void) {
}
void battleStateEndingDispose(void) {
}
@@ -1,29 +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 struct {
} battlestateending_t;
/**
* Called when the battle enters BATTLE_STATE_ENDING. Disposes the battle
* (see battleDispose) and switches back to SCENE_TYPE_OVERWORLD.
*/
void battleStateEndingInit(void);
/**
* Updates BATTLE_STATE_ENDING for one frame.
*/
void battleStateEndingUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_ENDING.
*/
void battleStateEndingDispose(void);
@@ -1,116 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestateexecuting.h"
#include "rpg/battle/battle.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/cutscene.h"
#include "asset/assetfile.h"
#include "ui/overlay/uifatalerror.h"
#include "assert/assert.h"
#include "util/string.h"
void battleStateExecutingResolveFighterIndex(
battlefighterpos_t *fighterIndex,
const uint8_t userIndex,
const battlemoveaction_t *action
) {
if(*fighterIndex == BATTLE_FIGHTER_POS_TARGET) {
*fighterIndex = action->targets[0];
} else if(*fighterIndex == BATTLE_FIGHTER_POS_USER) {
*fighterIndex = userIndex;
}
}
void battleStateExecutingInit(void) {
cutsceneitem_t items[BATTLE_STATE_EXECUTING_ITEMS_MAX];
uint8_t itemsCount = 0;
// For each action (just queued during selection). actionsPending is
// indexed by fighter position, not sequentially, so scan every position
// and stop early once every pending action has been processed.
uint8_t processed = 0;
for(
uint8_t i = 0;
i < BATTLE_FIGHTER_POS_COUNT && processed < BATTLE.actionsPendingCount;
i++
) {
// Get action.
battlemoveaction_t *action = &BATTLE.actionsPending[i];
if(action->move == BATTLE_MOVE_ID_NULL) continue;
processed++;
assertTrue(action->move < BATTLE_MOVE_COUNT, "Invalid move id");
// Get ability.
battleability_t *ability = &BATTLE_MOVES[action->move];
assertStrLenMin(ability->name, 1, "Invalid move name");
// Determine cutscene path
char_t path[ASSET_FILE_NAME_MAX];
stringFormat(
path, ASSET_FILE_NAME_MAX - 1,
"cutscenes/battle/moves/%s.jsonc", ability->name
);
// Load and parse the move's cutscene straight into the tail of items.
const uint8_t moveItemsStart = itemsCount;
cutscene_t scene;
errorret_t result = cutsceneLoadParse(
path, &scene, &items[itemsCount],
BATTLE_STATE_EXECUTING_ITEMS_MAX - itemsCount
);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
continue;
}
itemsCount += scene.itemCount;
// Resolve BATTLE_FIGHTER_POS_TARGET/USER sentinels against this
// action, now that its user/target fighters are known.
for(uint8_t j = moveItemsStart; j < itemsCount; j++) {
cutsceneitem_t *moveItem = &items[j];
if(moveItem->type == CUTSCENE_ITEM_TYPE_BATTLE_FIGHTER_DAMAGE) {
battleStateExecutingResolveFighterIndex(
&moveItem->battleFighterDamage.fighterIndex, i, action
);
} else if(moveItem->type == CUTSCENE_ITEM_TYPE_BATTLE_FIGHTER_HEAL) {
battleStateExecutingResolveFighterIndex(
&moveItem->battleFighterHeal.fighterIndex, i, action
);
}
}
// Insert end of attack cutscene item.
items[itemsCount++] = (cutsceneitem_t){
.type = CUTSCENE_ITEM_TYPE_BATTLE_POST_MOVE
};
// This action has now been resolved - clear it so it isn't
// re-executed (and doesn't block re-selection) next round.
action->move = BATTLE_MOVE_ID_NULL;
}
BATTLE.actionsPendingCount = 0;
// After all moves completed, transition to pre-round for next round.
items[itemsCount++] = (cutsceneitem_t){
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_PRE_ROUND }
};
cutsceneSystemInsertItemsNext(items, itemsCount);
}
void battleStateExecutingUpdate(void) {
}
void battleStateExecutingDispose(void) {
}
@@ -1,50 +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"
#include "rpg/battle/fighter/battlefighterpos.h"
#include "rpg/battle/move/battlemoveaction.h"
typedef struct {
} battlestateexecuting_t;
#define BATTLE_STATE_EXECUTING_ITEMS_MAX 64
/**
* Called when the battle enters BATTLE_STATE_EXECUTING.
*/
void battleStateExecutingInit(void);
/**
* Resolves a BATTLE_FIGHTER_POS_TARGET/BATTLE_FIGHTER_POS_USER sentinel
* (as parsed onto a BATTLE_FIGHTER_DAMAGE/BATTLE_FIGHTER_HEAL item inside
* a move's own cutscene) against the action currently being resolved
* into cutscene items. Leaves fighterIndex untouched if it's already a
* concrete BATTLE.fighters index.
*
* @param fighterIndex The field to resolve in place.
* @param userIndex Index into BATTLE.fighters of the fighter performing
* the action.
* @param action The action being resolved.
*/
void battleStateExecutingResolveFighterIndex(
battlefighterpos_t *fighterIndex,
const uint8_t userIndex,
const battlemoveaction_t *action
);
/**
* Updates BATTLE_STATE_EXECUTING for one frame.
*/
void battleStateExecutingUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_EXECUTING.
*/
void battleStateExecutingDispose(void);
@@ -1,32 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestatefled.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void battleStateFledInit(void) {
const cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_TEXT,
.text = { .text = "Got away safely!" }
},
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_ENDING }
}
};
cutsceneSystemInsertItemsNext(items, sizeof(items) / sizeof(cutsceneitem_t));
}
void battleStateFledUpdate(void) {
}
void battleStateFledDispose(void) {
}
@@ -1,30 +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 struct {
} battlestatefled_t;
/**
* Called when the battle enters BATTLE_STATE_FLED. Queues a textbox
* announcing the escape, then transitions to BATTLE_STATE_ENDING once
* dismissed.
*/
void battleStateFledInit(void);
/**
* Updates BATTLE_STATE_FLED for one frame.
*/
void battleStateFledUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_FLED.
*/
void battleStateFledDispose(void);
@@ -1,32 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestatelost.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void battleStateLostInit(void) {
const cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_TEXT,
.text = { .text = "You were defeated..." }
},
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_ENDING }
}
};
cutsceneSystemInsertItemsNext(items, sizeof(items) / sizeof(cutsceneitem_t));
}
void battleStateLostUpdate(void) {
}
void battleStateLostDispose(void) {
}
@@ -1,30 +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 struct {
} battlestatelost_t;
/**
* Called when the battle enters BATTLE_STATE_LOST. Queues a textbox
* announcing the loss, then transitions to BATTLE_STATE_ENDING once
* dismissed.
*/
void battleStateLostInit(void);
/**
* Updates BATTLE_STATE_LOST for one frame.
*/
void battleStateLostUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_LOST.
*/
void battleStateLostDispose(void);
@@ -1,28 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestateopening.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void battleStateOpeningInit(void) {
const cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_PRE_ROUND }
}
};
cutsceneSystemInsertItemsNext(items, sizeof(items) / sizeof(cutsceneitem_t));
}
void battleStateOpeningUpdate(void) {
}
void battleStateOpeningDispose(void) {
}
@@ -1,28 +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 struct {
} battlestateopening_t;
/**
* Called when the battle enters BATTLE_STATE_OPENING.
*/
void battleStateOpeningInit(void);
/**
* Updates BATTLE_STATE_OPENING for one frame.
*/
void battleStateOpeningUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_OPENING.
*/
void battleStateOpeningDispose(void);
@@ -1,20 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestatepostround.h"
void battleStatePostRoundInit(void) {
}
void battleStatePostRoundUpdate(void) {
}
void battleStatePostRoundDispose(void) {
}
@@ -1,28 +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 struct {
} battlestatepostround_t;
/**
* Called when the battle enters BATTLE_STATE_POST_ROUND.
*/
void battleStatePostRoundInit(void);
/**
* Updates BATTLE_STATE_POST_ROUND for one frame.
*/
void battleStatePostRoundUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_POST_ROUND.
*/
void battleStatePostRoundDispose(void);
@@ -1,41 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestatepreround.h"
#include "rpg/battle/battle.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void battleStatePreRoundInit(void) {
// TODO: Determine selection order and who gets to act.
BATTLE.fighterOrderCount = 0;
for(uint8_t i = 0; i < BATTLE_FIGHTER_POS_COUNT; i++) {
if(BATTLE.fighters[i] == NULL) continue;
if(!battleFighterIsAlive(BATTLE.fighters[i])) continue;
BATTLE.fighterOrder[BATTLE.fighterOrderCount++] = i;
}
// Queue up the set state and wait for selection to stop cutscene ending.
cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_SELECTION }
},
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_SELECTION
}
};
cutsceneSystemInsertItemsNext(items, sizeof(items) / sizeof(cutsceneitem_t));
}
void battleStatePreRoundUpdate(void) {
}
void battleStatePreRoundDispose(void) {
}
@@ -1,28 +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 struct {
} battlestatepreround_t;
/**
* Called when the battle enters BATTLE_STATE_PRE_ROUND.
*/
void battleStatePreRoundInit(void);
/**
* Updates BATTLE_STATE_PRE_ROUND for one frame.
*/
void battleStatePreRoundUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_PRE_ROUND.
*/
void battleStatePreRoundDispose(void);
@@ -1,146 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestateselection.h"
#include "rpg/battle/battle.h"
#include "assert/assert.h"
void battleStateSelectionInit(void) {
BATTLE.stateData.selection.selectionIndex = 0;
}
bool_t battleStateSelectionFighterHasDecided(const uint8_t fighterIndex) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "Fighter OOB");
return BATTLE.actionsPending[fighterIndex].move != BATTLE_MOVE_ID_NULL;
}
bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "Fighter OOB");
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter == NULL) return false;
if(!battleFighterIsAlive(fighter)) return false;
return !battleStateSelectionFighterHasDecided(fighterIndex);
}
bool_t battleStateSelectionIsComplete(void) {
return BATTLE.stateData.selection.selectionIndex >= BATTLE.fighterOrderCount;
}
void battleStateSelectionAiDecide(const uint8_t fighterIndex) {
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
const battlefighterteam_t enemyTeam =
fighter->team == BATTLE_FIGHTER_TEAM_ALLY ?
BATTLE_FIGHTER_TEAM_ENEMY : BATTLE_FIGHTER_TEAM_ALLY;
// Simple placeholder AI: attack the first living fighter on the
// opposing team. No move variety or target scoring yet.
for(uint8_t i = 0; i < BATTLE_FIGHTER_POS_COUNT; i++) {
battlefighter_t *candidate = BATTLE.fighters[i];
if(candidate == NULL) continue;
if(candidate->team != enemyTeam) continue;
if(!battleFighterIsAlive(candidate)) continue;
battleStateSelectionQueueAttack(fighterIndex, i);
return;
}
}
void battleStateSelectionUpdate(void) {
battlestateselection_t *state = &BATTLE.stateData.selection;
// Are we still waiting for a selection?
while(state->selectionIndex < BATTLE.fighterOrderCount) {
const uint8_t fighterIndex = BATTLE.fighterOrder[state->selectionIndex];
// Is this fighter still owed a decision?
if(!battleStateSelectionFighterNeedsDecision(fighterIndex)) {
state->selectionIndex++;
continue;
}
// Players will act on their own.
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_AI) return;
battleStateSelectionAiDecide(fighterIndex);
state->selectionIndex++;
}
}
void battleStateSelectionDispose(void) {
}
void battleStateSelectionQueueAttack(
const uint8_t fighterIndex,
const uint8_t targetIndex
) {
assertTrue(fighterIndex < BATTLE_FIGHTER_POS_COUNT, "Fighter OOB");
assertTrue(targetIndex < BATTLE_FIGHTER_POS_COUNT, "Target OOB");
assertNotNull(BATTLE.fighters[fighterIndex], "Fighter cannot be NULL");
assertNotNull(BATTLE.fighters[targetIndex], "Target cannot be NULL");
const battlemoveid_t move = battleMoveGetIdByName("slash");
assertTrue(move != BATTLE_MOVE_ID_NULL, "Missing 'slash' battle move");
battlemoveaction_t *action = &BATTLE.actionsPending[fighterIndex];
if(action->move == BATTLE_MOVE_ID_NULL) BATTLE.actionsPendingCount++;
action->move = move;
action->targets[0] = targetIndex;
action->user = BATTLE.fighters[fighterIndex];
}
battlefighter_t *battleStateSelectionGetCurrentFighter(void) {
if(BATTLE.state != BATTLE_STATE_SELECTION) return NULL;
const uint8_t index = BATTLE.stateData.selection.selectionIndex;
if(index >= BATTLE.fighterOrderCount) return NULL;
return BATTLE.fighters[BATTLE.fighterOrder[index]];
}
void battleStateSelectionPlayerAttack(const uint8_t targetIndex) {
battlefighter_t *fighter = battleStateSelectionGetCurrentFighter();
if(fighter == NULL) return;
const uint8_t selectionIndex = BATTLE.stateData.selection.selectionIndex;
const uint8_t fighterIndex = BATTLE.fighterOrder[selectionIndex];
battleStateSelectionQueueAttack(fighterIndex, targetIndex);
BATTLE.stateData.selection.selectionIndex++;
}
void battleStateSelectionGoBack(void) {
battlestateselection_t *state = &BATTLE.stateData.selection;
uint8_t index = state->selectionIndex;
while(index > 0) {
index--;
const uint8_t fighterIndex = BATTLE.fighterOrder[index];
battlefighter_t *fighter = BATTLE.fighters[fighterIndex];
if(fighter == NULL) continue;
if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) continue;
if(!battleStateSelectionFighterHasDecided(fighterIndex)) continue;
battlemoveaction_t *action = &BATTLE.actionsPending[fighterIndex];
action->move = BATTLE_MOVE_ID_NULL;
BATTLE.actionsPendingCount--;
state->selectionIndex = index;
return;
}
}
void battleStateSelectionPlayerFlee(void) {
battlefighter_t *fighter = battleStateSelectionGetCurrentFighter();
if(fighter == NULL) return;
BATTLE.result = BATTLE_RESULT_FLED;
battleSetState(BATTLE_STATE_FLED);
}
@@ -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 "rpg/battle/fighter/battlefighter.h"
typedef struct {
// Cursor into BATTLE.fighters (by position index) for the fighter
// currently deciding a move this round.
uint8_t selectionIndex;
} battlestateselection_t;
/**
* Called when the battle enters BATTLE_STATE_SELECTION.
*/
void battleStateSelectionInit(void);
/**
* Updates BATTLE_STATE_SELECTION for one frame: auto-decides (see
* battleStateSelectionAiDecide) for every AI-controlled fighter the
* cursor passes, stopping once it reaches a player-controlled fighter
* awaiting input (see battleStateSelectionGetCurrentFighter). Once every
* position has decided, BATTLE_WAIT_SELECTION (see
* cutsceneBattleWaitSelectionUpdate) queues the move to
* BATTLE_STATE_EXECUTING.
*/
void battleStateSelectionUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_SELECTION.
*/
void battleStateSelectionDispose(void);
/**
* Checks whether the fighter at fighterIndex has already queued a move
* this round.
*
* @param fighterIndex Index into BATTLE.fighters to check.
* @return True if an action is already pending for this fighter.
*/
bool_t battleStateSelectionFighterHasDecided(const uint8_t fighterIndex);
/**
* Checks whether the fighter at fighterIndex is a live, undecided
* candidate that BATTLE_STATE_SELECTION should still be deciding a move
* for.
*
* @param fighterIndex Index into BATTLE.fighters to check.
* @return True if the fighter is alive and has no move queued yet.
*/
bool_t battleStateSelectionFighterNeedsDecision(const uint8_t fighterIndex);
/**
* Decides and queues a move for an AI-controlled fighter: currently
* always a basic attack against the first living fighter on the
* opposing team (see battleStateSelectionQueueAttack). No-ops if there's
* no living target (the battle should already be over by then via
* BATTLE_POST_MOVE).
*
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
*/
void battleStateSelectionAiDecide(const uint8_t fighterIndex);
/**
* Checks whether every fighter in BATTLE.fighterOrder has decided a move
* this round.
*
* @return True once the selection cursor has passed every fighter.
*/
bool_t battleStateSelectionIsComplete(void);
/**
* Queues a basic attack (the "slash" move) from fighterIndex against
* targetIndex.
*
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
* @param targetIndex Index into BATTLE.fighters of the target.
*/
void battleStateSelectionQueueAttack(
const uint8_t fighterIndex,
const uint8_t targetIndex
);
/**
* Returns the fighter currently awaiting a decision in
* BATTLE_STATE_SELECTION.
*
* @return Pointer to the fighter awaiting a decision, or NULL if the
* battle isn't in BATTLE_STATE_SELECTION or every position has
* already decided.
*/
battlefighter_t *battleStateSelectionGetCurrentFighter(void);
/**
* Submits the currently-selecting fighter's attack against a target, if
* the battle is in BATTLE_STATE_SELECTION and awaiting a decision. Queues
* the move and advances the selection cursor.
*
* @param targetIndex Index into BATTLE.fighters of the target.
*/
void battleStateSelectionPlayerAttack(const uint8_t targetIndex);
/**
* Ends the battle immediately with BATTLE_STATE_FLED, if the battle is in
* BATTLE_STATE_SELECTION and awaiting a decision.
*/
void battleStateSelectionPlayerFlee(void);
/**
* Rewinds the selection cursor to the nearest earlier player-controlled
* fighter that has already decided a move this round, clearing its
* pending action so it can be re-decided. AI-decided fighters along the
* way are left alone (skipped over, not rewound onto). No-ops if there's
* no such fighter (e.g. already at the first decision of the round).
*/
void battleStateSelectionGoBack(void);
@@ -1,32 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlestatewon.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenesystem.h"
void battleStateWonInit(void) {
const cutsceneitem_t items[] = {
{
.type = CUTSCENE_ITEM_TYPE_TEXT,
.text = { .text = "You won the battle!" }
},
{
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
.battleSetState = { .state = BATTLE_STATE_ENDING }
}
};
cutsceneSystemInsertItemsNext(items, sizeof(items) / sizeof(cutsceneitem_t));
}
void battleStateWonUpdate(void) {
}
void battleStateWonDispose(void) {
}
@@ -1,30 +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 struct {
} battlestatewon_t;
/**
* Called when the battle enters BATTLE_STATE_WON. Queues a textbox
* announcing the win, then transitions to BATTLE_STATE_ENDING once
* dismissed.
*/
void battleStateWonInit(void);
/**
* Updates BATTLE_STATE_WON for one frame.
*/
void battleStateWonUpdate(void);
/**
* Called when the battle leaves BATTLE_STATE_WON.
*/
void battleStateWonDispose(void);
-15
View File
@@ -1,15 +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
cutscene.c
cutsceneref.c
cutscenesystem.c
)
# Subdirs
add_subdirectory(item)
-178
View File
@@ -1,178 +0,0 @@
/**
* Copyright (c) 2025 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "cutscene.h"
#include "item/json/cutscenejsonpauseflags.h"
#include "util/memory.h"
#include "assert/assert.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
errorret_t cutsceneParseItem(
yyjson_val *itemObj,
cutsceneitem_t *item,
const uint8_t index
) {
assertNotNull(itemObj, "Item object cannot be NULL");
assertNotNull(item, "Item cannot be NULL");
if(!yyjson_is_obj(itemObj)) {
errorThrow("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)) {
errorThrow(
"Cutscene item %u: missing/unsupported item type %s",
(uint32_t)index, typeName
);
}
cutsceneitemloadcallback_t *load = CUTSCENE_ITEM_CALLBACKS[item->type].load;
if(load == NULL) {
errorThrow(
"Cutscene item %u: unhandled item type %u",
(uint32_t)index, (uint32_t)item->type
);
}
errorret_t result = load(itemObj, item);
if(errorIsNotOk(result)) errorChain(result);
errorOk();
}
size_t cutsceneCountItems(yyjson_doc *doc) {
assertNotNull(doc, "Doc cannot be NULL");
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
if(!yyjson_is_arr(itemsArr)) return 0;
return yyjson_arr_size(itemsArr);
}
errorret_t cutsceneParseDoc(
yyjson_doc *doc,
cutscene_t *scene,
cutsceneitem_t *items,
size_t itemsMax
) {
assertNotNull(doc, "Doc cannot be NULL");
assertNotNull(scene, "Scene cannot be NULL");
assertNotNull(items, "Items cannot be NULL");
assertTrue(itemsMax > 0, "Items max cannot be 0");
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
if(!yyjson_is_arr(itemsArr)) {
errorThrow("Cutscene JSON is missing an 'items' array");
}
size_t itemCount = yyjson_arr_size(itemsArr);
if(itemCount > itemsMax) {
errorThrow("Cutscene has more items than its target buffer allows");
}
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
yyjson_val *pauseArr = yyjson_obj_get(root, "pause");
if(yyjson_is_arr(pauseArr)) {
if(!cutsceneJsonParsePauseFlags(pauseArr, &pause)) {
errorThrow("Cutscene JSON has an invalid 'pause' flag");
}
}
memoryZero(items, itemCount * sizeof(cutsceneitem_t));
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 = cutsceneParseItem(itemVal, &items[index], index);
if(errorIsNotOk(itemResult)) errorChain(itemResult);
index++;
}
scene->itemCount = (uint8_t)itemCount;
scene->itemsMax = itemsMax;
scene->pause = pause;
errorOk();
}
void cutsceneAppendAt(
cutscene_t *scene,
const uint8_t atIndex,
const cutsceneitem_t *items,
const uint8_t count
) {
assertNotNull(scene, "Scene cannot be NULL");
assertNotNull(items, "Items cannot be NULL");
assertTrue(count > 0, "Count cannot be 0");
assertTrue(atIndex <= scene->itemCount, "atIndex out of bounds");
assertTrue(
(size_t)scene->itemCount + count <= scene->itemsMax,
"Cutscene item append exceeds itemsMax"
);
const size_t tailCount = (size_t)scene->itemCount - atIndex;
if(tailCount > 0) {
memoryMove(
&scene->items[atIndex + count],
&scene->items[atIndex],
tailCount * sizeof(cutsceneitem_t)
);
}
memoryCopy(&scene->items[atIndex], items, count * sizeof(cutsceneitem_t));
scene->itemCount += count;
}
void cutsceneAppend(
cutscene_t *scene,
const cutsceneitem_t *items,
const uint8_t count
) {
cutsceneAppendAt(scene, scene->itemCount, items, count);
}
void cutsceneAppendNext(
cutscene_t *scene,
const uint8_t currentIndex,
const cutsceneitem_t *items,
const uint8_t count
) {
cutsceneAppendAt(scene, (uint8_t)(currentIndex + 1), items, count);
}
errorret_t cutsceneLoadParse(
const char_t *path,
cutscene_t *scene,
cutsceneitem_t *items,
const size_t itemsMax
) {
assertNotNull(path, "Path cannot be NULL");
assertNotNull(scene, "Scene cannot be NULL");
assertNotNull(items, "Items cannot be NULL");
// Lock the asset, require it loaded.
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_JSON, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorChain(result);
}
// Parse the loaded JSON
errorret_t parseResult = cutsceneParseDoc(
entry->data.json, scene, items, itemsMax
);
// Free up the asset entry.
assetUnlockEntry(entry);
errorChain(parseResult);
errorOk();
}
-154
View File
@@ -1,154 +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 {
cutsceneitem_t *items;
uint8_t itemCount;
// Capacity of items - see cutsceneAppend/cutsceneAppendAt/
// cutsceneAppendNext, which assert against this rather than taking a
// capacity parameter of their own. Set once, either by cutsceneParseDoc
// (to whatever itemsMax it was given) or by whoever declares a
// C-authored cutscene_t directly (to items' true array capacity, which
// may be larger than itemCount to leave room for a later append/insert).
size_t itemsMax;
cutscenepause_t pause;
} cutscene_t;
/**
* Parses one cutscene item object into item. 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 item/cutsceneitem.h) - the
* per-type parsing logic itself lives there, colocated with that type's
* runtime behavior.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param index Index of this item within the cutscene, for error messages.
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneParseItem(
yyjson_val *itemObj,
cutsceneitem_t *item,
const uint8_t index
);
/**
* Counts how many entries are in doc's top-level "items" array, without
* parsing any of them - e.g. so a caller that wants to malloc its own
* items array instead of using a fixed one could size it first (we don't
* do that ourselves, but the option stays open).
*
* @param doc Already-parsed JSON document.
* @return Number of entries in "items", or 0 if it's missing/not an array.
*/
size_t cutsceneCountItems(yyjson_doc *doc);
/**
* Parses doc's "items" (and optional "pause") into items, and updates
* scene's itemCount/itemsMax/pause - scene->items is left untouched, since
* the caller already points it at items. Purely a parser: doesn't touch
* the asset system, doesn't lock/load anything, and doesn't take
* ownership of doc - the caller is responsible for both loading doc
* beforehand and for its lifetime afterward (though as of cutscene items
* owning their own string fields by value, nothing parsed here actually
* needs doc to survive past this call returning).
*
* @param doc Already-parsed JSON document to read from.
* @param scene Destination scene - itemCount/itemsMax/pause are
* overwritten.
* @param items Destination item array, capacity itemsMax.
* @param itemsMax Capacity of items.
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneParseDoc(
yyjson_doc *doc,
cutscene_t *scene,
cutsceneitem_t *items,
size_t itemsMax
);
/**
* Inserts items into scene->items starting at atIndex, growing
* scene->itemCount. Every existing item from atIndex onward shifts right
* by count to make room. Asserts if this would exceed scene->itemsMax.
*
* @param scene Cutscene to insert into.
* @param atIndex Index the first inserted item should end up at - must be
* <= scene->itemCount.
* @param items Items to insert, copied in order.
* @param count Number of entries in items.
*/
void cutsceneAppendAt(
cutscene_t *scene,
const uint8_t atIndex,
const cutsceneitem_t *items,
const uint8_t count
);
/**
* Same as cutsceneAppendAt, but always inserts at the end of
* scene->items.
*
* @param scene Cutscene to append to.
* @param items Items to append, copied in order.
* @param count Number of entries in items.
*/
void cutsceneAppend(
cutscene_t *scene,
const cutsceneitem_t *items,
const uint8_t count
);
/**
* Same as cutsceneAppendAt, but inserts right after currentIndex (i.e. at
* currentIndex + 1) instead of at a literal index - e.g. pass
* CUTSCENE_SYSTEM.currentItem to make these items run immediately after
* the currently-executing one. currentIndex may be 0xFF (nothing has run
* yet), which wraps to insert at index 0, matching that sentinel's usual
* meaning elsewhere in the cutscene system.
*
* @param scene Cutscene to insert into.
* @param currentIndex Index to insert after.
* @param items Items to insert, copied in order.
* @param count Number of entries in items.
*/
void cutsceneAppendNext(
cutscene_t *scene,
const uint8_t currentIndex,
const cutsceneitem_t *items,
const uint8_t count
);
/**
* Loads a cutscene JSON asset by path and parses it into scene/items (see
* cutsceneParseDoc) - the common "lock the asset, require it loaded,
* parse its 'items' array, unlock it again" sequence shared by every
* place that resolves a cutscene by file path (cutsceneSystemLoad,
* cutsceneCutsceneResolve, cutsceneInsertResolveAndSplice,
* battleStateExecutingInit, cutsceneSystemGoTo's reload). Purely propagates the
* error on failure (like cutsceneParseDoc) - this is core cutscene logic,
* not UI, so it doesn't reach for uiFatalErrorOpen itself; each caller
* decides how to surface the failure (typically errorCatch(errorPrint())
* + uiFatalErrorOpen(NULL), same as any other asset load failure).
*
* @param path Asset path of the cutscene JSON file to load.
* @param scene Destination scene - itemCount/itemsMax/pause overwritten.
* @param items Destination item array, capacity itemsMax.
* @param itemsMax Capacity of items.
* @return Error code indicating success or failure of the load/parse.
*/
errorret_t cutsceneLoadParse(
const char_t *path,
cutscene_t *scene,
cutsceneitem_t *items,
const size_t itemsMax
);
-26
View File
@@ -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 \
))
-24
View File
@@ -1,24 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "cutsceneref.h"
#include "assert/assert.h"
void cutsceneRefReset(
cutsceneref_t *refs,
entity_t *interact,
entity_t *interacted
) {
assertNotNull(refs, "Refs cannot be null");
refs->entityInteract = interact;
refs->entityInteracted = interacted;
refs->entityLastCreated = NULL;
refs->entityLastRef = NULL;
refs->areaLastCreated = CUTSCENE_REF_AREA_LAST_CREATED;
refs->textMiniLastCreated = CUTSCENE_REF_TEXT_MINI_LAST_CREATED;
}
-36
View File
@@ -1,36 +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 struct entity_s entity_t;
#define CUTSCENE_REF_AREA_LAST_CREATED ((uint8_t)0xFF)
#define CUTSCENE_REF_TEXT_MINI_LAST_CREATED ((uint8_t)0xFA)
typedef struct {
entity_t *entityInteract;
entity_t *entityInteracted;
entity_t *entityLastCreated;
entity_t *entityLastRef;
uint8_t areaLastCreated;
uint8_t textMiniLastCreated;
} cutsceneref_t;
/**
* Resets refs to an empty state.
*
* @param refs Refs to reset.
* @param interact The entity that initiated the interaction, or NULL.
* @param interacted The entity being interacted with, or NULL.
*/
void cutsceneRefReset(
cutsceneref_t *refs,
entity_t *interact,
entity_t *interacted
);
-358
View File
@@ -1,358 +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 "ui/overlay/uifatalerror.h"
#include "save/slot/saveslot.h"
cutscenesystem_t CUTSCENE_SYSTEM;
void cutsceneSystemInit() {
memoryZero(&CUTSCENE_SYSTEM, sizeof(cutscenesystem_t));
CUTSCENE_SYSTEM.loadedScene.items = CUTSCENE_SYSTEM.loadedItems;
CUTSCENE_SYSTEM.loadedScene.itemsMax = CUTSCENE_LOADED_ITEMS_MAX;
}
void cutsceneSystemUpdate() {
if(CUTSCENE_SYSTEM.scene == NULL) return;
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
if(cutsceneItemUpdate(item, &CUTSCENE_SYSTEM.data)) cutsceneSystemNext();
}
void cutsceneSystemDispose() {
CUTSCENE_SYSTEM.scene = NULL;
CUTSCENE_SYSTEM.currentItem = CUTSCENE_CURRENT_ITEM_NONE;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
cutsceneRefReset(&CUTSCENE_SYSTEM.refs, NULL, NULL);
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.onComplete = NULL;
CUTSCENE_SYSTEM.loadedFile[0] = '\0';
}
void cutsceneSystemPrepare(
cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
) {
assertNotNull(cutscene, "Cutscene cannot be NULL");
CUTSCENE_SYSTEM.scene = cutscene;
CUTSCENE_SYSTEM.pause = cutscene->pause;
cutsceneRefReset(&CUTSCENE_SYSTEM.refs, interact, interacted);
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.onComplete = NULL;
// cutsceneNext will cause overflow to index 0 (intentional).
CUTSCENE_SYSTEM.currentItem = CUTSCENE_CURRENT_ITEM_NONE;
// Clear loaded file if cutscene not running from file.
if(cutscene != &CUTSCENE_SYSTEM.loadedScene) {
CUTSCENE_SYSTEM.loadedFile[0] = '\0';
}
}
void cutsceneSystemStartCutscene(cutscene_t *cutscene) {
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
}
void cutsceneSystemStartCutsceneWith(
cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
) {
assertNotNull(cutscene, "Cutscene cannot be NULL");
assertTrue(
cutscene->itemCount <= cutscene->itemsMax,
"Cutscene itemCount exceeds itemsMax"
);
// Prep cutscene, save it to the save slot, and advance, aka overflow to 0.
cutsceneSystemPrepare(cutscene, interact, interacted);
saveSlotSetCurrentCutscene(CUTSCENE_SYSTEM.loadedFile);
cutsceneSystemNext();
}
void cutsceneSystemStartCutsceneAndGoToMarker(
cutscene_t *cutscene,
const char_t *marker
) {
assertNotNull(marker, "Marker cannot be NULL");
// Restarts the cutscene
cutsceneSystemPrepare(cutscene, NULL, NULL);
cutsceneSystemGoTo(marker);
}
void cutsceneSystemInsertItemsNext(
const cutsceneitem_t *items,
const size_t itemCount
) {
assertNotNull(items, "Items cannot be NULL");
assertNotNull(
CUTSCENE_SYSTEM.scene,
"cutsceneSystemInsertItemsNext called with no cutscene running"
);
cutsceneAppendNext(
CUTSCENE_SYSTEM.scene, CUTSCENE_SYSTEM.currentItem, items, itemCount
);
}
void cutsceneSystemInsertItems(
const cutsceneitem_t *items,
const size_t itemCount
) {
cutsceneSystemInsertItemsNext(items, itemCount);
cutsceneSystemNext();
}
void cutsceneSystemInsertCutscene(const cutscene_t *cutscene) {
assertNotNull(cutscene, "Cutscene cannot be NULL");
cutsceneSystemInsertItems(cutscene->items, cutscene->itemCount);
}
void cutsceneSystemClearAfter(const uint8_t index) {
assertNotNull(
CUTSCENE_SYSTEM.scene,
"cutsceneSystemClearAfter called with no cutscene running"
);
assertTrue(
index < CUTSCENE_SYSTEM.scene->itemCount,
"cutsceneSystemClearAfter index out of range"
);
CUTSCENE_SYSTEM.scene->itemCount = index + 1;
}
void cutsceneSystemLoad(const char_t *file) {
assertNotNull(file, "File cannot be NULL");
// Take a copy of file, as it may be a part of the cutscene itself.
char_t fileCopy[ASSET_FILE_NAME_MAX];
stringCopy(fileCopy, file, ASSET_FILE_NAME_MAX - 1);
// Load the cutscene asset, parse it, and start it.
errorret_t result = cutsceneLoadParse(
fileCopy, &CUTSCENE_SYSTEM.loadedScene, CUTSCENE_SYSTEM.loadedItems,
CUTSCENE_LOADED_ITEMS_MAX
);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
return;
}
// Store the loaded file so we can restart if needed.
stringCopy(CUTSCENE_SYSTEM.loadedFile, fileCopy, ASSET_FILE_NAME_MAX - 1);
cutsceneSystemStartCutscene(&CUTSCENE_SYSTEM.loadedScene);
}
void cutsceneRestart(void) {
assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running"
);
assertTrue(
CUTSCENE_SYSTEM.scene == &CUTSCENE_SYSTEM.loadedScene,
"cutsceneRestart called with a cutscene not loaded from file"
);
assertTrue(
CUTSCENE_SYSTEM.loadedFile[0] != '\0',
"cutsceneRestart called with no loaded file"
);
// Copy the refs and callbacks
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
cutsceneref_t refs = CUTSCENE_SYSTEM.refs;
// Reload the file.
char_t file[ASSET_FILE_NAME_MAX];
stringCopy(file, CUTSCENE_SYSTEM.loadedFile, ASSET_FILE_NAME_MAX - 1);
cutsceneSystemLoad(file);
// Restore the refs and callbacks.
CUTSCENE_SYSTEM.onComplete = onComplete;
CUTSCENE_SYSTEM.refs = refs;
}
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete) {
assertNotNull(
CUTSCENE_SYSTEM.scene,
"cutsceneSystemSetOnComplete called with no cutscene running"
);
CUTSCENE_SYSTEM.onComplete = onComplete;
}
entity_t * cutsceneSystemGetEntity(const uint8_t entityIndex) {
entity_t *entity;
if(entityIndex == CUTSCENE_ENTITY_INTERACT) {
assertNotNull(
CUTSCENE_SYSTEM.refs.entityInteract,
"CUTSCENE_ENTITY_INTERACT used but no interact entity is set"
);
entity = CUTSCENE_SYSTEM.refs.entityInteract;
} else if(entityIndex == CUTSCENE_ENTITY_INTERACTED) {
assertNotNull(
CUTSCENE_SYSTEM.refs.entityInteracted,
"CUTSCENE_ENTITY_INTERACTED used but no interacted entity is set"
);
entity = CUTSCENE_SYSTEM.refs.entityInteracted;
} else if(entityIndex == CUTSCENE_ENTITY_LAST_CREATED) {
assertNotNull(
CUTSCENE_SYSTEM.refs.entityLastCreated,
"CUTSCENE_ENTITY_LAST_CREATED used but no entity has been created"
);
entity = CUTSCENE_SYSTEM.refs.entityLastCreated;
} else if(entityIndex == CUTSCENE_ENTITY_LAST_REF) {
assertNotNull(
CUTSCENE_SYSTEM.refs.entityLastRef,
"CUTSCENE_ENTITY_LAST_REF used but no entity has been referenced"
);
entity = CUTSCENE_SYSTEM.refs.entityLastRef;
} else {
assertTrue(
entityIndex < ENTITY_COUNT,
"Entity index is out of range"
);
entity = &ENTITIES[entityIndex];
}
CUTSCENE_SYSTEM.refs.entityLastRef = entity;
return entity;
}
uint8_t cutsceneSystemGetAreaId(const uint8_t areaId) {
if(areaId == CUTSCENE_REF_AREA_LAST_CREATED) {
assertTrue(
CUTSCENE_SYSTEM.refs.areaLastCreated != CUTSCENE_REF_AREA_LAST_CREATED,
"CUTSCENE_REF_AREA_LAST_CREATED used but no map area has been created"
);
return CUTSCENE_SYSTEM.refs.areaLastCreated;
}
return areaId;
}
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index) {
if(index == CUTSCENE_REF_TEXT_MINI_LAST_CREATED) {
assertTrue(
CUTSCENE_SYSTEM.refs.textMiniLastCreated !=
CUTSCENE_REF_TEXT_MINI_LAST_CREATED,
"CUTSCENE_REF_TEXT_MINI_LAST_CREATED used but no mini textbox has been "
"shown"
);
return CUTSCENE_SYSTEM.refs.textMiniLastCreated;
}
return index;
}
const char_t * cutsceneSystemGetTextCache(void) {
return CUTSCENE_SYSTEM.textCache;
}
void cutsceneSystemSetTextCache(const char_t *text) {
assertNotNull(text, "Text cannot be NULL");
stringCopy(
CUTSCENE_SYSTEM.textCache, text, CUTSCENE_TEXT_CACHE_MAX - 1
);
}
void cutsceneSystemNext() {
if(CUTSCENE_SYSTEM.scene == NULL) return;
// Pop the just-finished item off the front.
if(CUTSCENE_SYSTEM.currentItem == CUTSCENE_CURRENT_ITEM_NONE) {
CUTSCENE_SYSTEM.currentItem = 0;
} else {
cutscene_t *scene = CUTSCENE_SYSTEM.scene;
const size_t tailCount = (size_t)scene->itemCount - 1;
if(tailCount > 0) {
memoryMove(
&scene->items[0], &scene->items[1], tailCount * sizeof(cutsceneitem_t)
);
}
scene->itemCount--;
}
// End of the cutscene?
if(CUTSCENE_SYSTEM.scene->itemCount == 0) {
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
CUTSCENE_SYSTEM.scene = NULL;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
if(onComplete != NULL) onComplete(NULL);
return;
}
// Start next (now current) item
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
memset(&CUTSCENE_SYSTEM.data, 0, sizeof(CUTSCENE_SYSTEM.data));
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
}
void cutsceneSystemGoTo(const char_t *name) {
assertNotNull(name, "Name cannot be NULL");
assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneSystemGoTo called with no cutscene running"
);
assertTrue(
CUTSCENE_SYSTEM.scene == &CUTSCENE_SYSTEM.loadedScene,
"cutsceneSystemGoTo requires the running cutscene file reference (1)"
);
assertTrue(
CUTSCENE_SYSTEM.loadedFile[0] != '\0',
"cutsceneSystemGoTo requires the running cutscene file reference (2)"
);
cutscene_t freshScene = { .items = CUTSCENE_SYSTEM.gotoScratchItems };
errorret_t result = cutsceneLoadParse(
CUTSCENE_SYSTEM.loadedFile, &freshScene, CUTSCENE_SYSTEM.gotoScratchItems,
CUTSCENE_LOADED_ITEMS_MAX
);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
return;
}
// Search for the marker with the given name...
for(uint8_t i = 0; i < freshScene.itemCount; i++) {
const cutsceneitem_t *item = &freshScene.items[i];
if(
item->type != CUTSCENE_ITEM_TYPE_MARKER ||
!stringEquals(item->marker.name, name)
) continue;
// Found, so splice the cutscene at that point and start it, the remaining
// items being the rest of the cutscene after the marker.
const uint8_t remaining = freshScene.itemCount - i;
memoryCopy(
CUTSCENE_SYSTEM.loadedItems, &freshScene.items[i],
remaining * sizeof(cutsceneitem_t)
);
CUTSCENE_SYSTEM.loadedScene.itemCount = remaining;
CUTSCENE_SYSTEM.currentItem = 0;
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data));
cutsceneItemStart(
&CUTSCENE_SYSTEM.loadedItems[0], &CUTSCENE_SYSTEM.data
);
return;
}
assertTrue(false, "cutsceneSystemGoTo: no marker found with that name");
}
const cutsceneitem_t * cutsceneSystemGetCurrentItem() {
if(CUTSCENE_SYSTEM.scene == NULL) return NULL;
return &CUTSCENE_SYSTEM.scene->items[0];
}
-241
View File
@@ -1,241 +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"
#include "cutsceneref.h"
#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_CURRENT_ITEM_NONE ((uint8_t)0xFF)
#define CUTSCENE_TEXT_CACHE_MAX 64
#define CUTSCENE_LOADED_ITEMS_MAX 128
#define CUTSCENE_INSERT_ITEMS_MAX 16
typedef struct {
cutscene_t *scene;
uint8_t currentItem;
cutscenepause_t pause;
cutsceneref_t refs;
// Free-form text cache for the running cutscene - see
// cutsceneSystemGetTextCache/cutsceneSystemSetTextCache.
char_t textCache[CUTSCENE_TEXT_CACHE_MAX];
// Runtime data for the current item.
cutsceneitemdata_t data;
// See cutsceneSystemSetOnComplete.
cutscenecallback_t onComplete;
// Cache and storage used for the dynamically loaded cutscenes.
cutsceneitem_t loadedItems[CUTSCENE_LOADED_ITEMS_MAX];
cutscene_t loadedScene;
char_t loadedFile[ASSET_FILE_NAME_MAX];
// Cutscene items scratch, mostly used for CUTSCENE_INSERT items.
cutsceneitem_t gotoScratchItems[CUTSCENE_LOADED_ITEMS_MAX];
} cutscenesystem_t;
extern cutscenesystem_t CUTSCENE_SYSTEM;
/**
* Initialize the cutscene system.
*/
void cutsceneSystemInit();
/**
* Disposes of the cutscene system, stopping any active cutscene.
*/
void cutsceneSystemDispose();
/**
* Resets CUTSCENE_SYSTEM to run cutscene from its first item, binding
* interact/interacted entities. Does not itself advance to the first item.
*
* @param cutscene Pointer to the cutscene to prepare.
* @param interact The entity that initiated the interaction (player), or
* NULL.
* @param interacted The entity being interacted with (NPC), or NULL.
*/
void cutsceneSystemPrepare(
cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
);
/**
* Start a cutscene with no bound entities.
*
* @param cutscene Pointer to the cutscene to start.
*/
void cutsceneSystemStartCutscene(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(
cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
);
/**
* Starts a cutscene with no bound entities, jumping straight to the
* marker with the given name instead of running from the first item.
* Asserts if no marker with that name exists.
*
* @param cutscene Pointer to the cutscene to start.
* @param marker Marker name to jump to.
*/
void cutsceneSystemStartCutsceneAndGoToMarker(
cutscene_t *cutscene,
const char_t *marker
);
/**
* Splices items into the running cutscene so that items[0] becomes the
* very next item, without otherwise disturbing the currently-running
* item's progress.
*
* @param items Items to splice in.
* @param itemCount Number of items in items.
*/
void cutsceneSystemInsertItemsNext(
const cutsceneitem_t *items,
const size_t itemCount
);
/**
* Splices items into the running cutscene right after the current item
* (see cutsceneSystemInsertItemsNext), then immediately advances into the
* first spliced item.
*
* @param items Items to splice in.
* @param itemCount Number of items in items.
*/
void cutsceneSystemInsertItems(
const cutsceneitem_t *items,
const size_t itemCount
);
/**
* Splices cutscene's items into the running cutscene.
*
* @param cutscene The cutscene whose items to splice in.
*/
void cutsceneSystemInsertCutscene(const cutscene_t *cutscene);
/**
* Discards every item queued after index (e.g. index 0 keeps only the
* currently-running item and drops everything spliced in behind it),
* without disturbing index's own progress - e.g. so a callback that
* decides the rest of a batch-spliced sequence is now moot (a battle
* ending mid-round, with other fighters' still-queued move cutscenes and
* the round's trailing state transition left behind) can drop it before
* splicing in its own follow-up items, instead of leaving it to run
* afterward against torn-down state.
*
* @param index Index to keep - everything after it is discarded. Must be
* < the running cutscene's current item count.
*/
void cutsceneSystemClearAfter(const uint8_t index);
/**
* Loads and immediately starts a cutscene asset by file name.
*
* @param file Cutscene file name.
*/
void cutsceneSystemLoad(const char_t *file);
/**
* Restarts the currently running cutscene. Will perform a load if the cutscene
* was initially from a file.
*/
void cutsceneRestart(void);
/**
* Sets a native callback to fire once when the currently running cutscene
* finishes.
*
* @param onComplete Callback to fire on completion.
*/
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete);
/**
* Resolves a raw entity index (or sentinel) to an entity pointer.
*
* @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_REF_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_REF_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 the running cutscene's cached text - see
* cutsceneSystemSetTextCache. Empty ("") if nothing has been cached yet.
*
* @returns The cached text.
*/
const char_t * cutsceneSystemGetTextCache(void);
/**
* Overwrites the running cutscene's cached text.
*
* @param text The text to cache; copied internally.
*/
void cutsceneSystemSetTextCache(const char_t *text);
/**
* Advance to the next item in the cutscene.
*/
void cutsceneSystemNext();
/**
* Jumps to a cutscene marker directly. If the cutscene is a loaded cutscene,
* this will result in it being reloaded (due to unshift nature of cutscenes).
*
* @param name Marker name to search for.
*/
void cutsceneSystemGoTo(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();
-24
View File
@@ -1,24 +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)
add_subdirectory(story)
add_subdirectory(party)
@@ -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,59 +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
) {
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,56 +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.
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioFadeLoad(
yyjson_val *itemObj,
cutsceneitem_t *item
);
@@ -1,24 +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
) {
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioFadeWaitChannel);
}
@@ -1,36 +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.
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioFadeWaitLoad(
yyjson_val *itemObj,
cutsceneitem_t *item
);
Loaded 100 of 464 files, more files were not shown because too many files have changed in this diff. Show more