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
YourWishesandClaude Sonnet 5 3948607b1e Rename cutscenesystemrefs_t to cutsceneref_t, cutsceneRefsReset to cutsceneRefReset, and cutscenerefs.c/.h to cutsceneref.c/.h
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-25 10:23:38 -05:00
YourWishes 6c180372fe First pass cleaning cutscene system. 2026-09-25 09:57:48 -05:00
YourWishesandClaude Sonnet 5 fed04b66b8 Persist and resume in-progress cutscene via save slot; add species stub
Save slots now track the currently-active cutscene file (defaulting to a
new placeholder new_game.jsonc intro) so loading a save resumes it;
cutsceneSystemStartCutsceneWith persists this on every cutscene start, and
a cutsceneSystemLoad ordering bug (loadedFile stamped after starting) is
fixed so the persisted path is always correct. Also adds a
battlefighterspecies_t/battlespeciesid_t stub for upcoming species data.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-22 18:44:45 -05:00
YourWishesandClaude Sonnet 5 a4ca718070 Add shared battleFighterParse/Serialize, use it everywhere a fighter is (de)serialized
Replaces three independently hand-rolled battlefighter_t JSON readers/
writers (party.jsonc roster loading, BATTLE_START enemy definitions, and
the v1 save-file party member format) with one generic pair in
battlefighter.c: battleFighterParse(fighter, obj) and its inverse
battleFighterSerialize(fighter, doc). Covers team/controller/status
(optional, default ally/player/normal), the 7 base stats (required),
current health/mp (optional, default to max), and an optional abilities
name array.

- party.c: partyInit now parses each roster member via battleFighterParse
  directly, dropping the old partyParseStats/partyParseStatField.
- cutscenebattlestart.c/.h: cutscenebattlestartenemy_t now embeds a full
  battlefighter_t, parsed once at Load time; Start just copies it into the
  enemy pool instead of re-initializing. battletest.jsonc's enemies are
  now flat (dropped the "stats" wrapper) to match.
- saveslotver1.c/.h: rewritten in place to use the shared parser/
  serializer, changing the v1 on-disk field names/shape (no migration -
  acceptable pre-release, documented in saveslotver1.h).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-22 14:20:45 -05:00
YourWishesandClaude Sonnet 5 8dbd3e1dd2 Key-based party roster, show party member names in battle UI
- party.jsonc's "members" is now a key/value object (e.g. "hero",
  "companion") instead of an array - partyInit seeds party_t.keys
  alongside members, and partyGetMemberIndexByKey/partyGetMemberKey do the
  lookups. Cutscene items (PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/
  PARTY_HEAL_MEMBER) now take a "key" JSON field, resolved to a roster
  index at load time, instead of a raw index.
- Battle UI shows who's acting: uibattlemenu.c draws the current fighter's
  party key above the action menu, and uibattlehud.c draws each ally
  fighter's key as an extra line above their HP/MP.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 22:38:04 -05:00
YourWishesandClaude Sonnet 5 7a9a2b9832 Add order-based party roster, back-navigation in battle selection, fix win-state cutscene leak
- Battle selection menu can now go back a fighter to re-decide their move,
  and battle win/loss no longer leaks the round's remaining queued move
  cutscenes into the ending state (cutsceneSystemDiscardQueued).
- Party roster is now driven by party.jsonc: every defined member always
  exists (partyInit), while party->order alone determines who's currently
  active - partyAddMember/partyRemoveMember (and their cutscene items) now
  activate/deactivate an existing roster member by index instead of
  creating/destroying one, with removal compacting order to stay packed.
- New PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/PARTY_HEAL_MEMBER/PARTY_HEAL_ALL
  cutscene items.
- saveSlotInit/partyInit now return errorret_t and take their target
  struct directly (saveslot_t*/party_t*) instead of implicitly operating
  on SAVE.slot; saveSlotReadJSON resets through saveSlotInit rather than
  duplicating the reset logic.
- Test coverage: stub the JSON asset loader in save test fixtures so
  partyInit's real party.jsonc load runs against an in-memory doc instead
  of hanging with no asset system initialized.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 22:27:46 -05:00
YourWishes e8757c33e3 Fix U_IDLE asset error from cutscene item pointer aliasing
cutsceneCutsceneResolve and cutsceneSystemLoad could be called with a
name/path pointer that aliases into CUTSCENE_SYSTEM.loadedItems (e.g. a
CUTSCENE item's own name field). cutsceneLoadParse then overwrites that
same buffer while loading the target cutscene, so the subsequent
stringCopy into loadedFile read stale, corrupted memory. Copy the
name/path into a local buffer before use in both functions.
2026-09-21 19:48:28 -05:00
YourWishes b498a12458 Merge branch 'battle-refactor' 2026-09-21 15:04:52 -05:00
YourWishesandClaude Sonnet 5 c9f098693c Wire up battle AI/scene-return, reclaim cutscene item slots, fix GOTO
- battleStateSelectionAiDecide: AI-controlled fighters now actually
  queue a basic attack against the first living enemy instead of doing
  nothing every round.
- battleStateEndingInit: disposes the battle and switches back to
  SCENE_TYPE_OVERWORLD instead of leaving the game stuck in the battle
  scene forever.
- cutsceneSystemNext now pops each finished item off the front of the
  running cutscene (cutsceneRemoveFront) instead of leaving it in place
  forever, so a long-running cutscene (many battle rounds splicing
  items in repeatedly) no longer grows scene->items unboundedly.
- cutsceneGoTo reworked to match: since consumed items are evicted, a
  marker behind the current position can't be found in the live queue
  anymore, so it now re-parses the cutscene fresh from its source file
  (cutsceneLoadParse, into a persistent scratch buffer, not the stack)
  and replaces the live queue with marker-onward from that fresh parse.
  Only supported when the running cutscene is the one
  cutsceneSystemLoad/cutsceneCutsceneResolve populated.
- Fixed a real bug this surfaced: cutsceneSystemPrepare unconditionally
  cleared loadedFile, which ran *after* cutsceneCutsceneResolve had
  just stamped it but *before* the cutscene started - wiping it out
  immediately and breaking cutsceneGoTo for any cutscene entered via a
  CUTSCENE item or cutsceneCutsceneResolve (e.g. the main menu's
  NEW_GAME/OPTIONS/QUIT navigation). Now only cleared when switching
  away from loadedScene entirely.
- Added cutsceneLoadParse, the shared "lock asset, require loaded,
  parse items, unlock" sequence, replacing four near-identical copies
  across cutsceneSystemLoad/cutsceneCutsceneResolve/
  cutsceneInsertResolveAndSplice/battleStateExecutingInit. Each call
  site handles its own fatal-error display; cutsceneLoadParse itself
  just propagates.
- Added test_cutscene.c (cutsceneRemoveFront) and a regression test for
  the loadedFile bug. Writing the zero-count RemoveFront test caught a
  real self-move bug (count == 0 passed dest == src into memoryMove).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-21 15:04:41 -05:00
YourWishesandClaude Sonnet 5 7aeffac9fc Add battle fighter damage/heal cutscene items, wire up battle results
BATTLE_FIGHTER_DAMAGE/BATTLE_FIGHTER_HEAL let a move's own cutscene
apply damage/healing to "target" or "user", resolved from
BATTLE_FIGHTER_POS_TARGET/USER sentinels against the current action by
battleStateExecutingInit. fire/slash now call BATTLE_FIGHTER_DAMAGE on
their target.

BATTLE_POST_MOVE checks fighter health after each move and sets
BATTLE.result (now a real field, previously commented out) to
win/loss, transitioning into BATTLE_STATE_WON/LOST; fleeing sets
BATTLE_RESULT_FLED. All three end states now show a textbox before
moving on to BATTLE_STATE_ENDING.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 21:14:00 -05:00
YourWishesandClaude Sonnet 5 37e7e95ace Resolve moves into per-move cutscenes during battle execution
Each queued action now resolves to its move's cutscene
(cutscenes/battle/moves/<name>.jsonc), spliced into the executing state
with a BATTLE_POST_MOVE checkpoint after each one. Fighter turn order is
tracked via fighterOrder/fighterOrderCount, and resolved actions/pending
counts are cleared so fighters aren't skipped in later rounds. Also fixes
a BATTLE_WAIT_SELECTION deadlock and adds an optional "blocking" flag to
MODAL cutscene items.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 17:49:06 -05:00
YourWishesandClaude Sonnet 5 e3f56c4d12 Drive battle rounds through cutscene items, fix render/init bugs
Adds BATTLE_START, BATTLE_SET_STATE and BATTLE_WAIT_SELECTION cutscene
items so a battle's state transitions (opening -> pre-round -> selection
-> executing) are chained through the running cutscene, with
cutsceneSystemInsertItemsNext/InsertItems splitting "splice" from
"splice and advance immediately". Adds battlestateselection's
get-current-fighter/player-attack/player-flee API backing the battle UI,
and battleMoveGetIdByName for JSON-authored ability lookups.

Fixes: off-by-one in battleMoveInit that let the first-defined move
alias BATTLE_MOVE_ID_NULL; a segfault in scenebattle.c from rendering
unfilled (NULL) fighter slots; stale battleGetCurrentFighter/
battlePlayerAttack/battlePlayerFlee references in the battle UI.

Adds assertions across the battle/scene/UI code for fighter-index
bounds and non-null invariants. Updates battletest.jsonc for the new
BATTLE_START item schema.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 13:32:52 -05:00
YourWishesandClaude Sonnet 5 3675ec634f Rework battle system: pointer-based fighters, per-state files, move split
Splits battlemoveaction_t into its own header, adds a per-state
struct/init/update/dispose file under rpg/battle/state driven by a
dispatch table, switches BATTLE.fighters to battlefighter_t* backed by a
pool, folds current/max health and mp into battlefighterstats_t, and adds
battleFighterDamage/Heal/AbilityAdd. Removes the now-unused battle
cutscene items (START_BATTLE, BATTLE_WAIT_STATE, BATTLE_FORCE_MOVE,
REGULAR_BATTLE) and their JSON lookup helpers, and adds JSON validation
to battleMoveInit. battle.h/battle.c are mid-rewrite around the new state
machine, so some UI consumers (uibattlehud.c, uibattlemenu.c) and
test_battle.c are currently out of sync with the new API.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 23:17:27 -05:00
YourWishes d8273a225c Battle changes, first pass. 2026-09-19 22:30:01 -05:00
YourWishesandClaude Sonnet 5 c1c8174b27 Use CUTSCENE/INSERT item names as full asset paths directly
cutsceneCutsceneResolve/cutsceneInsertResolveAndSplice no longer inject
a "cutscenes/%s.jsonc" prefix/suffix around an item's "name" field -
matching cutsceneSystemLoad, which already takes a full path. Fixes a
latent double-prefix bug in initial.jsonc's CUTSCENE reference (also had
a "jsonsc" typo) that this change would otherwise have exposed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 21:38:09 -05:00
YourWishes 5697f5c064 Fix main menu crash 2026-09-19 21:33:21 -05:00
YourWishesandClaude Sonnet 5 3ce16cf7f4 Fix uilabel dirty bug and add word-wrap support
uiLabelInit now marks a fresh label dirty itself, instead of every call
site having to remember to do it. Adds uiLabelSetWrap (NULL buffer
disables wrapping, otherwise requires a positive width) built on the
existing textWrap(), and wires the fatal error overlay's message label
up to wrap at 80% of screen width.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 21:32:24 -05:00
YourWishes 2abd5b0c3c Beginning a bit of cleanup 2026-09-19 21:17:11 -05:00
YourWishesandClaude Sonnet 5 4b29bf7a30 Wire story flags into the save slot and add cutscene controls
Implements storyInit/storyGetFlagByName/storyGetFlagValue/etc, moves flag
storage into saveslot_t (persisted keyed by name so save files survive
flag reordering), and adds STORY_SET_FLAG/STORY_WAIT_FLAG cutscene items.
Also removes the now-superseded storyflag.c/h stub.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 20:39:53 -05:00
YourWishes 6f1bbe4f2f First pass story flags 2026-09-19 10:45:06 -05:00
YourWishesandClaude Sonnet 5 f64364f37d Trim cutscenesystem.h doc comments to the essentials
Comments-only change - drops implementation rationale/cross-referencing
detail in favor of shorter one-liners, matching the simplified style
already applied to part of the file.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 11:21:12 -05:00
YourWishesandClaude Sonnet 5 d16e0bbfbf Stop stealing/reaping cutscene JSON docs - parse in place instead
cutsceneSystemLoad, cutsceneCutsceneResolve and
cutsceneInsertResolveAndSplice all used to steal ownership of the
asset entry's parsed yyjson_doc (nulling entry->data.json) and force
an immediate assetReapUnused() to avoid handing back a corrupted
cache entry on a later load of the same file - which doubled as a
latent crash if that reap was ever skipped (as it briefly was),
since a stale cache hit would then have a NULL doc.

Turns out none of that was needed: every cutsceneitem_t field that
could reference the doc's memory already copies its string data by
value (cutsceneJsonCopyString into fixed buffers), so the parsed
items don't depend on the doc surviving past cutsceneParseDoc. All
three call sites now parse straight out of entry->data.json while
still locked, then unlock normally - the asset entry stays a normal,
correctly-cached resource, and CUTSCENE_LOADED_DOC/cutsceneLoadedSetDoc
are gone entirely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 09:47:05 -05:00
YourWishesandClaude Sonnet 5 70ae41bd29 Track the loaded cutscene filename and re-load it on restart
Adds CUTSCENE_SYSTEM.loadedFile, set by cutsceneSystemLoad and cleared
by cutsceneSystemPrepare. cutsceneRestart now re-reads and re-parses
that file via cutsceneSystemLoad instead of just rerunning whatever's
still resident in loadedScene/loadedItems, so restarting a file-loaded
cutscene always reflects its current on-disk contents - restart of a
C-authored cutscene is unchanged.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 09:28:15 -05:00
YourWishesandClaude Sonnet 5 abf6863044 Assert NULL pointer/marker args in cutscenesystem.c entry points
Matches the existing assertNotNull convention in cutscene.c
(cutsceneAppend/cutsceneParseDoc etc.) - these are internal-API
preconditions on trusted callers, not untrusted input.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-18 09:09:35 -05:00
YourWishesandClaude Sonnet 5 4279bd9675 Remove unused CUTSCENE_SYSTEM.userData scratch buffer
Nothing ever wrote to it - it only ever passed its own address through
to onComplete/CUTSCENE_CALLBACK/modal-option callbacks, so every call
site now passes NULL directly and the 8KB buffer is gone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-17 22:32:09 -05:00
YourWishesandClaude Sonnet 5 5910110b95 Rework CUTSCENE_ITEM_TYPE_INSERT to splice into the running scene's item array
Replaces the separate insert buffer/scene and insertStack return-frame
mechanism with cutsceneAppend/cutsceneAppendNext, so an inserted
cutscene's items become part of the running scene's own array instead
of a jump-and-return. Moves the loaded-items buffer/scene from file-scope
globals into cutscenesystem_t, and extracts shared start-cutscene setup
into cutsceneSystemPrepare.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-17 22:26:36 -05:00
YourWishesandClaude Sonnet 5 44594f7011 Add cutsceneAppend/cutsceneAppendNext for mutating a running cutscene
cutsceneAppend adds items to the end of a cutscene_t's item array;
cutsceneAppendNext inserts them at a given index instead (e.g.
CUTSCENE_SYSTEM.currentItem + 1, to run immediately after the
currently-executing item), shifting everything from that index
onward to make room. Both are capacity-bounded against the caller-
supplied itemsMax, matching cutsceneParseDoc's convention of never
assuming ownership of the backing buffer's size.

cutscene_t.items drops its const qualifier to allow this in-place
mutation; cutsceneitem_t backing arrays in the test fixtures that get
assigned into a mutable cutscene_t.items are no longer declared const
either, since a const source would otherwise silently violate the
field's new type.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-15 13:16:02 -05:00
YourWishesandClaude Sonnet 5 dbb5301ade Extract battleactiontype_t/battleaction_t into battleaction.h
Matches the existing battleability.h/battlefighter.h split - keeps
battle.h focused on the overall battle_t state machine rather than
also owning the action data shape.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 12:28:40 -05:00
YourWishesandClaude Sonnet 5 74a4290be4 Add optional cutscene field to move schema
Lets a move reference a cutscene to play when used, resolved by name
the same way a CUTSCENE item's "name" field is. Still just a schema
placeholder - no loader consumes moves.jsonc yet.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 12:10:06 -05:00
YourWishesandClaude Sonnet 5 3ce892f846 Move items.jsonc under items/, add starter battle/moves.jsonc
Groups item data under assets/items/ to match the cutscenes/ folder
convention, updating itemInit()'s asset path accordingly. Also adds
assets/battle/moves.jsonc as a starting point for move data - there's
no JSON loader consuming it yet (battleability_t is still just a bare
enum), so its field shape is a placeholder rather than a contract.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 12:00:46 -05:00
YourWishesandClaude Sonnet 5 68cebce04a Remove per-item cutscene authoring macros
Every CUTSCENE_XXX(...) shorthand (CUTSCENE_WAIT, CUTSCENE_TEXT,
CUTSCENE_ENTITY_WALK_TO, etc.) existed to build a cutsceneitem_t literal
in C; real cutscenes are now authored exclusively as .jsonc files, and
the only other users were test fixtures. Deletes all ~55 of these
macros (and the shared CUTSCENE_ITEM(...) helper they built on) along
with their doc comments, leaving each item's struct/enum/Start/Update/
Load declarations untouched.

test_battle.c, test_cutscenecontrol.c, test_cutscenemaparea.c,
test_cutscenesystem.c and test_entityinteract.c now build their
cutsceneitem_t fixtures as plain struct literals instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 11:20:58 -05:00
YourWishesandClaude Sonnet 5 1646e964b5 Remove the C-authoring shorthand for declaring cutscenes
CUTSCENE(...)/CUTSCENE_REFERENCE/CUTSCENE_CUTSCENE/CUTSCENE_INSERT
existed to build a cutscene_t and its item array directly in C; real
cutscenes are now authored exclusively as .jsonc files parsed via
cutsceneParseDoc, so these are dead weight in production. Deletes the
demo scene that still used them (testcutscene.h, wired to NPC #3) along
with its now-dead include in npc.c. test_cutscenesystem.c - the only
other user, building cutscenes in-memory for tests - now writes the
equivalent struct literals directly, matching the convention already
used by test_battle.c/test_entityinteract.c.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-14 09:52:42 -05:00
YourWishesandClaude Sonnet 5 2ee97ad4ce Sunset the dedicated cutscene asset loader and the item pool
Cutscenes now load through the generic ASSET_LOADER_TYPE_JSON loader
and parse straight into fixed static buffers via new cutsceneParseDoc/
cutsceneParseItem/cutsceneCountItems helpers (cutscene.c), removing the
bespoke ASSET_LOADER_TYPE_CUTSCENE pipeline entirely. Only one cutscene
(plus one INSERT target) is ever resident, so each resolve locks,
steals the parsed doc, unlocks, and force-reaps immediately - fixing a
stale-cache-reuse bug where revisiting a cutscene name after a
different one could return data from the shared buffer's previous
occupant.

ENTITY_WALK_TO and MAP_AREA_WAIT now store their waypoints/area ids in
fixed-size arrays inline on their own item struct instead of a shared
bump-allocated pool, so pool/poolOffset are gone from every item type's
Load callback. cutscene_t.dataSize (and the CUTSCENE(...) macro's SIZE
parameter) is removed along with it - it sized entries out of that same
now-deleted pool and had no other reader.

NPC #4's battletest cutscene and the main menu's cutscene now resolve
their target by name on interact instead of eagerly at init, so a
resolved-once pointer is never held past the point its backing buffer
can be reused for something else.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 15:18:57 -05:00
YourWishesandClaude Sonnet 5 109ddc9e1e Switch item definitions to a JSON-driven runtime registry
Replaces the build-time item.json/tools.item.py code generator with a
single item_t ITEM registry populated at runtime by parsing
assets/items.jsonc, so items/types can grow without a rebuild. Adds
itemGetDefById/itemGetTypeById/itemGetIdByName/itemTypeGetOrCreate,
updates inventory/backpack to size off the new *_DEF_COUNT_MAX caps
and look up types dynamically, and reorders engine init so rpgInit
(which loads items) runs before uiInit (which needs the item type
count to size the backpack's tabs).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 11:36:50 -05:00
YourWishesandClaude Sonnet 5 804f8aa1fa Re-add CUTSCENE_INSERT item with its own dedicated load buffer
Splicing a JSON-authored INSERT target by name previously resolved
through the same shared CUTSCENE_LOADED_ITEMS/POOL buffer as whatever
outer cutscene was currently running, so loading it would clobber the
outer cutscene's own items mid-playback - that's why it was reverted.
Gives INSERT its own smaller CUTSCENE_INSERT_ITEMS/POOL buffer and lets
the cutscene asset loader be told which buffer set to parse into, so an
INSERT's dynamic load no longer stomps the running cutscene.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 11:12:43 -05:00
YourWishes 49b97ba508 Switch cutscene system to a set of pre deffed buffers 2026-09-13 09:58:57 -05:00
YourWishes 6c11d8165d Revert "Add CUTSCENE_INSERT item to splice another cutscene's items in place"
This reverts commit 6af0e9cf28.
2026-09-13 09:29:23 -05:00
YourWishesandClaude Sonnet 5 93554b39ba Add DUSK_TRACK_MEMORY allocation tracker and memoryCheckUnfreed report
memoryAllocate() becomes a macro capturing __FILE__/__LINE__ into a
fixed-size tracking table when DUSK_TRACK_MEMORY is enabled (Linux only
for now), so a leak surviving to shutdown can be traced back to its call
site. Replaces engineDispose's hard assertTrue with memoryCheckUnfreed(),
which reports unfreed allocations (with call sites when tracking is on,
just a count otherwise) instead of aborting.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 09:19:24 -05:00
YourWishesandClaude Sonnet 5 6af0e9cf28 Add CUTSCENE_INSERT item to splice another cutscene's items in place
Unlike CUTSCENE (a one-way jump that replaces the running cutscene),
INSERT splices a referenced cutscene's items into the running one and
resumes right after itself once they run out. Backed by a small return
stack on CUTSCENE_SYSTEM so inserts can nest.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-13 08:56:26 -05:00
YourWishesandClaude Sonnet 5 083deb8357 Move cutscene item type enum and authoring macros to their own headers
cutsceneitemtype_t moves from cutsceneitem.h into cutsceneitembase.h,
alongside a new CUTSCENE_ITEM(TYPE, UNION_NAME, ...) shorthand that
fills in a cutsceneitem_t literal's .type and union member together.

Every CUTSCENE_* authoring macro (CUTSCENE_WAIT, CUTSCENE_TEXT,
CUTSCENE_AUDIO_PLAY, etc.) moves out of the single, ever-growing
cutscene.h into the header of the item type it actually authors,
built on top of CUTSCENE_ITEM. cutscene.h now only holds cutscene_t
itself plus the CUTSCENE/CUTSCENE_REFERENCE/CUTSCENE_CUTSCENE macros,
which aren't tied to any one item type. Every relocated macro also
gained a proper JSDoc-style comment block with @param tags.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 22:30:23 -05:00
YourWishesandClaude Sonnet 5 ffb375faeb Add battle abilities and wire them into START_BATTLE cutscene parsing
Adds a battleability_t enum (starting with FIRE) and a fixed
abilities[8] array on battlefighter_t. Extends the START_BATTLE
cutscene item to parse an optional per-enemy "abilities" JSON array
(via a new cutsceneJsonLookupBattleAbility name lookup) and copies
abilities onto every spawned fighter, from the party roster for
allies and from the parsed config for enemies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 22:30:12 -05:00
YourWishesandClaude Sonnet 5 b8cbd8ff6a Make party and save-slot init/new-game operate on SAVE.slot directly
party.c's functions no longer take a party_t* - they always operate on
SAVE.slot.party, since gameplay only ever has one active party. That
forced the same change on saveSlotInit/saveSlotNewGame (they called
partyInit/partyAddMember internally), so those are now parameterless
too, always resetting/seeding SAVE.slot. Callers building a save at a
specific index (new-game creation, slot deletion) now set
SAVE.slotCurrent first instead of building an independent local struct -
safe since the select-save screen is only ever reached before any
gameplay starts.

saveSlotWriteJSON/saveSlotReadJSON deliberately keep their explicit
saveslot_t* parameter - they're genuine serialization primitives used
by the save-device layer and tests against arbitrary structs, unrelated
to the single-current-party assumption. saveSlotReadJSON's internal
reset is now a plain memset instead of delegating to the now-global-only
saveSlotInit().

Updates test_save/test_savedevice/test_savedevicelinux/test_saveslot for
the new signatures. Note: test_savedevice/test_savedevicelinux/test_save
have pre-existing failures in this sandbox unrelated to this change -
savetestfixture.c swaps $HOME, but saveDeviceLinuxGetDirectory actually
derives the save path from ASSET.baseDirectory (the executable's own
location), so the fixture's sandboxing never actually applies. test_
saveslot.c (which doesn't depend on that fixture) passes 15/15.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 21:30:08 -05:00
YourWishesandClaude Sonnet 5 c2bd761383 Remove testbattle - superseded by the JSON-driven battle cutscene
testBattleStart() was never actually called anywhere (its only trace
was a stray unused include in uimainmenu.c) - battletest.jsonc's
START_BATTLE/REGULAR_BATTLE flow replaces it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 21:14:50 -05:00
YourWishesandClaude Sonnet 5 878e3438c8 Complete REGULAR_BATTLE immediately, fix stale saveslot tests
REGULAR_BATTLE previously never completed on its own, hanging any
cutscene that reached it - START_BATTLE already waits out the whole
encounter and returns to the overworld before this item ever runs, so
it's now a completes-immediately hook point for future regular-battle-
specific follow-up.

Also updates test_saveslot.c for the mapName/partyMembers schema
additions: the round-trip test now writes a valid map name for its
in-use slot, "missingFieldsUseDefaults" is replaced with a test for the
blank-slot short-circuit it was actually hitting, and a new regression
test locks in that an in-use slot with no map name is treated as corrupt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 19:59:16 -05:00
YourWishesandClaude Sonnet 5 3da65370e0 Add requireStrMin save-JSON shorthand
Replaces the hand-rolled mapName missing/null/empty check in
saveSlotVer1ReadJSON with a reusable macro.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 19:55:17 -05:00
YourWishesandClaude Sonnet 5 83e646cba9 Load the save's map on select, seed a real new-game slot, fix readString off-by-one
New games now go through saveSlotNewGame (starting party + default map
name) instead of the empty saveSlotInit, and selecting a save loads its
stored map instead of the hardcoded boot-time overworld. Save loading now
requires an in-use slot to have a non-empty map name, treating a blank one
as corruption instead of silently faking a default.

Also fixes a real off-by-one in the readString save-JSON macro: it passed
its maxLength (documented as excluding the null terminator) straight
through as stringCopy's destSize, which needs +1 for the terminator - so a
string exactly at the length limit (e.g. "overworld", 9 chars) always
asserted "src is too long".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-10 23:14:44 -05:00
YourWishesandClaude Sonnet 5 0566166af4 Persist party across saves and flag/warn on corrupt save slots
Party moves off a global singleton onto each save slot, letting
START_BATTLE pick specific active-order members to bring into a fight,
and round-trips through save/load via saveslotver1. A corrupt slot load
now flags the cache and shows a red "Corrupted Save" warning in the
select-save UI instead of silently resetting.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-10 14:19:03 -05:00
YourWishesandClaude Sonnet 5 ed0b8c3911 Exit delete mode on save select after delete or back button
Previously deleting a save left the panel in delete mode, and pressing
back while in delete mode closed the whole save-select screen instead
of just returning to the normal load view.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 21:38:28 -05:00
YourWishesandClaude Sonnet 5 03d753a83f Move cutscene JSON parsing entirely into the async loader phase
File read, yyjson parse, and all item dispatch now happen in
assetCutsceneLoaderAsync - none of it touches the main thread now that
CUTSCENE items defer their nested asset resolution to start time. The
sync phase is now just a state flip, kept only so assetUpdate()'s
onLoaded dispatch (which only fires for entries finishing via the sync
path) still runs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 21:34:47 -05:00
YourWishes cf51ed1988 Fixed cutscene item cutscene trying to recursively load 2026-09-09 21:21:50 -05:00
YourWishesandClaude Sonnet 5 70f46e89d3 Merge branch 'raw-json-maps' into main
Load map chunks from raw JSON at runtime instead of a baked DCF/DMF
step, add a reusable perf timing module, fix a real PSP logDebug/logError
Memory Stick I/O cost bug, and switch chunk tile storage to a dense
minified grid.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 15:34:08 -05:00
444 changed files with 671 additions and 30465 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
+21
View File
@@ -0,0 +1,21 @@
{
"moves": {
"fire": {
// Target flags
"enemies": true,
"allies": true,
"multiple": false,
"self": true,
"default": "enemy",
"target": "SINGLE_ENEMY"
},
"slash": {
"enemies": true,
"allies": true,
"self": false,
"default": "enemy",
"target": "SINGLE_ENEMY"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"items": [
{
"type": "MODAL",
"title": "Fire",
"message": "A burst of flame scorches the target."
},
{
"type": "WAIT",
"seconds": 2
},
{
"type": "MODAL_CLOSE"
},
{
"type": "BATTLE_FIGHTER_DAMAGE",
"fighter": "target",
"amount": 12
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"items": [
{
"type": "MODAL",
"title": "Slash",
"message": "A quick blade strike cuts across the target."
},
{
"type": "WAIT",
"seconds": 2
},
{
"type": "MODAL_CLOSE"
},
{
"type": "BATTLE_FIGHTER_DAMAGE",
"fighter": "target",
"amount": 8
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"items": [
{
"type": "BATTLE_START",
"enemies": [
{
"attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2,
"healthMax": 30, "mpMax": 0,
"abilities": ["fire"]
},
{
"attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2,
"healthMax": 20, "mpMax": 0
}
]
}
]
}
+1 -1
View File
@@ -62,7 +62,7 @@
},
{
"type": "CUTSCENE",
"name": "main_menu"
"name": "cutscenes/main_menu.jsonc"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
// Placeholder for the new-game intro flow - just shows a modal for now.
"items": [
{
"type": "MODAL",
"title": "New Game",
"message": "This is a placeholder for the new game intro.",
"blocking": true
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"items": {
"potion": { "type": "MEDICINE" },
"potato": { "type": "FOOD" },
"apple": { "type": "FOOD" }
}
}
+20 -1
View File
@@ -73,6 +73,12 @@ msgstr "Select Save"
msgid "ui.select_save.empty"
msgstr "Empty Slot"
msgid "ui.select_save.corrupt"
msgstr "Corrupted Save"
msgid "ui.select_save.corrupt_warning"
msgstr "This save file is corrupted and could not be loaded. Please contact support if this issue persists."
msgid "ui.select_save.slot_format"
msgstr "%s Lv.%d %s"
@@ -260,6 +266,10 @@ msgstr "loading"
msgid "ui.autosave.saving"
msgstr "SAVING"
#
# Items
#
msgid "item.potion.name"
msgstr "Potion"
@@ -267,4 +277,13 @@ msgid "item.potato.name"
msgstr "Potato"
msgid "item.apple.name"
msgstr "Apple"
msgstr "Apple"
#
# Moves
#
msgid "move.fire.name"
msgstr "Fire"
msgid "move.slash.name"
msgstr "Slash"
@@ -1 +1 @@
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"item","itemId":1,"quantity":1,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"global","globalId":4,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
+22
View File
@@ -0,0 +1,22 @@
{
"members": {
"hero": {
"attack": 10,
"defense": 5,
"magic": 0,
"speed": 10,
"luck": 0,
"healthMax": 30,
"mpMax": 10
},
"companion": {
"attack": 8,
"defense": 6,
"magic": 4,
"speed": 8,
"luck": 1,
"healthMax": 25,
"mpMax": 15
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"flags": {
"test": {
"initial": true
},
"test2": {
"initial": 1
}
}
}
+1
View File
@@ -27,6 +27,7 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
)
set(DUSK_BACKTRACE ON CACHE BOOL "Enable backtrace support for assert failures.")
set(DUSK_TRACK_MEMORY ON CACHE BOOL "Track memoryAllocate() call sites to help find memory leaks.")
# Define platform-specific macros.
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
-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.
+6 -2
View File
@@ -50,6 +50,12 @@ if(DUSK_BACKTRACE)
)
endif()
if(DUSK_TRACK_MEMORY)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_TRACK_MEMORY
)
endif()
# Includes
target_include_directories(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
@@ -75,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
);
+8 -5
View File
@@ -12,13 +12,16 @@
// "DSK2" - distinct from a plain zip's "PK\x03\x04" so a stray plain zip
// never gets misread as a valid dusk.dsk.
#define ASSET_DSK_MAGIC_SIZE 4
#define ASSET_DSK_MAGIC "DSK2"
#define ASSET_DSK_MAGIC "DSK"
#define ASSET_DSK_VERSION 2
// magic(4) + version(4) + size(4) + checksum(4), all little-endian
// regardless of host - see assetDskParseHeader. The zip archive itself
// starts immediately after, at ASSET_DSK_HEADER_SIZE.
#define ASSET_DSK_HEADER_SIZE 16
// Magic + version + size + checksum
#define ASSET_DSK_HEADER_SIZE ( \
ASSET_DSK_MAGIC_SIZE + \
sizeof(uint32_t) + \
sizeof(uint32_t) + \
sizeof(uint32_t) \
)
/**
* Parsed dusk.dsk (DSK2 format) header. dusk.dsk is a single uncompressed
-2
View File
@@ -15,8 +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(cutscene)
add_subdirectory(wav)
add_subdirectory(mp3)
-12
View File
@@ -46,18 +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_CUTSCENE] = {
.loadSync = assetCutsceneLoaderSync,
.loadAsync = assetCutsceneLoaderAsync,
.dispose = assetCutsceneDispose
},
[ASSET_LOADER_TYPE_WAV] = {
.loadSync = assetWavLoaderSync,
.loadAsync = assetWavLoaderAsync,
-10
View File
@@ -12,8 +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/cutscene/assetcutsceneloader.h"
#include "asset/loader/wav/assetwavloader.h"
#include "asset/loader/mp3/assetmp3loader.h"
@@ -26,8 +24,6 @@ typedef enum {
ASSET_LOADER_TYPE_TILESET,
ASSET_LOADER_TYPE_LOCALE,
ASSET_LOADER_TYPE_JSON,
ASSET_LOADER_TYPE_CHUNK,
ASSET_LOADER_TYPE_CUTSCENE,
ASSET_LOADER_TYPE_WAV,
ASSET_LOADER_TYPE_MP3,
@@ -41,8 +37,6 @@ typedef union {
assettilesetloaderloading_t tileset;
assetlocaleloaderloading_t locale;
assetjsonloaderloading_t json;
assetchunkloaderloading_t chunk;
assetcutsceneloaderloading_t cutscene;
assetwavloaderloading_t wav;
assetmp3loaderloading_t mp3;
} assetloaderloading_t;
@@ -54,8 +48,6 @@ typedef union {
assettilesetoutput_t tileset;
assetlocaleoutput_t locale;
assetjsonoutput_t json;
assetchunkoutput_t chunk;
assetcutsceneoutput_t cutscene;
assetwavoutput_t wav;
assetmp3output_t mp3;
} assetloaderoutput_t;
@@ -65,8 +57,6 @@ typedef union {
assettilesetloaderinput_t tileset;
assetlocaleloaderinput_t locale;
assetjsonloaderinput_t json;
assetchunkloaderinput_t chunk;
assetcutsceneloaderinput_t cutscene;
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,9 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
assetcutsceneloader.c
)
@@ -1,225 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "assetcutsceneloader.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h"
#include "asset/asset.h"
#include "rpg/cutscene/item/json/cutscenejsonpauseflags.h"
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
) {
if(!yyjson_is_obj(itemObj)) {
assetLoaderErrorThrow(
loading,
"Cutscene item %u is not an object",
(uint32_t)index
);
}
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemObj, "type"));
if(!cutsceneItemLookupType(typeName, &item->type)) {
assetLoaderErrorThrow(
loading,
"Cutscene item %u: missing/unsupported item type %s",
(uint32_t)index,
typeName
);
}
cutsceneitemloadcallback_t *load = CUTSCENE_ITEM_CALLBACKS[item->type].load;
if(load == NULL) {
assetLoaderErrorThrow(
loading, "Cutscene item %u: unhandled item type %u", (uint32_t)index,
(uint32_t)item->type
);
}
errorret_t result = load(itemObj, item, pool, poolOffset);
if(errorIsNotOk(result)) {
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(result);
}
errorOk();
}
size_t assetCutsceneComputePoolSize(yyjson_val *itemsArr) {
size_t size = 0;
yyjson_arr_iter iter = yyjson_arr_iter_with(itemsArr);
yyjson_val *itemVal;
while((itemVal = yyjson_arr_iter_next(&iter)) != NULL) {
const char_t *typeName = yyjson_get_str(yyjson_obj_get(itemVal, "type"));
cutsceneitemtype_t type;
if(!cutsceneItemLookupType(typeName, &type)) continue;
cutsceneitempoolsizecallback_t *poolSize =
CUTSCENE_ITEM_CALLBACKS[type].poolSize;
if(poolSize == NULL) continue;
size = cutsceneJsonPoolAlign(size) + poolSize(itemVal);
}
return size;
}
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out) {
if(out->items != NULL) {
memoryFree(out->items);
out->items = NULL;
}
if(out->pool != NULL) {
memoryFree(out->pool);
out->pool = NULL;
}
if(out->doc != NULL) {
yyjson_doc_free(out->doc);
out->doc = NULL;
}
}
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread.");
if(
loading->loading.cutscene.state != ASSET_CUTSCENE_LOADING_STATE_READ_FILE
) {
errorOk();
}
assertNull(loading->loading.cutscene.buffer, "Buffer already defined?");
assetfile_t *file = &loading->loading.cutscene.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
if(file->size > ASSET_CUTSCENE_FILE_SIZE_MAX) {
assetLoaderErrorThrow(
loading, "Cutscene JSON exceeds maximum allowed size"
);
}
size_t fileSize = (size_t)file->size;
uint8_t *buffer = memoryAllocate(fileSize);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, buffer, fileSize));
assertTrue(
file->lastRead == file->size,
"Failed to read entire cutscene file."
);
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
loading->loading.cutscene.buffer = buffer;
loading->loading.cutscene.size = fileSize;
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
errorret_t assetCutsceneLoaderSync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL");
assertTrue(loading->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
if(loading->loading.cutscene.state == ASSET_CUTSCENE_LOADING_STATE_INITIAL) {
loading->loading.cutscene.state = ASSET_CUTSCENE_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk();
}
assetcutsceneoutput_t *out = &loading->entry->data.cutscene;
uint8_t *buffer = loading->loading.cutscene.buffer;
assertNotNull(buffer, "Cutscene data should have been loaded by now.");
size_t bufferSize = loading->loading.cutscene.size;
yyjson_doc *doc = yyjson_read(
(char *)buffer, bufferSize,
YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS
);
memoryFree(buffer);
loading->loading.cutscene.buffer = NULL;
if(doc == NULL) {
assetLoaderErrorThrow(loading, "Failed to parse cutscene JSON");
}
out->doc = doc;
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *itemsArr = yyjson_obj_get(root, "items");
if(!yyjson_is_arr(itemsArr)) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(loading, "Cutscene JSON is missing an 'items' array");
}
size_t itemCount = yyjson_arr_size(itemsArr);
if(itemCount > UINT8_MAX) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(loading, "Cutscene has more than 255 items");
}
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
yyjson_val *pauseArr = yyjson_obj_get(root, "pause");
if(yyjson_is_arr(pauseArr)) {
if(!cutsceneJsonParsePauseFlags(pauseArr, &pause)) {
assetCutsceneFreeParsed(out);
assetLoaderErrorThrow(
loading, "Cutscene JSON has an invalid 'pause' flag"
);
}
}
out->items = memoryAllocate(itemCount * sizeof(cutsceneitem_t));
memoryZero(out->items, itemCount * sizeof(cutsceneitem_t));
size_t poolSize = assetCutsceneComputePoolSize(itemsArr);
out->pool = poolSize > 0 ? memoryAllocate(poolSize) : NULL;
size_t poolOffset = 0;
yyjson_arr_iter itemIter = yyjson_arr_iter_with(itemsArr);
yyjson_val *itemVal;
uint8_t index = 0;
while((itemVal = yyjson_arr_iter_next(&itemIter)) != NULL) {
errorret_t itemResult = assetCutsceneParseItem(
loading, itemVal, &out->items[index], out->pool, &poolOffset, index
);
if(errorIsNotOk(itemResult)) {
assetCutsceneFreeParsed(out);
errorChain(itemResult);
}
index++;
}
out->cutscene.items = out->items;
out->cutscene.itemCount = (uint8_t)itemCount;
out->cutscene.pause = pause;
out->cutscene.dataSize = 0;
loading->entry->state = ASSET_ENTRY_STATE_LOADED;
errorOk();
}
errorret_t assetCutsceneDispose(assetentry_t *entry) {
assertNotNull(entry, "Entry cannot be NULL");
assertTrue(entry->type == ASSET_LOADER_TYPE_CUTSCENE, "Invalid type.");
assertIsMainThread("Must be called from the main thread.");
assetCutsceneFreeParsed(&entry->data.cutscene);
errorOk();
}
@@ -1,106 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "asset/assetfile.h"
#include "rpg/cutscene/cutscene.h"
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/item/json/cutscenejsonpool.h"
#include "yyjson.h"
#define ASSET_CUTSCENE_FILE_SIZE_MAX (1024 * 64)
typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t;
typedef struct {
void *nothing;
} assetcutsceneloaderinput_t;
typedef enum {
ASSET_CUTSCENE_LOADING_STATE_INITIAL,
ASSET_CUTSCENE_LOADING_STATE_READ_FILE,
ASSET_CUTSCENE_LOADING_STATE_PARSE
} assetcutsceneloadingstate_t;
typedef struct {
assetfile_t file;
assetcutsceneloadingstate_t state;
uint8_t *buffer;
size_t size;
} assetcutsceneloaderloading_t;
typedef struct {
cutscene_t cutscene;
cutsceneitem_t *items;
uint8_t *pool;
yyjson_doc *doc;
} assetcutsceneoutput_t;
/**
* Parses one cutscene item object into item, writing any pool-backed
* array data (entityWalkTo positions, mapAreaWait areaIds) into pool at
* *poolOffset and advancing it. Every field/enum name is validated as
* real (untrusted, authored) file content - failures are real errors, not
* asserts. Dispatches to the matching type's Load callback (see
* CUTSCENE_ITEM_CALLBACKS in rpg/cutscene/item/cutsceneitem.h) - the
* per-type parsing logic itself lives there now, colocated with that
* type's runtime behavior.
*
* @param loading Loading information for the asset being loaded.
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Base pointer of the entry's pool allocation (may be NULL if
* assetCutsceneComputePoolSize returned 0).
* @param poolOffset In/out cursor into pool.
* @param index Index of this item within the cutscene, for error messages.
* @return Error code indicating success or failure of the parse.
*/
errorret_t assetCutsceneParseItem(
assetloading_t *loading,
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset,
const uint8_t index
);
/**
* Frees whatever of out->items/out->pool/out->doc is currently non-NULL
* and clears them - shared by both the normal disposer and every parse
* failure path's cleanup.
*
* @param out The output to free.
*/
void assetCutsceneFreeParsed(assetcutsceneoutput_t *out);
/**
* Asynchronous loader for cutscene assets. Reads the raw JSONC file bytes
* into the loading buffer so the sync phase can parse without blocking the
* main thread on I/O.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderAsync(assetloading_t *loading);
/**
* Synchronous loader for cutscene assets. Parses the JSONC previously read
* by the async phase into a heap-allocated cutsceneitem_t array + pool.
*
* @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation.
*/
errorret_t assetCutsceneLoaderSync(assetloading_t *loading);
/**
* Disposer for cutscene assets.
*
* @param entry Asset entry containing the cutscene data to dispose.
* @return Error code indicating success or failure of the dispose operation.
*/
errorret_t assetCutsceneDispose(assetentry_t *entry);
+4 -5
View File
@@ -12,11 +12,10 @@
// Forward-declared rather than including asset/asset.h - this header only
// ever needs a pointer to it, and pulling in the full asset module here
// creates a real include cycle for anything that reaches this header via
// asset/loader/cutscene/assetcutsceneloader.h (asset.h -> ... ->
// assetloader.h -> assetcutsceneloader.h -> cutscene.h -> cutsceneitem.h
// -> the audio cutscene items -> this header -> asset/asset.h again,
// mid-parse). audiomixerchannel.c itself still includes the full header.
// risks an include cycle for anything that reaches this header via a
// cutscene item (cutscene.h -> cutsceneitem.h -> the audio cutscene items
// -> this header -> asset/asset.h, mid-parse). audiomixerchannel.c itself
// still includes the full header.
typedef struct assetentry_s assetentry_t;
typedef struct audiostream_s audiostream_t;
-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"
+2 -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(uiInit());
errorChain(rpgInit());
#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());
@@ -108,5 +99,7 @@ errorret_t engineDispose(void) {
errorChain(assetDispose());
perfDispose();
memoryCheckUnfreed();
errorOk();
}
-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
battlefighter.c
party.c
)
# Subdirs
add_subdirectory(testbattle)
-328
View File
@@ -1,328 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battle.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "rpg/cutscene/cutscenesystem.h"
battle_t BATTLE;
void battleInit(void) {
memoryZero(&BATTLE, sizeof(battle_t));
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
BATTLE.fighters[i].id = i;
}
}
uint8_t battleGetAvailableFighter(void) {
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
if(BATTLE.fighters[i].status == BATTLE_FIGHTER_STATUS_NULL) return i;
}
return 0xFF;
}
battlefighter_t *battleAddFighter(
const battlefighterteam_t team,
const battlefightercontroller_t controller,
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
) {
const uint8_t index = battleGetAvailableFighter();
if(index == 0xFF) return NULL;
battlefighter_t *fighter = &BATTLE.fighters[index];
battleFighterInit(fighter, team, controller, stats, healthMax, mpMax);
return fighter;
}
void battleStart(
const battleencountertype_t encounterType,
const bool_t fleeAvailable
) {
assertTrue(encounterType < BATTLE_ENCOUNTER_COUNT, "Invalid encounter type");
BATTLE.encounterType = encounterType;
BATTLE.fleeAvailable = fleeAvailable;
BATTLE.result = BATTLE_RESULT_NONE;
BATTLE.round = 1;
battleSetState(BATTLE_STATE_OPENING);
}
void battleDispose(void) {
battleInit();
}
battlefighter_t *battleGetCurrentFighter(void) {
if(BATTLE.state != BATTLE_STATE_PLAYER_SELECTION) return NULL;
if(BATTLE.selectionIndex >= BATTLE.executionCount) return NULL;
return &BATTLE.fighters[BATTLE.executionOrder[BATTLE.selectionIndex]];
}
uint8_t battleGetAliveCount(const battlefighterteam_t team) {
uint8_t count = 0;
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
if(BATTLE.fighters[i].team != team) continue;
if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue;
count++;
}
return count;
}
void battleResolveAttack(
battlefighter_t *attacker,
battlefighter_t *defender
) {
assertNotNull(attacker, "Attacker cannot be NULL");
assertNotNull(defender, "Defender cannot be NULL");
const int32_t rawDamage =
(int32_t)attacker->stats.attack - (int32_t)defender->stats.defense;
const uint16_t damage = rawDamage > 0 ? (uint16_t)rawDamage : 1;
defender->health = damage >= defender->health ? 0 : defender->health - damage;
if(defender->health == 0) defender->status = BATTLE_FIGHTER_STATUS_DEAD;
}
battleresult_t battleCheckResult(void) {
if(BATTLE.result != BATTLE_RESULT_NONE) return BATTLE.result;
if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ALLY) == 0) {
battleSetResult(BATTLE_RESULT_LOSS);
} else if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ENEMY) == 0) {
battleSetResult(BATTLE_RESULT_WIN);
}
return BATTLE.result;
}
void battleQueueAction(
const uint8_t fighterIndex,
const battleactiontype_t type,
const uint8_t targetIndex
) {
battleaction_t *action = &BATTLE.actions[fighterIndex];
action->type = type;
action->targetIndex = targetIndex;
if(BATTLE.onActionDecided != NULL) {
BATTLE.onActionDecided(&BATTLE.fighters[fighterIndex], action);
}
}
void battlePlayerAttack(const uint8_t targetIndex) {
battlefighter_t *fighter = battleGetCurrentFighter();
if(fighter == NULL) return;
if(targetIndex >= BATTLE_FIGHTER_COUNT_MAX) return;
if(!battleFighterIsAlive(&BATTLE.fighters[targetIndex])) return;
battleQueueAction(fighter->id, BATTLE_ACTION_ATTACK, targetIndex);
BATTLE.selectionIndex++;
battleAdvanceSelection();
}
void battlePlayerFlee(void) {
battlefighter_t *fighter = battleGetCurrentFighter();
if(fighter == NULL) return;
if(!BATTLE.fleeAvailable) return;
battleSetResult(BATTLE_RESULT_FLED);
}
void battleUpdate(void) {
if(BATTLE.state == BATTLE_STATE_NONE) return;
if(BATTLE.state == BATTLE_STATE_ENDED) return;
if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE) return;
switch(BATTLE.state) {
case BATTLE_STATE_OPENING:
battleSetState(BATTLE_STATE_PRE_ROUND);
break;
case BATTLE_STATE_PRE_ROUND:
battleUpdatePreRound();
break;
case BATTLE_STATE_AI_SELECTION:
battleUpdateAiSelection();
break;
case BATTLE_STATE_MOVES_EXECUTING:
battleUpdateMovesExecuting();
break;
case BATTLE_STATE_POST_ROUND:
battleUpdatePostRound();
break;
default:
// BATTLE_STATE_PLAYER_SELECTION: waits on battlePlayerAttack/Flee.
// BATTLE_STATE_NONE/ENDED: handled above.
break;
}
}
void battleBuildExecutionOrder(const bool_t applyEncounterBias) {
BATTLE.executionCount = 0;
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue;
BATTLE.executionOrder[BATTLE.executionCount++] = i;
}
// Insertion sort by speed descending -- fine for BATTLE_FIGHTER_COUNT_MAX.
for(uint8_t i = 1; i < BATTLE.executionCount; i++) {
const uint8_t key = BATTLE.executionOrder[i];
const uint16_t keySpeed = BATTLE.fighters[key].stats.speed;
int8_t j = (int8_t)i - 1;
while(
j >= 0 &&
BATTLE.fighters[BATTLE.executionOrder[j]].stats.speed < keySpeed
) {
BATTLE.executionOrder[j + 1] = BATTLE.executionOrder[j];
j--;
}
BATTLE.executionOrder[j + 1] = key;
}
if(!applyEncounterBias) return;
if(BATTLE.encounterType == BATTLE_ENCOUNTER_PLAYER_ADVANTAGE) {
battleMoveTeamFirst(BATTLE_FIGHTER_TEAM_ALLY);
} else if(BATTLE.encounterType == BATTLE_ENCOUNTER_BACK_ATTACK) {
battleMoveTeamFirst(BATTLE_FIGHTER_TEAM_ENEMY);
}
}
void battleMoveTeamFirst(const battlefighterteam_t team) {
uint8_t sorted[BATTLE_FIGHTER_COUNT_MAX];
uint8_t count = 0;
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
if(BATTLE.fighters[BATTLE.executionOrder[i]].team != team) continue;
sorted[count++] = BATTLE.executionOrder[i];
}
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
if(BATTLE.fighters[BATTLE.executionOrder[i]].team == team) continue;
sorted[count++] = BATTLE.executionOrder[i];
}
memoryCopy(
BATTLE.executionOrder, sorted, sizeof(uint8_t) * BATTLE.executionCount
);
}
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) {
const battlefighterteam_t enemyTeam =
fighter->team == BATTLE_FIGHTER_TEAM_ALLY ?
BATTLE_FIGHTER_TEAM_ENEMY : BATTLE_FIGHTER_TEAM_ALLY;
battlefighter_t *weakest = NULL;
for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) {
battlefighter_t *candidate = &BATTLE.fighters[i];
if(candidate->team != enemyTeam) continue;
if(!battleFighterIsAlive(candidate)) continue;
if(weakest == NULL || candidate->health < weakest->health) {
weakest = candidate;
}
}
return weakest;
}
void battleSetState(const battlestate_t next) {
const battlestate_t previous = BATTLE.state;
BATTLE.state = next;
if(BATTLE.onStateChanged != NULL) BATTLE.onStateChanged(previous, next);
}
void battleSetResult(const battleresult_t result) {
BATTLE.result = result;
battleSetState(BATTLE_STATE_ENDED);
}
bool_t battleFighterNeedsDecision(
const uint8_t fighterIndex,
const battlefightercontroller_t controller
) {
return battleFighterIsAlive(&BATTLE.fighters[fighterIndex])
&& BATTLE.fighters[fighterIndex].controller == controller
&& BATTLE.actions[fighterIndex].type == BATTLE_ACTION_NONE;
}
void battleAdvanceSelection(void) {
while(BATTLE.selectionIndex < BATTLE.executionCount) {
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.selectionIndex];
if(
battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_PLAYER)
) {
return;
}
BATTLE.selectionIndex++;
}
battleSetState(BATTLE_STATE_AI_SELECTION);
}
void battleUpdatePreRound(void) {
// No need to clear BATTLE.actions here: every living fighter's action is
// unconditionally reset to BATTLE_ACTION_NONE as it's processed in
// battleUpdateMovesExecuting, and round 1 starts pre-zeroed by
// battleInit(). Clearing it here would also wipe out any action a
// cutscene force-queued while parked at BATTLE_STATE_PRE_ROUND.
battleBuildExecutionOrder(BATTLE.round == 1);
BATTLE.selectionIndex = 0;
battleSetState(BATTLE_STATE_PLAYER_SELECTION);
battleAdvanceSelection();
}
void battleUpdateAiSelection(void) {
for(uint8_t i = 0; i < BATTLE.executionCount; i++) {
const uint8_t fighterIndex = BATTLE.executionOrder[i];
if(
!battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_AI)
) continue;
battlefighter_t *target =
battleAIChooseTarget(&BATTLE.fighters[fighterIndex]);
if(target == NULL) continue;
battleQueueAction(fighterIndex, BATTLE_ACTION_ATTACK, target->id);
}
BATTLE.executionIndex = 0;
battleSetState(BATTLE_STATE_MOVES_EXECUTING);
}
void battleUpdateMovesExecuting(void) {
if(BATTLE.executionIndex >= BATTLE.executionCount) {
battleSetState(BATTLE_STATE_POST_ROUND);
return;
}
const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.executionIndex++];
battlefighter_t *fighter = &BATTLE.fighters[fighterIndex];
if(!battleFighterIsAlive(fighter)) return;
battleaction_t *action = &BATTLE.actions[fighterIndex];
if(action->type == BATTLE_ACTION_ATTACK) {
battlefighter_t *target = &BATTLE.fighters[action->targetIndex];
if(battleFighterIsAlive(target)) battleResolveAttack(fighter, target);
}
action->type = BATTLE_ACTION_NONE;
battleCheckResult();
}
void battleUpdatePostRound(void) {
BATTLE.round++;
battleSetState(BATTLE_STATE_PRE_ROUND);
}
-313
View File
@@ -1,313 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "battlefighter.h"
#define BATTLE_FIGHTER_COUNT_MAX 8
typedef enum {
BATTLE_ENCOUNTER_REGULAR,
BATTLE_ENCOUNTER_PLAYER_ADVANTAGE,
BATTLE_ENCOUNTER_BACK_ATTACK,
BATTLE_ENCOUNTER_COUNT
} battleencountertype_t;
typedef enum {
BATTLE_RESULT_NONE,
BATTLE_RESULT_WIN,
BATTLE_RESULT_LOSS,
BATTLE_RESULT_FLED,
BATTLE_RESULT_COUNT
} battleresult_t;
// Where BATTLE currently is within a round. A cutscene can pause progression
// (CUTSCENE_PAUSE_BATTLE) and use CUTSCENE_BATTLE_WAIT_STATE to synchronize
// with any of these, or CUTSCENE_BATTLE_FORCE_ACTION to decide a fighter's
// action ahead of PLAYER_SELECTION/AI_SELECTION reaching them.
typedef enum {
BATTLE_STATE_NONE, // Battle inactive.
BATTLE_STATE_OPENING, // Entered once by battleStart().
BATTLE_STATE_PRE_ROUND, // Rebuilds execution order, clears the action queue.
BATTLE_STATE_PLAYER_SELECTION, // Waits on battlePlayerAttack/Flee.
BATTLE_STATE_AI_SELECTION, // Auto-queues every undecided AI fighter.
BATTLE_STATE_MOVES_EXECUTING, // Resolves one queued action per update.
BATTLE_STATE_POST_ROUND, // Round wrap-up; loops back to PRE_ROUND.
BATTLE_STATE_ENDED, // Terminal for WIN/LOSS/FLED alike -- see BATTLE.result.
BATTLE_STATE_COUNT
} battlestate_t;
typedef enum {
BATTLE_ACTION_NONE, // No action decided yet for this fighter this round.
BATTLE_ACTION_ATTACK,
BATTLE_ACTION_COUNT
} battleactiontype_t;
typedef struct {
battleactiontype_t type;
uint8_t targetIndex; // Meaningful for BATTLE_ACTION_ATTACK.
} battleaction_t;
typedef void (*battlestatechangedcallback_t)(
const battlestate_t previous,
const battlestate_t next
);
typedef void (*battleactiondecidedcallback_t)(
const battlefighter_t *fighter,
const battleaction_t *action
);
typedef struct {
battlestate_t state;
battlefighter_t fighters[BATTLE_FIGHTER_COUNT_MAX];
battleaction_t actions[BATTLE_FIGHTER_COUNT_MAX];
battleencountertype_t encounterType;
bool_t fleeAvailable;
battleresult_t result;
// Fighter indices (into fighters[]), sorted for the current round.
uint8_t executionOrder[BATTLE_FIGHTER_COUNT_MAX];
uint8_t executionCount;
uint8_t executionIndex;
uint16_t round;
// Cursor into executionOrder used by BATTLE_STATE_PLAYER_SELECTION to find
// the next player-controlled fighter that still needs a decision.
uint8_t selectionIndex;
battlestatechangedcallback_t onStateChanged;
battleactiondecidedcallback_t onActionDecided;
} battle_t;
extern battle_t BATTLE;
/**
* Initializes the battle system. Marks it as inactive with no fighters.
*/
void battleInit(void);
/**
* Gets an available (unused) fighter slot index.
*
* @return The index of an available fighter slot, or 0xFF if none are
* available.
*/
uint8_t battleGetAvailableFighter(void);
/**
* Adds a fighter to the battle in the next available slot.
*
* @param team The team the fighter belongs to.
* @param controller Who makes decisions for the fighter.
* @param stats The fighter's base combat stats.
* @param healthMax The fighter's maximum health.
* @param mpMax The fighter's maximum mp.
* @return Pointer to the newly added fighter, or NULL if the battle is
* already full.
*/
battlefighter_t *battleAddFighter(
const battlefighterteam_t team,
const battlefightercontroller_t controller,
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
);
/**
* Starts the battle: enters BATTLE_STATE_OPENING and marks the battle
* active. Call once every fighter has been added via battleAddFighter.
*
* @param encounterType Determines the opening round's execution order.
* @param fleeAvailable Whether the party may attempt to flee this battle.
*/
void battleStart(
const battleencountertype_t encounterType,
const bool_t fleeAvailable
);
/**
* Disposes of the battle, clearing all fighters and marking it inactive.
*/
void battleDispose(void);
/**
* Returns the fighter currently awaiting a player decision.
*
* @return Pointer to the fighter awaiting a decision, or NULL if the battle
* isn't in BATTLE_STATE_PLAYER_SELECTION or every player-controlled
* fighter has already decided.
*/
battlefighter_t *battleGetCurrentFighter(void);
/**
* Returns the number of living fighters on a team.
*
* @param team The team to count.
* @return Count of living fighters on that team.
*/
uint8_t battleGetAliveCount(const battlefighterteam_t team);
/**
* Resolves a physical attack from attacker onto defender: damage is the
* attacker's attack stat minus the defender's defense stat (minimum 1),
* subtracted from the defender's health. The defender is marked dead
* once health reaches 0.
*
* @param attacker The attacking fighter.
* @param defender The defending fighter.
*/
void battleResolveAttack(
battlefighter_t *attacker,
battlefighter_t *defender
);
/**
* Checks whether the battle has been won or lost, transitioning to
* BATTLE_STATE_ENDED and updating BATTLE.result if so. Does nothing if a
* result has already been set (e.g. by a successful flee).
*
* @return The battle's current result.
*/
battleresult_t battleCheckResult(void);
/**
* Queues an action for a fighter to perform once BATTLE_STATE_MOVES_EXECUTING
* reaches them this round, overwriting any action already queued for that
* fighter. Fires BATTLE.onActionDecided.
*
* @param fighterIndex Index into BATTLE.fighters of the deciding fighter.
* @param type The type of action to perform.
* @param targetIndex Index into BATTLE.fighters of the target, meaningful
* for BATTLE_ACTION_ATTACK.
*/
void battleQueueAction(
const uint8_t fighterIndex,
const battleactiontype_t type,
const uint8_t targetIndex
);
/**
* Submits the currently-selecting fighter's attack against a target, if the
* battle is in BATTLE_STATE_PLAYER_SELECTION and awaiting a decision.
* Queues the action and advances the selection cursor.
*
* @param targetIndex Index into BATTLE.fighters of the target.
*/
void battlePlayerAttack(const uint8_t targetIndex);
/**
* Submits a flee attempt for the currently-selecting fighter, if the battle
* is in BATTLE_STATE_PLAYER_SELECTION and fleeing is available for this
* battle. Always succeeds, ending the battle with BATTLE_RESULT_FLED.
*/
void battlePlayerFlee(void);
/**
* Updates the battle simulation for one frame, dispatching on BATTLE.state.
* No-op if the battle isn't active, has already ended, or
* CUTSCENE_PAUSE_BATTLE is set.
*/
void battleUpdate(void);
/**
* Rebuilds BATTLE.executionOrder/executionCount from every currently living
* fighter, sorted by speed descending.
*
* @param applyEncounterBias If true, reorders the freshly speed-sorted
* queue so BATTLE.encounterType's favoured team goes first (used only
* for the opening round).
*/
void battleBuildExecutionOrder(const bool_t applyEncounterBias);
/**
* Stably partitions BATTLE.executionOrder so every fighter on the given team
* comes first, preserving each side's relative (speed-sorted) order.
*
* @param team The team to move to the front of the execution order.
*/
void battleMoveTeamFirst(const battlefighterteam_t team);
/**
* Picks an AI target for fighter: the lowest-health living fighter on
* the opposing team.
*
* @param fighter The AI-controlled fighter choosing a target.
* @return The chosen target, or NULL if the opposing team has no
* living fighters.
*/
battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter);
/**
* Sets BATTLE.state and fires BATTLE.onStateChanged with the previous and
* new state.
*
* @param next The state to transition to.
*/
void battleSetState(const battlestate_t next);
/**
* Sets BATTLE.result and transitions to BATTLE_STATE_ENDED.
*
* @param result The result to end the battle with.
*/
void battleSetResult(const battleresult_t result);
/**
* Checks whether a fighter is a live, undecided candidate for the given
* controller -- i.e. whether PLAYER_SELECTION or AI_SELECTION should still
* be deciding an action for it this round.
*
* @param fighterIndex Index into BATTLE.fighters to check.
* @param controller The controller PLAYER_SELECTION/AI_SELECTION is
* currently deciding for.
* @return True if the fighter is alive, matches controller, and has no
* action queued yet.
*/
bool_t battleFighterNeedsDecision(
const uint8_t fighterIndex,
const battlefightercontroller_t controller
);
/**
* Advances BATTLE.selectionIndex to the next player-controlled fighter that
* still needs a decision, or transitions to BATTLE_STATE_AI_SELECTION once
* none remain.
*/
void battleAdvanceSelection(void);
/**
* Handles BATTLE_STATE_PRE_ROUND: rebuilds the execution order and moves on
* to BATTLE_STATE_PLAYER_SELECTION, positioning the selection cursor.
*/
void battleUpdatePreRound(void);
/**
* Handles BATTLE_STATE_AI_SELECTION: queues an attack for every undecided
* AI-controlled fighter, then moves on to BATTLE_STATE_MOVES_EXECUTING.
*/
void battleUpdateAiSelection(void);
/**
* Handles BATTLE_STATE_MOVES_EXECUTING: resolves one queued action from
* BATTLE.executionOrder per call, or transitions to BATTLE_STATE_POST_ROUND
* once every fighter this round has been processed.
*/
void battleUpdateMovesExecuting(void);
/**
* Handles BATTLE_STATE_POST_ROUND: advances BATTLE.round and transitions
* back to BATTLE_STATE_PRE_ROUND.
*/
void battleUpdatePostRound(void);
-43
View File
@@ -1,43 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "battlefighter.h"
#include "assert/assert.h"
#include "util/memory.h"
void battleFighterInit(
battlefighter_t *fighter,
const battlefighterteam_t team,
const battlefightercontroller_t controller,
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
) {
assertNotNull(fighter, "Fighter pointer cannot be NULL");
assertTrue(team < BATTLE_FIGHTER_TEAM_COUNT, "Invalid fighter team");
assertTrue(
controller < BATTLE_FIGHTER_CONTROLLER_COUNT,
"Invalid fighter controller"
);
const uint8_t id = fighter->id;
memoryZero(fighter, sizeof(battlefighter_t));
fighter->id = id;
fighter->status = BATTLE_FIGHTER_STATUS_NORMAL;
fighter->team = team;
fighter->controller = controller;
fighter->stats = stats;
fighter->healthMax = healthMax;
fighter->health = healthMax;
fighter->mpMax = mpMax;
fighter->mp = mpMax;
}
bool_t battleFighterIsAlive(const battlefighter_t *fighter) {
assertNotNull(fighter, "Fighter pointer cannot be NULL");
return fighter->status == BATTLE_FIGHTER_STATUS_NORMAL;
}
-87
View File
@@ -1,87 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
// An empty status means the slot in BATTLE.fighters is unused.
typedef enum {
BATTLE_FIGHTER_STATUS_NULL,
BATTLE_FIGHTER_STATUS_NORMAL,
BATTLE_FIGHTER_STATUS_DEAD,
BATTLE_FIGHTER_STATUS_COUNT
} battlefighterstatus_t;
typedef enum {
BATTLE_FIGHTER_TEAM_ALLY,
BATTLE_FIGHTER_TEAM_ENEMY,
BATTLE_FIGHTER_TEAM_COUNT
} battlefighterteam_t;
typedef enum {
BATTLE_FIGHTER_CONTROLLER_PLAYER,
BATTLE_FIGHTER_CONTROLLER_AI,
BATTLE_FIGHTER_CONTROLLER_COUNT
} battlefightercontroller_t;
// Base combat stats, kept separate from the resource pools (health/mp) on
// battlefighter_t so that equipment/buffs can later modify them without
// touching current health/mp state.
typedef struct {
uint16_t attack;
uint16_t defense;
uint16_t magic;
uint16_t speed;
uint16_t luck;
} battlefighterstats_t;
typedef struct {
uint8_t id;
battlefighterstatus_t status;
battlefighterteam_t team;
battlefightercontroller_t controller;
uint16_t health;
uint16_t healthMax;
uint16_t mp;
uint16_t mpMax;
battlefighterstats_t stats;
} battlefighter_t;
/**
* Initializes a battle fighter in place, filling health/mp to their maximum
* values and setting its status to normal.
*
* @param fighter Pointer to the fighter to initialize.
* @param team The team the fighter belongs to.
* @param controller Who makes decisions for the fighter.
* @param stats The fighter's base combat stats.
* @param healthMax The fighter's maximum health.
* @param mpMax The fighter's maximum mp.
*/
void battleFighterInit(
battlefighter_t *fighter,
const battlefighterteam_t team,
const battlefightercontroller_t controller,
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
);
/**
* Returns true if the fighter is in a state where it can still act (i.e.
* is not dead).
*
* @param fighter Pointer to the fighter to check.
* @returns True if the fighter can act.
*/
bool_t battleFighterIsAlive(const battlefighter_t *fighter);
-71
View File
@@ -1,71 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "party.h"
#include "assert/assert.h"
#include "util/memory.h"
party_t PARTY;
void partyInit(void) {
memoryZero(&PARTY, sizeof(party_t));
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
PARTY.members[i].id = i;
}
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
PARTY.order[i] = PARTY_ORDER_EMPTY;
}
}
uint8_t partyGetAvailableMember(void) {
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
if(PARTY.members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i;
}
return 0xFF;
}
battlefighter_t *partyAddMember(
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
) {
const uint8_t index = partyGetAvailableMember();
if(index == 0xFF) return NULL;
battlefighter_t *member = &PARTY.members[index];
battleFighterInit(
member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
stats, healthMax, mpMax
);
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
if(PARTY.order[i] != PARTY_ORDER_EMPTY) continue;
PARTY.order[i] = index;
break;
}
return member;
}
battlefighter_t *partyGetOrderMember(const uint8_t slot) {
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
const uint8_t index = PARTY.order[slot];
if(index == PARTY_ORDER_EMPTY) return NULL;
return &PARTY.members[index];
}
void partySetOrder(const uint8_t slot, const uint8_t memberIndex) {
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
assertTrue(
memberIndex == PARTY_ORDER_EMPTY || memberIndex < PARTY_MEMBER_COUNT_MAX,
"Invalid party member index"
);
PARTY.order[slot] = memberIndex;
}
-73
View File
@@ -1,73 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "battlefighter.h"
#define PARTY_MEMBER_COUNT_MAX 4
#define PARTY_ACTIVE_SIZE_MAX 3
#define PARTY_ORDER_EMPTY 0xFF
typedef struct {
battlefighter_t members[PARTY_MEMBER_COUNT_MAX];
// Maps an active battle slot to the roster member filling it, or
// PARTY_ORDER_EMPTY if the slot is unfilled. Only the first
// PARTY_ACTIVE_SIZE_MAX of the PARTY_MEMBER_COUNT_MAX roster members
// can be in the active lineup at once.
uint8_t order[PARTY_ACTIVE_SIZE_MAX];
} party_t;
extern party_t PARTY;
/**
* Initializes the party system with an empty roster and order.
*/
void partyInit(void);
/**
* Gets an available (unused) party member slot index.
*
* @return The index of an available slot, or 0xFF if the party is full.
*/
uint8_t partyGetAvailableMember(void);
/**
* Adds a member to the party roster in the next available slot. Party
* members are always allies controlled by the player. If there is a
* free active order slot, the new member is placed into it.
*
* @param stats The member's base combat stats.
* @param healthMax The member's maximum health.
* @param mpMax The member's maximum mp.
* @return Pointer to the newly added party member, or NULL if the party is
* already full.
*/
battlefighter_t *partyAddMember(
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
);
/**
* Gets the roster member currently occupying an active order slot.
*
* @param slot The active order slot to query.
* @return Pointer to the member in that slot, or NULL if the slot is
* empty.
*/
battlefighter_t *partyGetOrderMember(const uint8_t slot);
/**
* Assigns a roster member to an active order slot, replacing whatever
* was there. Use PARTY_ORDER_EMPTY to clear a slot.
*
* @param slot The active order slot to assign.
* @param memberIndex The roster member index to place there, or
* PARTY_ORDER_EMPTY to clear the slot.
*/
void partySetOrder(const uint8_t slot, const uint8_t memberIndex);
@@ -1,10 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
testbattle.c
)
@@ -1,43 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "testbattle.h"
#include "rpg/battle/battle.h"
#include "scene/scene.h"
void testBattleStart(void) {
battleInit();
const battlefighterstats_t allyOneStats =
{ .attack = 10, .defense = 5, .magic = 0, .speed = 10, .luck = 0 };
const battlefighterstats_t allyTwoStats =
{ .attack = 8, .defense = 4, .magic = 0, .speed = 8, .luck = 0 };
const battlefighterstats_t enemyOneStats =
{ .attack = 6, .defense = 3, .magic = 0, .speed = 6, .luck = 0 };
const battlefighterstats_t enemyTwoStats =
{ .attack = 7, .defense = 3, .magic = 0, .speed = 5, .luck = 0 };
battleAddFighter(
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
allyOneStats, 30, 10
);
battleAddFighter(
BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
allyTwoStats, 25, 10
);
battleAddFighter(
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
enemyOneStats, 20, 5
);
battleAddFighter(
BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI,
enemyTwoStats, 20, 5
);
battleStart(BATTLE_ENCOUNTER_REGULAR, true);
sceneSet(SCENE_TYPE_BATTLE);
}
@@ -1,16 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
/**
* TEMPORARY test hook: sets up a hardcoded mock battle and switches to the
* battle scene, so the battle scene (camera/fighters/HUD) can be seen and
* played without a real encounter trigger yet.
*/
void testBattleStart(void);
-13
View File
@@ -1,13 +0,0 @@
# Copyright (c) 2025 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
# Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
cutscenesystem.c
)
# Subdirs
add_subdirectory(item)
-513
View File
@@ -1,513 +0,0 @@
/**
* Copyright (c) 2025 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenepause.h"
typedef struct cutscene_s {
const cutsceneitem_t *items;
uint8_t itemCount;
cutscenepause_t pause;
// Size in bytes of this cutscene's custom user data, carved out of
// CUTSCENE_SYSTEM.data while the cutscene is running.
size_t dataSize;
} cutscene_t;
#define CUTSCENE(NAME, SIZE, PAUSE_TYPE, ...) \
static const cutsceneitem_t CUTSCENE_##NAME##_ITEMS[] = { __VA_ARGS__ }; \
static const cutscene_t CUTSCENE_##NAME = { \
.items = CUTSCENE_##NAME##_ITEMS, \
.itemCount = sizeof(CUTSCENE_##NAME##_ITEMS) / sizeof(cutsceneitem_t), \
.pause = CUTSCENE_PAUSE_##PAUSE_TYPE, \
.dataSize = SIZE \
};
#define CUTSCENE_REFERENCE(CUTSCENE) \
&CUTSCENE_##CUTSCENE
#define CUTSCENE_TEXT(TEXT) \
{ .type = CUTSCENE_ITEM_TYPE_TEXT, .text = { .text = TEXT } }
#define CUTSCENE_TEXT_MINI(TEXT, X, Y, Z, DURATION) \
{ \
.type = CUTSCENE_ITEM_TYPE_TEXT_MINI, \
.textMini = { \
.text = TEXT, \
.position = { X, Y, Z }, \
.duration = DURATION \
} \
}
#define CUTSCENE_TEXT_MINI_HIDE(INDEX) \
{ \
.type = CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE, \
.textMiniHide = { .index = INDEX } \
}
#define CUTSCENE_WAIT(WAIT) \
{ .type = CUTSCENE_ITEM_TYPE_WAIT, .wait = WAIT }
// A named, otherwise no-op position in the item list that cutsceneGoTo
// can jump execution straight to. NAME is matched with stringEquals,
// not pointer identity, so it's safe to use separate string literals
// with the same contents at the marker and at each call site.
#define CUTSCENE_MARKER(NAME) \
{ .type = CUTSCENE_ITEM_TYPE_MARKER, .marker = { .name = NAME } }
// Restarts the currently running cutscene from its first item,
// preserving whatever interact/interacted entities triggered it.
#define CUTSCENE_RESTART() \
{ .type = CUTSCENE_ITEM_TYPE_RESTART }
// Blocks the cutscene here indefinitely - it never completes on its own,
// only cutsceneGoTo (called externally, e.g. from a UI callback) can move
// execution past it.
#define CUTSCENE_IDLE() \
{ .type = CUTSCENE_ITEM_TYPE_IDLE }
// Opens a full-screen UI panel (e.g. the main menu) declaratively, then
// immediately continues on to whatever follows this item.
#define CUTSCENE_UI_SHOW(SCREEN) \
{ .type = CUTSCENE_ITEM_TYPE_UI_SHOW, .uiShow = { .screen = SCREEN } }
// Requests a switch to a different SCENE_TYPE via sceneSet, then
// immediately continues on to whatever follows this item - the switch
// itself doesn't happen until the next sceneUpdate() tick, so it does
// not take effect this frame.
#define CUTSCENE_SCENE(TYPE) \
{ .type = CUTSCENE_ITEM_TYPE_SCENE, .sceneChange = { .type = TYPE } }
#define CUTSCENE_CUTSCENE(CUTSCENE) \
{ \
.type = CUTSCENE_ITEM_TYPE_CUTSCENE, \
.cutscene = CUTSCENE_REFERENCE(CUTSCENE) \
}
#define CUTSCENE_CALLBACK(CALLBACK) \
{ .type = CUTSCENE_ITEM_TYPE_CALLBACK, .callback = CALLBACK }
#define CUTSCENE_PRINT(TEXT) \
{ .type = CUTSCENE_ITEM_TYPE_PRINT, .print = { .text = TEXT } }
// Shows a message-only modal (no option buttons) and immediately
// continues on to whatever follows this item - it does not wait for
// the dialog to be dismissed. Script the rest of the interaction (e.g.
// CUTSCENE_CALLBACK to kick off work, CUTSCENE_WAIT, then
// CUTSCENE_MODAL_CLOSE) as later items in the same cutscene.
// TITLE and MESSAGE are each displayed as-is unless they match a
// locale message ID, in which case the translated string is shown
// instead - see uiModalLocalize.
#define CUTSCENE_MODAL(TITLE, MESSAGE) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL, \
.modal = { .title = TITLE, .message = MESSAGE } \
}
// Shows a modal with option buttons and immediately continues on, same
// as CUTSCENE_MODAL - it does not block waiting for a selection.
// Back/cancel input is disabled while it's open (see
// uiMenuSetDisableBack), so it must be dismissed by picking one; CALLBACK
// then fires with the selected option index once the dialog closes.
// Option labels are passed as trailing arguments, e.g.
// CUTSCENE_MODAL_OPTIONS(title, message, callback, "Retry", "Cancel") -
// their strings are not copied by this item, so they must stay valid
// until the modal opens (string literals are fine). Like TITLE and
// MESSAGE, each option is translated if it matches a locale message ID.
#define CUTSCENE_MODAL_OPTIONS(TITLE, MESSAGE, CALLBACK, ...) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL, \
.modal = { \
.title = TITLE, .message = MESSAGE, \
.options = (const char_t *[]){ __VA_ARGS__ }, \
.optionCount = (uint8_t)( \
sizeof((const char_t *[]){ __VA_ARGS__ }) / sizeof(const char_t *) \
), \
.callback = CALLBACK \
} \
}
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly one option and
// MARKER1 to jump straight to via cutsceneGoTo once it's selected - no
// callback function to write. Does not fall through to whatever follows
// this item, so MARKER1 must be scripted elsewhere in the same cutscene.
#define CUTSCENE_MODAL_OPTIONS_ONE(TITLE, MESSAGE, OPTION1, MARKER1) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
.modalOptionsMarkers = { \
.title = TITLE, .message = MESSAGE, \
.options = { OPTION1 }, \
.markers = { MARKER1 }, \
.optionCount = 1 \
} \
}
// Same as CUTSCENE_MODAL_OPTIONS, but fixed to exactly two options,
// jumping straight to OPTION1_MARKER or OPTION2_MARKER via cutsceneGoTo
// once the corresponding option is selected - no callback function to
// write. Does not fall through to whatever follows this item, so both
// markers must be scripted elsewhere in the same cutscene.
#define CUTSCENE_MODAL_OPTIONS_TWO( \
TITLE, MESSAGE, OPTION1, OPTION1_MARKER, OPTION2, OPTION2_MARKER \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_MODAL_OPTIONS_MARKERS, \
.modalOptionsMarkers = { \
.title = TITLE, .message = MESSAGE, \
.options = { OPTION1, OPTION2 }, \
.markers = { OPTION1_MARKER, OPTION2_MARKER }, \
.optionCount = 2 \
} \
}
// Closes the currently open modal (if any). Useful when a modal was
// opened outside of a blocking CUTSCENE_MODAL item (e.g. directly via
// uiModalOpen) and this cutscene just needs to dismiss it and continue
// on to whatever follows this item in the sequence.
#define CUTSCENE_MODAL_CLOSE() \
{ .type = CUTSCENE_ITEM_TYPE_MODAL_CLOSE }
// Opens the on-screen keyboard and blocks the cutscene until it closes,
// then caches whatever was typed - see cutsceneSystemGetTextCache to
// read it back afterwards. __VA_ARGS__ are uikeyboardopen_t designated
// initializers, e.g. CUTSCENE_KEYBOARD(.cancel = true, .maxLength = 8).
#define CUTSCENE_KEYBOARD(...) \
{ \
.type = CUTSCENE_ITEM_TYPE_KEYBOARD, \
.keyboard = { .open = { __VA_ARGS__ } } \
}
// (Re)requests an available save device and jumps straight to
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same as a
// CUTSCENE_MODAL_OPTIONS callback - it does not fall through to
// whatever follows this item, so both markers must be scripted
// elsewhere in the same cutscene.
#define CUTSCENE_SAVE_DEVICE_CHECK(SUCCESS_MARKER, FAILURE_MARKER) \
{ \
.type = CUTSCENE_ITEM_TYPE_SAVE_DEVICE_CHECK, \
.saveDeviceCheck = { \
.successMarker = SUCCESS_MARKER, \
.failureMarker = FAILURE_MARKER \
} \
}
// (Re)loads every save slot via saveLoadAllSlots() and jumps straight to
// SUCCESS_MARKER or FAILURE_MARKER once it resolves, same shape as
// CUTSCENE_SAVE_DEVICE_CHECK - it does not fall through to whatever
// follows this item, so both markers must be scripted elsewhere in the
// same cutscene.
#define CUTSCENE_SAVE_LOAD_ALL_SLOTS(SUCCESS_MARKER, FAILURE_MARKER) \
{ \
.type = CUTSCENE_ITEM_TYPE_SAVE_LOAD_ALL_SLOTS, \
.saveLoadAllSlots = { \
.successMarker = SUCCESS_MARKER, \
.failureMarker = FAILURE_MARKER \
} \
}
#define CUTSCENE_ENTITY_WALK_TO(ENTITY_INDEX, X, Y, Z) \
{ \
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \
.entityWalkTo = { \
.entityIndex = ENTITY_INDEX, \
.positions = (const worldpos_t[]){ { X, Y, Z } }, \
.count = 1, \
.walkAround = true \
} \
}
#define CUTSCENE_ENTITY_WALK_PATH(NAME, ENTITY_INDEX, ...) \
static const worldpos_t CUTSCENE_##NAME##_POSITIONS[] = { __VA_ARGS__ }; \
static const cutsceneitem_t CUTSCENE_##NAME = { \
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO, \
.entityWalkTo = { \
.entityIndex = ENTITY_INDEX, \
.positions = CUTSCENE_##NAME##_POSITIONS, \
.count = sizeof(CUTSCENE_##NAME##_POSITIONS) / sizeof(worldpos_t), \
.walkAround = true \
} \
}
#define CUTSCENE_ENTITY_REMOVE(ENTITY_INDEX) \
{ \
.type = CUTSCENE_ITEM_TYPE_ENTITY_REMOVE, \
.entityRemove = { .entityIndex = ENTITY_INDEX } \
}
#define CUTSCENE_ENTITY_ADD(TYPE, X, Y, Z) \
{ \
.type = CUTSCENE_ITEM_TYPE_ENTITY_ADD, \
.entityAdd = { .entityType = TYPE, .position = { X, Y, Z } } \
}
#define CUTSCENE_ENTITY_TURN(ENTITY_INDEX, DIRECTION) \
{ \
.type = CUTSCENE_ITEM_TYPE_ENTITY_TURN, \
.entityTurn = { .entityIndex = ENTITY_INDEX, .direction = DIRECTION } \
}
// Walks ENTITY_INDEX to stand beside TARGET_ENTITY_INDEX, offset by
// (OFFSET_X, OFFSET_Y) on the 2D plane. The destination Z is resolved
// from nearby terrain each frame, so ramps between the two entities are
// accounted for automatically.
#define CUTSCENE_ENTITY_WALK_TO_ENTITY( \
ENTITY_INDEX, TARGET_ENTITY_INDEX, OFFSET_X, OFFSET_Y \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY, \
.entityWalkToEntity = { \
.entityIndex = ENTITY_INDEX, \
.targetEntityIndex = TARGET_ENTITY_INDEX, \
.offsetX = OFFSET_X, \
.offsetY = OFFSET_Y \
} \
}
#define CUTSCENE_ENTITY_TELEPORT(ENTITY_INDEX, X, Y, Z) \
{ \
.type = CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT, \
.entityTeleport = { .entityIndex = ENTITY_INDEX, .target = { X, Y, Z } } \
}
#define CUTSCENE_FADE(FROM, TO, DURATION, EASING) \
{ \
.type = CUTSCENE_ITEM_TYPE_FADE, \
.fade = { .from = FROM, .to = TO, .duration = DURATION, .easing = EASING } \
}
#define CUTSCENE_FADE_TO_BLACK(DURATION) \
CUTSCENE_FADE(COLOR_TRANSPARENT_BLACK, COLOR_BLACK, DURATION, EASING_LINEAR)
#define CUTSCENE_FADE_FROM_BLACK(DURATION) \
CUTSCENE_FADE(COLOR_BLACK, COLOR_TRANSPARENT_BLACK, DURATION, EASING_LINEAR)
#define CUTSCENE_FADE_TO_WHITE(DURATION) \
CUTSCENE_FADE(COLOR_TRANSPARENT_WHITE, COLOR_WHITE, DURATION, EASING_LINEAR)
#define CUTSCENE_FADE_FROM_WHITE(DURATION) \
CUTSCENE_FADE(COLOR_WHITE, COLOR_TRANSPARENT_WHITE, DURATION, EASING_LINEAR)
#define CUTSCENE_EMOJI(ENTITY_INDEX, EMOJI_TYPE, DURATION) \
{ \
.type = CUTSCENE_ITEM_TYPE_EMOJI, \
.emoji = { \
.entityIndex = ENTITY_INDEX, \
.emojiType = EMOJI_TYPE, \
.duration = DURATION \
} \
}
// AMOUNT ranges 0 (no shake) to 4 (three tiles): 1 is half a tile, 2 is
// a full tile, 3 is two tiles, and 4 is three tiles.
#define CUTSCENE_SHAKE(AMOUNT, DURATION) \
{ \
.type = CUTSCENE_ITEM_TYPE_SHAKE, \
.shake = { .amount = AMOUNT, .duration = DURATION } \
}
// Waits until BATTLE.state reaches STATE. Put this BEFORE
// CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE), not after -- pausing first
// freezes BATTLE.state wherever it already is, so it would never reach
// STATE on its own to satisfy the wait. Waiting unpaused, then pausing the
// moment it's satisfied, catches the battle right at STATE before it can
// advance further.
#define CUTSCENE_BATTLE_WAIT_STATE(STATE) \
{ \
.type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, \
.battleWaitState = { .state = STATE } \
}
// Immediately queues an attack for FIGHTER_INDEX against TARGET_INDEX,
// bypassing normal player/AI selection for that fighter this round.
#define CUTSCENE_BATTLE_FORCE_ACTION(FIGHTER_INDEX, TARGET_INDEX) \
{ \
.type = CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, \
.battleForceAction = { \
.fighterIndex = FIGHTER_INDEX, .targetIndex = TARGET_INDEX \
} \
}
#define CUTSCENE_SET_PAUSE(FLAGS) \
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) }
#define CUTSCENE_ITEM_GIVE(ITEM_ID, QUANTITY) \
{ \
.type = CUTSCENE_ITEM_TYPE_ITEM_GIVE, \
.itemGive = { .item = ITEM_ID, .quantity = QUANTITY } \
}
// Runs all listed items simultaneously and waits until all are done.
// Concurrent items cannot be nested inside another CUTSCENE_CONCURRENT.
#define CUTSCENE_CONCURRENT(...) \
{ \
.type = CUTSCENE_ITEM_TYPE_CONCURRENT, \
.concurrent = { \
.items = (const cutsceneitem_t[]){ __VA_ARGS__ }, \
.count = (uint8_t)( \
sizeof((cutsceneitem_t[]){ __VA_ARGS__ }) / \
sizeof(cutsceneitem_t) \
) \
} \
}
#define CUTSCENE_MAP_AREA_ADD( \
MIN_X, MIN_Y, MIN_Z, MAX_X, MAX_Y, MAX_Z, CALLBACK, NOTIFY, TRIGGER \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_MAP_AREA_ADD, \
.mapAreaAdd = { \
.min = { MIN_X, MIN_Y, MIN_Z }, \
.max = { MAX_X, MAX_Y, MAX_Z }, \
.callback = CALLBACK, \
.notify = NOTIFY, \
.trigger = TRIGGER \
} \
}
#define CUTSCENE_MAP_AREA_REMOVE(AREA_ID) \
{ \
.type = CUTSCENE_ITEM_TYPE_MAP_AREA_REMOVE, \
.mapAreaRemove = { .areaId = AREA_ID } \
}
// Waits until any one of the given map area IDs has its callback invoked.
// Accepts CUTSCENE_AREA_LAST_CREATED in place of a literal area ID.
#define CUTSCENE_MAP_AREA_WAIT(...) \
{ \
.type = CUTSCENE_ITEM_TYPE_MAP_AREA_WAIT, \
.mapAreaWait = { \
.areaIds = (const uint8_t[]){ __VA_ARGS__ }, \
.count = (uint8_t)( \
sizeof((const uint8_t[]){ __VA_ARGS__ }) / sizeof(uint8_t) \
) \
} \
}
// Adds a map area, waits for it to be triggered once, then removes it
// before the cutscene continues. Uses a no-op callback since the wait is
// driven by the area's trigger count rather than callback logic.
#define CUTSCENE_MAP_AREA_TRIGGER_ONCE( \
MIN_X, MIN_Y, MIN_Z, MAX_X, MAX_Y, MAX_Z, NOTIFY, TRIGGER \
) \
CUTSCENE_MAP_AREA_ADD( \
MIN_X, MIN_Y, MIN_Z, MAX_X, MAX_Y, MAX_Z, \
mapAreaNoopCallback, NOTIFY, TRIGGER \
), \
CUTSCENE_MAP_AREA_WAIT(CUTSCENE_AREA_LAST_CREATED), \
CUTSCENE_MAP_AREA_REMOVE(CUTSCENE_AREA_LAST_CREATED)
// Longhand - every audioMixerQueuePlay() parameter. See
// CUTSCENE_AUDIO_PLAY_SIMPLE/CUTSCENE_AUDIO_PLAY_LOOPED below for the
// shorthands.
#define CUTSCENE_AUDIO_PLAY( \
FILE, CHANNEL, VOLUME, PAN, LOOPING, LOOP_COUNT, LOOP_START, LOOP_TO \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_AUDIO_PLAY, \
.audioPlay = { \
.file = FILE, \
.channel = CHANNEL, \
.volume = VOLUME, \
.pan = PAN, \
.looping = LOOPING, \
.loopCount = LOOP_COUNT, \
.loopStart = LOOP_START, \
.loopTo = LOOP_TO \
} \
}
// Shorthand: play FILE on CHANNEL once, full volume, centered (0.0f, same
// as AUDIO_STREAM_CENTER), no loop.
#define CUTSCENE_AUDIO_PLAY_SIMPLE(FILE, CHANNEL) \
CUTSCENE_AUDIO_PLAY(FILE, CHANNEL, 1.0f, 0.0f, false, 0, -1.0f, 0.0f)
// Shorthand: play FILE on CHANNEL looping the whole clip forever, full
// volume, centered (0.0f, same as AUDIO_STREAM_CENTER).
#define CUTSCENE_AUDIO_PLAY_LOOPED(FILE, CHANNEL) \
CUTSCENE_AUDIO_PLAY(FILE, CHANNEL, 1.0f, 0.0f, true, 0, -1.0f, 0.0f)
#define CUTSCENE_AUDIO_STOP(CHANNEL) \
{ .type = CUTSCENE_ITEM_TYPE_AUDIO_STOP, .audioStopChannel = CHANNEL }
#define CUTSCENE_AUDIO_PAUSE(CHANNEL) \
{ .type = CUTSCENE_ITEM_TYPE_AUDIO_PAUSE, .audioPauseChannel = CHANNEL }
#define CUTSCENE_AUDIO_RESUME(CHANNEL) \
{ .type = CUTSCENE_ITEM_TYPE_AUDIO_RESUME, .audioResumeChannel = CHANNEL }
// Longhand - starts CHANNEL fading from FROM to TO over DURATION seconds,
// eased by EASING. Doesn't wait for it - pair with
// CUTSCENE_AUDIO_FADE_WAIT(CHANNEL) to block until it finishes. See
// CUTSCENE_AUDIO_FADE_OUT/CUTSCENE_AUDIO_FADE_IN below for the shorthands;
// use this directly for a custom range, e.g.
// CUTSCENE_AUDIO_FADE(CHANNEL, 1.0f, 0.3f, 5.0f, EASING_LINEAR) to fade
// down to "playing quietly" rather than silent.
#define CUTSCENE_AUDIO_FADE(CHANNEL, FROM, TO, DURATION, EASING) \
{ \
.type = CUTSCENE_ITEM_TYPE_AUDIO_FADE, \
.audioFade = { \
.channel = CHANNEL, \
.from = FROM, \
.to = TO, \
.duration = DURATION, \
.easing = EASING \
} \
}
// Shorthand: fade CHANNEL from full volume to silent over DURATION seconds.
#define CUTSCENE_AUDIO_FADE_OUT(CHANNEL, DURATION) \
CUTSCENE_AUDIO_FADE(CHANNEL, 1.0f, 0.0f, DURATION, EASING_LINEAR)
// Shorthand: fade CHANNEL from silent to full volume over DURATION seconds.
#define CUTSCENE_AUDIO_FADE_IN(CHANNEL, DURATION) \
CUTSCENE_AUDIO_FADE(CHANNEL, 0.0f, 1.0f, DURATION, EASING_LINEAR)
// Waits until CHANNEL's fade (started elsewhere by CUTSCENE_AUDIO_FADE)
// has finished - a no-op wait if CHANNEL isn't fading.
#define CUTSCENE_AUDIO_FADE_WAIT(CHANNEL) \
{ \
.type = CUTSCENE_ITEM_TYPE_AUDIO_FADE_WAIT, \
.audioFadeWaitChannel = CHANNEL \
}
#define CUTSCENE_AUDIO_SET_PAN(CHANNEL, PAN) \
{ \
.type = CUTSCENE_ITEM_TYPE_AUDIO_SET_PAN, \
.audioSetPan = { .channel = CHANNEL, .pan = PAN } \
}
#define CUTSCENE_AUDIO_SET_LOOP( \
CHANNEL, LOOPING, LOOP_COUNT, LOOP_START, LOOP_TO \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_AUDIO_SET_LOOP, \
.audioSetLoop = { \
.channel = CHANNEL, \
.looping = LOOPING, \
.loopCount = LOOP_COUNT, \
.loopStart = LOOP_START, \
.loopTo = LOOP_TO \
} \
}
// Combined form of CUTSCENE_AUDIO_SET_PAN + CUTSCENE_AUDIO_SET_LOOP, for
// setting both at once rather than as two separate items.
#define CUTSCENE_AUDIO_SET( \
CHANNEL, PAN, LOOPING, LOOP_COUNT, LOOP_START, LOOP_TO \
) \
{ \
.type = CUTSCENE_ITEM_TYPE_AUDIO_SET, \
.audioSet = { \
.channel = CHANNEL, \
.pan = PAN, \
.looping = LOOPING, \
.loopCount = LOOP_COUNT, \
.loopStart = LOOP_START, \
.loopTo = LOOP_TO \
} \
}
-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 \
))
-262
View File
@@ -1,262 +0,0 @@
/**
* Copyright (c) 2025 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "cutscenesystem.h"
#include "rpg/entity/entity.h"
#include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h"
#include "asset/asset.h"
#include "ui/overlay/uifatalerror.h"
cutscenesystem_t CUTSCENE_SYSTEM;
void cutsceneSystemInit() {
memoryZero(&CUTSCENE_SYSTEM, sizeof(cutscenesystem_t));
}
void cutsceneSystemPrepare(
const cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
) {
assertTrue(
cutscene->dataSize < CUTSCENE_SYSTEM_SIZE_MAX,
"Cutscene data size exceeds CUTSCENE_SYSTEM_SIZE_MAX"
);
CUTSCENE_SYSTEM.scene = cutscene;
CUTSCENE_SYSTEM.pause = cutscene->pause;
CUTSCENE_SYSTEM.entityInteract = interact;
CUTSCENE_SYSTEM.entityInteracted = interacted;
CUTSCENE_SYSTEM.entityLastCreated = NULL;
CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.currentItem = 0xFF;// Set to 0xFF so Next wraps to 0.
CUTSCENE_SYSTEM.onComplete = NULL;
}
void cutsceneSystemStartCutscene(const cutscene_t *cutscene) {
cutsceneSystemStartCutsceneWith(cutscene, NULL, NULL);
}
void cutsceneSystemStartCutsceneWith(
const cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
) {
cutsceneSystemPrepare(cutscene, interact, interacted);
cutsceneSystemNext();
}
void cutsceneSystemStartCutsceneAndGoToMarker(
const cutscene_t *cutscene,
const char_t *marker
) {
cutsceneSystemPrepare(cutscene, NULL, NULL);
cutsceneGoTo(marker);
}
void cutsceneSystemLoad(const char_t *file) {
char_t path[ASSET_FILE_NAME_MAX];
stringFormat(path, sizeof(path), "cutscenes/%s", file);
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_CUTSCENE, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
errorCatch(errorPrint(result));
uiFatalErrorOpen(NULL);
return;
}
cutsceneSystemStartCutscene(&entry->data.cutscene.cutscene);
}
void cutsceneRestart(void) {
assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneRestart called with no cutscene running"
);
// A restart is the same logical run trying again (e.g. retrying a failed
// save-device check), not a fresh unrelated start, so it should not
// silently drop a completion callback the caller already armed.
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
cutsceneSystemStartCutsceneWith(
CUTSCENE_SYSTEM.scene,
CUTSCENE_SYSTEM.entityInteract,
CUTSCENE_SYSTEM.entityInteracted
);
CUTSCENE_SYSTEM.onComplete = onComplete;
}
void cutsceneSystemUpdate() {
if(CUTSCENE_SYSTEM.scene == NULL) return;
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
if(cutsceneItemUpdate(item, &CUTSCENE_SYSTEM.data)) cutsceneSystemNext();
}
void cutsceneSystemNext() {
if(CUTSCENE_SYSTEM.scene == NULL) return;
CUTSCENE_SYSTEM.currentItem++;
// End of the cutscene?
if(
CUTSCENE_SYSTEM.currentItem >= CUTSCENE_SYSTEM.scene->itemCount
) {
// Saved and cleared before firing so a callback that immediately
// starts another cutscene (or sets its own onComplete) isn't clobbered
// by this function's own cleanup running after it - same reentrancy
// hazard as uiFocusPop, see src/dusk/ui/focus/uifocus.c.
cutscenecallback_t onComplete = CUTSCENE_SYSTEM.onComplete;
void *userData = CUTSCENE_SYSTEM.userData;
CUTSCENE_SYSTEM.scene = NULL;
CUTSCENE_SYSTEM.currentItem = 0xFF;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
CUTSCENE_SYSTEM.entityInteract = NULL;
CUTSCENE_SYSTEM.entityInteracted = NULL;
CUTSCENE_SYSTEM.entityLastCreated = NULL;
CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.onComplete = NULL;
if(onComplete != NULL) onComplete(userData);
return;
}
// Start item.
const cutsceneitem_t *item = cutsceneSystemGetCurrentItem();
memset(&CUTSCENE_SYSTEM.data, 0, sizeof(CUTSCENE_SYSTEM.data));
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
}
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete) {
assertNotNull(
CUTSCENE_SYSTEM.scene,
"cutsceneSystemSetOnComplete called with no cutscene running"
);
CUTSCENE_SYSTEM.onComplete = onComplete;
}
void cutsceneGoTo(const char_t *name) {
assertNotNull(
CUTSCENE_SYSTEM.scene, "cutsceneGoTo called with no cutscene running"
);
for(uint8_t i = 0; i < CUTSCENE_SYSTEM.scene->itemCount; i++) {
const cutsceneitem_t *item = &CUTSCENE_SYSTEM.scene->items[i];
if(
item->type == CUTSCENE_ITEM_TYPE_MARKER &&
stringEquals(item->marker.name, name)
) {
CUTSCENE_SYSTEM.currentItem = i;
memoryZero(&CUTSCENE_SYSTEM.data, sizeof(CUTSCENE_SYSTEM.data));
cutsceneItemStart(item, &CUTSCENE_SYSTEM.data);
return;
}
}
assertTrue(false, "cutsceneGoTo: no marker found with that name");
}
const cutsceneitem_t * cutsceneSystemGetCurrentItem() {
if(CUTSCENE_SYSTEM.scene == NULL) return NULL;
return &CUTSCENE_SYSTEM.scene->items[CUTSCENE_SYSTEM.currentItem];
}
entity_t * cutsceneSystemGetEntity(const uint8_t entityIndex) {
entity_t *entity;
if(entityIndex == CUTSCENE_ENTITY_INTERACT) {
assertNotNull(
CUTSCENE_SYSTEM.entityInteract,
"CUTSCENE_ENTITY_INTERACT used but no interact entity is set"
);
entity = CUTSCENE_SYSTEM.entityInteract;
} else if(entityIndex == CUTSCENE_ENTITY_INTERACTED) {
assertNotNull(
CUTSCENE_SYSTEM.entityInteracted,
"CUTSCENE_ENTITY_INTERACTED used but no interacted entity is set"
);
entity = CUTSCENE_SYSTEM.entityInteracted;
} else if(entityIndex == CUTSCENE_ENTITY_LAST_CREATED) {
assertNotNull(
CUTSCENE_SYSTEM.entityLastCreated,
"CUTSCENE_ENTITY_LAST_CREATED used but no entity has been created"
);
entity = CUTSCENE_SYSTEM.entityLastCreated;
} else if(entityIndex == CUTSCENE_ENTITY_LAST_REF) {
assertNotNull(
CUTSCENE_SYSTEM.entityLastRef,
"CUTSCENE_ENTITY_LAST_REF used but no entity has been referenced"
);
entity = CUTSCENE_SYSTEM.entityLastRef;
} else {
assertTrue(
entityIndex < ENTITY_COUNT,
"Entity index is out of range"
);
entity = &ENTITIES[entityIndex];
}
CUTSCENE_SYSTEM.entityLastRef = entity;
return entity;
}
uint8_t cutsceneSystemGetAreaId(const uint8_t areaId) {
if(areaId == CUTSCENE_AREA_LAST_CREATED) {
assertTrue(
CUTSCENE_SYSTEM.areaLastCreated != CUTSCENE_AREA_LAST_CREATED,
"CUTSCENE_AREA_LAST_CREATED used but no map area has been created"
);
return CUTSCENE_SYSTEM.areaLastCreated;
}
return areaId;
}
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index) {
if(index == CUTSCENE_TEXT_MINI_LAST_CREATED) {
assertTrue(
CUTSCENE_SYSTEM.textMiniLastCreated != CUTSCENE_TEXT_MINI_LAST_CREATED,
"CUTSCENE_TEXT_MINI_LAST_CREATED used but no mini textbox has been "
"shown"
);
return CUTSCENE_SYSTEM.textMiniLastCreated;
}
return index;
}
const char_t * cutsceneSystemGetTextCache(void) {
return CUTSCENE_SYSTEM.textCache;
}
void cutsceneSystemSetTextCache(const char_t *text) {
stringCopy(
CUTSCENE_SYSTEM.textCache, text, CUTSCENE_TEXT_CACHE_MAX - 1
);
}
void cutsceneSystemDispose() {
CUTSCENE_SYSTEM.scene = NULL;
CUTSCENE_SYSTEM.currentItem = 0xFF;
CUTSCENE_SYSTEM.pause = CUTSCENE_PAUSE_NONE;
CUTSCENE_SYSTEM.entityInteract = NULL;
CUTSCENE_SYSTEM.entityInteracted = NULL;
CUTSCENE_SYSTEM.entityLastCreated = NULL;
CUTSCENE_SYSTEM.entityLastRef = NULL;
CUTSCENE_SYSTEM.areaLastCreated = CUTSCENE_AREA_LAST_CREATED;
CUTSCENE_SYSTEM.textMiniLastCreated = CUTSCENE_TEXT_MINI_LAST_CREATED;
CUTSCENE_SYSTEM.textCache[0] = '\0';
CUTSCENE_SYSTEM.onComplete = NULL;
}
-224
View File
@@ -1,224 +0,0 @@
/**
* Copyright (c) 2025 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "cutscene.h"
typedef struct entity_s entity_t;
#define CUTSCENE_ENTITY_INTERACT ((uint8_t)0xFE)
#define CUTSCENE_ENTITY_INTERACTED ((uint8_t)0xFD)
#define CUTSCENE_ENTITY_LAST_CREATED ((uint8_t)0xFC)
#define CUTSCENE_ENTITY_LAST_REF ((uint8_t)0xFB)
#define CUTSCENE_AREA_LAST_CREATED ((uint8_t)0xFF)
#define CUTSCENE_TEXT_MINI_LAST_CREATED ((uint8_t)0xFA)
// Maximum number of bytes a running cutscene may request via
// cutscene_t.dataSize.
#define CUTSCENE_SYSTEM_SIZE_MAX 8192
// Size of CUTSCENE_SYSTEM.textCache - keep >= UI_KEYBOARD_TEXT_MAX (see
// ui/dialog/keyboard/uikeyboard.h) so text entered via a
// CUTSCENE_ITEM_TYPE_KEYBOARD item is never truncated caching it here.
#define CUTSCENE_TEXT_CACHE_MAX 64
typedef struct {
const cutscene_t *scene;
uint8_t currentItem;
cutscenepause_t pause;
entity_t *entityInteract;
entity_t *entityInteracted;
entity_t *entityLastCreated;
entity_t *entityLastRef;
uint8_t areaLastCreated;
uint8_t textMiniLastCreated;
// Free-form text cache, e.g. holding whatever was last typed via a
// CUTSCENE_ITEM_TYPE_KEYBOARD item - see cutsceneSystemGetTextCache/
// cutsceneSystemSetTextCache. Not tied to any one item type; any item
// may read or write it.
char_t textCache[CUTSCENE_TEXT_CACHE_MAX];
// Data (used by the current item).
cutsceneitemdata_t data;
// Custom user data for the running cutscene, sized per-scene by
// cutscene_t.dataSize.
uint8_t userData[CUTSCENE_SYSTEM_SIZE_MAX];
// See cutsceneSystemSetOnComplete.
cutscenecallback_t onComplete;
} cutscenesystem_t;
extern cutscenesystem_t CUTSCENE_SYSTEM;
/**
* Initialize the cutscene system.
*/
void cutsceneSystemInit();
/**
* Start a cutscene with no bound entities.
*
* @param cutscene Pointer to the cutscene to start.
*/
void cutsceneSystemStartCutscene(const cutscene_t *cutscene);
/**
* Start a cutscene with the two entities that triggered it.
*
* @param cutscene Pointer to the cutscene to start.
* @param interact The entity that initiated the interaction (player).
* @param interacted The entity that was interacted with (NPC).
*/
void cutsceneSystemStartCutsceneWith(
const cutscene_t *cutscene,
entity_t *interact,
entity_t *interacted
);
/**
* Starts a cutscene with no bound entities, jumping straight to the
* CUTSCENE_MARKER item with the given name instead of running from the
* first item - as if cutsceneGoTo(marker) had been called immediately
* after cutsceneSystemStartCutscene. Asserts if no marker with that
* name exists in the cutscene.
*
* @param cutscene Pointer to the cutscene to start.
* @param marker Marker name to jump to, matched with stringEquals.
*/
void cutsceneSystemStartCutsceneAndGoToMarker(
const cutscene_t *cutscene,
const char_t *marker
);
/**
* Locks, loads and immediately starts a cutscene asset by file name,
* e.g. cutsceneSystemLoad("main_menu.jsonc") loads and starts
* assets/cutscenes/main_menu.jsonc - same lock/assetRequireLoaded/start
* sequence as sceneInitialInit's INITIAL_CUTSCENE_ENTRY. The entry is
* locked forever (never unlocked), same lifetime convention as
* LOCALE.entry - it's fine to call this again later for the same file,
* it'll just resolve to the same already-loaded entry. Opens the fatal
* error overlay (see uiFatalErrorOpen) instead of starting anything if
* the asset fails to load.
*
* @param file Cutscene file name (with .jsonc extension), relative to
* assets/cutscenes/.
*/
void cutsceneSystemLoad(const char_t *file);
/**
* Restarts the currently running cutscene from its first item,
* preserving whatever interact/interacted entities triggered it.
* Asserts if no cutscene is running.
*/
void cutsceneRestart(void);
/**
* Sets a native callback to fire once when the currently running cutscene
* finishes by running off the end of its item list. A fresh
* cutsceneSystemStartCutscene* call clears any previously set callback, so
* call this again after starting a new cutscene to arm it - but
* cutsceneRestart() preserves whatever was armed, since a restart (e.g.
* retrying a failed check) is the same logical run trying again, not a new
* one. Invoked with CUTSCENE_SYSTEM.userData, same as CUTSCENE_CALLBACK.
*
* Exists so a runtime-loaded cutscene file (which can't store a native
* function pointer) can still hand off to native code once it's done,
* without needing a whole name->function registry: the file just ends
* normally, and whoever started it supplies what happens next.
*
* @param onComplete Callback to fire on natural completion. May be NULL
* to clear a previously set one.
*/
void cutsceneSystemSetOnComplete(cutscenecallback_t onComplete);
/**
* Resolves a raw entity index (or sentinel) to an entity pointer.
* Handles CUTSCENE_ENTITY_INTERACT, CUTSCENE_ENTITY_INTERACTED,
* CUTSCENE_ENTITY_LAST_CREATED and CUTSCENE_ENTITY_LAST_REF.
* Updates CUTSCENE_SYSTEM.entityLastRef to the resolved entity.
* Asserts the resolved entity is within bounds.
*
* @param entityIndex Raw entity index or sentinel value.
* @returns Pointer to the resolved entity.
*/
entity_t * cutsceneSystemGetEntity(const uint8_t entityIndex);
/**
* Resolves a raw map area ID (or CUTSCENE_AREA_LAST_CREATED sentinel) to
* a concrete map area ID.
*
* @param areaId Raw map area ID or sentinel value.
* @returns The resolved map area ID.
*/
uint8_t cutsceneSystemGetAreaId(const uint8_t areaId);
/**
* Resolves a raw mini textbox slot index (or CUTSCENE_TEXT_MINI_LAST_CREATED
* sentinel) to a concrete UI_TEXTBOX_MINI_LIST slot index.
*
* @param index Raw slot index or sentinel value.
* @returns The resolved slot index.
*/
uint8_t cutsceneSystemGetTextMiniId(const uint8_t index);
/**
* Returns CUTSCENE_SYSTEM.textCache - whatever was last written there via
* cutsceneSystemSetTextCache (e.g. by a CUTSCENE_ITEM_TYPE_KEYBOARD item
* once its keyboard closes). Empty ("") if nothing has been cached yet
* for the running cutscene.
*
* @returns The cached text.
*/
const char_t * cutsceneSystemGetTextCache(void);
/**
* Overwrites CUTSCENE_SYSTEM.textCache with a copy of text. Any item may
* call this - it isn't tied to any one item type - so later items can
* read back whatever the caller wants to pass along, up to
* CUTSCENE_TEXT_CACHE_MAX - 1 characters.
*
* @param text The text to cache; copied internally, safe to be
* transient. Must not exceed CUTSCENE_TEXT_CACHE_MAX - 1 characters.
*/
void cutsceneSystemSetTextCache(const char_t *text);
/**
* Advance to the next item in the cutscene.
*/
void cutsceneSystemNext();
/**
* Jumps the running cutscene directly to the CUTSCENE_MARKER item with
* the given name and starts it immediately, as if cutsceneSystemNext()
* had advanced straight to it. Intended to be called from within
* another item's start/update (e.g. a CUTSCENE_CALLBACK) to implement
* flow control. Asserts if no cutscene is running or no marker with
* that name exists in it.
*
* @param name Marker name to search for, matched with stringEquals.
*/
void cutsceneGoTo(const char_t *name);
/**
* Update the cutscene system for one frame.
*/
void cutsceneSystemUpdate();
/**
* Get the current cutscene item.
*
* @return Pointer to the current cutscene item.
*/
const cutsceneitem_t * cutsceneSystemGetCurrentItem();
/**
* Disposes of the cutscene system, stopping any active cutscene.
*/
void cutsceneSystemDispose();
-22
View File
@@ -1,22 +0,0 @@
# Copyright (c) 2025 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
cutsceneitem.c
cutsceneitembase.c
cutscenecallback.c
cutsceneprint.c
)
add_subdirectory(control)
add_subdirectory(entity)
add_subdirectory(item)
add_subdirectory(maparea)
add_subdirectory(ui)
add_subdirectory(battle)
add_subdirectory(save)
add_subdirectory(audio)
add_subdirectory(json)
@@ -1,17 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
cutsceneaudioplay.c
cutsceneaudiostop.c
cutsceneaudiopause.c
cutsceneaudioresume.c
cutsceneaudiofade.c
cutsceneaudiofadewait.c
cutsceneaudiosetpan.c
cutsceneaudiosetloop.c
cutsceneaudioset.c
)
@@ -1,61 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
#include "rpg/cutscene/item/json/cutscenejsoneasing.h"
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
void cutsceneAudioFadeStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerFadeTo(
item->audioFade.channel,
item->audioFade.from,
item->audioFade.to,
item->audioFade.duration,
item->audioFade.easing
);
}
bool_t cutsceneAudioFadeUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioFadeLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
audiomixerchannel_t channelValue;
if(!cutsceneJsonLookupAudioChannel(
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
)) {
errorThrow("Cutscene item (AUDIO_FADE): invalid 'channel'");
}
item->audioFade.channel = channelValue;
item->audioFade.from = cutsceneJsonGetFloat(itemObj, "from", 0.0f);
item->audioFade.to = cutsceneJsonGetFloat(itemObj, "to", 0.0f);
item->audioFade.duration = cutsceneJsonGetFloat(itemObj, "duration", 0.0f);
const char_t *easingName =
yyjson_get_str(yyjson_obj_get(itemObj, "easing"));
easingtype_t easingValue = EASING_LINEAR;
if(easingName != NULL &&
!cutsceneJsonLookupEasing(easingName, &easingValue)) {
errorThrow("Cutscene item (AUDIO_FADE): invalid 'easing'");
}
item->audioFade.easing = easingValue;
errorOk();
}
@@ -1,60 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "animation/easing.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct {
audiomixerchannel_t channel;
float_t from;
float_t to;
float_t duration;
easingtype_t easing;
} cutsceneaudiofade_t;
/**
* Starts an audio fade step (starts the fade via audioMixerFadeTo() -
* doesn't wait for it; pair with CUTSCENE_AUDIO_FADE_WAIT to block on it).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioFadeStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio fade step (always completes immediately - the fade
* itself keeps running independently of cutscene progression).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioFadeUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_FADE cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_FADE needs no pool storage).
* @param poolOffset Unused (AUDIO_FADE needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioFadeLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,26 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
bool_t cutsceneAudioFadeWaitUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return !audioMixerIsFading(item->audioFadeWaitChannel);
}
errorret_t cutsceneAudioFadeWaitLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioFadeWaitChannel);
}
@@ -1,40 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Updates an audio fade-wait step, completing once the watched channel's
* fade (started by a CUTSCENE_AUDIO_FADE elsewhere) finishes. Has no Start
* callback -- there's nothing to do until the fade is actually done.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true once the channel is no longer fading.
*/
bool_t cutsceneAudioFadeWaitUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_FADE_WAIT cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_FADE_WAIT needs no pool storage).
* @param poolOffset Unused (AUDIO_FADE_WAIT needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioFadeWaitLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,33 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
void cutsceneAudioPauseStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerPause(item->audioPauseChannel);
}
bool_t cutsceneAudioPauseUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioPauseLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioPauseChannel);
}
@@ -1,49 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Starts an audio pause step (pauses the channel immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioPauseStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio pause step (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioPauseUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_PAUSE cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_PAUSE needs no pool storage).
* @param poolOffset Unused (AUDIO_PAUSE needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioPauseLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,64 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
void cutsceneAudioPlayStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerQueuePlay(
item->audioPlay.file,
item->audioPlay.channel,
item->audioPlay.volume,
item->audioPlay.pan,
item->audioPlay.looping,
item->audioPlay.loopCount,
item->audioPlay.loopStart,
item->audioPlay.loopTo
);
}
bool_t cutsceneAudioPlayUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioPlayLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
if(!cutsceneJsonCopyString(
itemObj, "file", NULL, item->audioPlay.file, AUDIO_PATH_MAX
)) {
errorThrow("Cutscene item (AUDIO_PLAY): missing/too-long 'file'");
}
audiomixerchannel_t channelValue;
if(!cutsceneJsonLookupAudioChannel(
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
)) {
errorThrow("Cutscene item (AUDIO_PLAY): invalid 'channel'");
}
item->audioPlay.channel = channelValue;
item->audioPlay.volume = cutsceneJsonGetFloat(itemObj, "volume", 1.0f);
item->audioPlay.pan = cutsceneJsonGetFloat(itemObj, "pan", 0.0f);
item->audioPlay.looping = cutsceneJsonGetBool(itemObj, "looping", false);
item->audioPlay.loopCount =
(uint8_t)cutsceneJsonGetInt(itemObj, "loopCount", 0);
item->audioPlay.loopStart =
cutsceneJsonGetFloat(itemObj, "loopStart", -1.0f);
item->audioPlay.loopTo = cutsceneJsonGetFloat(itemObj, "loopTo", 0.0f);
errorOk();
}
@@ -1,62 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct {
char_t file[AUDIO_PATH_MAX];
audiomixerchannel_t channel;
float_t volume;
float_t pan;
bool_t looping;
uint8_t loopCount;
float_t loopStart;
float_t loopTo;
} cutsceneaudioplay_t;
/**
* Starts an audio play step (queues the sound via audioMixerQueuePlay() -
* see its own comment on when it actually starts).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioPlayStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio play step (always completes immediately - the sound
* keeps loading/playing independently of cutscene progression).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioPlayUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_PLAY cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_PLAY needs no pool storage).
* @param poolOffset Unused (AUDIO_PLAY needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioPlayLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,33 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
void cutsceneAudioResumeStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerResume(item->audioResumeChannel);
}
bool_t cutsceneAudioResumeUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioResumeLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioResumeChannel);
}
@@ -1,49 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Starts an audio resume step (resumes the channel immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioResumeStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio resume step (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioResumeUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_RESUME cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_RESUME needs no pool storage).
* @param poolOffset Unused (AUDIO_RESUME needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioResumeLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,56 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
void cutsceneAudioSetStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerSetPan(item->audioSet.channel, item->audioSet.pan);
audioMixerSetLoop(
item->audioSet.channel,
item->audioSet.looping,
item->audioSet.loopCount,
item->audioSet.loopStart,
item->audioSet.loopTo
);
}
bool_t cutsceneAudioSetUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioSetLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
audiomixerchannel_t channelValue;
if(!cutsceneJsonLookupAudioChannel(
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
)) {
errorThrow("Cutscene item (AUDIO_SET): invalid 'channel'");
}
item->audioSet.channel = channelValue;
item->audioSet.pan = cutsceneJsonGetFloat(itemObj, "pan", 0.0f);
item->audioSet.looping = cutsceneJsonGetBool(itemObj, "looping", true);
item->audioSet.loopCount =
(uint8_t)cutsceneJsonGetInt(itemObj, "loopCount", 0);
item->audioSet.loopStart =
cutsceneJsonGetFloat(itemObj, "loopStart", -1.0f);
item->audioSet.loopTo = cutsceneJsonGetFloat(itemObj, "loopTo", 0.0f);
errorOk();
}
@@ -1,61 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct {
audiomixerchannel_t channel;
float_t pan;
bool_t looping;
uint8_t loopCount;
float_t loopStart;
float_t loopTo;
} cutsceneaudioset_t;
/**
* Starts an audio set step - applies pan and looping behaviour together in
* one item (via audioMixerSetPan()/audioMixerSetLoop()), for when you want
* to change both at once rather than using CUTSCENE_AUDIO_SET_PAN/
* CUTSCENE_AUDIO_SET_LOOP separately.
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioSetStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio set step (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioSetUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_SET cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_SET needs no pool storage).
* @param poolOffset Unused (AUDIO_SET needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioSetLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,54 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
void cutsceneAudioSetLoopStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerSetLoop(
item->audioSetLoop.channel,
item->audioSetLoop.looping,
item->audioSetLoop.loopCount,
item->audioSetLoop.loopStart,
item->audioSetLoop.loopTo
);
}
bool_t cutsceneAudioSetLoopUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioSetLoopLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
audiomixerchannel_t channelValue;
if(!cutsceneJsonLookupAudioChannel(
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
)) {
errorThrow("Cutscene item (AUDIO_SET_LOOP): invalid 'channel'");
}
item->audioSetLoop.channel = channelValue;
item->audioSetLoop.looping = cutsceneJsonGetBool(itemObj, "looping", true);
item->audioSetLoop.loopCount =
(uint8_t)cutsceneJsonGetInt(itemObj, "loopCount", 0);
item->audioSetLoop.loopStart =
cutsceneJsonGetFloat(itemObj, "loopStart", -1.0f);
item->audioSetLoop.loopTo = cutsceneJsonGetFloat(itemObj, "loopTo", 0.0f);
errorOk();
}
@@ -1,58 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct {
audiomixerchannel_t channel;
bool_t looping;
uint8_t loopCount;
float_t loopStart;
float_t loopTo;
} cutsceneaudiosetloop_t;
/**
* Starts an audio set-loop step (applies the new looping behaviour
* immediately, via audioMixerSetLoop()).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioSetLoopStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio set-loop step (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioSetLoopUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_SET_LOOP cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_SET_LOOP needs no pool storage).
* @param poolOffset Unused (AUDIO_SET_LOOP needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioSetLoopLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,43 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
void cutsceneAudioSetPanStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerSetPan(item->audioSetPan.channel, item->audioSetPan.pan);
}
bool_t cutsceneAudioSetPanUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioSetPanLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
audiomixerchannel_t channelValue;
if(!cutsceneJsonLookupAudioChannel(
yyjson_get_str(yyjson_obj_get(itemObj, "channel")), &channelValue
)) {
errorThrow("Cutscene item (AUDIO_SET_PAN): invalid 'channel'");
}
item->audioSetPan.channel = channelValue;
item->audioSetPan.pan = cutsceneJsonGetFloat(itemObj, "pan", 0.0f);
errorOk();
}
@@ -1,55 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
typedef struct {
audiomixerchannel_t channel;
float_t pan;
} cutsceneaudiosetpan_t;
/**
* Starts an audio set-pan step (applies the new pan immediately, via
* audioMixerSetPan()).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioSetPanStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio set-pan step (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioSetPanUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_SET_PAN cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_SET_PAN needs no pool storage).
* @param poolOffset Unused (AUDIO_SET_PAN needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioSetPanLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,33 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
#include "audio/mixer/audiomixer.h"
#include "rpg/cutscene/item/json/cutscenejsonaudiochannel.h"
void cutsceneAudioStopStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
audioMixerStop(item->audioStopChannel);
}
bool_t cutsceneAudioStopUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneAudioStopLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
return cutsceneJsonLoadAudioChannelOnly(itemObj, &item->audioStopChannel);
}
@@ -1,49 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "audio/mixer/audiomixerchannel.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Starts an audio stop step (stops the channel immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
*/
void cutsceneAudioStopStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Updates an audio stop step (always completes immediately).
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns true always.
*/
bool_t cutsceneAudioStopUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
/**
* Parses an AUDIO_STOP cutscene item's JSON fields into item.
*
* @param itemObj The item's JSON object.
* @param item Destination item, already zeroed by the caller.
* @param pool Unused (AUDIO_STOP needs no pool storage).
* @param poolOffset Unused (AUDIO_STOP needs no pool storage).
* @return Error code indicating success or failure of the parse.
*/
errorret_t cutsceneAudioStopLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
);
@@ -1,11 +0,0 @@
# Copyright (c) 2026 Dominic Masters
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
cutscenestartbattle.c
cutscenebattlewaitstate.c
cutscenebattleforceaction.c
)
@@ -1,39 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
void cutsceneBattleForceActionStart(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
battleQueueAction(
item->battleForceAction.fighterIndex, BATTLE_ACTION_ATTACK,
item->battleForceAction.targetIndex
);
}
bool_t cutsceneBattleForceActionUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return true;
}
errorret_t cutsceneBattleForceActionLoad(
yyjson_val *itemObj,
cutsceneitem_t *item,
uint8_t *pool,
size_t *poolOffset
) {
item->battleForceAction.fighterIndex =
(uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "fighterIndex"));
item->battleForceAction.targetIndex =
(uint8_t)yyjson_get_int(yyjson_obj_get(itemObj, "targetIndex"));
errorOk();
}
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more