17 Commits

Author SHA1 Message Date
YourWishes f501bb8e28 Detail the true events of the Great Explosion
Replaces the vague placeholder with the full account: the king's
personal, in-person purge of the culinary mages' hometown, the failed
hostage surrender, the town's collective final spell (built on a
shared folk song) that becomes the Great Explosion, and the
protagonist's survival via her mother's protective spell. Also adds
the king's official cover story (framing the mages as insurgents),
new characters (the Great Mage and the protagonist's mother), and
updates themes/open questions accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:29:39 -05:00
YourWishes d07cd3397d Start documenting the story: premise, structure, and Chapter 1
Adds story.md covering the overarching premise (a prophecy caused by
the attempt to prevent it), the eight-movement story structure, the
Great Explosion backstory, core characters, and a detailed breakdown
of Chapter 1's opening - the bounty party's retrieval of the
protagonist, the mountain teleportation-logistics trap that splits
the party, and the confrontation with the king.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:16:46 -05:00
YourWishes e008fb108a Embed default font as a hard-coded bitmap instead of loading it as an asset
Moves FONT_DEFAULT and its init/dispose into their own font.h/font.c,
and rebuilds it from a static glyph bit array + a runtime-generated
texture/tileset rather than loading ui/minogram.png/.dtf through the
asset system. The engine now always has a usable font to render with,
even if asset loading fails. The now-unused minogram assets are removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 22:37:22 -05:00
YourWishes 9aaffff7a8 Add autosave system with forced no-save-device prompt on failure
autoSaveQueue() is a single callable entry point for requesting an
autosave; autoSaveUpdate() pumps the queue each frame and, if the write
fails (e.g. no memory card), forces the existing no-card modal back
open and pauses world simulation until the player retries or accepts a
temporary session. A small overlay shows "SAVING" while a write is in
flight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 22:05:43 -05:00
YourWishes 7357b4a5df Localize remaining hardcoded UI strings and persist language preference
Replaces the last hardcoded English strings (initial-scene modals, game
menu save status messages) with locale-loaded text, adds a shared
LOCALE_LIST so the settings dropdown and locale manager stay in sync,
and persists the chosen language into savemeta_t across all platforms.
Also fixes two message buffers that were too small for their longest
translations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 21:39:24 -05:00
YourWishes 1ddc298a74 Only update overworld entities while the overworld scene is active
Entities (and the player's pause-to-open-game-menu handling with them)
previously kept updating every frame regardless of the active scene,
matching an existing TODO in rpgUpdate(). Gating the entity loop itself
means the game menu (and any other entity-driven input) naturally can't
trigger mid-battle or before the initial scene hands off to the
overworld, without needing a scene check at each individual call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:54:58 -05:00
YourWishes e2a9442aa6 Add initial boot scene to check/prompt for save data before overworld
A new SCENE_TYPE_INITIAL now runs before the overworld: it checks
save-device availability and existing save data, then shows one of two
new dedicated modals - "no save device found" (Retry / Continue Anyway)
or "no save data found, create one?" (Yes / No) - before handing off to
the overworld. Both modals are self-contained UI elements mirroring
uiconfirm.h's shape, registered like any other global UI element.

Choosing "Continue Anyway" marks the session temporary (SAVE.temporary,
folded into saveIsAvailable()) so saving stays disabled for the rest of
the session instead of silently retrying, and the game menu's Save
action now reports that distinctly instead of the generic "no device"
message.

Also removes rpg.c's leftover TEST block (unconditional player-name
stamp + save write on every boot) now that this real flow owns save
creation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:53:01 -05:00
YourWishes aa0180571e Unify save system into one save.h/.c; diverge storage format per platform
Renames savefile_t to saveslot_t and folds last session's standalone
settings.h/.c module back in as savemeta_t, so there's one save system
(SAVE.slots[] + SAVE.meta) instead of two parallel ones - while letting
each platform pick its own physical format for the two concepts:

- Linux now writes human-editable JSON (slot0.json, settings.json, ...)
  via yyjson's mutable writer API, so players can hand-fix a bad setting.
- PSP folds meta into the same sceUtilitySavedata binary payload as its
  one save slot (SAVE_SLOT_COUNT_MAX=1 there - a future save picker will
  let players manage multiple named saves via the OS's own browser).
- GameCube consolidates the 3 per-slot memory card files and the separate
  settings file into one combined card file.

Also fixes two bugs surfaced while building this: the CRC finalize step
seeked to a hardcoded offset (only safe for one section per file, breaks
once meta+slots share a buffer), and save.c's async/sync dispatch left an
unconditional fallback call that doesn't exist on PSP-only platforms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:35:06 -05:00
YourWishes 7f7be39230 Split device settings out of the per-slot save file
Deadzone (and future prefs like locale) now live in their own
settingsfile_t/settings.c, loaded eagerly at boot and saved immediately
on Apply, instead of inside savefile_t - a setting shouldn't reset or
diverge just because the player is on a different save slot, and this
also fixes settings changes not actually reaching disk until the next
full game Save.

PSP settings use a new plain sceIo path rather than sceUtilitySavedata,
since that dialog would flash its native icon on every settings tweak.
GameCube reuses the save system's existing memory card mount rather than
mounting it twice (settingsInit() now runs after saveInit()).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:54:15 -05:00
YourWishes 4d95415232 Move global item collected-state, deadzone, and story flags into save file
Adds three new pieces of save-file state, all following the same shape:
the save file is the single source of truth, not a separate live runtime
copy that gets synced in/out.

- globalitemstore.h/.c: per-global-entity-ID "collected" flags
  (savefile_t.globalItemCollected), so a global item entity's init
  callback can check whether it was already picked up in a prior session
  without needing to keep the entity itself alive to remember that.

- Gamepad deadzone: removed input_t.deadzone entirely. The setting UI and
  every platform's actual deadzone-applying code (inputGetDeadzoneDolphin/
  SDL2, previously hardcoded per-platform literals that the settings menu
  didn't actually affect) now read savefile_t.deadzone directly via
  saveGet(SAVE_ACTIVE_SLOT). Default lives in savefile.h
  (SAVE_DEADZONE_DEFAULT), stamped onto every slot in saveInit().

- Story flags: STORY_FLAG_VALUES (a live, codegen-initialized array) is
  replaced by savefile_t.storyFlags, read/written via the existing
  storyFlagGet()/storyFlagSet() call sites (now macros/functions over the
  active save file instead of a separate array). tools/story.py now
  generates STORY_FLAG_DEFAULTS (const) instead; storyFlagInitDefaults()
  stamps those onto a save the first time it's used (file->exists false),
  called from rpgInit().

Added SAVE_ACTIVE_SLOT (0) to savefile.h as the one shared "which slot is
actually being played" constant, replacing three different local/implicit
0s (uigamemenu.c, rpg.c, and now the settings/input call sites).

Verified round-trip on Linux (all three together in one save/load cycle);
both Linux and PSP build clean.
2026-08-04 11:10:44 -05:00
YourWishes 2cbd80a004 Check for existing save data before saving, confirm before creating new
uiGameMenuSave() now attempts a real load first (via the existing generic
saveLoad()/saveExists() primitives) instead of writing blind. If a save
already exists, it saves straight over it as before. If not, it prompts
via the existing uiConfirm dialog ("No save data found. Create a new
save?") before writing - this is exactly the flow GameCube needs (no
native OS save browser to lean on, unlike PSP), but implemented generically
so it also applies correctly on every other platform without any
platform-specific UI code: saveIsAvailable()/saveExists() already reflect
each platform's real state (e.g. Dolphin's memory-card presence and
existing-file checks), so the same logic just does the right thing
everywhere.

Verified the two branches directly on Linux (temporarily wiring the same
saveLoad -> check -> uiConfirmOpen sequence into rpgInit): with no save
file, saveExists() is false and the confirm dialog opens; with one already
written, it's true and the confirm dialog is correctly skipped. Not
verified via actual menu navigation (no input-injection tooling available
here) or on Dolphin (no devkitPPC toolchain in this environment).
2026-08-04 10:33:57 -05:00
YourWishes 9abf8101da PSP: save through the real sceUtilitySavedata API, not raw file I/O
Rewrote savepsp.c/savestreampsp.c to use sceUtilitySavedataInitStart/
Update/GetStatus/ShutdownStart instead of sceIoOpen/Read/Write, so PSP
saves get a proper OS-generated PARAM.SFO (title/savedataTitle/detail) and
show up correctly in the native save browser.

This dialog spans multiple frames and, per this project's prior experience
with the network config dialog, must be pumped non-blocking one step per
real engine frame rather than blocked on synchronously - a raw-sceGu
blocking loop already froze the app on real hardware for that dialog,
since pspGL owns the GU context. So save.h's saveWrite()/saveLoad() are
now callback-based (savecallback_t onComplete) instead of returning a
result directly, mirroring networkRequestConnection()'s shape, with a new
saveUpdate() (wired into engineUpdate()) pumping the active op each frame.
Linux/Dolphin behavior is unchanged - their fallback path in save.c still
completes synchronously, just via an immediate callback call instead of a
direct return.

Two real bugs found via PPSSPP testing (not just code review): SAVE/LOAD
modes show a confirm screen even for brand-new data, which blocks forever
headlessly - switched to AUTOSAVE/AUTOLOAD, which write/read silently and
generate the identical PARAM.SFO. And PPSSPP's dialog status goes straight
from QUIT to NONE without a separately observable FINISHED in between,
which the first version misread as "disappeared without a result" even on
a successful save - fixed by tracking whether QUIT was already seen.

Confirmed end-to-end in PPSSPP: write, dialog completes, PARAM.SFO +
encrypted save.bin appear on the virtual memory stick, and a subsequent
load decrypts/deserializes back to the exact original data. Not tested on
real PSP hardware.
2026-08-04 09:54:45 -05:00
YourWishes 24badd06a5 Add player name field to save file, save it out as a round-trip test
Adds savefile_t.playerName (SAVE_PLAYER_NAME_MAX) serialized via the
existing saveFileReadString/WriteString helpers, and stamps + saves it in
rpgInit() as a test that the save system now persists actual game data,
not just the header/version. Verified manually: written bytes end in
"Dusk\0" immediately after the version field, and loading it back returns
the same string.
2026-08-04 08:59:29 -05:00
YourWishes 7a03ef8eaf Re-enable save system, fix header/version stamping, handle missing media
- Fixed the actual reason saving never worked on any platform: saveWrite()
  never stamped file->header/file->version before serializing, so every
  written save file had a zeroed magic header and failed its own
  validation on the next load. Confirmed via a manual write/load round
  trip that this alone fully explains "saving doesn't work."
- Re-enabled saveInit()/saveDispose() in engine.c (previously commented out
  under "Temporarily disable save code").
- Added SAVE.available + saveIsAvailable(), refreshed by every real
  save/load/delete attempt. saveInit() no longer treats an unreachable
  save medium as fatal to booting - it logs and continues, since a missing
  memory card/stick shouldn't prevent playing.
- Hardened PSP's saveInitPSP() to actually detect a missing memory stick
  (sceIoGetstat on ms0:/) instead of assuming success, and fixed
  single-level sceIoMkdir to build the full PSP/SAVEDATA directory chain.
- Added busy-retry (CARD_ERROR_BUSY) and a not-mounted guard to Dolphin's
  live savestreamdolphin.c path, extending the same handling already
  backported into savedolphin.c.
- Added a "Save" entry to the game menu wired to saveWrite(0), showing a
  clear message on success, on failure, and when saveIsAvailable() is false.
2026-08-04 08:30:24 -05:00
YourWishes f3ea507313 Fixed save crash
Backported from branch ac2 (commit 85b61097) - CARD_Mount was being called
without CARD_Init first, leaving per-channel control blocks and the DSP
unlock sequence unset. On real Dolphin/hardware this surfaced as a hard
MMIO crash instead of a clean CARD_ERROR_* failure.

Co-Authored-By: Dominic Masters <dominic@domsplace.com>
2026-08-04 08:12:57 -05:00
YourWishes 4b0388a0e1 Chunk streaming concurrency, entity slot fix, and map-data-driven spawns
- Allow 2 chunks to be mid-load concurrently instead of 1 (MAP_CHUNK_LOAD_CONCURRENCY).
- Fix entitySetChunk silently losing track of an entity when its target chunk's
  entity slots are full - it now stays detached (and retries later) instead of
  claiming a chunk that never actually registered it.
- DCF format bumped to v5: chunks can now declare entity spawns (global/NPC via
  the existing entityglobal registry, or one-shot item pickups) and map area
  triggers, resolved via a new callback-ID registry (mapareagloballist.h)
  mirroring the entity one. rpg.c's hardcoded TEST entity/item/area spawns are
  gone - chunk_0_0_0.json now carries that data instead. The player is still
  bootstrapped in code since it isn't map-authored content.
2026-08-04 07:37:48 -05:00
YourWishes a84137b5ff del md 2026-08-04 06:58:03 -05:00
141 changed files with 18077 additions and 4290 deletions
-455
View File
@@ -1,455 +0,0 @@
# Dusk — Claude Code rules
## File headers
Every C, H, and JS file starts with:
```c
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
```
JS files use `//` comment style instead.
---
## C conventions
### Types
Always use the project-defined aliases instead of bare C primitives:
| Use | Not |
|-----------|--------------|
| `bool_t` | `bool` |
| `int_t` | `int` |
| `float_t` | `float` |
| `char_t` | `char` |
Use `uint8_t`, `uint16_t`, `int32_t`, etc. for fixed-width integers.
All struct and enum types end in `_t` (`animation_t`, `errorret_t`, …).
### Naming
- **Functions** — snake_case, prefixed with their module:
`assetLock()`, `entityPositionInit()`, `moduleAssetBatchCtor()`
- **Struct fields** — camelCase: `keyframeCount`, `localPosition`
- **Macros / constants** — UPPER_SNAKE_CASE:
`ENTITY_ID_INVALID`, `ERROR_OK`, `COMPONENT_TYPE_COUNT`
- **Files** — snake_case matching the primary type: `entityposition.c`,
`moduleassetbatch.c`
### Header files (`.h`)
- Use `#pragma once` — no include guards.
- Declare every public function, `#define`, and `extern` global.
- Write a JSDoc block (`/** … */`) above every declaration explaining
purpose, `@param`s, and `@returns`.
- Only include headers that the `.h` file itself strictly requires for
the types it exposes. Move everything else to the `.c` file.
Do not use forward declarations as a workaround — use the real
include in the `.c` file instead.
### Implementation files (`.c`)
- Contain function bodies only; no declarations.
- Pull in whatever additional includes the implementation needs.
- Do not use `static` or `inline` on **functions**. Every function,
including internal helpers, must be declared in the matching `.h` and
defined in the `.c` file. Internal helpers belong near the bottom of
the `.c` file, not at the top with a `static` qualifier.
`static` and `inline` on functions are only appropriate when the
function body is written directly inside a `.h` file.
`static` on **variables** (file-scope state) is fine and expected.
### Formatting
- Hard-wrap all lines at **80 characters**.
### Error handling
Return `errorret_t` from fallible functions. Use these macros:
```c
errorOk(); // return success
errorThrow("msg %d", val); // return failure with message
errorChain(someCall()); // propagate failure, continue on success
errorIsOk(ret) / errorIsNotOk(ret) // test a result
errorCatch(ret); // handle + free an error
```
Never return raw error codes or use `errno` for in-engine errors.
### Memory
Use the project allocator — never raw `malloc`/`free`:
```c
memoryAllocate(size) // allocate
memoryFree(ptr) // free
memoryZero(dest, size) // zero a block
memoryCopy(dest, src, size) // copy
```
### Asserts
Prefer specific assert macros over bare `assert()`:
```c
assertNotNull(ptr, "msg");
assertTrue(cond, "msg");
assertFalse(cond, "msg");
assertUnreachable("msg");
assertIsMainThread("msg");
```
---
## Build system
Each subdirectory has its own `CMakeLists.txt` that adds sources with:
```cmake
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
myfile.c
)
```
Never add source files to the root `CMakeLists.txt` directly.
---
## Platform support
### Targets
Set `DUSK_TARGET_SYSTEM` at CMake configure time to select a platform:
| `DUSK_TARGET_SYSTEM` | Macro defined | Platform |
|----------------------|-------------------|------------------|
| `linux` | `DUSK_LINUX` | Linux desktop |
| `knulli` | `DUSK_KNULLI` | Knulli (handheld)|
| `psp` | `DUSK_PSP` | Sony PSP |
| `vita` | `DUSK_VITA` | PlayStation Vita |
| `gamecube` | `DUSK_GAMECUBE` | Nintendo GameCube|
| `wii` | `DUSK_WII` | Nintendo Wii |
### Layer structure
```
src/dusk/ core, platform-agnostic game logic
src/duskgl/ OpenGL abstraction (Linux, Knulli, PSP, Vita)
src/dusksdl2/ SDL2 window + input (Linux, Knulli, PSP, Vita)
src/dusklinux/ Linux + Knulli platform impl
src/duskpsp/ PSP platform impl
src/duskvita/ Vita platform impl
src/duskdolphin/ GameCube / Wii platform impl (no SDL2/OpenGL)
```
Dolphin is the only target that bypasses SDL2 and OpenGL entirely —
it uses native GameCube/Wii rendering and input APIs.
### Platform guards
Use the compile-time macros for platform-specific code:
```c
#ifdef DUSK_PSP
// PSP-only path
#elif defined(DUSK_GAMECUBE) || defined(DUSK_WII)
// GameCube / Wii path
#else
// Generic / Linux fallback
#endif
```
Additional capability macros set per-target:
`DUSK_SDL2`, `DUSK_OPENGL`, `DUSK_OPENGL_ES`, `DUSK_OPENGL_LEGACY`,
`DUSK_INPUT_GAMEPAD`, `DUSK_INPUT_KEYBOARD`, `DUSK_INPUT_POINTER`,
`DUSK_PLATFORM_ENDIAN_BIG` / `DUSK_PLATFORM_ENDIAN_LITTLE`.
### Abstraction pattern
Platform-specific implementations are wired in via `#define` macros in
each platform's `displayplatform.h` / `inputplatform.h` etc., which
the core calls through. Functions that a platform does not support are
simply left undefined — the core guards calls with `#ifdef`.
### Adding platform-specific code
- Put it under `src/dusk<platform>/` in the matching subsystem folder.
- Gate any core call-site with the appropriate `#ifdef DUSK_<PLATFORM>`
or capability macro.
- Keep the `src/dusk/` core free of platform ifdefs — delegate through
the platform header macros instead.
---
## Adding a new asset loader type
1. Add an enum value to `assetloadertype_t` (before `_COUNT`) in
`src/dusk/asset/loader/assetloader.h`.
2. Add fields to the input/loading/output unions in `assetloader.h`.
3. Implement `assetXxxLoaderSync`, `assetXxxLoaderAsync`, and
`assetXxxDispose` in a new `src/dusk/asset/loader/xxx/` directory.
4. Register the three callbacks in `ASSET_LOADER_CALLBACKS[]` in
`src/dusk/asset/loader/assetloader.c`.
5. If user-facing, create a JS module (see below) and a `.d.ts` file.
---
## Adding a new entity component
1. Create `src/dusk/entity/component/<category>/entityMyComp.h/.c` with
struct `entityMyComp_t`, `entityMyCompInit()`, and optionally
`entityMyCompDispose()`.
2. Add the include to `src/dusk/entity/componentlist.h` header block.
3. Add a row to `src/dusk/entity/componentlist.h`:
```c
X(MYCOMP, entityMyComp_t, myComp, entityMyCompInit, NULL, NULL)
```
This auto-generates the enum, union field, and definition entry.
4. If JS-facing, create the script module and `.d.ts` (see below).
---
## Adding a new script (JS) module
1. Create `src/dusk/script/module/<category>/moduleMyMod.h/.c`.
- Declare `extern scriptproto_t MODULE_MYMOD_PROTO;` in the header.
- Use `moduleBaseFunction(name)` to define JS-callable functions.
- Register props/funcs in `moduleMyModInit()` with
`scriptProtoDefineProp` / `scriptProtoDefineFunc` /
`scriptProtoDefineStaticFunc`.
2. `#include` the header in
`src/dusk/script/module/modulelist.c` and call
`moduleMyModInit()` in `moduleListInit()` (and `Dispose` in
`moduleListDispose()`).
3. For component modules also register in
`src/dusk/script/module/entity/component/modulecomponentlist.c`
so `entity.add()` returns the typed wrapper.
4. Create `types/<category>/mymod.d.ts` and add a
`/// <reference path="..." />` line to `types/index.d.ts`.
---
## Script module type declarations
Whenever a `src/dusk/script/module/**/*.c` file is created or modified,
check whether the corresponding `types/**/*.d.ts` needs updating and
apply any changes before finishing the task.
---
## JavaScript (asset scripts)
- Use `var` for module-level state; `const` for values that never
change.
- Always use semicolons.
- Scene objects are plain objects (`var scene = {}`) with assigned
methods.
- Export via `module.exports = scene`.
- Async scene init should use `async function` and `await`.
---
## Coding style
### ASCII only
Source files (`.c`, `.h`, `.js`) must contain only ASCII characters (U+0000U+007F).
Non-ASCII characters are banned even in comments and string literals.
Use ASCII-only substitutes instead:
- `--` or `-` instead of `` (em dash)
- `->` instead of `` (arrow)
- `x` or `*` instead of `×` (multiplication)
Only non-script asset files (e.g. `.po` locale files) may contain non-ASCII text.
### Indentation
2 spaces. No tabs.
### Keyword and operator spacing
No space between a keyword or function name and its opening parenthesis:
```c
if(!ptr) return;
for(uint8_t i = 0; i < count; i++) {
while(entry->state != DONE) {
switch(type) {
sizeof(assetbatch_t)
memoryZero(ptr, size)
```
Spaces around all binary operators and after every comma:
```c
pos->flags |= ENTITY_POSITION_FLAG_WORLD_DIRTY;
(size_t)end - (size_t)start
foo(a, b, c)
```
### Braces
Opening brace on the **same line** as the statement (K&R style) for all
constructs — functions, `if`, `else`, `for`, `while`, `switch`:
```c
void assetEntryLock(assetentry_t *entry) {
...
}
if(dirty) {
...
} else {
...
}
```
### Guard returns
Short guards go on one line with no braces:
```c
if(!ptr) return;
if(!b || !b->batch) return jerry_undefined();
if(!(flags & DIRTY)) return;
```
### Blank lines
- One blank line between functions; no blank line at the start or end of
a function body.
- One blank line between logical blocks inside a function body.
- No trailing blank lines at the end of a file.
### Pointer placement
`*` is attached to the variable name, not the type:
```c
assetentry_t *entry
const char_t *name
void *ptr
uint8_t *d = (uint8_t *)dest;
```
### Casts
Space between cast and operand:
```c
(assetbatch_t *)user
(uint8_t *)dest
(textureformat_t)v
```
### Return
No parentheses around the return value:
```c
return ptr;
return MEMORY_POINTERS_IN_USE;
```
### switch / case
`case` indented 2 spaces from `switch`; body indented 2 more from `case`:
```c
switch(type) {
case ASSET_LOADER_TYPE_TEXTURE:
descs[i].input.texture = (textureformat_t)v;
break;
default:
break;
}
```
### Multi-line function signatures
When parameters don't fit on one line, put each on its own line indented
2 spaces; the closing `) {` (definition) or `);` (declaration) goes on
its own line at column 0:
```c
void assetEntryInit(
assetentry_t *entry,
const char_t *name,
const assetloadertype_t type,
assetloaderinput_t *input
) {
errorret_t memoryCompare(
const void *a,
const void *b,
const size_t size
);
```
### Structs and enums
Anonymous inner struct or enum with a `typedef`, `_t` suffix, closing
brace and name on the same line:
```c
typedef struct {
errorcode_t code;
char_t *message;
} errorstate_t;
typedef enum {
ASSET_LOADER_TYPE_NULL,
ASSET_LOADER_TYPE_COUNT
} assetloadertype_t;
```
### Designated initialisers
Spaces inside braces; `.field = value`:
```c
jsassetentry_t e = { .entry = entry };
assetbatchloadedpend_t init = { .batch = batch };
```
### Ternary operator
Spaces around `?` and `:`:
```c
const float val = psx > 0.0f ? pt[0][0] / psx : 0.0f;
```
### const placement
`const` before the type, `*` attached to the variable:
```c
const char_t *name
const void *src
const size_t size
```
### Comments in `.c` files
- Do not use section dividers (`/* ---- ... ---- */`). Just let the
functions follow one another with a single blank line between them.
- Multi-line explanatory comments inside function bodies use `//` lines:
```c
// Script modules are freed; orphaned JS wrapper objects now get GC'd
// so their finalizers fire before assetDispose() checks ref counts.
jerry_heap_gc(JERRY_GC_PRESSURE_HIGH);
```
- Do not use `/* */` for inline or inline-block comments inside `.c`
function bodies.
### Comments in `.h` files
Every public declaration gets a Javadoc block (`/** … */`) with
`@param` and `@returns` where relevant. Keep it on the lines immediately
above the declaration with no blank line in between.
---
## Color system
Colors are defined in `src/dusk/display/color.csv` and code-generated
into a `color.h` header by `tools/color/csv/__main__.py`.
Each row in the CSV has `name,r,g,b,a` with channel values in `[0.0, 1.0]`.
The script emits four `#define` variants per color plus a bare alias:
```
COLOR_<NAME>_4B color4b(r8, g8, b8, a8) // default alias target
COLOR_<NAME>_3B color3b(r8, g8, b8)
COLOR_<NAME>_3F color3f(rf, gf, bf)
COLOR_<NAME>_4F color4f(rf, gf, bf, af)
COLOR_<NAME> COLOR_<NAME>_4B
```
`color_t` is `color4b_t` (four `uint8_t` channels).
To add a new color, append a row to `color.csv` and rebuild — do not
hand-edit the generated header.
---
## Tests
- Tests live in `test/` mirroring `src/dusk/` structure.
- Use cmocka; include `dusktest.h`.
- Test functions: `static void test_something(void **state)`.
- After each test, assert `memoryGetAllocatedCount() == 0` to catch
leaks.
- Build with `-DDUSK_BUILD_TESTS=ON`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-25
View File
@@ -1,25 +0,0 @@
{
"pause": "DEFAULT",
"items": [
{ "type": "text", "text": "Test Two." },
{ "type": "entityAdd", "entityType": "npc", "position": [4, 4, 0] },
{
"type": "textMini",
"text": "Hello!",
"position": [4, 4, 0],
"duration": 3.0
},
{
"type": "emoji",
"entityIndex": "lastCreated",
"emojiType": "exclamation",
"duration": 2.0
},
{
"type": "entityWalkTo",
"entityIndex": "lastCreated",
"positions": [[8, 2, 0]]
},
{ "type": "text", "text": "Done." }
]
}
+56
View File
@@ -56,6 +56,62 @@ msgstr "Items"
msgid "ui.game_menu.settings" msgid "ui.game_menu.settings"
msgstr "Settings" msgstr "Settings"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Save"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "Game saved."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "Save cancelled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "Can't save - no save device found."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "This session is temporary - no save device was found, so saving is disabled."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "Save failed: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "Can't save: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "No save device found. You can continue, but\nprogress will not be saved."
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "Retry"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "Continue Anyway"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "No save data found. Create a new save?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "Yes"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "No"
msgid "item.potion.name" msgid "item.potion.name"
msgstr "Potion" msgstr "Potion"
+56
View File
@@ -57,6 +57,62 @@ msgstr "Objetos"
msgid "ui.game_menu.settings" msgid "ui.game_menu.settings"
msgstr "Configuración" msgstr "Configuración"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "Guardar"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "Partida guardada."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "Guardado cancelado."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "No se puede guardar: no se encontró ningún dispositivo de guardado."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "Esta sesión es temporal - no se encontró ningún dispositivo de guardado, por lo que guardar está deshabilitado."
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "No se encontraron datos guardados. ¿Crear una partida nueva?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "Error al guardar: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "No se puede guardar: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "No se encontró ningún dispositivo de guardado. Puedes continuar, pero\nel progreso no se guardará."
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "Reintentar"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "Continuar de todos modos"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "No se encontraron datos guardados. ¿Crear una partida nueva?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "Sí"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "No"
#: src/dusk/rpg/item/item.json #: src/dusk/rpg/item/item.json
msgid "item.potion.name" msgid "item.potion.name"
msgstr "Poción" msgstr "Poción"
+56
View File
@@ -57,6 +57,62 @@ msgstr "アイテム"
msgid "ui.game_menu.settings" msgid "ui.game_menu.settings"
msgstr "設定" msgstr "設定"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save"
msgstr "セーブ"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_success"
msgstr "セーブしました。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_cancelled"
msgstr "セーブをキャンセルしました。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_unavailable"
msgstr "セーブできません - セーブデバイスが見つかりません。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_temporary"
msgstr "このセッションは一時的です - セーブデバイスが見つからなかったため、セーブは無効になっています。"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_create_confirm"
msgstr "セーブデータが見つかりません。新しいセーブを作成しますか?"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_failed_format"
msgstr "セーブに失敗しました: %s"
#: src/dusk/ui/frame/game/uigamemenu.c
msgid "ui.game_menu.save_check_failed_format"
msgstr "セーブできません: %s"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.message"
msgstr "セーブデバイスが見つかりません。続行できますが、\n進行状況は保存されません。"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.retry"
msgstr "再試行"
#: src/dusk/ui/frame/initial/uiinitialnocard.c
msgid "ui.initial.no_card.continue"
msgstr "続行する"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.message"
msgstr "セーブデータが見つかりません。新しいセーブを作成しますか?"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.yes"
msgstr "はい"
#: src/dusk/ui/frame/initial/uiinitialcreatesave.c
msgid "ui.initial.create_save.no"
msgstr "いいえ"
#: src/dusk/rpg/item/item.json #: src/dusk/rpg/item/item.json
msgid "item.potion.name" msgid "item.potion.name"
msgstr "ポーション" msgstr "ポーション"
-1
View File
@@ -1 +0,0 @@
{"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], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [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], [0, 0], [0, 0], [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], [0, 0], [0, 0], [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], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [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], [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": [{"model": "models/chunks/chunk_-1_0_0_0.json", "offset": [0.0, 0.0, 0.0]}, {"model": "models/buildings/house_4_4.json", "offset": [2.0, 1.0, 0.0]}, {"model": "models/buildings/house_6_3.json", "offset": [1.0, 9.0, 0.0]}, {"model": "models/buildings/house_2_2.json", "offset": [12.0, 6.0, 0.0]}]}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
{"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], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [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], [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], [0, 0], [0, 0], [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], [0, 0], [0, 0], [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], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0]], "meshes": [{"model": "models/chunks/chunk_0_1_0_0.json", "offset": [0.0, 0.0, 0.0]}, {"model": "models/buildings/house_8_4.json", "offset": [1.0, 1.0, 0.0]}, {"model": "models/buildings/house_3_3.json", "offset": [11.0, 2.0, 0.0]}, {"model": "models/buildings/house_2_2.json", "offset": [3.0, 11.0, 0.0]}]}
-1
View File
@@ -1 +0,0 @@
{"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], [0, 0], [0, 0], [0, 0], [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], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [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], [0, 0], [0, 0], [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], [0, 0], [0, 0], [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], [0, 0], [0, 0], [0, 0], [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], [0, 0], [0, 0], [0, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [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], [0, 0], [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], [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": [{"model": "models/chunks/chunk_1_0_0_0.json", "offset": [0.0, 0.0, 0.0]}, {"model": "models/buildings/house_3_2.json", "offset": [1.0, 1.0, 0.0]}, {"model": "models/buildings/house_2_3.json", "offset": [7.0, 2.0, 0.0]}, {"model": "models/buildings/house_4_2.json", "offset": [1.0, 8.0, 0.0]}, {"model": "models/buildings/house_1_1.json", "offset": [11.0, 10.0, 0.0]}, {"model": "models/buildings/house_5_2.json", "offset": [13.0, 6.0, 0.0]}]}
-1
View File
@@ -1 +0,0 @@
{"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], [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], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [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], [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], [0, 0], [0, 0], [0, 0], [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], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0]], "meshes": [{"model": "models/chunks/chunk_1_1_0_0.json", "offset": [0.0, 0.0, 0.0]}, {"model": "models/buildings/house_5_5.json", "offset": [3.0, 2.0, 0.0]}, {"model": "models/buildings/house_2_3.json", "offset": [10.0, 3.0, 0.0]}, {"model": "models/buildings/house_3_1.json", "offset": [1.0, 12.0, 0.0]}]}
-1
View File
@@ -1 +0,0 @@
{"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], [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], [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, 3], [1, 3], [1, 3], [1, 3], [1, 3], [1, 3], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 3], [1, 3], [1, 3], [1, 3], [1, 3], [1, 3], [1, 0], [1, 0], [1, 0], [1, 0], [0, 0], [0, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 3], [1, 3], [1, 3], [1, 3], [1, 3], [1, 3], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [4, 2], [4, 2], [4, 2], [4, 2], [4, 2], [4, 2], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [4, 1], [4, 1], [4, 1], [4, 1], [4, 1], [4, 1], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [1, 0], [4, 0], [4, 0], [4, 0], [4, 0], [4, 0], [4, 0], [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], [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": [{"model": "models/chunks/chunk_2_0_0_0.json", "offset": [0.0, 0.0, 0.0]}]}
-13
View File
@@ -1,13 +0,0 @@
{
"name": "Test Map",
"entities": [
{ "type": "player", "position": [10, 2, 0], "direction": "north" },
{ "type": "item", "position": [12, 2, 0], "item": "POTION", "quantity": 1 },
{
"type": "npc",
"position": [8, 8, 1],
"path": [[4, 4, 0], [10, 10, 1], [4, 4, 0], [10, 10, 1]],
"cutscene": "test_npc"
}
]
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+173 -136
View File
@@ -8,17 +8,60 @@
#include "assetchunkloader.h" #include "assetchunkloader.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/endian.h"
#include "asset/loader/assetloading.h" #include "asset/loader/assetloading.h"
#include "asset/loader/assetentry.h" #include "asset/loader/assetentry.h"
#include "asset/loader/assetloader.h" #include "asset/loader/assetloader.h"
#include "asset/loader/json/assetjsonloader.h"
#include "asset/asset.h" #include "asset/asset.h"
#include "yyjson.h"
// Reads a little-endian int16 from a potentially-unaligned offset into a
// worldunit_t, advancing *offset past it.
static worldunit_t assetChunkReadWorldUnit(
const uint8_t *data,
size_t *offset
) {
int16_t value;
memoryCopy(&value, data + *offset, sizeof(int16_t));
*offset += sizeof(int16_t);
return (worldunit_t)endianLittleToHost16((uint16_t)value);
}
static worldpos_t assetChunkReadWorldPos(const uint8_t *data, size_t *offset) {
worldpos_t pos;
pos.x = assetChunkReadWorldUnit(data, offset);
pos.y = assetChunkReadWorldUnit(data, offset);
pos.z = assetChunkReadWorldUnit(data, offset);
return pos;
}
errorret_t assetChunkLoaderAsync(assetloading_t *loading) { errorret_t assetChunkLoaderAsync(assetloading_t *loading) {
assertNotNull(loading, "Loading cannot be NULL"); assertNotNull(loading, "Loading cannot be NULL");
assertNotMainThread("Should be called from an async thread."); assertNotMainThread("Should be called from an async thread.");
if(loading->loading.chunk.state != ASSET_CHUNK_LOADING_STATE_READ_FILE) {
errorOk();
}
assertNull(loading->loading.chunk.data, "Data already defined?");
assetfile_t *file = &loading->loading.chunk.file;
assetLoaderErrorChain(loading,
assetFileInit(file, loading->entry->name, NULL, NULL)
);
uint8_t *data = memoryAllocate(file->size);
assetLoaderErrorChain(loading, assetFileOpen(file));
assetLoaderErrorChain(loading, assetFileRead(file, data, file->size));
assetLoaderErrorChain(loading, assetFileClose(file));
assetLoaderErrorChain(loading, assetFileDispose(file));
assertTrue(
file->lastRead == file->size,
"Failed to read entire chunk file."
);
loading->loading.chunk.data = data;
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_PARSE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk(); errorOk();
} }
@@ -31,141 +74,12 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
switch(loading->loading.chunk.state) { switch(loading->loading.chunk.state) {
case ASSET_CHUNK_LOADING_STATE_INITIAL: case ASSET_CHUNK_LOADING_STATE_INITIAL:
loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_LOAD_JSON; loading->loading.chunk.state = ASSET_CHUNK_LOADING_STATE_READ_FILE;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC;
errorOk(); errorOk();
case ASSET_CHUNK_LOADING_STATE_LOAD_JSON: { case ASSET_CHUNK_LOADING_STATE_PARSE:
// Lock the chunk's JSON as a sub-asset. The entry key is prefixed break;
// with "json:" to avoid a type-collision with the chunk entry
// itself (both share the same filename). The JSON loader reads
// the real file path supplied in the input.
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 ret = assetRequireLoaded(jsonEntry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorChain(ret);
}
yyjson_val *root = yyjson_doc_get_root(jsonEntry->data.json);
yyjson_val *tilesVal = yyjson_obj_get(root, "tiles");
if(
!tilesVal || !yyjson_is_arr(tilesVal) ||
yyjson_arr_size(tilesVal) != CHUNK_TILE_COUNT
) {
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow(
"Chunk JSON 'tiles' must have exactly %d entries",
CHUNK_TILE_COUNT
);
}
out->tiles = memoryAllocate(CHUNK_TILE_COUNT * sizeof(tile_t));
size_t tileIdx, tileMax;
yyjson_val *tileVal;
yyjson_arr_foreach(tilesVal, tileIdx, tileMax, tileVal) {
yyjson_val *shapeVal = yyjson_arr_get(tileVal, 0);
yyjson_val *zVal = yyjson_arr_get(tileVal, 1);
if(
!yyjson_is_arr(tileVal) || yyjson_arr_size(tileVal) != 2 ||
!yyjson_is_int(shapeVal) || !yyjson_is_int(zVal)
) {
memoryFree(out->tiles);
out->tiles = NULL;
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow("Chunk JSON tile entries must be [shape, z] arrays");
}
out->tiles[tileIdx] = (tile_t){
.shape = (tileshape_t)yyjson_get_int(shapeVal),
.z = (uint8_t)yyjson_get_int(zVal)
};
}
yyjson_val *meshesVal = yyjson_obj_get(root, "meshes");
out->meshCount = 0;
if(meshesVal && yyjson_is_arr(meshesVal)) {
size_t meshCount = yyjson_arr_size(meshesVal);
if(meshCount > CHUNK_MESH_COUNT_MAX) {
memoryFree(out->tiles);
out->tiles = NULL;
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow(
"Chunk JSON 'meshes' exceeds CHUNK_MESH_COUNT_MAX (%d)",
CHUNK_MESH_COUNT_MAX
);
}
size_t meshIdx, meshMax;
yyjson_val *meshVal;
yyjson_arr_foreach(meshesVal, meshIdx, meshMax, meshVal) {
yyjson_val *modelVal = yyjson_obj_get(meshVal, "model");
if(!modelVal || !yyjson_is_str(modelVal)) {
memoryFree(out->tiles);
out->tiles = NULL;
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow("Chunk JSON mesh entry missing 'model' string");
}
const char_t *modelStr = yyjson_get_str(modelVal);
size_t modelLen = yyjson_get_len(modelVal);
if(modelLen >= CHUNK_MESH_NAME_MAX) {
memoryFree(out->tiles);
out->tiles = NULL;
assetUnlockEntry(jsonEntry);
loading->entry->state = ASSET_ENTRY_STATE_ERROR;
errorThrow(
"Chunk JSON model path '%s' exceeds max length", modelStr
);
}
memoryCopy(out->modelNames[meshIdx], modelStr, modelLen + 1);
vec3 offset = { 0.0f, 0.0f, 0.0f };
yyjson_val *offsetVal = yyjson_obj_get(meshVal, "offset");
if(
offsetVal && yyjson_is_arr(offsetVal) &&
yyjson_arr_size(offsetVal) == 3
) {
size_t offIdx, offMax;
yyjson_val *offElem;
yyjson_arr_foreach(offsetVal, offIdx, offMax, offElem) {
if(yyjson_is_num(offElem)) {
offset[offIdx] = (float_t)yyjson_get_num(offElem);
}
}
}
glm_vec3_copy(offset, out->meshOffsets[meshIdx]);
}
out->meshCount = (uint8_t)meshCount;
}
assetUnlockEntry(jsonEntry);
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 = 0;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
}
case ASSET_CHUNK_LOADING_STATE_LOAD_MODELS: case ASSET_CHUNK_LOADING_STATE_LOAD_MODELS:
while(loading->loading.chunk.modelIndex < out->meshCount) { while(loading->loading.chunk.modelIndex < out->meshCount) {
@@ -197,6 +111,129 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
default: default:
errorOk(); errorOk();
} }
uint8_t *data = loading->loading.chunk.data;
assertNotNull(data, "Chunk data should have been loaded by now.");
if(data[0] != 'D' || data[1] != 'C' || data[2] != 'F') {
memoryFree(data);
assetLoaderErrorThrow(loading, "Invalid chunk file header");
}
uint32_t version = endianLittleToHost32(*(uint32_t *)(data + 4));
if(version != ASSET_CHUNK_FILE_VERSION) {
memoryFree(data);
assetLoaderErrorThrow(
loading, "Unsupported chunk file version %u", version
);
}
size_t offset = 8;
size_t tileSize = CHUNK_TILE_COUNT * sizeof(tile_t);
out->tiles = memoryAllocate(tileSize);
memoryCopy(out->tiles, data + offset, tileSize);
offset += tileSize;
for(size_t t = 0; t < CHUNK_TILE_COUNT; t++) {
uint32_t *shape = (uint32_t *)&out->tiles[t].shape;
*shape = endianLittleToHost32(*shape);
}
out->meshCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->meshCount <= CHUNK_MESH_COUNT_MAX,
"Chunk mesh count exceeds maximum."
);
for(uint8_t m = 0; m < out->meshCount; m++) {
uint8_t nameLen = 0;
while(
data[offset + nameLen] != '\0' &&
nameLen < CHUNK_MESH_NAME_MAX - 1
) {
nameLen++;
}
memoryCopy(out->modelNames[m], data + offset, nameLen);
out->modelNames[m][nameLen] = '\0';
offset += nameLen + 1;
memoryCopy(out->meshOffsets[m], data + offset, sizeof(vec3));
offset += sizeof(vec3);
out->meshOffsets[m][0] = endianLittleToHostFloat(out->meshOffsets[m][0]);
out->meshOffsets[m][1] = endianLittleToHostFloat(out->meshOffsets[m][1]);
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]);
}
out->entitySpawnCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->entitySpawnCount <= CHUNK_ENTITY_SPAWN_COUNT_MAX,
"Chunk entity spawn count exceeds maximum."
);
for(uint8_t s = 0; s < out->entitySpawnCount; s++) {
chunkentityspawn_t *spawn = &out->entitySpawns[s];
spawn->kind = (chunkentityspawnkind_t)data[offset];
offset += sizeof(uint8_t);
uint16_t a;
memoryCopy(&a, data + offset, sizeof(uint16_t));
a = endianLittleToHost16(a);
offset += sizeof(uint16_t);
uint8_t b = data[offset];
offset += sizeof(uint8_t);
if(spawn->kind == CHUNK_ENTITY_SPAWN_KIND_ITEM) {
spawn->globalId = 0;
spawn->itemId = a;
spawn->itemQuantity = b;
} else {
spawn->globalId = a;
spawn->itemId = 0;
spawn->itemQuantity = 0;
}
spawn->position = assetChunkReadWorldPos(data, &offset);
}
out->areaSpawnCount = data[offset];
offset += sizeof(uint8_t);
assertTrue(
out->areaSpawnCount <= CHUNK_AREA_COUNT_MAX,
"Chunk area spawn count exceeds maximum."
);
for(uint8_t s = 0; s < out->areaSpawnCount; s++) {
chunkareaspawn_t *area = &out->areaSpawns[s];
area->min = assetChunkReadWorldPos(data, &offset);
area->max = assetChunkReadWorldPos(data, &offset);
uint16_t callbackId;
memoryCopy(&callbackId, data + offset, sizeof(uint16_t));
area->callbackId = endianLittleToHost16(callbackId);
offset += sizeof(uint16_t);
area->notify = data[offset];
offset += sizeof(uint8_t);
area->trigger = data[offset];
offset += sizeof(uint8_t);
}
memoryFree(data);
loading->loading.chunk.data = NULL;
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 = 0;
loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC;
errorOk();
} }
errorret_t assetChunkDispose(assetentry_t *entry) { errorret_t assetChunkDispose(assetentry_t *entry) {
+41 -21
View File
@@ -9,6 +9,8 @@
#include "asset/assetfile.h" #include "asset/assetfile.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/chunk.h"
#define ASSET_CHUNK_FILE_VERSION 5
typedef struct assetloading_s assetloading_t; typedef struct assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
@@ -18,27 +20,58 @@ typedef struct {
typedef enum { typedef enum {
ASSET_CHUNK_LOADING_STATE_INITIAL, ASSET_CHUNK_LOADING_STATE_INITIAL,
ASSET_CHUNK_LOADING_STATE_LOAD_JSON, ASSET_CHUNK_LOADING_STATE_READ_FILE,
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS ASSET_CHUNK_LOADING_STATE_PARSE,
ASSET_CHUNK_LOADING_STATE_LOAD_MODELS,
ASSET_CHUNK_LOADING_STATE_DONE
} assetchunkloadingstate_t; } assetchunkloadingstate_t;
typedef struct { typedef struct {
assetfile_t file;
assetchunkloadingstate_t state; assetchunkloadingstate_t state;
uint8_t *data;
uint8_t modelIndex; uint8_t modelIndex;
} assetchunkloaderloading_t; } 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 { typedef struct {
tile_t *tiles; tile_t *tiles;
uint8_t meshCount; uint8_t meshCount;
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX]; char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
vec3 meshOffsets[CHUNK_MESH_COUNT_MAX]; vec3 meshOffsets[CHUNK_MESH_COUNT_MAX];
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX]; assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
uint8_t entitySpawnCount;
chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX];
uint8_t areaSpawnCount;
chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX];
} assetchunkoutput_t; } assetchunkoutput_t;
/** /**
* Asynchronous loader for chunk assets. No-op - the chunk's JSON file is * Asynchronous loader for chunk assets. Reads the raw DCF file bytes into
* loaded via a JSON sub-asset in the sync phase (see assetChunkLoaderSync), * the loading buffer so the sync phase can parse without blocking the
* which handles its own async file I/O. * main thread on I/O.
* *
* @param loading Loading information for the asset being loaded. * @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation. * @return Error code indicating success or failure of the load operation.
@@ -46,22 +79,9 @@ typedef struct {
errorret_t assetChunkLoaderAsync(assetloading_t *loading); errorret_t assetChunkLoaderAsync(assetloading_t *loading);
/** /**
* Synchronous loader for chunk assets. Locks and parses the chunk's JSON * Synchronous loader for chunk assets. Validates the DCF binary previously
* file (tiles plus referenced model paths/offsets), then locks each * read by the async phase and populates the output assetchunkoutput_t with
* referenced model asset before marking the entry loaded. * tile data and model paths.
*
* Expected JSON shape:
* {
* "tiles": [ [shape, z], ... ] // exactly CHUNK_TILE_COUNT entries,
* // one per (x, y) column, x-major
* "meshes": [
* { "model": "models/chunks/chunk_0_0_0_0.json",
* "offset": [0.0, 0.0, 0.0] }
* ]
* }
* "meshes" is optional; each entry's "offset" defaults to [0, 0, 0] if
* omitted. By convention mesh index 0 is the chunk's terrain and the
* rest are props (see sceneOverworldDrawChunksBase/Props).
* *
* @param loading Loading information for the asset being loaded. * @param loading Loading information for the asset being loaded.
* @return Error code indicating success or failure of the load operation. * @return Error code indicating success or failure of the load operation.
+1
View File
@@ -7,4 +7,5 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
text.c text.c
font.c
) )
+168
View File
@@ -0,0 +1,168 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "font.h"
#include "util/memory.h"
#include "util/math.h"
#include "display/color.h"
font_t FONT_DEFAULT;
static texture_t FONT_DEFAULT_TEXTURE;
static tileset_t FONT_DEFAULT_TILESET;
const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
FONT_DEFAULT_TILE_HEIGHT
] = {
{ 0x00, 0x10, 0x10, 0x10, 0x10, 0x10, 0x00, 0x10, 0x00, 0x00 }, // !
{ 0x00, 0x14, 0x14, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // "
{ 0x00, 0x14, 0x14, 0x3E, 0x14, 0x3E, 0x14, 0x14, 0x00, 0x00 }, // #
{ 0x00, 0x08, 0x1E, 0x28, 0x1C, 0x0A, 0x3C, 0x08, 0x00, 0x00 }, // $
{ 0x00, 0x00, 0x22, 0x24, 0x08, 0x12, 0x22, 0x00, 0x00, 0x00 }, // %
{ 0x00, 0x08, 0x14, 0x14, 0x1A, 0x24, 0x24, 0x1A, 0x00, 0x00 }, // &
{ 0x00, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '
{ 0x00, 0x04, 0x08, 0x08, 0x08, 0x08, 0x08, 0x04, 0x00, 0x00 }, // (
{ 0x00, 0x10, 0x08, 0x08, 0x08, 0x08, 0x08, 0x10, 0x00, 0x00 }, // )
{ 0x00, 0x00, 0x08, 0x2A, 0x1C, 0x2A, 0x08, 0x00, 0x00, 0x00 }, // *
{ 0x00, 0x00, 0x08, 0x08, 0x3E, 0x08, 0x08, 0x00, 0x00, 0x00 }, // +
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, 0x20, 0x00 }, // ,
{ 0x00, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00 }, // -
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x10, 0x00, 0x00 }, // .
{ 0x00, 0x04, 0x04, 0x08, 0x08, 0x08, 0x10, 0x10, 0x00, 0x00 }, // /
{ 0x00, 0x1C, 0x22, 0x26, 0x2A, 0x32, 0x22, 0x1C, 0x00, 0x00 }, // 0
{ 0x00, 0x08, 0x18, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // 1
{ 0x00, 0x1C, 0x22, 0x02, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // 2
{ 0x00, 0x1C, 0x22, 0x02, 0x0C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 3
{ 0x00, 0x22, 0x22, 0x22, 0x3E, 0x02, 0x02, 0x02, 0x00, 0x00 }, // 4
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 5
{ 0x00, 0x1C, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // 6
{ 0x00, 0x3E, 0x02, 0x02, 0x04, 0x08, 0x08, 0x08, 0x00, 0x00 }, // 7
{ 0x00, 0x1C, 0x22, 0x22, 0x1C, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // 8
{ 0x00, 0x1C, 0x22, 0x22, 0x1E, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // 9
{ 0x00, 0x00, 0x10, 0x10, 0x00, 0x10, 0x10, 0x00, 0x00, 0x00 }, // :
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ;
{ 0x00, 0x04, 0x08, 0x10, 0x20, 0x10, 0x08, 0x04, 0x00, 0x00 }, // <
{ 0x00, 0x00, 0x00, 0x3E, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00 }, // =
{ 0x00, 0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00 }, // >
{ 0x00, 0x1C, 0x22, 0x02, 0x04, 0x08, 0x00, 0x08, 0x00, 0x00 }, // ?
{ 0x00, 0x1C, 0x26, 0x2A, 0x2A, 0x26, 0x20, 0x1C, 0x00, 0x00 }, // @
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x00, 0x00 }, // A
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // B
{ 0x00, 0x1C, 0x22, 0x20, 0x20, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // C
{ 0x00, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // D
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x20, 0x20, 0x3E, 0x00, 0x00 }, // E
{ 0x00, 0x3E, 0x20, 0x20, 0x3C, 0x20, 0x20, 0x20, 0x00, 0x00 }, // F
{ 0x00, 0x1C, 0x22, 0x20, 0x2E, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // G
{ 0x00, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x22, 0x00, 0x00 }, // H
{ 0x00, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // I
{ 0x00, 0x02, 0x02, 0x02, 0x02, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // J
{ 0x00, 0x22, 0x24, 0x28, 0x30, 0x28, 0x24, 0x22, 0x00, 0x00 }, // K
{ 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x3E, 0x00, 0x00 }, // L
{ 0x00, 0x22, 0x36, 0x2A, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // M
{ 0x00, 0x22, 0x22, 0x32, 0x2A, 0x26, 0x22, 0x22, 0x00, 0x00 }, // N
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // O
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x20, 0x20, 0x20, 0x00, 0x00 }, // P
{ 0x00, 0x1C, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x06, 0x00 }, // Q
{ 0x00, 0x3C, 0x22, 0x22, 0x3C, 0x22, 0x22, 0x22, 0x00, 0x00 }, // R
{ 0x00, 0x1C, 0x22, 0x20, 0x1C, 0x02, 0x22, 0x1C, 0x00, 0x00 }, // S
{ 0x00, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // T
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // U
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x14, 0x14, 0x08, 0x00, 0x00 }, // V
{ 0x00, 0x22, 0x22, 0x22, 0x22, 0x2A, 0x36, 0x22, 0x00, 0x00 }, // W
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x14, 0x22, 0x22, 0x00, 0x00 }, // X
{ 0x00, 0x22, 0x22, 0x14, 0x08, 0x08, 0x08, 0x08, 0x00, 0x00 }, // Y
{ 0x00, 0x3E, 0x02, 0x04, 0x08, 0x10, 0x20, 0x3E, 0x00, 0x00 }, // Z
{ 0x00, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0C, 0x00, 0x00 }, // [
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // backslash (not drawn in source font)
{ 0x00, 0x18, 0x08, 0x08, 0x08, 0x08, 0x08, 0x18, 0x00, 0x00 }, // ]
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ^ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // _ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ` (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // a
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x00, 0x00 }, // b
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x20, 0x22, 0x1C, 0x00, 0x00 }, // c
{ 0x00, 0x02, 0x02, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // d
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x3E, 0x20, 0x1C, 0x00, 0x00 }, // e
{ 0x00, 0x0C, 0x12, 0x10, 0x3C, 0x10, 0x10, 0x10, 0x00, 0x00 }, // f
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // g
{ 0x00, 0x20, 0x20, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // h
{ 0x00, 0x08, 0x00, 0x18, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00 }, // i
{ 0x00, 0x02, 0x00, 0x06, 0x02, 0x02, 0x02, 0x02, 0x22, 0x1C }, // j
{ 0x00, 0x20, 0x20, 0x22, 0x24, 0x38, 0x24, 0x22, 0x00, 0x00 }, // k
{ 0x00, 0x30, 0x10, 0x10, 0x10, 0x10, 0x10, 0x0E, 0x00, 0x00 }, // l
{ 0x00, 0x00, 0x00, 0x3C, 0x2A, 0x2A, 0x2A, 0x2A, 0x00, 0x00 }, // m
{ 0x00, 0x00, 0x00, 0x3C, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00 }, // n
{ 0x00, 0x00, 0x00, 0x1C, 0x22, 0x22, 0x22, 0x1C, 0x00, 0x00 }, // o
{ 0x00, 0x00, 0x00, 0x3C, 0x22, 0x22, 0x22, 0x3C, 0x20, 0x20 }, // p
{ 0x00, 0x00, 0x00, 0x1E, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x02 }, // q
{ 0x00, 0x00, 0x00, 0x2C, 0x32, 0x20, 0x20, 0x20, 0x00, 0x00 }, // r
{ 0x00, 0x00, 0x00, 0x1E, 0x20, 0x1C, 0x02, 0x3C, 0x00, 0x00 }, // s
{ 0x00, 0x10, 0x10, 0x3C, 0x10, 0x10, 0x10, 0x0E, 0x00, 0x00 }, // t
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x00, 0x00 }, // u
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x14, 0x08, 0x00, 0x00 }, // v
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x2A, 0x2A, 0x14, 0x00, 0x00 }, // w
{ 0x00, 0x00, 0x00, 0x22, 0x14, 0x08, 0x14, 0x22, 0x00, 0x00 }, // x
{ 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x1E, 0x02, 0x1C }, // y
{ 0x00, 0x00, 0x00, 0x3E, 0x04, 0x08, 0x10, 0x3E, 0x00, 0x00 }, // z
{ 0x00, 0x04, 0x08, 0x08, 0x10, 0x08, 0x08, 0x04, 0x00, 0x00 }, // {
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // | (not drawn in source font)
{ 0x00, 0x10, 0x08, 0x08, 0x04, 0x08, 0x08, 0x10, 0x00, 0x00 }, // }
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ~ (not drawn in source font)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // unused trailing tile
};
errorret_t fontDefaultInit(void) {
const int32_t width = (int32_t)mathNextPowTwo(
FONT_DEFAULT_COLUMNS * FONT_DEFAULT_TILE_WIDTH
);
const int32_t height = (int32_t)mathNextPowTwo(
FONT_DEFAULT_ROWS * FONT_DEFAULT_TILE_HEIGHT
);
color_t *pixels = memoryAllocate(sizeof(color_t) * width * height);
memoryZero(pixels, sizeof(color_t) * width * height);
for(uint16_t i = 0; i < FONT_DEFAULT_TILE_COUNT; i++) {
const uint16_t tileX = (i % FONT_DEFAULT_COLUMNS) * FONT_DEFAULT_TILE_WIDTH;
const uint16_t tileY = (i / FONT_DEFAULT_COLUMNS) * FONT_DEFAULT_TILE_HEIGHT;
for(uint8_t row = 0; row < FONT_DEFAULT_TILE_HEIGHT; row++) {
const uint8_t bits = FONT_DEFAULT_GLYPHS[i][row];
for(uint8_t col = 0; col < FONT_DEFAULT_TILE_WIDTH; col++) {
if(!((bits >> (FONT_DEFAULT_TILE_WIDTH - 1 - col)) & 1)) continue;
pixels[((tileY + row) * width) + (tileX + col)] = COLOR_WHITE;
}
}
}
FONT_DEFAULT_TILESET.tileWidth = FONT_DEFAULT_TILE_WIDTH;
FONT_DEFAULT_TILESET.tileHeight = FONT_DEFAULT_TILE_HEIGHT;
FONT_DEFAULT_TILESET.columns = FONT_DEFAULT_COLUMNS;
FONT_DEFAULT_TILESET.rows = FONT_DEFAULT_ROWS;
FONT_DEFAULT_TILESET.tileCount = FONT_DEFAULT_TILE_COUNT;
FONT_DEFAULT_TILESET.uv[0] = (float_t)FONT_DEFAULT_TILE_WIDTH / (float_t)width;
FONT_DEFAULT_TILESET.uv[1] = (float_t)FONT_DEFAULT_TILE_HEIGHT / (float_t)height;
const texturedata_t data = { .rgbaColors = pixels };
errorret_t textureResult = textureInit(
&FONT_DEFAULT_TEXTURE, width, height, TEXTURE_FORMAT_RGBA, data
);
memoryFree(pixels);
errorChain(textureResult);
FONT_DEFAULT.texture = &FONT_DEFAULT_TEXTURE;
FONT_DEFAULT.tileset = &FONT_DEFAULT_TILESET;
errorOk();
}
errorret_t fontDefaultDispose(void) {
errorChain(textureDispose(&FONT_DEFAULT_TEXTURE));
FONT_DEFAULT.texture = NULL;
FONT_DEFAULT.tileset = NULL;
errorOk();
}
+49
View File
@@ -6,6 +6,7 @@
*/ */
#pragma once #pragma once
#include "error/error.h"
#include "display/texture/texture.h" #include "display/texture/texture.h"
#include "display/texture/tileset.h" #include "display/texture/tileset.h"
@@ -13,3 +14,51 @@ typedef struct {
texture_t *texture; texture_t *texture;
tileset_t *tileset; tileset_t *tileset;
} font_t; } font_t;
/**
* Pixel width/height of a single default-font glyph tile.
*/
#define FONT_DEFAULT_TILE_WIDTH 6
#define FONT_DEFAULT_TILE_HEIGHT 10
/** Grid layout of the generated default-font texture, in tiles. */
#define FONT_DEFAULT_COLUMNS 16
#define FONT_DEFAULT_ROWS 6
/**
* Number of glyphs defined in FONT_DEFAULT_GLYPHS (FONT_DEFAULT_COLUMNS *
* FONT_DEFAULT_ROWS), covering the printable ASCII range starting at
* TEXT_CHAR_START ('!') plus a couple of unused trailing tiles.
*/
#define FONT_DEFAULT_TILE_COUNT (FONT_DEFAULT_COLUMNS * FONT_DEFAULT_ROWS)
extern font_t FONT_DEFAULT;
/**
* Hard coded bitmap data for the built-in default font. Indexed
* [glyph][row], where glyph 0 corresponds to TEXT_CHAR_START ('!') and
* glyphs run consecutively through the printable ASCII range. Each row
* byte holds FONT_DEFAULT_TILE_WIDTH bit flags, one per pixel column:
* bit (FONT_DEFAULT_TILE_WIDTH - 1) is the leftmost pixel and bit 0 is
* the rightmost; 1 means the pixel is set, 0 means it is not.
*/
extern const uint8_t FONT_DEFAULT_GLYPHS[FONT_DEFAULT_TILE_COUNT][
FONT_DEFAULT_TILE_HEIGHT
];
/**
* Builds the default font's texture + tileset directly from
* FONT_DEFAULT_GLYPHS, without going through the asset system - so the
* engine always has a usable font to render with regardless of whether
* asset loading (e.g. the packed .dsk archive) succeeds.
*
* @return Either an error or success result.
*/
errorret_t fontDefaultInit(void);
/**
* Disposes of the default font created by fontDefaultInit().
*
* @return Either an error or success result.
*/
errorret_t fontDefaultDispose(void);
+2 -21
View File
@@ -9,34 +9,15 @@
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
#include "asset/asset.h"
#include "asset/loader/display/assettextureloader.h"
#include "asset/loader/display/assettilesetloader.h"
#include "display/shader/shaderunlit.h" #include "display/shader/shaderunlit.h"
font_t FONT_DEFAULT;
errorret_t textInit(void) { errorret_t textInit(void) {
assetloaderinput_t input = { .texture = TEXTURE_FORMAT_RGBA }; errorChain(fontDefaultInit());
assetentry_t *entryTexture = assetLock(
"ui/minogram.png", ASSET_LOADER_TYPE_TEXTURE, &input
);
assetentry_t *entryTileset = assetLock(
"ui/minogram.dtf", ASSET_LOADER_TYPE_TILESET, NULL
);
errorChain(assetRequireLoaded(entryTexture));
errorChain(assetRequireLoaded(entryTileset));
FONT_DEFAULT.texture = &entryTexture->data.texture;
FONT_DEFAULT.tileset = &entryTileset->data.tileset;
errorOk(); errorOk();
} }
errorret_t textDispose(void) { errorret_t textDispose(void) {
FONT_DEFAULT.texture = NULL; errorChain(fontDefaultDispose());
FONT_DEFAULT.tileset = NULL;
assetUnlock("ui/minogram.png");
assetUnlock("ui/minogram.dtf");
errorOk(); errorOk();
} }
-2
View File
@@ -12,8 +12,6 @@
#define TEXT_CHAR_START '!' #define TEXT_CHAR_START '!'
extern font_t FONT_DEFAULT;
/** /**
* Initializes the text system. * Initializes the text system.
* *
+6 -6
View File
@@ -10,7 +10,6 @@
#include "time/time.h" #include "time/time.h"
#include "input/input.h" #include "input/input.h"
#include "locale/localemanager.h" #include "locale/localemanager.h"
#include "rpg/item/item.h"
#include "rpg/rpg.h" #include "rpg/rpg.h"
#include "display/display.h" #include "display/display.h"
#include "scene/scene.h" #include "scene/scene.h"
@@ -21,6 +20,7 @@
#include "system/system.h" #include "system/system.h"
#include "console/console.h" #include "console/console.h"
#include "save/save.h" #include "save/save.h"
#include "save/autosave.h"
engine_t ENGINE; engine_t ENGINE;
@@ -38,9 +38,8 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorChain(systemInit()); errorChain(systemInit());
errorChain(inputInit()); errorChain(inputInit());
errorChain(assetInit()); errorChain(assetInit());
// errorChain(saveInit()); errorChain(saveInit());
errorChain(localeManagerInit()); errorChain(localeManagerInit());
errorChain(itemInit());
errorChain(displayInit()); errorChain(displayInit());
errorChain(uiInit()); errorChain(uiInit());
errorChain(rpgInit()); errorChain(rpgInit());
@@ -55,8 +54,7 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
consolePrint("Assertions real"); consolePrint("Assertions real");
#endif #endif
sceneSet(SCENE_TYPE_OVERWORLD); sceneSet(SCENE_TYPE_INITIAL);
errorOk(); errorOk();
} }
@@ -64,6 +62,8 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) {
errorret_t engineUpdate(void) { errorret_t engineUpdate(void) {
// Order here is important. // Order here is important.
errorChain(networkUpdate()); errorChain(networkUpdate());
errorChain(saveUpdate());
autoSaveUpdate();
timeUpdate(); timeUpdate();
inputUpdate(); inputUpdate();
consoleUpdate(); consoleUpdate();
@@ -90,7 +90,7 @@ errorret_t engineDispose(void) {
errorChain(uiDispose()); errorChain(uiDispose());
consoleDispose(); consoleDispose();
errorChain(displayDispose()); errorChain(displayDispose());
// errorChain(saveDispose()); errorChain(saveDispose());
errorChain(assetDispose()); errorChain(assetDispose());
errorOk(); errorOk();
-1
View File
@@ -17,7 +17,6 @@ input_t INPUT;
errorret_t inputInit(void) { errorret_t inputInit(void) {
memoryZero(&INPUT, sizeof(input_t)); memoryZero(&INPUT, sizeof(input_t));
INPUT.deadzone = INPUT_DEADZONE_DEFAULT;
for(uint8_t i = 0; i < INPUT_ACTION_COUNT; i++) { for(uint8_t i = 0; i < INPUT_ACTION_COUNT; i++) {
INPUT.actions[i].action = (inputaction_t)i; INPUT.actions[i].action = (inputaction_t)i;
-4
View File
@@ -12,15 +12,11 @@
#define INPUT_LISTENER_PRESSED_MAX 16 #define INPUT_LISTENER_PRESSED_MAX 16
#define INPUT_LISTENER_RELEASED_MAX INPUT_LISTENER_PRESSED_MAX #define INPUT_LISTENER_RELEASED_MAX INPUT_LISTENER_PRESSED_MAX
#define INPUT_DEADZONE_DEFAULT 0.1f
typedef struct { typedef struct {
inputactiondata_t actions[INPUT_ACTION_COUNT]; inputactiondata_t actions[INPUT_ACTION_COUNT];
inputplatform_t platform; inputplatform_t platform;
/** User-configured gamepad axis deadzone (0.0f to 1.0f). */
float_t deadzone;
} input_t; } input_t;
extern input_t INPUT; extern input_t INPUT;
+27 -1
View File
@@ -7,16 +7,42 @@
#include "localemanager.h" #include "localemanager.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "save/save.h"
localemanager_t LOCALE; localemanager_t LOCALE;
const localeinfo_t * const LOCALE_LIST[LOCALE_LIST_COUNT] = {
&LOCALE_EN_US,
&LOCALE_JP_JP,
&LOCALE_ES_MX
};
errorret_t localeManagerInit() { errorret_t localeManagerInit() {
memoryZero(&LOCALE, sizeof(localemanager_t)); memoryZero(&LOCALE, sizeof(localemanager_t));
errorChain(localeManagerSetLocale(&LOCALE_EN_US)); // saveInit() runs before this (see engine.c) and, on most platforms,
// has already loaded a persisted language choice into savemeta_t by
// this point - see localeManagerGetByIndex().
errorChain(localeManagerSetLocale(
localeManagerGetByIndex(saveGetMeta()->language)
));
errorOk(); errorOk();
} }
uint8_t localeManagerGetIndex(const localeinfo_t *locale) {
assertNotNull(locale, "Locale cannot be NULL");
for(uint8_t i = 0; i < LOCALE_LIST_COUNT; i++) {
if(stringCompare(LOCALE_LIST[i]->file, locale->file) == 0) return i;
}
return 0;
}
const localeinfo_t * localeManagerGetByIndex(const uint8_t index) {
if(index >= LOCALE_LIST_COUNT) return LOCALE_LIST[0];
return LOCALE_LIST[index];
}
errorret_t localeManagerSetLocale(const localeinfo_t *locale) { errorret_t localeManagerSetLocale(const localeinfo_t *locale) {
assertNotNull(locale, "Locale cannot be NULL"); assertNotNull(locale, "Locale cannot be NULL");
+32 -2
View File
@@ -18,21 +18,51 @@ typedef struct {
extern localemanager_t LOCALE; extern localemanager_t LOCALE;
/**
* Every locale the game supports, in a fixed, stable order - index into
* this array is what gets persisted as savemeta_t.language (see
* save/savemeta.h), so the order here must never change once shipped
* (only append new locales at the end).
*/
#define LOCALE_LIST_COUNT 3
extern const localeinfo_t * const LOCALE_LIST[LOCALE_LIST_COUNT];
/** /**
* Initialize the locale system. * Initialize the locale system.
* *
* @return An error code if a failure occurs. * @return An error code if a failure occurs.
*/ */
errorret_t localeManagerInit(); errorret_t localeManagerInit();
/** /**
* Set the current locale. * Set the current locale.
* *
* @param locale The locale to set. * @param locale The locale to set.
* @return An error code if a failure occurs. * @return An error code if a failure occurs.
*/ */
errorret_t localeManagerSetLocale(const localeinfo_t *locale); errorret_t localeManagerSetLocale(const localeinfo_t *locale);
/**
* Finds the LOCALE_LIST index matching the given locale's file, by
* content rather than pointer identity (localeinfo_t instances are
* declared `static const` in a header, so the same locale gets a
* distinct pointer in every translation unit that references it).
*
* @param locale The locale to find.
* @return The matching index in LOCALE_LIST, or 0 if not found.
*/
uint8_t localeManagerGetIndex(const localeinfo_t *locale);
/**
* Gets the locale at the given LOCALE_LIST index, clamping to index 0
* (LOCALE_EN_US) if out of range - e.g. a save file's persisted language
* index from a future build with more locales than this one supports.
*
* @param index The index to look up.
* @return The locale at that index, or LOCALE_LIST[0] if out of range.
*/
const localeinfo_t * localeManagerGetByIndex(const uint8_t index);
/** /**
* Get a localized string for the given message ID. * Get a localized string for the given message ID.
* *
-1
View File
@@ -6,7 +6,6 @@
# Sources # Sources
target_sources(${DUSK_LIBRARY_TARGET_NAME} target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
cutscene.c
cutscenesystem.c cutscenesystem.c
) )
-444
View File
@@ -1,444 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "cutscene.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/json/assetjsonloader.h"
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/entity/entitytype.h"
#include "rpg/entity/entitydir.h"
#include "ui/rpg/uiemoji.h"
#include "yyjson.h"
static cutscenejsoncacheentry_t CUTSCENE_JSON_CACHE[CUTSCENE_JSON_CACHE_MAX];
static uint32_t CUTSCENE_JSON_CACHE_COUNT;
errorret_t cutsceneJsonParsePause(yyjson_val *val, cutscenepause_t *outPause) {
assertNotNull(outPause, "Output pause pointer cannot be NULL");
if(!val || !yyjson_is_str(val)) {
errorThrow("Cutscene pause value must be a string");
}
const char_t *str = yyjson_get_str(val);
if(stringEquals(str, "NONE")) {
*outPause = CUTSCENE_PAUSE_NONE;
} else if(stringEquals(str, "DEFAULT")) {
*outPause = CUTSCENE_PAUSE_DEFAULT;
} else if(stringEquals(str, "ALL")) {
*outPause = CUTSCENE_PAUSE_ALL;
} else {
errorThrow("Unknown cutscene pause value '%s'", str);
}
errorOk();
}
errorret_t cutsceneJsonParseEntityIndex(yyjson_val *val, uint8_t *outIndex) {
assertNotNull(outIndex, "Output entity index pointer cannot be NULL");
if(val && yyjson_is_str(val)) {
const char_t *str = yyjson_get_str(val);
if(stringEquals(str, "interact")) {
*outIndex = CUTSCENE_ENTITY_INTERACT;
} else if(stringEquals(str, "interacted")) {
*outIndex = CUTSCENE_ENTITY_INTERACTED;
} else if(stringEquals(str, "lastCreated")) {
*outIndex = CUTSCENE_ENTITY_LAST_CREATED;
} else {
errorThrow("Unknown entity index sentinel '%s'", str);
}
errorOk();
}
if(!val || !yyjson_is_int(val)) {
errorThrow("Entity index must be a number or sentinel string");
}
*outIndex = (uint8_t)yyjson_get_int(val);
errorOk();
}
errorret_t cutsceneJsonParseWorldPos(yyjson_val *val, worldpos_t *outPos) {
assertNotNull(outPos, "Output position pointer cannot be NULL");
if(!val || !yyjson_is_arr(val) || yyjson_arr_size(val) != 3) {
errorThrow("Position must be a [x, y, z] array");
}
worldunit_t comps[3];
size_t idx, max;
yyjson_val *elem;
yyjson_arr_foreach(val, idx, max, elem) {
if(!yyjson_is_num(elem)) {
errorThrow("Position elements must be numbers");
}
comps[idx] = (worldunit_t)yyjson_get_num(elem);
}
*outPos = (worldpos_t){ comps[0], comps[1], comps[2] };
errorOk();
}
errorret_t cutsceneItemCreateFromJson(
yyjson_val *obj,
cutsceneitem_t *outItem,
worldpos_t *waypoints,
const uint8_t waypointsMax
) {
assertNotNull(obj, "Cutscene item JSON object cannot be NULL");
assertNotNull(outItem, "Output item pointer cannot be NULL");
yyjson_val *typeVal = yyjson_obj_get(obj, "type");
if(!typeVal || !yyjson_is_str(typeVal)) {
errorThrow("Cutscene item JSON missing 'type' string");
}
const char_t *typeStr = yyjson_get_str(typeVal);
memoryZero(outItem, sizeof(cutsceneitem_t));
if(stringEquals(typeStr, "text")) {
yyjson_val *textVal = yyjson_obj_get(obj, "text");
if(!textVal || !yyjson_is_str(textVal)) {
errorThrow("Cutscene 'text' item missing 'text' string");
}
outItem->type = CUTSCENE_ITEM_TYPE_TEXT;
stringCopy(
outItem->text.text, yyjson_get_str(textVal), CUTSCENE_TEXT_MAX_CHARS
);
} else if(stringEquals(typeStr, "textMini")) {
yyjson_val *textVal = yyjson_obj_get(obj, "text");
if(!textVal || !yyjson_is_str(textVal)) {
errorThrow("Cutscene 'textMini' item missing 'text' string");
}
worldpos_t pos;
errorChain(
cutsceneJsonParseWorldPos(yyjson_obj_get(obj, "position"), &pos)
);
yyjson_val *durVal = yyjson_obj_get(obj, "duration");
if(!durVal || !yyjson_is_num(durVal)) {
errorThrow("Cutscene 'textMini' item missing 'duration' number");
}
outItem->type = CUTSCENE_ITEM_TYPE_TEXT_MINI;
stringCopy(
outItem->textMini.text, yyjson_get_str(textVal),
CUTSCENE_TEXT_MINI_MAX_CHARS
);
outItem->textMini.position[0] = (float_t)pos.x;
outItem->textMini.position[1] = (float_t)pos.y;
outItem->textMini.position[2] = (float_t)pos.z;
outItem->textMini.duration = (float_t)yyjson_get_num(durVal);
} else if(stringEquals(typeStr, "textMiniHide")) {
yyjson_val *indexVal = yyjson_obj_get(obj, "index");
if(!indexVal || !yyjson_is_int(indexVal)) {
errorThrow("Cutscene 'textMiniHide' item missing 'index' number");
}
outItem->type = CUTSCENE_ITEM_TYPE_TEXT_MINI_HIDE;
outItem->textMiniHide.index = (uint8_t)yyjson_get_int(indexVal);
} else if(stringEquals(typeStr, "wait")) {
yyjson_val *durVal = yyjson_obj_get(obj, "duration");
if(!durVal || !yyjson_is_num(durVal)) {
errorThrow("Cutscene 'wait' item missing 'duration' number");
}
outItem->type = CUTSCENE_ITEM_TYPE_WAIT;
outItem->wait = (float_t)yyjson_get_num(durVal);
} else if(stringEquals(typeStr, "entityAdd")) {
yyjson_val *entityTypeVal = yyjson_obj_get(obj, "entityType");
if(!entityTypeVal || !yyjson_is_str(entityTypeVal)) {
errorThrow("Cutscene 'entityAdd' item missing 'entityType' string");
}
const char_t *entityTypeStr = yyjson_get_str(entityTypeVal);
entitytype_t entityType;
if(stringEquals(entityTypeStr, "player")) {
entityType = ENTITY_TYPE_PLAYER;
} else if(stringEquals(entityTypeStr, "npc")) {
entityType = ENTITY_TYPE_NPC;
} else if(stringEquals(entityTypeStr, "item")) {
entityType = ENTITY_TYPE_ITEM;
} else {
errorThrow(
"Cutscene 'entityAdd' has unknown entityType '%s'", entityTypeStr
);
}
worldpos_t pos;
errorChain(
cutsceneJsonParseWorldPos(yyjson_obj_get(obj, "position"), &pos)
);
outItem->type = CUTSCENE_ITEM_TYPE_ENTITY_ADD;
outItem->entityAdd.entityType = entityType;
outItem->entityAdd.position = pos;
} else if(stringEquals(typeStr, "entityRemove")) {
uint8_t entityIndex;
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "entityIndex"), &entityIndex
));
outItem->type = CUTSCENE_ITEM_TYPE_ENTITY_REMOVE;
outItem->entityRemove.entityIndex = entityIndex;
} else if(stringEquals(typeStr, "entityTurn")) {
uint8_t entityIndex;
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "entityIndex"), &entityIndex
));
yyjson_val *dirVal = yyjson_obj_get(obj, "direction");
if(!dirVal || !yyjson_is_str(dirVal)) {
errorThrow("Cutscene 'entityTurn' item missing 'direction' string");
}
const char_t *dirStr = yyjson_get_str(dirVal);
entitydir_t direction;
if(stringEquals(dirStr, "north")) {
direction = ENTITY_DIR_NORTH;
} else if(stringEquals(dirStr, "east")) {
direction = ENTITY_DIR_EAST;
} else if(stringEquals(dirStr, "south")) {
direction = ENTITY_DIR_SOUTH;
} else if(stringEquals(dirStr, "west")) {
direction = ENTITY_DIR_WEST;
} else {
errorThrow("Cutscene 'entityTurn' has unknown direction '%s'", dirStr);
}
outItem->type = CUTSCENE_ITEM_TYPE_ENTITY_TURN;
outItem->entityTurn.entityIndex = entityIndex;
outItem->entityTurn.direction = direction;
} else if(stringEquals(typeStr, "entityWalkTo")) {
uint8_t entityIndex;
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "entityIndex"), &entityIndex
));
yyjson_val *positionsVal = yyjson_obj_get(obj, "positions");
if(!positionsVal || !yyjson_is_arr(positionsVal)) {
errorThrow("Cutscene 'entityWalkTo' item missing 'positions' array");
}
size_t count = yyjson_arr_size(positionsVal);
if(count == 0 || count > waypointsMax) {
errorThrow(
"Cutscene 'entityWalkTo' 'positions' must have 1-%d entries",
waypointsMax
);
}
assertNotNull(waypoints, "Waypoint storage cannot be NULL");
size_t idx, max;
yyjson_val *posVal;
yyjson_arr_foreach(positionsVal, idx, max, posVal) {
errorChain(cutsceneJsonParseWorldPos(posVal, &waypoints[idx]));
}
bool_t walkAround = true;
yyjson_val *walkAroundVal = yyjson_obj_get(obj, "walkAround");
if(walkAroundVal && yyjson_is_bool(walkAroundVal)) {
walkAround = yyjson_get_bool(walkAroundVal);
}
outItem->type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO;
outItem->entityWalkTo.entityIndex = entityIndex;
outItem->entityWalkTo.positions = waypoints;
outItem->entityWalkTo.count = (uint8_t)count;
outItem->entityWalkTo.walkAround = walkAround;
} else if(stringEquals(typeStr, "entityWalkToEntity")) {
uint8_t entityIndex, targetEntityIndex;
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "entityIndex"), &entityIndex
));
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "targetEntityIndex"), &targetEntityIndex
));
yyjson_val *offsetXVal = yyjson_obj_get(obj, "offsetX");
yyjson_val *offsetYVal = yyjson_obj_get(obj, "offsetY");
if(
!offsetXVal || !yyjson_is_num(offsetXVal) ||
!offsetYVal || !yyjson_is_num(offsetYVal)
) {
errorThrow(
"Cutscene 'entityWalkToEntity' item missing "
"'offsetX'/'offsetY' numbers"
);
}
outItem->type = CUTSCENE_ITEM_TYPE_ENTITY_WALK_TO_ENTITY;
outItem->entityWalkToEntity.entityIndex = entityIndex;
outItem->entityWalkToEntity.targetEntityIndex = targetEntityIndex;
outItem->entityWalkToEntity.offsetX =
(worldunit_t)yyjson_get_num(offsetXVal);
outItem->entityWalkToEntity.offsetY =
(worldunit_t)yyjson_get_num(offsetYVal);
} else if(stringEquals(typeStr, "entityTeleport")) {
uint8_t entityIndex;
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "entityIndex"), &entityIndex
));
worldpos_t pos;
errorChain(
cutsceneJsonParseWorldPos(yyjson_obj_get(obj, "position"), &pos)
);
outItem->type = CUTSCENE_ITEM_TYPE_ENTITY_TELEPORT;
outItem->entityTeleport.entityIndex = entityIndex;
outItem->entityTeleport.target = pos;
} else if(stringEquals(typeStr, "emoji")) {
uint8_t entityIndex;
errorChain(cutsceneJsonParseEntityIndex(
yyjson_obj_get(obj, "entityIndex"), &entityIndex
));
yyjson_val *emojiTypeVal = yyjson_obj_get(obj, "emojiType");
if(!emojiTypeVal || !yyjson_is_str(emojiTypeVal)) {
errorThrow("Cutscene 'emoji' item missing 'emojiType' string");
}
const char_t *emojiTypeStr = yyjson_get_str(emojiTypeVal);
uiemojitype_t emojiType;
if(stringEquals(emojiTypeStr, "question")) {
emojiType = UI_EMOJI_QUESTION_MARK;
} else if(stringEquals(emojiTypeStr, "exclamation")) {
emojiType = UI_EMOJI_EXCLAMATION_MARK;
} else {
errorThrow(
"Cutscene 'emoji' has unknown emojiType '%s'", emojiTypeStr
);
}
yyjson_val *durVal = yyjson_obj_get(obj, "duration");
if(!durVal || !yyjson_is_num(durVal)) {
errorThrow("Cutscene 'emoji' item missing 'duration' number");
}
outItem->type = CUTSCENE_ITEM_TYPE_EMOJI;
outItem->emoji.entityIndex = entityIndex;
outItem->emoji.emojiType = emojiType;
outItem->emoji.duration = (float_t)yyjson_get_num(durVal);
} else if(stringEquals(typeStr, "shake")) {
yyjson_val *amountVal = yyjson_obj_get(obj, "amount");
yyjson_val *durVal = yyjson_obj_get(obj, "duration");
if(
!amountVal || !yyjson_is_int(amountVal) ||
!durVal || !yyjson_is_num(durVal)
) {
errorThrow("Cutscene 'shake' item missing 'amount'/'duration' numbers");
}
outItem->type = CUTSCENE_ITEM_TYPE_SHAKE;
outItem->shake.amount = (uint8_t)yyjson_get_int(amountVal);
outItem->shake.duration = (float_t)yyjson_get_num(durVal);
} else if(stringEquals(typeStr, "setPause")) {
cutscenepause_t pause;
errorChain(
cutsceneJsonParsePause(yyjson_obj_get(obj, "pause"), &pause)
);
outItem->type = CUTSCENE_ITEM_TYPE_SET_PAUSE;
outItem->setPause = pause;
} else {
errorThrow("Cutscene item JSON has unknown 'type': %s", typeStr);
}
errorOk();
}
errorret_t cutsceneGetByName(
const char_t *name, const cutscene_t **outCutscene
) {
assertNotNull(name, "Cutscene name cannot be NULL");
assertNotNull(outCutscene, "Output cutscene pointer cannot be NULL");
assertStrLenMax(name, CUTSCENE_JSON_NAME_MAX, "Cutscene name too long");
for(uint32_t i = 0; i < CUTSCENE_JSON_CACHE_COUNT; i++) {
if(!stringEquals(CUTSCENE_JSON_CACHE[i].name, name)) continue;
*outCutscene = &CUTSCENE_JSON_CACHE[i].cutscene;
errorOk();
}
if(CUTSCENE_JSON_CACHE_COUNT >= CUTSCENE_JSON_CACHE_MAX) {
errorThrow(
"Too many cutscenes loaded: exceeds CUTSCENE_JSON_CACHE_MAX (%d)",
CUTSCENE_JSON_CACHE_MAX
);
}
char_t path[CUTSCENE_JSON_NAME_MAX + 24];
stringFormat(path, sizeof(path), "cutscene/%s.json", name);
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_JSON, NULL);
errorret_t ret = assetRequireLoaded(entry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(entry);
errorChain(ret);
}
yyjson_val *root = yyjson_doc_get_root(entry->data.json);
cutscenepause_t pause = CUTSCENE_PAUSE_DEFAULT;
yyjson_val *pauseVal = yyjson_obj_get(root, "pause");
if(pauseVal) {
errorret_t pauseRet = cutsceneJsonParsePause(pauseVal, &pause);
if(errorIsNotOk(pauseRet)) {
assetUnlockEntry(entry);
errorChain(pauseRet);
}
}
yyjson_val *itemsVal = yyjson_obj_get(root, "items");
if(!itemsVal || !yyjson_is_arr(itemsVal)) {
assetUnlockEntry(entry);
errorThrow("Cutscene '%s' JSON missing 'items' array", name);
}
size_t itemCount = yyjson_arr_size(itemsVal);
if(itemCount == 0 || itemCount > CUTSCENE_JSON_ITEM_COUNT_MAX) {
assetUnlockEntry(entry);
errorThrow(
"Cutscene '%s' 'items' must have 1-%d entries",
name, CUTSCENE_JSON_ITEM_COUNT_MAX
);
}
cutscenejsoncacheentry_t *cache =
&CUTSCENE_JSON_CACHE[CUTSCENE_JSON_CACHE_COUNT];
memoryZero(cache, sizeof(cutscenejsoncacheentry_t));
stringCopy(cache->name, name, CUTSCENE_JSON_NAME_MAX);
size_t idx, max;
yyjson_val *itemVal;
yyjson_arr_foreach(itemsVal, idx, max, itemVal) {
errorret_t itemRet = cutsceneItemCreateFromJson(
itemVal, &cache->items[idx], cache->waypoints[idx],
CUTSCENE_JSON_WAYPOINT_COUNT_MAX
);
if(errorIsNotOk(itemRet)) {
assetUnlockEntry(entry);
errorChain(itemRet);
}
}
assetUnlockEntry(entry);
cache->cutscene.items = cache->items;
cache->cutscene.itemCount = (uint8_t)itemCount;
cache->cutscene.pause = pause;
cache->cutscene.dataSize = 0;
CUTSCENE_JSON_CACHE_COUNT++;
*outCutscene = &cache->cutscene;
errorOk();
}
+2 -119
View File
@@ -9,8 +9,6 @@
#include "rpg/cutscene/item/cutsceneitem.h" #include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/cutscene/cutscenepause.h" #include "rpg/cutscene/cutscenepause.h"
typedef struct yyjson_val yyjson_val;
typedef struct cutscene_s { typedef struct cutscene_s {
const cutsceneitem_t *items; const cutsceneitem_t *items;
uint8_t itemCount; uint8_t itemCount;
@@ -167,10 +165,10 @@ typedef struct cutscene_s {
#define CUTSCENE_SET_PAUSE(FLAGS) \ #define CUTSCENE_SET_PAUSE(FLAGS) \
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) } { .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) }
#define CUTSCENE_ITEM_GIVE(ITEM_NAME, QUANTITY) \ #define CUTSCENE_ITEM_GIVE(ITEM_ID, QUANTITY) \
{ \ { \
.type = CUTSCENE_ITEM_TYPE_ITEM_GIVE, \ .type = CUTSCENE_ITEM_TYPE_ITEM_GIVE, \
.itemGive = { .itemName = ITEM_NAME, .quantity = QUANTITY } \ .itemGive = { .item = ITEM_ID, .quantity = QUANTITY } \
} }
// Runs all listed items simultaneously and waits until all are done. // Runs all listed items simultaneously and waits until all are done.
@@ -232,118 +230,3 @@ typedef struct cutscene_s {
), \ ), \
CUTSCENE_MAP_AREA_WAIT(CUTSCENE_AREA_LAST_CREATED), \ CUTSCENE_MAP_AREA_WAIT(CUTSCENE_AREA_LAST_CREATED), \
CUTSCENE_MAP_AREA_REMOVE(CUTSCENE_AREA_LAST_CREATED) CUTSCENE_MAP_AREA_REMOVE(CUTSCENE_AREA_LAST_CREATED)
#define CUTSCENE_JSON_NAME_MAX 32
#define CUTSCENE_JSON_ITEM_COUNT_MAX 32
#define CUTSCENE_JSON_WAYPOINT_COUNT_MAX 8
#define CUTSCENE_JSON_CACHE_MAX 16
// One slot of the cutsceneGetByName cache: a parsed cutscene plus the
// backing storage its items point into (items themselves, and the
// waypoint lists any "entityWalkTo" items reference).
typedef struct {
char_t name[CUTSCENE_JSON_NAME_MAX];
cutscene_t cutscene;
cutsceneitem_t items[CUTSCENE_JSON_ITEM_COUNT_MAX];
worldpos_t waypoints
[CUTSCENE_JSON_ITEM_COUNT_MAX][CUTSCENE_JSON_WAYPOINT_COUNT_MAX];
} cutscenejsoncacheentry_t;
/**
* Parses a cutscene pause value ("NONE", "DEFAULT" or "ALL") from a
* yyjson string value.
*
* @param val The yyjson value to parse.
* @param outPause Output pointer, set to the parsed pause flags.
* @return Any error that occurs (missing/invalid/unknown value).
*/
errorret_t cutsceneJsonParsePause(yyjson_val *val, cutscenepause_t *outPause);
/**
* Parses an entityIndex-shaped yyjson value - a raw number, or one of
* "interact", "interacted", "lastCreated" - into a raw uint8_t index or
* the matching CUTSCENE_ENTITY_* sentinel (see cutscenesystem.h).
*
* @param val The yyjson value to parse.
* @param outIndex Output pointer, set to the parsed index.
* @return Any error that occurs (missing/invalid/unknown value).
*/
errorret_t cutsceneJsonParseEntityIndex(yyjson_val *val, uint8_t *outIndex);
/**
* Parses a [x, y, z] yyjson array into a worldpos_t.
*
* @param val The yyjson value to parse.
* @param outPos Output pointer, set to the parsed position.
* @return Any error that occurs (missing/invalid value).
*/
errorret_t cutsceneJsonParseWorldPos(yyjson_val *val, worldpos_t *outPos);
/**
* Parses a single cutscene item from a yyjson object into outItem.
* "type" selects the item shape (required):
* { "type": "text", "text": "Hello!" }
* { "type": "textMini", "text": "Hi", "position": [x, y, z],
* "duration": 3.0 }
* { "type": "textMiniHide", "index": 0 }
* { "type": "wait", "duration": 1.5 }
* { "type": "entityAdd", "entityType": "npc", "position": [x, y, z] }
* { "type": "entityRemove", "entityIndex": 0 }
* { "type": "entityTurn", "entityIndex": 0, "direction": "south" }
* { "type": "entityWalkTo", "entityIndex": 0,
* "positions": [[x, y, z], ...], "walkAround": true }
* { "type": "entityWalkToEntity", "entityIndex": 0,
* "targetEntityIndex": 1, "offsetX": 1, "offsetY": 0 }
* { "type": "entityTeleport", "entityIndex": 0, "position": [x, y, z] }
* { "type": "emoji", "entityIndex": 0, "emojiType": "exclamation",
* "duration": 2.0 }
* { "type": "shake", "amount": 2, "duration": 0.5 }
* { "type": "setPause", "pause": "ALL" }
* "entityIndex"/"targetEntityIndex" accept a raw number, or one of
* "interact", "interacted", "lastCreated" for the matching
* CUTSCENE_ENTITY_* sentinel. "entityType" uses the same strings as
* entityCreateFromJson ("player", "npc", "item"); "direction" is one of
* "north"/"east"/"south"/"west"; "emojiType" is "question" or
* "exclamation"; "pause" is "NONE", "DEFAULT" or "ALL".
*
* Not supported (would need persistent string/array storage or function
* pointers this parser doesn't provide): itemGive, concurrent, fade,
* map area items, callbacks, nested cutscene references.
*
* @param obj The yyjson object describing a single cutscene item.
* @param outItem Output pointer, filled with the parsed item.
* @param waypoints Backing storage for an "entityWalkTo" item's waypoint
* list; must remain valid for as long as the parsed item is used.
* Unused (may be NULL) for any other item type.
* @param waypointsMax Capacity of waypoints.
* @return Any error that occurs (missing/invalid/unknown fields).
*/
errorret_t cutsceneItemCreateFromJson(
yyjson_val *obj,
cutsceneitem_t *outItem,
worldpos_t *waypoints,
const uint8_t waypointsMax
);
/**
* Loads (or returns the cached result of an earlier load of) the named
* cutscene from assets/cutscene/<name>.json, parsing each entry of its
* "items" array with cutsceneItemCreateFromJson. Parsed once per name
* and cached for the lifetime of the process; holds at most
* CUTSCENE_JSON_CACHE_MAX distinct names, each with at most
* CUTSCENE_JSON_ITEM_COUNT_MAX items.
*
* File shape:
* { "pause": "DEFAULT", "items": [ { "type": "text", ... }, ... ] }
* "pause" is optional (one of "NONE", "DEFAULT", "ALL"; defaults to
* "DEFAULT" when absent).
*
* @param name The cutscene's file name (without extension), under
* assets/cutscene/.
* @param outCutscene Output pointer, set to the loaded cutscene.
* @return Any error that occurs (missing/malformed file, cache full,
* unsupported/invalid item).
*/
errorret_t cutsceneGetByName(
const char_t *name, const cutscene_t **outCutscene
);
@@ -9,7 +9,7 @@
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "rpg/entity/entity.h" #include "rpg/entity/entity.h"
#include "rpg/entity/entitypathstep.h" #include "rpg/entity/entitypathstep.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/map.h"
void cutsceneEntityWalkToEntityStart( void cutsceneEntityWalkToEntityStart(
const cutsceneitem_t *item, const cutsceneitem_t *item,
@@ -35,7 +35,7 @@ bool_t cutsceneEntityWalkToEntityUpdate(
}; };
worldunit_t z; worldunit_t z;
if(chunkGetWalkableZNear(dest.x, dest.y, target->position.z, &z)) dest.z = z; if(mapGetWalkableZNear(dest.x, dest.y, target->position.z, &z)) dest.z = z;
return entityPathStep(entity, dest, true); return entityPathStep(entity, dest, true);
} }
@@ -6,7 +6,6 @@
*/ */
#include "rpg/cutscene/item/cutsceneitem.h" #include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/item/item.h"
#include "rpg/item/itemgive.h" #include "rpg/item/itemgive.h"
#include "ui/rpg/textbox/uitextboxmain.h" #include "ui/rpg/textbox/uitextboxmain.h"
@@ -14,8 +13,7 @@ void cutsceneItemGiveStart(
const cutsceneitem_t *item, const cutsceneitem_t *item,
cutsceneitemdata_t *data cutsceneitemdata_t *data
) { ) {
itemid_t itemId = itemGetIdByName(item->itemGive.itemName); itemGive(item->itemGive.item, item->itemGive.quantity);
itemGive(itemId, item->itemGive.quantity);
} }
bool_t cutsceneItemGiveUpdate( bool_t cutsceneItemGiveUpdate(
@@ -6,13 +6,13 @@
*/ */
#pragma once #pragma once
#include "dusk.h" #include "rpg/item/item.h"
typedef struct cutsceneitem_s cutsceneitem_t; typedef struct cutsceneitem_s cutsceneitem_t;
typedef union cutsceneitemdata_u cutsceneitemdata_t; typedef union cutsceneitemdata_u cutsceneitemdata_t;
typedef struct { typedef struct {
const char_t *itemName; itemid_t item;
uint8_t quantity; uint8_t quantity;
} cutsceneitemgive_t; } cutsceneitemgive_t;
@@ -11,4 +11,21 @@
CUTSCENE(TEST_ONE, 0, DEFAULT, CUTSCENE(TEST_ONE, 0, DEFAULT,
CUTSCENE_TEXT("Test One."), CUTSCENE_TEXT("Test One."),
);
CUTSCENE(TEST_TWO, 0, DEFAULT,
CUTSCENE_TEXT("Test Two."),
CUTSCENE_ENTITY_ADD(ENTITY_TYPE_NPC, 4, 4, 0),
CUTSCENE_TEXT_MINI("Hello!", 4, 4, 0, 3.0f),
CUTSCENE_EMOJI(
CUTSCENE_ENTITY_LAST_CREATED, UI_EMOJI_EXCLAMATION_MARK, 2.0f
),
CUTSCENE_ENTITY_WALK_TO(CUTSCENE_ENTITY_LAST_CREATED, 8, 2, 0),
// CUTSCENE_CONCURRENT(
// CUTSCENE_ENTITY_WALK_TO(CUTSCENE_ENTITY_INTERACT, 4, 4, 0),
// CUTSCENE_ENTITY_WALK_TO(CUTSCENE_ENTITY_INTERACTED, 8, 2, 0),
// ),
// CUTSCENE_ITEM_GIVE(ITEM_ID_POTATO, 3),
// CUTSCENE_ENTITY_REMOVE(CUTSCENE_ENTITY_INTERACT),
CUTSCENE_TEXT("Done."),
); );
+2 -1
View File
@@ -15,4 +15,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
add_subdirectory(anim) add_subdirectory(anim)
add_subdirectory(interact) add_subdirectory(interact)
add_subdirectory(npc) add_subdirectory(npc)
add_subdirectory(item) add_subdirectory(item)
add_subdirectory(global)
+2 -2
View File
@@ -6,7 +6,7 @@
*/ */
#include "rpg/entity/entity.h" #include "rpg/entity/entity.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/map.h"
#include "rpg/overworld/tile.h" #include "rpg/overworld/tile.h"
#include "time/time.h" #include "time/time.h"
#include "entityanimwalk.h" #include "entityanimwalk.h"
@@ -19,7 +19,7 @@ const entityanimcallback_t ENTITY_ANIM_CALLBACKS[ENTITY_ANIM_COUNT] = {
}; };
float_t entityAnimTileZOffset(const worldpos_t pos) { float_t entityAnimTileZOffset(const worldpos_t pos) {
return tileShapeIsRamp(chunkGetTile(pos).shape) ? 0.5f : 0.0f; return tileShapeIsRamp(mapGetTile(pos).shape) ? 0.5f : 0.0f;
} }
void entityAnimUpdate(entity_t *entity) { void entityAnimUpdate(entity_t *entity) {
+21 -164
View File
@@ -8,14 +8,13 @@
#include "entity.h" #include "entity.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
#include "time/time.h" #include "time/time.h"
#include "util/math.h" #include "util/math.h"
#include "console/console.h"
#include "rpg/overworld/map.h"
#include "rpg/overworld/maparea.h" #include "rpg/overworld/maparea.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/chunk.h"
#include "rpg/overworld/tile.h" #include "rpg/overworld/tile.h"
#include "rpg/cutscene/cutscene.h"
#include "yyjson.h"
entity_t ENTITIES[ENTITY_COUNT]; entity_t ENTITIES[ENTITY_COUNT];
@@ -90,8 +89,8 @@ void entityWalk(entity_t *entity, const entitydir_t direction) {
} }
// Get tile under foot // Get tile under foot
tile_t tileCurrent = chunkGetTile(entity->position); tile_t tileCurrent = mapGetTile(entity->position);
tile_t tileNew = chunkGetTile(newPos); tile_t tileNew = mapGetTile(newPos);
bool_t fall = false; bool_t fall = false;
bool_t raise = false; bool_t raise = false;
@@ -140,7 +139,7 @@ void entityWalk(entity_t *entity, const entitydir_t direction) {
tileNew = TILE_NULL; tileNew = TILE_NULL;
worldpos_t abovePos = newPos; worldpos_t abovePos = newPos;
abovePos.z += 1; abovePos.z += 1;
tile_t tileAbove = chunkGetTile(abovePos); tile_t tileAbove = mapGetTile(abovePos);
if( if(
tileAbove.shape != TILE_SHAPE_NULL && tileAbove.shape != TILE_SHAPE_NULL &&
@@ -154,7 +153,7 @@ void entityWalk(entity_t *entity, const entitydir_t direction) {
// Falling down? // Falling down?
worldpos_t belowPos = newPos; worldpos_t belowPos = newPos;
belowPos.z -= 1; belowPos.z -= 1;
tile_t tileBelow = chunkGetTile(belowPos); tile_t tileBelow = mapGetTile(belowPos);
if( if(
tileBelow.shape != TILE_SHAPE_NULL && tileBelow.shape != TILE_SHAPE_NULL &&
tileShapeIsRamp(tileBelow.shape) && tileShapeIsRamp(tileBelow.shape) &&
@@ -284,7 +283,7 @@ void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) {
assertNotNull(entity, "Entity pointer cannot be NULL"); assertNotNull(entity, "Entity pointer cannot be NULL");
if(entity->chunkIndex != 0xFF) { if(entity->chunkIndex != 0xFF) {
chunk_t *old = chunkGet(entity->chunkIndex); chunk_t *old = mapGetChunk(entity->chunkIndex);
if(old != NULL) { if(old != NULL) {
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) { for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
if(old->entities[i] != entity->id) continue; if(old->entities[i] != entity->id) continue;
@@ -294,16 +293,27 @@ void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) {
} }
} }
entity->chunkIndex = chunkIndex; // Only claim the new chunk once actually inserted into one of its slots -
// otherwise entity->chunkIndex would point at a chunk that doesn't know
// about this entity, so it would never be torn down on unload.
entity->chunkIndex = 0xFF;
if(chunkIndex != 0xFF) { if(chunkIndex != 0xFF) {
chunk_t *next = chunkGet(chunkIndex); chunk_t *next = mapGetChunk(chunkIndex);
if(next != NULL) { if(next != NULL) {
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) { for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
if(next->entities[i] != 0xFF) continue; if(next->entities[i] != 0xFF) continue;
next->entities[i] = entity->id; next->entities[i] = entity->id;
entity->chunkIndex = chunkIndex;
break; break;
} }
if(entity->chunkIndex != chunkIndex) {
consolePrint(
"entitySetChunk: chunk %u has no free entity slots, entity %u "
"left untracked",
chunkIndex, entity->id
);
}
} }
} }
} }
@@ -313,159 +323,6 @@ void entityUpdateChunk(entity_t *entity) {
chunkpos_t cp; chunkpos_t cp;
worldPosToChunkPos(&entity->position, &cp); worldPosToChunkPos(&entity->position, &cp);
chunkindex_t ci = chunkGetIndexAt(cp); chunkindex_t ci = mapGetChunkIndexAt(cp);
if(ci != -1) entitySetChunk(entity, (uint8_t)ci); if(ci != -1) entitySetChunk(entity, (uint8_t)ci);
}
errorret_t entityCreateFromJson(yyjson_val *obj, entity_t **outEntity) {
assertNotNull(obj, "Entity JSON object cannot be NULL");
assertNotNull(outEntity, "Output entity pointer cannot be NULL");
yyjson_val *typeVal = yyjson_obj_get(obj, "type");
if(!typeVal || !yyjson_is_str(typeVal)) {
errorThrow("Entity JSON missing 'type' string");
}
const char_t *typeStr = yyjson_get_str(typeVal);
entitytype_t type;
if(stringEquals(typeStr, "player")) {
type = ENTITY_TYPE_PLAYER;
} else if(stringEquals(typeStr, "npc")) {
type = ENTITY_TYPE_NPC;
} else if(stringEquals(typeStr, "item")) {
type = ENTITY_TYPE_ITEM;
} else {
errorThrow("Entity JSON has unknown 'type': %s", typeStr);
}
yyjson_val *posVal = yyjson_obj_get(obj, "position");
if(!posVal || !yyjson_is_arr(posVal) || yyjson_arr_size(posVal) != 3) {
errorThrow("Entity JSON missing 'position' [x, y, z] array");
}
worldunit_t pos[3];
size_t posIdx, posLen;
yyjson_val *posElem;
yyjson_arr_foreach(posVal, posIdx, posLen, posElem) {
if(!yyjson_is_num(posElem)) {
errorThrow("Entity JSON 'position' elements must be numbers");
}
pos[posIdx] = (worldunit_t)yyjson_get_num(posElem);
}
entitydir_t direction = ENTITY_DIR_SOUTH;
yyjson_val *dirVal = yyjson_obj_get(obj, "direction");
if(dirVal && yyjson_is_str(dirVal)) {
const char_t *dirStr = yyjson_get_str(dirVal);
if(stringEquals(dirStr, "north")) {
direction = ENTITY_DIR_NORTH;
} else if(stringEquals(dirStr, "east")) {
direction = ENTITY_DIR_EAST;
} else if(stringEquals(dirStr, "south")) {
direction = ENTITY_DIR_SOUTH;
} else if(stringEquals(dirStr, "west")) {
direction = ENTITY_DIR_WEST;
} else {
errorThrow("Entity JSON has unknown 'direction': %s", dirStr);
}
}
// Item entities require a valid item reference, resolved up front so a
// bad reference fails before an entity slot is ever claimed.
itemid_t itemId = ITEM_ID_NULL;
uint8_t itemQuantity = 1;
if(type == ENTITY_TYPE_ITEM) {
yyjson_val *itemVal = yyjson_obj_get(obj, "item");
if(!itemVal || !yyjson_is_str(itemVal)) {
errorThrow("Entity JSON with type 'item' missing 'item' string");
}
const char_t *itemStr = yyjson_get_str(itemVal);
itemId = itemGetIdByName(itemStr);
if(itemId == ITEM_ID_NULL) {
errorThrow("Entity JSON references unknown item '%s'", itemStr);
}
yyjson_val *quantityVal = yyjson_obj_get(obj, "quantity");
if(quantityVal && yyjson_is_int(quantityVal)) {
itemQuantity = (uint8_t)yyjson_get_int(quantityVal);
}
}
// Only one player may exist at a time - it always holds the reserved
// ENTITY_GLOBAL_ID_PLAYER global ID, so that's how callers (e.g. the
// camera) find it regardless of how it was spawned.
if(
type == ENTITY_TYPE_PLAYER &&
entityGetByGlobalId(ENTITY_GLOBAL_ID_PLAYER) != NULL
) {
errorThrow("A player entity has already been spawned");
}
// NPC path waypoints, resolved up front for the same reason as above.
worldpos_t path[NPC_PATH_COUNT_MAX];
uint8_t pathCount = 0;
if(type == ENTITY_TYPE_NPC) {
yyjson_val *pathVal = yyjson_obj_get(obj, "path");
if(pathVal) {
if(!yyjson_is_arr(pathVal)) {
errorThrow("Entity JSON 'path' must be an array");
}
size_t count = yyjson_arr_size(pathVal);
if(count == 0 || count > NPC_PATH_COUNT_MAX) {
errorThrow(
"Entity JSON 'path' must have 1-%d waypoints", NPC_PATH_COUNT_MAX
);
}
size_t pathIdx, pathLen;
yyjson_val *pathElem;
yyjson_arr_foreach(pathVal, pathIdx, pathLen, pathElem) {
if(!yyjson_is_arr(pathElem) || yyjson_arr_size(pathElem) != 3) {
errorThrow("Entity JSON 'path' entries must be [x, y, z] arrays");
}
worldunit_t comps[3];
size_t compIdx, compLen;
yyjson_val *compElem;
yyjson_arr_foreach(pathElem, compIdx, compLen, compElem) {
if(!yyjson_is_num(compElem)) {
errorThrow("Entity JSON 'path' elements must be numbers");
}
comps[compIdx] = (worldunit_t)yyjson_get_num(compElem);
}
path[pathIdx] = (worldpos_t){ comps[0], comps[1], comps[2] };
}
pathCount = (uint8_t)count;
}
}
// Interact cutscene, resolved up front for the same reason as above.
const cutscene_t *cutscene = NULL;
yyjson_val *cutsceneVal = yyjson_obj_get(obj, "cutscene");
if(cutsceneVal) {
if(!yyjson_is_str(cutsceneVal)) {
errorThrow("Entity JSON 'cutscene' must be a string");
}
errorChain(cutsceneGetByName(yyjson_get_str(cutsceneVal), &cutscene));
}
uint8_t index = entityGetAvailable();
if(index == 0xFF) errorThrow("No available entity slots");
entity_t *entity = &ENTITIES[index];
entityInit(entity, type);
entity->direction = direction;
entityPositionSet(entity, (worldpos_t){ pos[0], pos[1], pos[2] });
if(type == ENTITY_TYPE_ITEM) entityItemSet(entity, itemId, itemQuantity);
if(pathCount > 0) {
npcSetMoveType(entity, NPC_MOVE_TYPE_PATH);
for(uint8_t i = 0; i < pathCount; i++) {
npcPathAddNode(&entity->data.npc, path[i]);
}
}
if(cutscene != NULL) {
entity->interact.type = ENTITY_INTERACT_CUTSCENE;
entity->interact.data.cutscene = cutscene;
}
*outEntity = entity;
errorOk();
} }
+5 -41
View File
@@ -13,7 +13,6 @@
#include "npc/npc.h" #include "npc/npc.h"
typedef struct map_s map_t; typedef struct map_s map_t;
typedef struct yyjson_val yyjson_val;
typedef uint16_t entityglobalid_t; typedef uint16_t entityglobalid_t;
@@ -143,7 +142,10 @@ uint8_t entityGetAvailable();
/** /**
* Assigns an entity to a chunk, removing it from its current chunk first. * Assigns an entity to a chunk, removing it from its current chunk first.
* Pass 0xFF as chunkIndex to detach the entity from any chunk. * Pass 0xFF as chunkIndex to detach the entity from any chunk. If the
* target chunk has no free entity slots, the entity is left detached
* (chunkIndex 0xFF) rather than assigned to a chunk that isn't actually
* tracking it - entityUpdateChunk will keep retrying on subsequent moves.
* *
* @param entity Pointer to the entity. * @param entity Pointer to the entity.
* @param chunkIndex Index of the chunk to assign to, or 0xFF for none. * @param chunkIndex Index of the chunk to assign to, or 0xFF for none.
@@ -165,42 +167,4 @@ void entityUpdateChunk(entity_t *entity);
* @param entity Pointer to the entity to move. * @param entity Pointer to the entity to move.
* @param pos The world position to place the entity at. * @param pos The world position to place the entity at.
*/ */
void entityPositionSet(entity_t *entity, const worldpos_t pos); void entityPositionSet(entity_t *entity, const worldpos_t pos);
/**
* Parses an entity descriptor from a yyjson object and spawns it as a new
* entity in an available slot. Expected shape:
* { "type": "npc", "position": [x, y, z], "direction": "south" }
* "type" must be one of "player", "npc", "item". "direction" is optional
* (one of "north", "east", "south", "west") and defaults to
* ENTITY_DIR_SOUTH when absent.
*
* When "type" is "item", two extra fields apply:
* { "type": "item", "position": [x, y, z], "item": "POTION",
* "quantity": 1 }
* "item" (required) is the item's string ID, resolved via
* itemGetIdByName. "quantity" (optional) defaults to 1.
*
* When "type" is "player", the spawned entity is assigned the reserved
* ENTITY_GLOBAL_ID_PLAYER global ID (so entityGetByGlobalId can find it
* regardless of how it was spawned), and it is an error to spawn a
* second one while one is already loaded.
*
* When "type" is "npc", an optional "path" field sets it up with
* NPC_MOVE_TYPE_PATH movement:
* { "type": "npc", "position": [x, y, z],
* "path": [[4, 4, 0], [10, 10, 1]] }
* "path" must have 1-NPC_PATH_COUNT_MAX waypoints.
*
* Any entity type may set an optional "cutscene" field to wire up an
* interact component that starts the named cutscene (resolved via
* cutsceneGetByName) when interacted with:
* { "type": "npc", "position": [x, y, z], "cutscene": "test_npc" }
*
* @param obj The yyjson object describing the entity.
* @param outEntity Output pointer, set to the newly spawned entity on
* success.
* @return Any error that occurs (missing/invalid fields, unknown item,
* duplicate player, unknown/malformed cutscene, no free slots).
*/
errorret_t entityCreateFromJson(yyjson_val *obj, entity_t **outEntity);
+10
View File
@@ -0,0 +1,10 @@
# 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
globalitemstore.c
)
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entityglobaldefs.h"
#include "entitygloballist.h"
#define ENTITY_GLOBAL_LIST_COUNT ( \
sizeof(ENTITY_GLOBAL_LIST) / \
sizeof(ENTITY_GLOBAL_LIST[0]) \
)
//EOF
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "rpg/overworld/worldpos.h"
#include "rpg/entity/entity.h"
typedef struct entity_s entity_t;
typedef struct {
entity_t *entity;
worldpos_t position;
} entityglobalcreate_t;
/**
* Callback invoked to initialize a global entity.
*
* @param create Pointer to the entity/position being initialized.
* @returns An error code.
*/
typedef void (*entityglobalinitcallback_t)(
entityglobalcreate_t *create
);
typedef struct {
entitytype_t type;
entityglobalinitcallback_t callback;
} entityglobaldef_t;
#define ENTITY_GLOBAL(id, entType, callbackFn) \
[id] = { .type = entType, .callback = callbackFn }
#define ENTITY_GLOBAL_CALLBACK(id) \
static void ENTTIYT_GLOBAL_CALLBACK_##id(entityglobalcreate_t *create)
#define ENTITY_GLOBAL_REF(id) \
ENTTIYT_GLOBAL_CALLBACK_##id
//EOF
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "entityglobaldefs.h"
#include "rpg/cutscene/scene/testcutscene.h"
ENTITY_GLOBAL_CALLBACK(3) {
create->entity->data.npc.moveType = NPC_MOVE_TYPE_PATH;
npcPathAddNode(&create->entity->data.npc, (worldpos_t){ 4, 4, 0 });
npcPathAddNode(&create->entity->data.npc, (worldpos_t){ 10, 10, 1 });
npcPathAddNode(&create->entity->data.npc, (worldpos_t){ 4, 4, 0 });
npcPathAddNode(&create->entity->data.npc, (worldpos_t){ 10, 10, 1 });
create->entity->interact.type = ENTITY_INTERACT_CUTSCENE;
create->entity->interact.data.cutscene = CUTSCENE_REFERENCE(TEST_TWO);
}
static const entityglobaldef_t ENTITY_GLOBAL_LIST[] = {
ENTITY_GLOBAL(ENTITY_GLOBAL_ID_NULL, ENTITY_TYPE_NULL, NULL),
ENTITY_GLOBAL(ENTITY_GLOBAL_ID_PLAYER, ENTITY_TYPE_PLAYER, NULL),
ENTITY_GLOBAL(3, ENTITY_TYPE_NPC, ENTITY_GLOBAL_REF(3)),
};
//EOF
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "globalitemstore.h"
#include "assert/assert.h"
bool_t globalItemStoreIsCollected(
const saveslot_t *file, const entityglobalid_t id
) {
assertNotNull(file, "Save slot cannot be NULL");
assertTrue(id < SAVE_GLOBAL_ITEM_COUNT_MAX, "Global item ID out of range");
return file->globalItemCollected[id];
}
void globalItemStoreSetCollected(
saveslot_t *file, const entityglobalid_t id, const bool_t collected
) {
assertNotNull(file, "Save slot cannot be NULL");
assertTrue(id < SAVE_GLOBAL_ITEM_COUNT_MAX, "Global item ID out of range");
file->globalItemCollected[id] = collected;
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
#include "save/saveslot.h"
#include "rpg/entity/entity.h"
/**
* Checks whether the global entity with the given ID has already been
* marked collected in the given save slot's data - e.g. so a global item
* entity's init callback (see rpg/entity/global/entitygloballist.h) can
* skip spawning itself if the player already picked it up in a prior
* session, without needing to keep the entity itself alive to remember
* that (which would need render/collision special-casing - this doesn't).
*
* @param file The save slot to check.
* @param id The global entity ID to check.
* @return True if already marked collected.
*/
bool_t globalItemStoreIsCollected(
const saveslot_t *file, const entityglobalid_t id
);
/**
* Marks the global entity with the given ID as collected (or not) in the
* given save slot's data. Does not itself write the save to disk - call
* saveWriteSlot() separately once ready to persist it.
*
* @param file The save slot to write into.
* @param id The global entity ID to mark.
* @param collected The new collected state.
*/
void globalItemStoreSetCollected(
saveslot_t *file, const entityglobalid_t id, const bool_t collected
);
+10 -1
View File
@@ -10,4 +10,13 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
inventory.c inventory.c
backpack.c backpack.c
itemgive.c itemgive.c
) )
# Item Definitions
dusk_run_python(
dusk_item_json_defs
tools.item
--json ${CMAKE_CURRENT_SOURCE_DIR}/item.json
--output ${DUSK_GENERATED_HEADERS_DIR}/rpg/item/itemdef.h
)
add_dependencies(${DUSK_LIBRARY_TARGET_NAME} dusk_item_json_defs)
+14 -14
View File
@@ -11,69 +11,69 @@
backpack_t BACKPACK; backpack_t BACKPACK;
void backpackInit() { void backpackInit() {
for(uint32_t i = 0; i < ITEM_TYPE_COUNT_MAX; i++) { for(uint8_t i = 0; i < ITEM_TYPE_COUNT; i++) {
inventoryInit( inventoryInit(
&BACKPACK.inventories[i], &BACKPACK.inventories[i],
BACKPACK.storage[i], BACKPACK.storage[i],
INVENTORY_CAPACITY_MAX ITEM_TYPE_COUNT_MAX
); );
} }
} }
inventory_t *backpackGetInventory(const itemtypeid_t type) { inventory_t *backpackGetInventory(const itemtype_t type) {
assertTrue(type > ITEM_TYPE_NULL, "Item type must not be null"); assertTrue(type > ITEM_TYPE_NULL, "Item type must not be null");
assertTrue(type <= ITEM_TYPE_COUNT, "Item type out of range"); assertTrue(type < ITEM_TYPE_COUNT, "Item type out of range");
return &BACKPACK.inventories[type]; return &BACKPACK.inventories[type];
} }
void backpackAdd(const itemid_t item, const uint8_t quantity) { void backpackAdd(const itemid_t item, const uint8_t quantity) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
inventoryAdd(backpackGetInventory(ITEMS[item].type), item, quantity); inventoryAdd(backpackGetInventory(ITEMS[item].type), item, quantity);
} }
void backpackRemove(const itemid_t item) { void backpackRemove(const itemid_t item) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
inventoryRemove(backpackGetInventory(ITEMS[item].type), item); inventoryRemove(backpackGetInventory(ITEMS[item].type), item);
} }
void backpackSet(const itemid_t item, const uint8_t quantity) { void backpackSet(const itemid_t item, const uint8_t quantity) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
inventorySet(backpackGetInventory(ITEMS[item].type), item, quantity); inventorySet(backpackGetInventory(ITEMS[item].type), item, quantity);
} }
uint8_t backpackGetCount(const itemid_t item) { uint8_t backpackGetCount(const itemid_t item) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
return inventoryGetCount(backpackGetInventory(ITEMS[item].type), item); return inventoryGetCount(backpackGetInventory(ITEMS[item].type), item);
} }
bool_t backpackItemExists(const itemid_t item) { bool_t backpackItemExists(const itemid_t item) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
return inventoryItemExists(backpackGetInventory(ITEMS[item].type), item); return inventoryItemExists(backpackGetInventory(ITEMS[item].type), item);
} }
bool_t backpackIsFull(const itemtypeid_t type) { bool_t backpackIsFull(const itemtype_t type) {
assertTrue(type > ITEM_TYPE_NULL, "Item type must not be null"); assertTrue(type > ITEM_TYPE_NULL, "Item type must not be null");
assertTrue(type <= ITEM_TYPE_COUNT, "Item type out of range"); assertTrue(type < ITEM_TYPE_COUNT, "Item type out of range");
return inventoryIsFull(backpackGetInventory(type)); return inventoryIsFull(backpackGetInventory(type));
} }
bool_t backpackItemFull(const itemid_t item) { bool_t backpackItemFull(const itemid_t item) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
return inventoryItemFull(backpackGetInventory(ITEMS[item].type), item); return inventoryItemFull(backpackGetInventory(ITEMS[item].type), item);
} }
void backpackSort( void backpackSort(
const itemtypeid_t type, const itemtype_t type,
const inventorysort_t sortBy, const inventorysort_t sortBy,
const bool_t reverse const bool_t reverse
) { ) {
assertTrue(type > ITEM_TYPE_NULL, "Item type must not be null"); assertTrue(type > ITEM_TYPE_NULL, "Item type must not be null");
assertTrue(type <= ITEM_TYPE_COUNT, "Item type out of range"); assertTrue(type < ITEM_TYPE_COUNT, "Item type out of range");
inventorySort(backpackGetInventory(type), sortBy, reverse); inventorySort(backpackGetInventory(type), sortBy, reverse);
} }
+5 -5
View File
@@ -9,8 +9,8 @@
#include "inventory.h" #include "inventory.h"
typedef struct { typedef struct {
inventorystack_t storage[ITEM_TYPE_COUNT_MAX][INVENTORY_CAPACITY_MAX]; inventorystack_t storage[ITEM_TYPE_COUNT][ITEM_TYPE_COUNT_MAX];
inventory_t inventories[ITEM_TYPE_COUNT_MAX]; inventory_t inventories[ITEM_TYPE_COUNT];
} backpack_t; } backpack_t;
extern backpack_t BACKPACK; extern backpack_t BACKPACK;
@@ -26,7 +26,7 @@ void backpackInit();
* @param type The item type. * @param type The item type.
* @returns Pointer to the inventory for that type. * @returns Pointer to the inventory for that type.
*/ */
inventory_t *backpackGetInventory(const itemtypeid_t type); inventory_t *backpackGetInventory(const itemtype_t type);
/** /**
* Adds a quantity of an item to the backpack. * Adds a quantity of an item to the backpack.
@@ -73,7 +73,7 @@ bool_t backpackItemExists(const itemid_t item);
* @param type The item type to check. * @param type The item type to check.
* @returns true if the type's inventory is full. * @returns true if the type's inventory is full.
*/ */
bool_t backpackIsFull(const itemtypeid_t type); bool_t backpackIsFull(const itemtype_t type);
/** /**
* Checks if an item's stack is full in the backpack. * Checks if an item's stack is full in the backpack.
@@ -91,7 +91,7 @@ bool_t backpackItemFull(const itemid_t item);
* @param reverse Whether to sort in reverse order. * @param reverse Whether to sort in reverse order.
*/ */
void backpackSort( void backpackSort(
const itemtypeid_t type, const itemtype_t type,
const inventorysort_t sortBy, const inventorysort_t sortBy,
const bool_t reverse const bool_t reverse
); );
+4 -4
View File
@@ -188,8 +188,8 @@ int_t inventorySortByIdReverse(const void *a, const void *b) {
int_t inventorySortByType(const void *a, const void *b) { int_t inventorySortByType(const void *a, const void *b) {
const inventorystack_t *stackA = (const inventorystack_t*)a; const inventorystack_t *stackA = (const inventorystack_t*)a;
const inventorystack_t *stackB = (const inventorystack_t*)b; const inventorystack_t *stackB = (const inventorystack_t*)b;
const itemtypeid_t typeA = ITEMS[stackA->item].type; const itemtype_t typeA = ITEMS[stackA->item].type;
const itemtypeid_t typeB = ITEMS[stackB->item].type; const itemtype_t typeB = ITEMS[stackB->item].type;
if(typeA < typeB) return -1; if(typeA < typeB) return -1;
if(typeA > typeB) return 1; if(typeA > typeB) return 1;
return 0; return 0;
@@ -198,8 +198,8 @@ int_t inventorySortByType(const void *a, const void *b) {
int_t inventorySortByTypeReverse(const void *a, const void *b) { int_t inventorySortByTypeReverse(const void *a, const void *b) {
const inventorystack_t *stackA = (const inventorystack_t*)a; const inventorystack_t *stackA = (const inventorystack_t*)a;
const inventorystack_t *stackB = (const inventorystack_t*)b; const inventorystack_t *stackB = (const inventorystack_t*)b;
const itemtypeid_t typeA = ITEMS[stackA->item].type; const itemtype_t typeA = ITEMS[stackA->item].type;
const itemtypeid_t typeB = ITEMS[stackB->item].type; const itemtype_t typeB = ITEMS[stackB->item].type;
if(typeA < typeB) return 1; if(typeA < typeB) return 1;
if(typeA > typeB) return -1; if(typeA > typeB) return -1;
return 0; return 0;
-1
View File
@@ -9,7 +9,6 @@
#include "rpg/item/item.h" #include "rpg/item/item.h"
#define ITEM_STACK_QUANTITY_MAX 99 #define ITEM_STACK_QUANTITY_MAX 99
#define INVENTORY_CAPACITY_MAX 250
typedef enum { typedef enum {
INVENTORY_SORT_BY_ID, INVENTORY_SORT_BY_ID,
+1 -132
View File
@@ -7,125 +7,8 @@
#include "item.h" #include "item.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/asset.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/json/assetjsonloader.h"
#include "locale/localemanager.h" #include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/locale/assetlocaleloader.h"
#include "yyjson.h"
#define ITEM_JSON_PATH "item.json"
itemdef_t ITEMS[ITEM_COUNT_MAX];
uint32_t ITEM_COUNT;
itemtype_t ITEM_TYPES[ITEM_TYPE_COUNT_MAX];
uint32_t ITEM_TYPE_COUNT;
errorret_t itemInit(void) {
memoryZero(ITEMS, sizeof(ITEMS));
memoryZero(ITEM_TYPES, sizeof(ITEM_TYPES));
ITEM_COUNT = 0;
ITEM_TYPE_COUNT = 0;
assetentry_t *jsonEntry = assetLock(
ITEM_JSON_PATH, ASSET_LOADER_TYPE_JSON, NULL
);
errorret_t ret = assetRequireLoaded(jsonEntry);
if(errorIsNotOk(ret)) {
assetUnlockEntry(jsonEntry);
errorChain(ret);
}
yyjson_val *root = yyjson_doc_get_root(jsonEntry->data.json);
if(!yyjson_is_arr(root)) {
assetUnlockEntry(jsonEntry);
errorThrow("item.json root must be an array");
}
size_t idx, max;
yyjson_val *entry;
yyjson_arr_foreach(root, idx, max, entry) {
if(ITEM_COUNT >= ITEM_COUNT_MAX - 1) {
assetUnlockEntry(jsonEntry);
errorThrow(
"Too many items defined: exceeds ITEM_COUNT_MAX (%d)",
ITEM_COUNT_MAX
);
}
yyjson_val *idVal = yyjson_obj_get(entry, "id");
yyjson_val *typeVal = yyjson_obj_get(entry, "type");
yyjson_val *nameVal = yyjson_obj_get(entry, "name");
yyjson_val *weightVal = yyjson_obj_get(entry, "weight");
if(!idVal || !yyjson_is_str(idVal)) {
assetUnlockEntry(jsonEntry);
errorThrow("Item entry %zu missing 'id' string", idx);
}
if(!typeVal || !yyjson_is_str(typeVal)) {
assetUnlockEntry(jsonEntry);
errorThrow("Item entry %zu missing 'type' string", idx);
}
if(!nameVal || !yyjson_is_str(nameVal)) {
assetUnlockEntry(jsonEntry);
errorThrow("Item entry %zu missing 'name' string", idx);
}
const char_t *idStr = yyjson_get_str(idVal);
size_t idLen = yyjson_get_len(idVal);
const char_t *typeStr = yyjson_get_str(typeVal);
size_t typeLen = yyjson_get_len(typeVal);
const char_t *nameStr = yyjson_get_str(nameVal);
size_t nameLen = yyjson_get_len(nameVal);
if(idLen >= ITEM_STRING_MAX) {
assetUnlockEntry(jsonEntry);
errorThrow("Item id '%s' exceeds max length", idStr);
}
if(nameLen + 10 >= ITEM_STRING_MAX) {
assetUnlockEntry(jsonEntry);
errorThrow("Item name '%s' exceeds max length", nameStr);
}
if(typeLen >= ITEM_STRING_MAX) {
assetUnlockEntry(jsonEntry);
errorThrow("Item type '%s' exceeds max length", typeStr);
}
itemtypeid_t typeId = itemResolveType(typeStr, typeLen);
if(typeId == ITEM_TYPE_NULL) {
assetUnlockEntry(jsonEntry);
errorThrow(
"Too many item types defined: exceeds ITEM_TYPE_COUNT_MAX (%d)",
ITEM_TYPE_COUNT_MAX
);
}
ITEM_COUNT++;
itemid_t id = (itemid_t)ITEM_COUNT;
itemdef_t *def = &ITEMS[id];
def->id = id;
def->type = typeId;
def->weight = (weightVal && yyjson_is_num(weightVal)) ?
(float_t)yyjson_get_num(weightVal) : 0.0f;
memoryCopy(def->idName, idStr, idLen + 1);
stringFormat(def->name, ITEM_STRING_MAX - 1, "item.%s.name", nameStr);
}
assetUnlockEntry(jsonEntry);
errorOk();
}
itemid_t itemGetIdByName(const char_t *name) {
assertNotNull(name, "Item name cannot be NULL");
for(uint32_t i = 1; i <= ITEM_COUNT; i++) {
if(stringEquals(ITEMS[i].idName, name)) return (itemid_t)i;
}
return ITEM_ID_NULL;
}
errorret_t itemGetName( errorret_t itemGetName(
const itemid_t item, const itemid_t item,
@@ -133,7 +16,7 @@ errorret_t itemGetName(
const size_t bufferSize const size_t bufferSize
) { ) {
assertTrue(item > ITEM_ID_NULL, "Item ID must not be null"); assertTrue(item > ITEM_ID_NULL, "Item ID must not be null");
assertTrue(item <= ITEM_COUNT, "Item ID out of range"); assertTrue(item < ITEM_ID_COUNT, "Item ID out of range");
errorChain(assetLocaleGetString( errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale, &LOCALE.entry->data.locale,
@@ -145,17 +28,3 @@ errorret_t itemGetName(
errorOk(); errorOk();
} }
itemtypeid_t itemResolveType(const char_t *name, const size_t nameLen) {
for(uint32_t i = 1; i <= ITEM_TYPE_COUNT; i++) {
if(stringEquals(ITEM_TYPES[i].name, name)) return (itemtypeid_t)i;
}
if(ITEM_TYPE_COUNT >= ITEM_TYPE_COUNT_MAX - 1) return ITEM_TYPE_NULL;
ITEM_TYPE_COUNT++;
itemtypeid_t typeId = (itemtypeid_t)ITEM_TYPE_COUNT;
ITEM_TYPES[typeId].id = typeId;
memoryCopy(ITEM_TYPES[typeId].name, name, nameLen + 1);
return typeId;
}
+1 -56
View File
@@ -7,62 +7,7 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "rpg/item/itemdef.h"
#define ITEM_COUNT_MAX 256
#define ITEM_TYPE_COUNT_MAX 16
#define ITEM_STRING_MAX 48
typedef uint16_t itemid_t;
typedef uint8_t itemtypeid_t;
#define ITEM_ID_NULL ((itemid_t)0)
#define ITEM_TYPE_NULL ((itemtypeid_t)0)
typedef struct {
itemid_t id;
itemtypeid_t type;
float_t weight;
char_t idName[ITEM_STRING_MAX];
char_t name[ITEM_STRING_MAX];
} itemdef_t;
typedef struct {
itemtypeid_t id;
char_t name[ITEM_STRING_MAX];
} itemtype_t;
extern itemdef_t ITEMS[ITEM_COUNT_MAX];
extern uint32_t ITEM_COUNT;
extern itemtype_t ITEM_TYPES[ITEM_TYPE_COUNT_MAX];
extern uint32_t ITEM_TYPE_COUNT;
/**
* Loads assets/item.json and parses it into the ITEMS/ITEM_TYPES tables.
* Must be called once, before any other item/backpack function.
*
* @return Any error that occurs (missing/malformed JSON, or too many
* items/types defined for ITEM_COUNT_MAX/ITEM_TYPE_COUNT_MAX).
*/
errorret_t itemInit(void);
/**
* Looks up an item's numeric ID from its JSON "id" string.
*
* @param name The item's string ID (e.g. "POTION"), case-sensitive.
* @return The matching item ID, or ITEM_ID_NULL if not found.
*/
itemid_t itemGetIdByName(const char_t *name);
/**
* Resolves a type name string to its numeric type ID, registering it as
* a new type in ITEM_TYPES if not already known.
*
* @param name The type's string name (e.g. "MEDICINE").
* @param nameLen Length of name, excluding the null terminator.
* @return The resolved type ID, or ITEM_TYPE_NULL if ITEM_TYPE_COUNT_MAX
* would be exceeded.
*/
itemtypeid_t itemResolveType(const char_t *name, const size_t nameLen);
/** /**
* Gets the localized display name for an item. * Gets the localized display name for an item.
+2
View File
@@ -14,3 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
tileshape.c tileshape.c
) )
add_subdirectory(global)
+2 -383
View File
@@ -1,29 +1,11 @@
/** /**
* Copyright (c) 2025 Dominic Masters * Copyright (c) 2025 Dominic Masters
* *
* This software is released under the MIT License. * This software is released under the MIT License.
* https://opensource.org/licenses/MIT * https://opensource.org/licenses/MIT
*/ */
#include "chunk.h" #include "chunk.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
#include "asset/loader/assetentry.h"
#include "console/console.h"
#include "event/event.h"
#include "rpg/entity/entity.h"
#include "rpg/overworld/map.h"
chunk_t CHUNKS[MAP_CHUNK_COUNT];
chunk_t *CHUNK_ORDER[MAP_CHUNK_COUNT];
static chunkpos_t CHUNK_POSITION;
static chunk_t *CHUNK_LOAD_QUEUE[MAP_CHUNK_COUNT];
static uint32_t CHUNK_LOAD_QUEUE_COUNT;
static chunk_t *CHUNK_LOADING;
uint32_t chunkGetTileIndex(const chunkpos_t position) { uint32_t chunkGetTileIndex(const chunkpos_t position) {
return (position.y * CHUNK_WIDTH) + position.x; return (position.y * CHUNK_WIDTH) + position.x;
@@ -31,367 +13,4 @@ uint32_t chunkGetTileIndex(const chunkpos_t position) {
bool_t chunkPositionIsEqual(const chunkpos_t a, const chunkpos_t b) { bool_t chunkPositionIsEqual(const chunkpos_t a, const chunkpos_t b) {
return (a.x == b.x) && (a.y == b.y) && (a.z == b.z); return (a.x == b.x) && (a.y == b.y) && (a.z == b.z);
} }
errorret_t chunksLoadGrid(void) {
CHUNK_POSITION = (chunkpos_t){ 0, 0, 0 };
chunkindex_t i = 0;
for(chunkunit_t z = 0; z < MAP_CHUNK_DEPTH; z++) {
for(chunkunit_t y = 0; y < MAP_CHUNK_HEIGHT; y++) {
for(chunkunit_t x = 0; x < MAP_CHUNK_WIDTH; x++) {
chunk_t *chunk = &CHUNKS[i++];
chunk->position = (chunkpos_t){
(chunkunit_t)x, (chunkunit_t)y, (chunkunit_t)z
};
errorChain(chunkLoad(chunk));
}
}
}
chunkRebuildOrder();
errorOk();
}
void chunksUnloadAll(void) {
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunkUnload(&CHUNKS[i]);
}
}
errorret_t chunkPositionSet(const chunkpos_t newPos) {
if(!mapIsLoaded()) errorThrow("No map loaded");
if(chunkPositionIsEqual(newPos, CHUNK_POSITION)) errorOk();
// Separate loaded chunks into "keep" and "free" buckets.
chunkindex_t chunksFreed[MAP_CHUNK_COUNT];
uint32_t freedCount = 0;
// Use a boolean grid so the inner load loop can check O(1).
bool_t posLoaded[MAP_CHUNK_WIDTH][MAP_CHUNK_HEIGHT][MAP_CHUNK_DEPTH];
memoryZero(posLoaded, sizeof(posLoaded));
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &CHUNKS[i];
chunkunit_t rx = chunk->position.x - newPos.x;
chunkunit_t ry = chunk->position.y - newPos.y;
chunkunit_t rz = chunk->position.z - newPos.z;
if(
rx >= 0 && rx < MAP_CHUNK_WIDTH &&
ry >= 0 && ry < MAP_CHUNK_HEIGHT &&
rz >= 0 && rz < MAP_CHUNK_DEPTH
) {
posLoaded[rx][ry][rz] = true;
} else {
chunkUnload(chunk);
chunksFreed[freedCount++] = i;
}
}
for(chunkunit_t z = 0; z < MAP_CHUNK_DEPTH; z++) {
for(chunkunit_t y = 0; y < MAP_CHUNK_HEIGHT; y++) {
for(chunkunit_t x = 0; x < MAP_CHUNK_WIDTH; x++) {
if(posLoaded[x][y][z]) continue;
assertTrue(freedCount > 0, "No free chunk slot available.");
chunk_t *chunk = &CHUNKS[chunksFreed[--freedCount]];
chunk->position = (chunkpos_t){
newPos.x + (chunkunit_t)x,
newPos.y + (chunkunit_t)y,
newPos.z + (chunkunit_t)z
};
errorChain(chunkLoad(chunk));
}
}
}
CHUNK_POSITION = newPos;
chunkRebuildOrder();
errorOk();
}
void chunkUnload(chunk_t *chunk) {
chunkLoadQueueRemove(chunk);
if(CHUNK_LOADING == chunk) CHUNK_LOADING = NULL;
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
if(chunk->entities[i] == 0xFF) continue;
entity_t *entity = &ENTITIES[chunk->entities[i]];
if(!entityCanUnload(entity)) {
entitySetChunk(entity, 0xFF);
} else {
entity->type = ENTITY_TYPE_NULL;
}
}
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities));
if(chunk->dataEntry != NULL) {
eventUnsubscribe(&chunk->dataEntry->onLoaded, chunkLoaded);
eventUnsubscribe(&chunk->dataEntry->onError, chunkLoadError);
assetUnlockEntry(chunk->dataEntry);
chunk->dataEntry = NULL;
}
// modelEntries are borrowed pointers, not independently locked - the
// chunk asset entry (released above) is what actually holds the ref on
// each model, so nothing to unlock here, just drop our own copies.
for(uint8_t m = 0; m < chunk->meshCount; m++) {
chunk->modelEntries[m] = NULL;
}
chunk->meshCount = 0;
}
errorret_t chunkLoad(chunk_t *chunk) {
if(!mapIsLoaded()) errorThrow("No map loaded");
chunkLoadQueueRemove(chunk);
if(CHUNK_LOADING == chunk) CHUNK_LOADING = NULL;
if(chunk->dataEntry != NULL) {
eventUnsubscribe(&chunk->dataEntry->onLoaded, chunkLoaded);
eventUnsubscribe(&chunk->dataEntry->onError, chunkLoadError);
assetUnlockEntry(chunk->dataEntry);
chunk->dataEntry = NULL;
}
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities));
chunk->meshCount = 0;
char_t path[MAP_FILE_PATH_MAX + 64];
stringFormat(
path, sizeof(path),
"map/%s/chunks/%d_%d_%d.json",
MAP.name,
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
if(!assetFileExists(path)) {
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
chunk->tiles[i] = (tile_t){ .shape = TILE_SHAPE_GROUND };
}
errorOk();
}
assertTrue(
CHUNK_LOAD_QUEUE_COUNT < MAP_CHUNK_COUNT,
"Chunk load queue overflow"
);
CHUNK_LOAD_QUEUE[CHUNK_LOAD_QUEUE_COUNT++] = chunk;
chunkLoadNext();
errorOk();
}
void chunkLoadNext(void) {
if(CHUNK_LOADING != NULL) return;
if(CHUNK_LOAD_QUEUE_COUNT == 0) return;
chunk_t *chunk = CHUNK_LOAD_QUEUE[0];
for(uint32_t i = 1; i < CHUNK_LOAD_QUEUE_COUNT; i++) {
CHUNK_LOAD_QUEUE[i - 1] = CHUNK_LOAD_QUEUE[i];
}
CHUNK_LOAD_QUEUE_COUNT--;
CHUNK_LOADING = chunk;
char_t path[MAP_FILE_PATH_MAX + 64];
stringFormat(
path, sizeof(path),
"map/%s/chunks/%d_%d_%d.json",
MAP.name,
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
assetentry_t *entry = assetLock(path, ASSET_LOADER_TYPE_CHUNK, NULL);
assertNotNull(entry, "Failed to get chunk asset entry");
chunk->dataEntry = entry;
// The entry may already be resident from an earlier load that hasn't been
// reaped yet - in that case onLoaded/onError already fired once and never
// will again, so handle the terminal state directly instead of waiting on
// a subscription that would never trigger.
if(entry->state == ASSET_ENTRY_STATE_LOADED) {
chunkLoaded(entry, chunk);
return;
}
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
chunkLoadError(entry, chunk);
return;
}
eventSubscribe(&entry->onLoaded, chunkLoaded, chunk);
eventSubscribe(&entry->onError, chunkLoadError, chunk);
}
void chunkLoadQueueRemove(chunk_t *chunk) {
for(uint32_t i = 0; i < CHUNK_LOAD_QUEUE_COUNT; i++) {
if(CHUNK_LOAD_QUEUE[i] != chunk) continue;
for(uint32_t j = i + 1; j < CHUNK_LOAD_QUEUE_COUNT; j++) {
CHUNK_LOAD_QUEUE[j - 1] = CHUNK_LOAD_QUEUE[j];
}
CHUNK_LOAD_QUEUE_COUNT--;
return;
}
}
chunkindex_t chunkGetIndexAt(const chunkpos_t position) {
if(!mapIsLoaded()) return -1;
chunkpos_t relPos = {
position.x - CHUNK_POSITION.x,
position.y - CHUNK_POSITION.y,
position.z - CHUNK_POSITION.z
};
if(
relPos.x < 0 || relPos.y < 0 || relPos.z < 0 ||
relPos.x >= MAP_CHUNK_WIDTH ||
relPos.y >= MAP_CHUNK_HEIGHT ||
relPos.z >= MAP_CHUNK_DEPTH
) {
return -1;
}
return chunkPosToIndex(&relPos);
}
chunk_t *chunkGet(const uint8_t index) {
if(index >= MAP_CHUNK_COUNT) return NULL;
if(!mapIsLoaded()) return NULL;
return CHUNK_ORDER[index];
}
tile_t chunkGetTile(const worldpos_t position) {
if(!mapIsLoaded()) return TILE_NULL;
chunkpos_t chunkPos;
worldPosToChunkPos(&position, &chunkPos);
chunkindex_t chunkIndex = chunkGetIndexAt(chunkPos);
if(chunkIndex == -1) return TILE_NULL;
chunk_t *chunk = chunkGet(chunkIndex);
assertNotNull(chunk, "Chunk pointer cannot be NULL");
chunktileindex_t tileIndex = worldPosToChunkTileIndex(&position);
tile_t tile = chunk->tiles[tileIndex];
if(tile.z != worldPosToChunkLocalZ(&position)) return TILE_NULL;
return tile;
}
bool_t chunkGetWalkableZNear(
const worldunit_t x,
const worldunit_t y,
const worldunit_t nearZ,
worldunit_t *outZ
) {
assertNotNull(outZ, "Output Z pointer cannot be NULL");
const worldunit_t candidates[] = {
nearZ, (worldunit_t)(nearZ + 1), (worldunit_t)(nearZ - 1)
};
for(uint8_t i = 0; i < 3; i++) {
const worldpos_t pos = { x, y, candidates[i] };
if(!tileShapeIsWalkable(chunkGetTile(pos).shape)) continue;
*outZ = candidates[i];
return true;
}
return false;
}
void chunkRebuildOrder(void) {
memoryZero(CHUNK_ORDER, sizeof(CHUNK_ORDER));
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &CHUNKS[i];
const chunkpos_t rel = {
chunk->position.x - CHUNK_POSITION.x,
chunk->position.y - CHUNK_POSITION.y,
chunk->position.z - CHUNK_POSITION.z
};
if(
rel.x < 0 || rel.x >= MAP_CHUNK_WIDTH ||
rel.y < 0 || rel.y >= MAP_CHUNK_HEIGHT ||
rel.z < 0 || rel.z >= MAP_CHUNK_DEPTH
) continue;
CHUNK_ORDER[chunkPosToIndex(&rel)] = chunk;
}
}
void chunkLoadError(void *params, void *user) {
assertNotNull(params, "chunkLoadError: params cannot be NULL");
assertNotNull(user, "chunkLoadError: user cannot be NULL");
assetentry_t *entry = (assetentry_t *)params;
chunk_t *chunk = (chunk_t *)user;
if(chunk->dataEntry != entry) return;
consolePrint(
"Chunk load error: %d %d %d",
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
eventUnsubscribe(&entry->onLoaded, chunkLoaded);
eventUnsubscribe(&entry->onError, chunkLoadError);
assetUnlockEntry(chunk->dataEntry);
chunk->dataEntry = NULL;
memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles));
if(CHUNK_LOADING == chunk) CHUNK_LOADING = NULL;
chunkLoadNext();
}
void chunkLoaded(void *params, void *user) {
assertNotNull(params, "chunkLoaded: params cannot be NULL");
assertNotNull(user, "chunkLoaded: user cannot be NULL");
assetentry_t *entry = (assetentry_t *)params;
chunk_t *chunk = (chunk_t *)user;
if(chunk->dataEntry != entry) return;
uint8_t meshCount = entry->data.chunk.meshCount;
memoryCopy(
chunk->tiles,
entry->data.chunk.tiles,
sizeof(chunk->tiles)
);
worldpos_t wp;
chunkPosToWorldPos(&chunk->position, &wp);
vec3 wpf = {
(float_t)wp.x, (float_t)wp.y, (float_t)wp.z * WORLD_LAYER_HEIGHT
};
for(uint8_t m = 0; m < meshCount; m++) {
stringCopy(
chunk->modelNames[m],
entry->data.chunk.modelNames[m],
CHUNK_MESH_NAME_MAX
);
glm_vec3_copy(
entry->data.chunk.meshOffsets[m],
chunk->meshOffsets[m]
);
vec3 scaledOffset = {
chunk->meshOffsets[m][0],
chunk->meshOffsets[m][1],
chunk->meshOffsets[m][2] * WORLD_LAYER_HEIGHT
};
vec3 pos;
glm_vec3_add(wpf, scaledOffset, pos);
glm_translate_make(chunk->meshModels[m], pos);
// Borrow the pointer rather than stealing it - the chunk asset entry
// keeps its own lock on each model (taken once while it loaded) and we
// keep the chunk asset entry itself locked (see below), so the models
// stay valid for as long as this chunk_t is using them. The entry may
// now be reused by a later chunkLoad for a different chunk_t once we
// eventually unlock it in chunkUnload, at which point its modelEntries
// must still be intact for that next reuse to copy from.
chunk->modelEntries[m] = entry->data.chunk.modelEntries[m];
}
eventUnsubscribe(&entry->onLoaded, chunkLoaded);
eventUnsubscribe(&entry->onError, chunkLoadError);
// Deliberately keep chunk->dataEntry locked and set - it is what keeps the
// chunk asset entry (and therefore its model locks) alive for as long as
// this chunk_t is displaying it. Released in chunkUnload instead.
chunk->meshCount = meshCount;
if(CHUNK_LOADING == chunk) CHUNK_LOADING = NULL;
chunkLoadNext();
}
+10 -134
View File
@@ -12,6 +12,8 @@
#define CHUNK_MESH_COUNT_MAX 10 #define CHUNK_MESH_COUNT_MAX 10
#define CHUNK_MESH_NAME_MAX 64 #define CHUNK_MESH_NAME_MAX 64
#define CHUNK_ENTITY_COUNT_MAX 10 #define CHUNK_ENTITY_COUNT_MAX 10
#define CHUNK_ENTITY_SPAWN_COUNT_MAX 8
#define CHUNK_AREA_COUNT_MAX 4
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
@@ -19,7 +21,7 @@ typedef struct chunk_s {
chunkpos_t position; chunkpos_t position;
tile_t tiles[CHUNK_TILE_COUNT]; tile_t tiles[CHUNK_TILE_COUNT];
assetentry_t *dataEntry; assetentry_t *dcfEntry;
uint8_t meshCount; uint8_t meshCount;
char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX]; char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX];
@@ -28,18 +30,15 @@ typedef struct chunk_s {
assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX]; assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX];
uint8_t entities[CHUNK_ENTITY_COUNT_MAX]; uint8_t entities[CHUNK_ENTITY_COUNT_MAX];
// Map area IDs (into MAP_AREAS) spawned from this chunk's file data.
// Removed via mapAreaRemove when this chunk unloads, and re-added if it
// streams back in - unlike entities (tracked by current position via
// entities[] above), areas have no position-based ownership mechanism of
// their own, so the owning chunk must track and tear them down directly.
uint8_t areas[CHUNK_AREA_COUNT_MAX];
} chunk_t; } chunk_t;
/** Every chunk slot for the currently loaded map. */
extern chunk_t CHUNKS[MAP_CHUNK_COUNT];
/**
* Chunk pointers arranged by position relative to the currently loaded
* window, indexed via chunkPosToIndex(). NULL where no chunk occupies
* that slot. Rebuilt by chunkRebuildOrder() whenever the window moves.
*/
extern chunk_t *CHUNK_ORDER[MAP_CHUNK_COUNT];
/** /**
* Gets the tile index for a tile position within a chunk. * Gets the tile index for a tile position within a chunk.
* *
@@ -56,126 +55,3 @@ uint32_t chunkGetTileIndex(const chunkpos_t position);
* @return true if equal, false otherwise. * @return true if equal, false otherwise.
*/ */
bool_t chunkPositionIsEqual(const chunkpos_t a, const chunkpos_t b); bool_t chunkPositionIsEqual(const chunkpos_t a, const chunkpos_t b);
/**
* Resets and starts loading the initial MAP_CHUNK_WIDTH x HEIGHT x DEPTH
* grid of chunks (async, see chunkLoad), anchored at chunk position
* (0,0,0). Does not unload chunks already loaded - call chunksUnloadAll()
* first when switching to a different map.
*
* @return Any error that occurs.
*/
errorret_t chunksLoadGrid(void);
/**
* Unloads every chunk slot in CHUNKS.
*/
void chunksUnloadAll(void);
/**
* Moves the loaded chunk window to be centered around newPos, unloading
* chunks that fall outside the new window and loading any newly exposed
* ones. No-op if newPos matches the currently loaded window.
*
* @param newPos The new chunk position.
* @return An error code.
*/
errorret_t chunkPositionSet(const chunkpos_t newPos);
/**
* Unloads a chunk.
*
* @param chunk The chunk to unload.
*/
void chunkUnload(chunk_t *chunk);
/**
* Loads a chunk. Starts async loading without blocking.
*
* @param chunk The chunk to load.
* @return An error code.
*/
errorret_t chunkLoad(chunk_t *chunk);
/**
* Starts loading the next queued chunk, if no chunk is currently mid-load.
* Called after chunkLoad enqueues a chunk, and again after the
* currently-loading chunk finishes (or is unloaded) to advance the queue.
*/
void chunkLoadNext(void);
/**
* Removes a chunk from the load queue if present. Used when a chunk is
* re-queued or unloaded before its turn to load has come up.
*
* @param chunk The chunk to remove from the load queue.
*/
void chunkLoadQueueRemove(chunk_t *chunk);
/**
* Callback invoked when a chunk JSON asset fails to load. Fills the
* chunk tiles with TILE_SHAPE_GROUND as a fallback.
* Always invoked on the main thread.
*
* @param params The failed assetentry_t.
* @param user The chunk_t that owns the entry.
*/
void chunkLoadError(void *params, void *user);
/**
* Callback invoked when a chunk JSON asset finishes loading.
* Always invoked on the main thread.
*
* @param params The loaded assetentry_t.
* @param user The chunk_t that owns the entry.
*/
void chunkLoaded(void *params, void *user);
/**
* Rebuilds CHUNK_ORDER from the loaded chunks that fall within the
* current render window. Called whenever the chunk position changes.
*/
void chunkRebuildOrder(void);
/**
* Gets the index of a chunk, within the currently loaded window, at the
* given position.
*
* @param position The chunk position.
* @return The index of the chunk, or -1 if out of bounds.
*/
chunkindex_t chunkGetIndexAt(const chunkpos_t position);
/**
* Gets a chunk by its index in CHUNK_ORDER.
*
* @param index The index of the chunk.
* @return A pointer to the chunk.
*/
chunk_t *chunkGet(const uint8_t index);
/**
* Gets the tile at the given world position.
*
* @param position The world position.
* @return The tile at that position, or TILE_NULL if the chunk is unloaded.
*/
tile_t chunkGetTile(const worldpos_t position);
/**
* Finds the closest walkable Z layer to nearZ at the given X/Y. Checks
* nearZ first, then nearZ + 1, then nearZ - 1, since ramps only ever
* change height by one Z layer between adjacent tiles.
*
* @param x The world X coordinate to check.
* @param y The world Y coordinate to check.
* @param nearZ The reference Z layer to search outward from.
* @param outZ Output pointer, set to the resolved Z layer on success.
* @return true if a walkable tile was found, false otherwise.
*/
bool_t chunkGetWalkableZNear(
const worldunit_t x,
const worldunit_t y,
const worldunit_t nearZ,
worldunit_t *outZ
);
@@ -0,0 +1,9 @@
# 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
)
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "mapareaglobaldefs.h"
#include "mapareagloballist.h"
#define MAP_AREA_CALLBACK_LIST_COUNT ( \
sizeof(MAP_AREA_CALLBACK_LIST) / \
sizeof(MAP_AREA_CALLBACK_LIST[0]) \
)
//EOF
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/overworld/maparea.h"
#define MAP_AREA_CALLBACK(id) \
static void MAP_AREA_CALLBACK_##id(entity_t *entity, const uint8_t trigger)
#define MAP_AREA_CALLBACK_REF(id) \
MAP_AREA_CALLBACK_##id
//EOF
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "mapareaglobaldefs.h"
#include "console/console.h"
MAP_AREA_CALLBACK(1) {
consolePrint("mapAreaGlobalCallback 1: trigger=%u", trigger);
}
// Index 0 is reserved (not a valid callback ID) - see mapAreaAddGlobal.
static const mapareacallback_t MAP_AREA_CALLBACK_LIST[] = {
NULL,
MAP_AREA_CALLBACK_REF(1),
};
//EOF
+462 -87
View File
@@ -10,64 +10,42 @@
#include "assert/assert.h" #include "assert/assert.h"
#include "asset/asset.h" #include "asset/asset.h"
#include "asset/loader/assetloader.h" #include "asset/loader/assetloader.h"
#include "asset/loader/assetentry.h"
#include "asset/loader/json/assetjsonloader.h"
#include "console/console.h" #include "console/console.h"
#include "event/event.h" #include "event/event.h"
#include "util/string.h" #include "util/string.h"
#include "rpg/overworld/chunk.h" #include "rpg/entity/global/entityglobal.h"
#include "rpg/entity/entity.h" #include "rpg/entity/item/entityitem.h"
#include "yyjson.h" #include "rpg/overworld/maparea.h"
map_t MAP; map_t MAP;
errorret_t mapInit(const char_t *name) { // Clears chunk's mid-load slot, if it currently holds one.
errorChain(mapSetMap(name)); static void mapChunkLoadingSlotClear(chunk_t *chunk) {
errorOk(); for(uint32_t i = 0; i < MAP_CHUNK_LOAD_CONCURRENCY; i++) {
if(MAP.loadingChunks[i] != chunk) continue;
MAP.loadingChunks[i] = NULL;
return;
}
} }
errorret_t mapSetMap(const char_t *name) { errorret_t mapInit() {
assertNotNull(name, "Map name cannot be NULL"); memoryZero(&MAP, sizeof(map_t));
assertStrLenMin(name, 1, "Map name cannot be empty"); MAP.loaded = true;
assertStrLenMax(name, MAP_FILE_PATH_MAX, "Map name too long");
if(mapIsLoaded() && stringEquals(MAP.name, name)) errorOk(); chunkindex_t i = 0;
for(chunkunit_t z = 0; z < MAP_CHUNK_DEPTH; z++) {
if(mapIsLoaded()) { for(chunkunit_t y = 0; y < MAP_CHUNK_HEIGHT; y++) {
chunksUnloadAll(); for(chunkunit_t x = 0; x < MAP_CHUNK_WIDTH; x++) {
if(MAP.defEntry != NULL) { chunk_t *chunk = &MAP.chunks[i++];
eventUnsubscribe(&MAP.defEntry->onLoaded, mapDefLoaded); chunk->position = (chunkpos_t){
eventUnsubscribe(&MAP.defEntry->onError, mapDefLoadError); (chunkunit_t)x, (chunkunit_t)y, (chunkunit_t)z
assetUnlockEntry(MAP.defEntry); };
MAP.defEntry = NULL; errorChain(mapChunkLoad(chunk));
}
} }
} }
memoryZero(&MAP, sizeof(map_t)); mapRebuildChunkOrder();
stringCopy(MAP.name, name, MAP_FILE_PATH_MAX);
MAP.loaded = true;
char_t defPath[MAP_FILE_PATH_MAX + 16];
stringFormat(defPath, sizeof(defPath), "map/%s/map.json", MAP.name);
assetentry_t *defEntry = assetLock(defPath, ASSET_LOADER_TYPE_JSON, NULL);
assertNotNull(defEntry, "Failed to get map def asset entry");
MAP.defEntry = defEntry;
// The entry may already be resident from an earlier load that hasn't been
// reaped yet - in that case onLoaded/onError already fired once and never
// will again, so handle the terminal state directly instead of waiting on
// a subscription that would never trigger.
if(defEntry->state == ASSET_ENTRY_STATE_LOADED) {
mapDefLoaded(defEntry, NULL);
} else if(defEntry->state == ASSET_ENTRY_STATE_ERROR) {
mapDefLoadError(defEntry, NULL);
} else {
eventSubscribe(&defEntry->onLoaded, mapDefLoaded, NULL);
eventSubscribe(&defEntry->onError, mapDefLoadError, NULL);
}
errorChain(chunksLoadGrid());
errorOk(); errorOk();
} }
@@ -75,61 +53,458 @@ bool_t mapIsLoaded() {
return MAP.loaded; return MAP.loaded;
} }
errorret_t mapPositionSet(const chunkpos_t newPos) {
if(!mapIsLoaded()) errorThrow("No map loaded");
if(chunkPositionIsEqual(newPos, MAP.chunkPosition)) errorOk();
// Separate loaded chunks into "keep" and "free" buckets.
chunkindex_t chunksFreed[MAP_CHUNK_COUNT];
uint32_t freedCount = 0;
// Use a boolean grid so the inner load loop can check O(1).
bool_t posLoaded[MAP_CHUNK_WIDTH][MAP_CHUNK_HEIGHT][MAP_CHUNK_DEPTH];
memoryZero(posLoaded, sizeof(posLoaded));
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &MAP.chunks[i];
chunkunit_t rx = chunk->position.x - newPos.x;
chunkunit_t ry = chunk->position.y - newPos.y;
chunkunit_t rz = chunk->position.z - newPos.z;
if(
rx >= 0 && rx < MAP_CHUNK_WIDTH &&
ry >= 0 && ry < MAP_CHUNK_HEIGHT &&
rz >= 0 && rz < MAP_CHUNK_DEPTH
) {
posLoaded[rx][ry][rz] = true;
} else {
mapChunkUnload(chunk);
chunksFreed[freedCount++] = i;
}
}
for(chunkunit_t z = 0; z < MAP_CHUNK_DEPTH; z++) {
for(chunkunit_t y = 0; y < MAP_CHUNK_HEIGHT; y++) {
for(chunkunit_t x = 0; x < MAP_CHUNK_WIDTH; x++) {
if(posLoaded[x][y][z]) continue;
assertTrue(freedCount > 0, "No free chunk slot available.");
chunk_t *chunk = &MAP.chunks[chunksFreed[--freedCount]];
chunk->position = (chunkpos_t){
newPos.x + (chunkunit_t)x,
newPos.y + (chunkunit_t)y,
newPos.z + (chunkunit_t)z
};
errorChain(mapChunkLoad(chunk));
}
}
}
MAP.chunkPosition = newPos;
mapRebuildChunkOrder();
errorOk();
}
errorret_t mapUpdate() { errorret_t mapUpdate() {
errorOk(); errorOk();
} }
errorret_t mapDispose() { errorret_t mapDispose() {
chunksUnloadAll(); for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
mapChunkUnload(&MAP.chunks[i]);
if(MAP.defEntry != NULL) {
eventUnsubscribe(&MAP.defEntry->onLoaded, mapDefLoaded);
eventUnsubscribe(&MAP.defEntry->onError, mapDefLoadError);
assetUnlockEntry(MAP.defEntry);
MAP.defEntry = NULL;
} }
errorOk(); errorOk();
} }
void mapDefLoadError(void *params, void *user) { void mapChunkUnload(chunk_t *chunk) {
assertNotNull(params, "mapDefLoadError: params cannot be NULL"); mapChunkLoadQueueRemove(chunk);
assetentry_t *entry = (assetentry_t *)params; mapChunkLoadingSlotClear(chunk);
if(MAP.defEntry != entry) return;
consolePrint("Failed to load map.json for '%s'", MAP.name);
eventUnsubscribe(&entry->onLoaded, mapDefLoaded);
eventUnsubscribe(&entry->onError, mapDefLoadError);
}
void mapDefLoaded(void *params, void *user) { for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
assertNotNull(params, "mapDefLoaded: params cannot be NULL"); if(chunk->entities[i] == 0xFF) continue;
assetentry_t *entry = (assetentry_t *)params; entity_t *entity = &ENTITIES[chunk->entities[i]];
if(MAP.defEntry != entry) return; if(!entityCanUnload(entity)) {
entitySetChunk(entity, 0xFF);
yyjson_val *root = yyjson_doc_get_root(entry->data.json);
yyjson_val *nameVal = yyjson_obj_get(root, "name");
if(!nameVal || !yyjson_is_str(nameVal)) {
consolePrint("map.json for '%s' missing 'name' string", MAP.name);
} else {
const char_t *nameStr = yyjson_get_str(nameVal);
size_t nameLen = yyjson_get_len(nameVal);
if(nameLen >= MAP_DISPLAY_NAME_MAX) {
consolePrint("Map display name '%s' exceeds max length", nameStr);
} else { } else {
memoryCopy(MAP.displayName, nameStr, nameLen + 1); entity->type = ENTITY_TYPE_NULL;
} }
} }
yyjson_val *entitiesVal = yyjson_obj_get(root, "entities"); memorySet(chunk->entities, 0xFF, sizeof(chunk->entities));
if(entitiesVal && yyjson_is_arr(entitiesVal)) {
size_t entIdx, entMax; for(uint8_t i = 0; i < CHUNK_AREA_COUNT_MAX; i++) {
yyjson_val *entObj; if(chunk->areas[i] == 0xFF) continue;
yyjson_arr_foreach(entitiesVal, entIdx, entMax, entObj) { mapAreaRemove(chunk->areas[i]);
entity_t *spawned = NULL; }
errorCatch(errorPrint(entityCreateFromJson(entObj, &spawned))); memorySet(chunk->areas, 0xFF, sizeof(chunk->areas));
}
if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded);
eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError);
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
} }
eventUnsubscribe(&entry->onLoaded, mapDefLoaded); // modelEntries are borrowed pointers, not independently locked - the
eventUnsubscribe(&entry->onError, mapDefLoadError); // chunk asset entry (released above) is what actually holds the ref on
// each model, so nothing to unlock here, just drop our own copies.
for(uint8_t m = 0; m < chunk->meshCount; m++) {
chunk->modelEntries[m] = NULL;
}
chunk->meshCount = 0;
}
errorret_t mapChunkLoad(chunk_t *chunk) {
if(!mapIsLoaded()) errorThrow("No map loaded");
mapChunkLoadQueueRemove(chunk);
mapChunkLoadingSlotClear(chunk);
if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded);
eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError);
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
}
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities));
// Normally already empty (mapChunkUnload clears these before a chunk is
// handed back for reuse), but cleared defensively here too so a reload
// never leaks a MAP_AREAS slot referenced by a stale owned area ID.
for(uint8_t i = 0; i < CHUNK_AREA_COUNT_MAX; i++) {
if(chunk->areas[i] == 0xFF) continue;
mapAreaRemove(chunk->areas[i]);
}
memorySet(chunk->areas, 0xFF, sizeof(chunk->areas));
chunk->meshCount = 0;
char_t name[64];
stringFormat(
name, sizeof(name),
"chunks/%d_%d_%d.dcf",
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
if(!assetFileExists(name)) {
for(uint32_t i = 0; i < CHUNK_TILE_COUNT; i++) {
// chunk->tiles[i] = (tile_t){ .shape = TILE_SHAPE_GROUND, .z = 0 };
chunk->tiles[i] = (tile_t){ .shape = TILE_SHAPE_GROUND };
}
errorOk();
}
assertTrue(
MAP.loadQueueCount < MAP_CHUNK_COUNT,
"Chunk load queue overflow"
);
MAP.loadQueue[MAP.loadQueueCount++] = chunk;
mapChunkLoadNext();
errorOk();
}
void mapChunkLoadNext() {
for(uint32_t slot = 0; slot < MAP_CHUNK_LOAD_CONCURRENCY; slot++) {
if(MAP.loadingChunks[slot] != NULL) continue;
if(MAP.loadQueueCount == 0) return;
chunk_t *chunk = MAP.loadQueue[0];
for(uint32_t i = 1; i < MAP.loadQueueCount; i++) {
MAP.loadQueue[i - 1] = MAP.loadQueue[i];
}
MAP.loadQueueCount--;
MAP.loadingChunks[slot] = chunk;
char_t name[64];
stringFormat(
name, sizeof(name),
"chunks/%d_%d_%d.dcf",
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
assetentry_t *entry = assetLock(name, ASSET_LOADER_TYPE_CHUNK, NULL);
assertNotNull(entry, "Failed to get chunk asset entry");
chunk->dcfEntry = entry;
// The entry may already be resident from an earlier load that hasn't
// been reaped yet - in that case onLoaded/onError already fired once
// and never will again, so handle the terminal state directly instead
// of waiting on a subscription that would never trigger. Both of these
// recurse back into mapChunkLoadNext once they clear this slot, so the
// outer loop just continues on to try filling the next one.
if(entry->state == ASSET_ENTRY_STATE_LOADED) {
mapChunkLoaded(entry, chunk);
continue;
}
if(entry->state == ASSET_ENTRY_STATE_ERROR) {
mapChunkLoadError(entry, chunk);
continue;
}
eventSubscribe(&entry->onLoaded, mapChunkLoaded, chunk);
eventSubscribe(&entry->onError, mapChunkLoadError, chunk);
}
}
void mapChunkLoadQueueRemove(chunk_t *chunk) {
for(uint32_t i = 0; i < MAP.loadQueueCount; i++) {
if(MAP.loadQueue[i] != chunk) continue;
for(uint32_t j = i + 1; j < MAP.loadQueueCount; j++) {
MAP.loadQueue[j - 1] = MAP.loadQueue[j];
}
MAP.loadQueueCount--;
return;
}
}
chunkindex_t mapGetChunkIndexAt(const chunkpos_t position) {
if(!mapIsLoaded()) return -1;
chunkpos_t relPos = {
position.x - MAP.chunkPosition.x,
position.y - MAP.chunkPosition.y,
position.z - MAP.chunkPosition.z
};
if(
relPos.x < 0 || relPos.y < 0 || relPos.z < 0 ||
relPos.x >= MAP_CHUNK_WIDTH ||
relPos.y >= MAP_CHUNK_HEIGHT ||
relPos.z >= MAP_CHUNK_DEPTH
) {
return -1;
}
return chunkPosToIndex(&relPos);
}
chunk_t *mapGetChunk(const uint8_t index) {
if(index >= MAP_CHUNK_COUNT) return NULL;
if(!mapIsLoaded()) return NULL;
return MAP.chunkOrder[index];
}
tile_t mapGetTile(const worldpos_t position) {
if(!mapIsLoaded()) return TILE_NULL;
chunkpos_t chunkPos;
worldPosToChunkPos(&position, &chunkPos);
chunkindex_t chunkIndex = mapGetChunkIndexAt(chunkPos);
if(chunkIndex == -1) return TILE_NULL;
chunk_t *chunk = mapGetChunk(chunkIndex);
assertNotNull(chunk, "Chunk pointer cannot be NULL");
chunktileindex_t tileIndex = worldPosToChunkTileIndex(&position);
tile_t tile = chunk->tiles[tileIndex];
if(tile.z != worldPosToChunkLocalZ(&position)) return TILE_NULL;
return tile;
}
bool_t mapGetWalkableZNear(
const worldunit_t x,
const worldunit_t y,
const worldunit_t nearZ,
worldunit_t *outZ
) {
assertNotNull(outZ, "Output Z pointer cannot be NULL");
const worldunit_t candidates[] = {
nearZ, (worldunit_t)(nearZ + 1), (worldunit_t)(nearZ - 1)
};
for(uint8_t i = 0; i < 3; i++) {
const worldpos_t pos = { x, y, candidates[i] };
if(!tileShapeIsWalkable(mapGetTile(pos).shape)) continue;
*outZ = candidates[i];
return true;
}
return false;
}
entity_t * mapSpawnEntity(
const entityglobalid_t globalId,
const worldpos_t position
) {
assertTrue(
globalId > ENTITY_GLOBAL_ID_START,
"mapSpawnEntity requires a global ID greater than ENTITY_GLOBAL_ID_START"
);
assertTrue(
globalId < ENTITY_GLOBAL_LIST_COUNT,
"Global ID is out of range for entity global init callbacks"
);
// Already spawned? Reuse the existing entity instead of making a
// duplicate - two entities must never share a global ID.
entity_t *existing = entityGetByGlobalId(globalId);
if(existing != NULL) return existing;
// See if there is a callback for this entity first.
const entityglobaldef_t *def = &ENTITY_GLOBAL_LIST[globalId];
assertNotNull(def, "No global entity definition for this ID");
assertNotNull(def->callback, "No callback registered for this global ID");
// Get available entity.
uint8_t index = entityGetAvailable();
assertTrue(index != 0xFF, "No available entity slots for mapSpawnEntity");
// Get the pointer and do the init.
entity_t *entity = &ENTITIES[index];
entityInit(entity, def->type);
entity->globalId = globalId;
entityPositionSet(entity, position);// Also assigns the entity's chunk.
// Invoke the callback to initialize the entity.
entityglobalcreate_t create = {
.entity = entity,
.position = position
};
def->callback(&create);
return entity;
}
void mapRebuildChunkOrder() {
memoryZero(MAP.chunkOrder, sizeof(MAP.chunkOrder));
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = &MAP.chunks[i];
const chunkpos_t rel = {
chunk->position.x - MAP.chunkPosition.x,
chunk->position.y - MAP.chunkPosition.y,
chunk->position.z - MAP.chunkPosition.z
};
if(
rel.x < 0 || rel.x >= MAP_CHUNK_WIDTH ||
rel.y < 0 || rel.y >= MAP_CHUNK_HEIGHT ||
rel.z < 0 || rel.z >= MAP_CHUNK_DEPTH
) continue;
MAP.chunkOrder[chunkPosToIndex(&rel)] = chunk;
}
}
void mapChunkLoadError(void *params, void *user) {
assertNotNull(params, "mapChunkLoadError: params cannot be NULL");
assertNotNull(user, "mapChunkLoadError: user cannot be NULL");
assetentry_t *entry = (assetentry_t *)params;
chunk_t *chunk = (chunk_t *)user;
if(chunk->dcfEntry != entry) return;
consolePrint(
"Chunk load error: %d %d %d",
(int32_t)chunk->position.x,
(int32_t)chunk->position.y,
(int32_t)chunk->position.z
);
eventUnsubscribe(&entry->onLoaded, mapChunkLoaded);
eventUnsubscribe(&entry->onError, mapChunkLoadError);
assetUnlockEntry(chunk->dcfEntry);
chunk->dcfEntry = NULL;
memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles));
mapChunkLoadingSlotClear(chunk);
mapChunkLoadNext();
}
void mapChunkLoaded(void *params, void *user) {
assertNotNull(params, "mapChunkLoaded: params cannot be NULL");
assertNotNull(user, "mapChunkLoaded: user cannot be NULL");
assetentry_t *entry = (assetentry_t *)params;
chunk_t *chunk = (chunk_t *)user;
if(chunk->dcfEntry != entry) return;
// consolePrint(
// "Chunk loaded: %d %d %d",
// (int32_t)chunk->position.x,
// (int32_t)chunk->position.y,
// (int32_t)chunk->position.z
// );
uint8_t meshCount = entry->data.chunk.meshCount;
memoryCopy(
chunk->tiles,
entry->data.chunk.tiles,
sizeof(chunk->tiles)
);
worldpos_t wp;
chunkPosToWorldPos(&chunk->position, &wp);
vec3 wpf = {
(float_t)wp.x, (float_t)wp.y, (float_t)wp.z * WORLD_LAYER_HEIGHT
};
for(uint8_t m = 0; m < meshCount; m++) {
stringCopy(
chunk->modelNames[m],
entry->data.chunk.modelNames[m],
CHUNK_MESH_NAME_MAX
);
glm_vec3_copy(
entry->data.chunk.meshOffsets[m],
chunk->meshOffsets[m]
);
vec3 scaledOffset = {
chunk->meshOffsets[m][0],
chunk->meshOffsets[m][1],
chunk->meshOffsets[m][2] * WORLD_LAYER_HEIGHT
};
vec3 pos;
glm_vec3_add(wpf, scaledOffset, pos);
glm_translate_make(chunk->meshModels[m], pos);
// Borrow the pointer rather than stealing it - the chunk asset entry
// keeps its own lock on each model (taken once while it loaded) and we
// keep the chunk asset entry itself locked (see below), so the models
// stay valid for as long as this chunk_t is using them. The entry may
// now be reused by a later mapChunkLoad for a different chunk_t once we
// eventually unlock it in mapChunkUnload, at which point its
// modelEntries must still be intact for that next reuse to copy from.
chunk->modelEntries[m] = entry->data.chunk.modelEntries[m];
}
eventUnsubscribe(&entry->onLoaded, mapChunkLoaded);
eventUnsubscribe(&entry->onError, mapChunkLoadError);
// Deliberately keep chunk->dcfEntry locked and set - it is what keeps the
// chunk asset entry (and therefore its model locks) alive for as long as
// this chunk_t is displaying it. Released in mapChunkUnload instead.
chunk->meshCount = meshCount;
// Spawn entities declared by this chunk's file. Global entities are
// deduped by mapSpawnEntity itself (a persistent NPC that streams back
// in won't be duplicated); item entities have no persistent identity, so
// each reload spawns a fresh one - picking an item up and then leaving
// and re-entering its chunk will currently respawn it, since nothing
// tracks "already collected" across a chunk unload/reload yet.
for(uint8_t s = 0; s < entry->data.chunk.entitySpawnCount; s++) {
chunkentityspawn_t *spawn = &entry->data.chunk.entitySpawns[s];
if(spawn->kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL) {
mapSpawnEntity((entityglobalid_t)spawn->globalId, spawn->position);
continue;
}
uint8_t index = entityGetAvailable();
assertTrue(index != 0xFF, "No available entity slots for chunk spawn");
entity_t *itemEntity = &ENTITIES[index];
entityInit(itemEntity, ENTITY_TYPE_ITEM);
entityItemSet(
itemEntity, (itemid_t)spawn->itemId, spawn->itemQuantity
);
entityPositionSet(itemEntity, spawn->position);
}
// Spawn map areas declared by this chunk's file, tracked as owned by
// this chunk so mapChunkUnload can tear them down again.
for(uint8_t s = 0; s < entry->data.chunk.areaSpawnCount; s++) {
chunkareaspawn_t *area = &entry->data.chunk.areaSpawns[s];
uint8_t areaId = mapAreaAddGlobal(
area->min, area->max, area->callbackId, area->notify, area->trigger
);
uint8_t slot = 0xFF;
for(uint8_t i = 0; i < CHUNK_AREA_COUNT_MAX; i++) {
if(chunk->areas[i] != 0xFF) continue;
slot = i;
break;
}
assertTrue(slot != 0xFF, "Chunk has no free owned-area slots");
chunk->areas[slot] = areaId;
}
mapChunkLoadingSlotClear(chunk);
mapChunkLoadNext();
} }
+127 -41
View File
@@ -1,58 +1,45 @@
/** /**
* Copyright (c) 2025 Dominic Masters * Copyright (c) 2025 Dominic Masters
* *
* This software is released under the MIT License. * This software is released under the MIT License.
* https://opensource.org/licenses/MIT * https://opensource.org/licenses/MIT
*/ */
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "rpg/overworld/chunk.h"
#include "rpg/entity/entity.h"
#define MAP_FILE_PATH_MAX 32 #define MAP_FILE_PATH_MAX 128
#define MAP_DISPLAY_NAME_MAX 64
typedef struct assetentry_s assetentry_t; // Number of chunks that may be mid-load (asset locked & awaiting onLoaded/
// onError) at the same time - everything past this waits in loadQueue.
#define MAP_CHUNK_LOAD_CONCURRENCY 2
typedef struct map_s { typedef struct map_s {
char_t name[MAP_FILE_PATH_MAX];
char_t displayName[MAP_DISPLAY_NAME_MAX];
bool_t loaded; bool_t loaded;
// Asset lock for the map's map.json, held while its async load is chunk_t chunks[MAP_CHUNK_COUNT];
// pending and for as long as the map stays loaded. chunk_t *chunkOrder[MAP_CHUNK_COUNT];
assetentry_t *defEntry; chunkpos_t chunkPosition;
chunk_t *loadQueue[MAP_CHUNK_COUNT];
uint32_t loadQueueCount;
chunk_t *loadingChunks[MAP_CHUNK_LOAD_CONCURRENCY];
} map_t; } map_t;
extern map_t MAP; extern map_t MAP;
/** /**
* Initializes the map, loading its chunks from beneath the given name's * Initializes the map.
* asset directory (e.g. "testmap" -> assets/map/testmap/chunks/X_Y_Z.dcf). *
*
* @param name The map's directory name, under assets/map/.
* @return An error code. * @return An error code.
*/ */
errorret_t mapInit(const char_t *name); errorret_t mapInit();
/**
* Switches to a different map, unloading every currently loaded chunk and
* starting an async load of the initial chunk grid (at chunk position
* 0,0,0) plus map.json (-> MAP.displayName), both from beneath the new
* name's asset directory. Once map.json loads, its optional "entities"
* array (see entityCreateFromJson) is spawned; entries that fail to parse
* are logged and skipped rather than failing the whole map load. Returns
* before either finishes loading - callers must not assume
* MAP.displayName, spawned entities, or chunk data is populated yet.
* No-op if name matches the currently loaded map.
*
* @param name The map's directory name, under assets/map/.
* @return An error code.
*/
errorret_t mapSetMap(const char_t *name);
/** /**
* Checks if a map is loaded. * Checks if a map is loaded.
* *
* @return true if a map is loaded, false otherwise. * @return true if a map is loaded, false otherwise.
*/ */
bool_t mapIsLoaded(); bool_t mapIsLoaded();
@@ -66,28 +53,127 @@ errorret_t mapUpdate();
/** /**
* Disposes of the map. * Disposes of the map.
* *
* @return An error code. * @return An error code.
*/ */
errorret_t mapDispose(); errorret_t mapDispose();
/** /**
* Callback invoked when a map's map.json asset fails to load. Leaves * Sets the map position and updates chunks accordingly.
* MAP.displayName empty and logs a console message. *
* @param newPos The new chunk position.
* @return An error code.
*/
errorret_t mapPositionSet(const chunkpos_t newPos);
/**
* Unloads a chunk.
*
* @param chunk The chunk to unload.
*/
void mapChunkUnload(chunk_t* chunk);
/**
* Loads a chunk. Starts async loading without blocking.
*
* @param chunk The chunk to load.
* @return An error code.
*/
errorret_t mapChunkLoad(chunk_t* chunk);
/**
* Starts loading queued chunks until MAP_CHUNK_LOAD_CONCURRENCY chunks are
* mid-load. Called after mapChunkLoad enqueues a chunk, and again after a
* mid-load chunk finishes (or is unloaded) to advance the queue.
*/
void mapChunkLoadNext();
/**
* Removes a chunk from the load queue if present. Used when a chunk is
* re-queued or unloaded before its turn to load has come up.
*
* @param chunk The chunk to remove from the load queue.
*/
void mapChunkLoadQueueRemove(chunk_t *chunk);
/**
* Callback invoked when a chunk DCF asset fails to load. Fills the
* chunk tiles with TILE_SHAPE_GROUND as a fallback.
* Always invoked on the main thread. * Always invoked on the main thread.
* *
* @param params The failed assetentry_t. * @param params The failed assetentry_t.
* @param user Unused. * @param user The chunk_t that owns the entry.
*/ */
void mapDefLoadError(void *params, void *user); void mapChunkLoadError(void *params, void *user);
/** /**
* Callback invoked when a map's map.json asset finishes loading. Parses * Callback invoked when a chunk DCF asset finishes loading.
* out the "name" string into MAP.displayName, and spawns each entry of
* the optional "entities" array via entityCreateFromJson.
* Always invoked on the main thread. * Always invoked on the main thread.
* *
* @param params The loaded assetentry_t. * @param params The loaded assetentry_t.
* @param user Unused. * @param user The chunk_t that owns the entry.
*/ */
void mapDefLoaded(void *params, void *user); void mapChunkLoaded(void *params, void *user);
/**
* Rebuilds chunkOrder from the loaded chunks that fall within the
* current render window. Called whenever chunkPosition changes.
*/
void mapRebuildChunkOrder();
/**
* Gets the index of a chunk, within the world, at the given position.
*
* @param position The chunk position.
* @return The index of the chunk, or -1 if out of bounds.
*/
chunkindex_t mapGetChunkIndexAt(const chunkpos_t position);
/**
* Gets a chunk by its index.
*
* @param chunkIndex The index of the chunk.
* @return A pointer to the chunk.
*/
chunk_t * mapGetChunk(const uint8_t chunkIndex);
/**
* Gets the tile at the given world position.
*
* @param position The world position.
* @return The tile at that position, or TILE_NULL if the chunk is unloaded.
*/
tile_t mapGetTile(const worldpos_t position);
/**
* Finds the closest walkable Z layer to nearZ at the given X/Y. Checks
* nearZ first, then nearZ + 1, then nearZ - 1, since ramps only ever
* change height by one Z layer between adjacent tiles.
*
* @param x The world X coordinate to check.
* @param y The world Y coordinate to check.
* @param nearZ The reference Z layer to search outward from.
* @param outZ Output pointer, set to the resolved Z layer on success.
* @return true if a walkable tile was found, false otherwise.
*/
bool_t mapGetWalkableZNear(
const worldunit_t x,
const worldunit_t y,
const worldunit_t nearZ,
worldunit_t *outZ
);
/**
* Spawns a global (persistent) entity into the world at the given position.
* Asserts globalId is greater than ENTITY_GLOBAL_ID_START - use entityInit
* directly for ephemeral, non-global entities.
*
* @param globalId The global entity ID to assign, must be greater than
* ENTITY_GLOBAL_ID_START.
* @param position The world position to spawn the entity at.
* @return Pointer to the spawned entity.
*/
entity_t * mapSpawnEntity(
const entityglobalid_t globalId,
const worldpos_t position
);
+20 -2
View File
@@ -9,7 +9,8 @@
#include "assert/assert.h" #include "assert/assert.h"
#include "util/math.h" #include "util/math.h"
#include "util/memory.h" #include "util/memory.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/map.h"
#include "rpg/overworld/global/mapareaglobal.h"
maparea_t MAP_AREAS[MAP_AREA_COUNT_MAX]; maparea_t MAP_AREAS[MAP_AREA_COUNT_MAX];
@@ -74,7 +75,7 @@ bool_t mapAreaCanUnload(const maparea_t *area) {
assertNotNull(area, "Map area pointer cannot be NULL"); assertNotNull(area, "Map area pointer cannot be NULL");
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) { for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
if(mapAreaIsChunkOverlappingOrInside(area, &CHUNKS[i])) return false; if(mapAreaIsChunkOverlappingOrInside(area, &MAP.chunks[i])) return false;
} }
return true; return true;
@@ -148,3 +149,20 @@ void mapAreaCheckEntity(entity_t *entity) {
void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger) { void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger) {
} }
uint8_t mapAreaAddGlobal(
const worldpos_t min,
const worldpos_t max,
const uint16_t callbackId,
const uint8_t notify,
const uint8_t trigger
) {
assertTrue(callbackId > 0, "Map area callback ID 0 is reserved");
assertTrue(
callbackId < MAP_AREA_CALLBACK_LIST_COUNT,
"Map area callback ID is out of range"
);
return mapAreaAdd(
min, max, MAP_AREA_CALLBACK_LIST[callbackId], notify, trigger
);
}
+26 -1
View File
@@ -158,4 +158,29 @@ void mapAreaCheckEntity(entity_t *entity);
* @param entity Pointer to the entity associated with the callback. * @param entity Pointer to the entity associated with the callback.
* @param trigger Which MAP_TRIGGER_* condition invoked the callback. * @param trigger Which MAP_TRIGGER_* condition invoked the callback.
*/ */
void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger); void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger);
/**
* Adds a map area using a compiled-in callback referenced by ID (see
* MAP_AREA_CALLBACK_LIST in rpg/overworld/global/mapareagloballist.h),
* rather than a direct function pointer. This is what lets chunk file
* data - which can only reference compiled code by a small integer ID,
* not a function pointer - declare map areas.
*
* @param min The minimum world position of the area.
* @param max The maximum world position of the area.
* @param callbackId Index into MAP_AREA_CALLBACK_LIST. Must be greater
* than 0 (0 is reserved) and within range.
* @param notify Bitwise MAP_AREA_NOTIFY_* flags for which entity types
* should trigger the callback.
* @param trigger Bitwise MAP_TRIGGER_* flags for which conditions should
* invoke the callback.
* @returns The ID of the newly added map area.
*/
uint8_t mapAreaAddGlobal(
const worldpos_t min,
const worldpos_t max,
const uint16_t callbackId,
const uint8_t notify,
const uint8_t trigger
);
+51 -28
View File
@@ -8,7 +8,6 @@
#include "rpg.h" #include "rpg.h"
#include "entity/entity.h" #include "entity/entity.h"
#include "rpg/overworld/map.h" #include "rpg/overworld/map.h"
#include "rpg/overworld/chunk.h"
#include "rpg/overworld/maparea.h" #include "rpg/overworld/maparea.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "rpg/item/backpack.h" #include "rpg/item/backpack.h"
@@ -17,44 +16,57 @@
#include "time/time.h" #include "time/time.h"
#include "rpgcamera.h" #include "rpgcamera.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "console/console.h" #include "save/save.h"
#include "save/autosave.h"
#include "error/error.h"
#include "scene/scene.h"
#include "ui/rpg/uiemoji.h" #include "ui/rpg/uiemoji.h"
#include "rpg/story/storyflag.h"
void rpgTestAreaCallback(entity_t *entity, const uint8_t trigger) {
consolePrint("rpgTestAreaCallback: trigger=%u", trigger);
}
errorret_t rpgInit(void) { errorret_t rpgInit(void) {
memoryZero(ENTITIES, sizeof(ENTITIES)); memoryZero(ENTITIES, sizeof(ENTITIES));
memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
saveslot_t *saveSlot = saveGetSlot(SAVE_ACTIVE_SLOT);
// saveInit() eagerly loads every slot from disk, but there's no
// continue-game flow yet - every boot is a fresh game regardless of
// what was found on disk, so force this false rather than let a stale
// "exists" from a real save skip storyFlagInitDefaults() below while
// everything else here still hardcodes new-game state.
saveSlot->exists = false;
// Must run before any code reads a story flag - stamps CSV-defined
// defaults onto the active save slot since it's now forced to look
// unloaded.
storyFlagInitDefaults(saveSlot);
backpackInit(); backpackInit();
partyInit(); partyInit();
cutsceneSystemInit(); cutsceneSystemInit();
errorChain(mapInit("testmap")); errorChain(mapInit());
rpgCameraInit(); rpgCameraInit();
// Init world // Init world
errorChain(chunkPositionSet((chunkpos_t){ 0, 0, 0 })); errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 }));
// TEST: Give the player a starting assortment of items. // The player is the one entity that isn't sourced from map/chunk data -
backpackAdd(itemGetIdByName("POTION"), 5); // every other entity (NPCs, items) and map area comes from the loaded
backpackAdd(itemGetIdByName("POTATO"), 3); // chunks' own spawn data (see rpg/overworld/map.c mapChunkLoaded).
backpackAdd(itemGetIdByName("APPLE"), 8); uint8_t entIndex = entityGetAvailable();
assertTrue(entIndex != 0xFF, "No available entity slots!.");
entity_t *ent = &ENTITIES[entIndex];
entityInit(ent, ENTITY_TYPE_PLAYER);
entityPositionSet(ent, (worldpos_t){ 10, 2, 0 });// Also assigns the chunk.
RPG_CAMERA.mode = RPG_CAMERA_MODE_FOLLOW_ENTITY;
RPG_CAMERA.followEntity.followEntityId = ent->id;
// TEST: Create a test map area. // Starting inventory.
uint8_t areaIndex = mapAreaAdd( backpackAdd(ITEM_ID_POTION, 5);
(worldpos_t){ 11, 3, 0 }, backpackAdd(ITEM_ID_POTATO, 3);
(worldpos_t){ 16, 9, 10 }, backpackAdd(ITEM_ID_APPLE, 8);
rpgTestAreaCallback,
MAP_AREA_NOTIFY_ALL,
MAP_TRIGGER_ENTER | MAP_TRIGGER_EXIT
);
assertTrue(areaIndex != 0xFF, "No available map area slots!.");
// All Good! // All Good!
errorOk(); errorOk();
@@ -67,15 +79,26 @@ errorret_t rpgUpdate(void) {
} }
#endif #endif
// A failed autosave forces a no-save-device prompt (see autosave.h) -
// freeze the world entirely until the player resolves it, rather than
// letting NPCs/cutscenes/camera keep running behind a modal that's
// supposed to be blocking.
if(autoSaveIsBlocking()) errorOk();
// TODO: Do not update if the scene is not the map scene? // TODO: Do not update if the scene is not the map scene?
errorChain(mapUpdate()); errorChain(mapUpdate());
// Update overworld ents. // Update overworld ents - only while actually in the overworld. Entities
entity_t *ent = &ENTITIES[0]; // (the player among them) keep existing across scene changes, but their
do { // input/movement/animation logic doesn't make sense to run mid-battle or
if(ent->type == ENTITY_TYPE_NULL) continue; // before the initial scene has handed off to the overworld.
entityUpdate(ent); if(SCENE.current == SCENE_TYPE_OVERWORLD) {
} while(++ent < &ENTITIES[ENTITY_COUNT]); entity_t *ent = &ENTITIES[0];
do {
if(ent->type == ENTITY_TYPE_NULL) continue;
entityUpdate(ent);
} while(++ent < &ENTITIES[ENTITY_COUNT]);
}
cutsceneSystemUpdate(); cutsceneSystemUpdate();
errorChain(rpgCameraUpdate()); errorChain(rpgCameraUpdate());
+1 -13
View File
@@ -10,7 +10,6 @@
#include "util/random.h" #include "util/random.h"
#include "rpg/entity/entity.h" #include "rpg/entity/entity.h"
#include "rpg/overworld/map.h" #include "rpg/overworld/map.h"
#include "rpg/overworld/chunk.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "time/time.h" #include "time/time.h"
@@ -115,17 +114,6 @@ errorret_t rpgCameraUpdate(void) {
RPG_CAMERA.shakeTime += TIME.delta; RPG_CAMERA.shakeTime += TIME.delta;
} }
// The player entity may spawn asynchronously (e.g. via map.json), so
// start following it as soon as it shows up rather than requiring
// whoever creates it to also wire up the camera.
if(RPG_CAMERA.mode == RPG_CAMERA_MODE_FREE) {
entity_t *player = entityGetByGlobalId(ENTITY_GLOBAL_ID_PLAYER);
if(player != NULL) {
RPG_CAMERA.mode = RPG_CAMERA_MODE_FOLLOW_ENTITY;
RPG_CAMERA.followEntity.followEntityId = player->id;
}
}
if(!mapIsLoaded()) errorOk(); if(!mapIsLoaded()) errorOk();
vec3 pos; vec3 pos;
@@ -137,7 +125,7 @@ errorret_t rpgCameraUpdate(void) {
.z = (chunkunit_t)floorf(pos[2] / WORLD_LAYER_HEIGHT / CHUNK_DEPTH) .z = (chunkunit_t)floorf(pos[2] / WORLD_LAYER_HEIGHT / CHUNK_DEPTH)
}; };
errorChain(chunkPositionSet((chunkpos_t){ errorChain(mapPositionSet((chunkpos_t){
.x = chunkPos.x - (MAP_CHUNK_WIDTH / 2), .x = chunkPos.x - (MAP_CHUNK_WIDTH / 2),
.y = chunkPos.y - (MAP_CHUNK_HEIGHT / 2), .y = chunkPos.y - (MAP_CHUNK_HEIGHT / 2),
.z = chunkPos.z - (MAP_CHUNK_DEPTH / 2) .z = chunkPos.z - (MAP_CHUNK_DEPTH / 2)
+15 -3
View File
@@ -1,6 +1,6 @@
/** /**
* Copyright (c) 2026 Dominic Masters * Copyright (c) 2026 Dominic Masters
* *
* This software is released under the MIT License. * This software is released under the MIT License.
* https://opensource.org/licenses/MIT * https://opensource.org/licenses/MIT
*/ */
@@ -10,5 +10,17 @@
void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value) { void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value) {
assertTrue(flag > STORY_FLAG_NULL && flag < STORY_FLAG_COUNT, "Bad Flag"); assertTrue(flag > STORY_FLAG_NULL && flag < STORY_FLAG_COUNT, "Bad Flag");
STORY_FLAG_VALUES[flag] = value; saveGetSlot(SAVE_ACTIVE_SLOT)->storyFlags[flag] = value;
} }
void storyFlagInitDefaults(saveslot_t *file) {
assertNotNull(file, "Save slot cannot be NULL");
if(file->exists) return;
assertTrue(
STORY_FLAG_COUNT <= SAVE_STORY_FLAG_COUNT_MAX,
"Too many story flags for the save format - bump SAVE_STORY_FLAG_COUNT_MAX"
);
for(storyflag_t i = 0; i < STORY_FLAG_COUNT; i++) {
file->storyFlags[i] = STORY_FLAG_DEFAULTS[i];
}
}
+22 -7
View File
@@ -1,25 +1,40 @@
/** /**
* Copyright (c) 2026 Dominic Masters * Copyright (c) 2026 Dominic Masters
* *
* This software is released under the MIT License. * This software is released under the MIT License.
* https://opensource.org/licenses/MIT * https://opensource.org/licenses/MIT
*/ */
#pragma once #pragma once
#include "rpg/story/storyflagvalue.h" #include "rpg/story/storyflagvalue.h"
#include "save/save.h"
/** /**
* Gets the value of a story flag. * Gets the value of a story flag. Reads directly from the active save
* * slot (see SAVE_ACTIVE_SLOT) - flag values have no separate live copy.
*
* @param flag The story flag to get. * @param flag The story flag to get.
* @return The value of the story flag. * @return The value of the story flag.
*/ */
#define storyFlagGet(flag) (STORY_FLAG_VALUES[(flag)]) #define storyFlagGet(flag) (saveGetSlot(SAVE_ACTIVE_SLOT)->storyFlags[(flag)])
/** /**
* Sets the value of a story flag. * Sets the value of a story flag, directly in the active save slot (see
* * SAVE_ACTIVE_SLOT). Does not itself write the save to disk - call
* saveWriteSlot() separately once ready to persist it.
*
* @param flag The story flag to set. * @param flag The story flag to set.
* @param value The value to set the story flag to. * @param value The value to set the story flag to.
*/ */
void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value); void storyFlagSet(const storyflag_t flag, const storyflagvalue_t value);
/**
* Stamps each story flag's CSV-defined default (STORY_FLAG_DEFAULTS) onto
* the given save slot, but only if it hasn't actually been loaded from
* disk yet (file->exists is false) - otherwise leaves already-played
* progress alone. Call once, e.g. during rpgInit(), before any gameplay
* code reads a story flag.
*
* @param file The save slot to stamp defaults onto.
*/
void storyFlagInitDefaults(saveslot_t *file);
+1
View File
@@ -8,4 +8,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC PUBLIC
save.c save.c
savestream.c savestream.c
autosave.c
) )
+72
View File
@@ -0,0 +1,72 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "autosave.h"
#include "save.h"
#include "error/error.h"
#include "ui/frame/initial/uiinitialnocard.h"
autosave_t AUTO_SAVE;
static void autoSaveNoCardResult(const bool_t retry, void *user) {
AUTO_SAVE.blocking = false;
// "Retry" just re-queues the write for the next tick - there's no way
// to force a re-probe of the hardware mid-session (see saveInit()'s doc
// comment), so this only actually helps for a transient failure.
if(retry) {
AUTO_SAVE.pending = true;
return;
}
// The player explicitly acknowledged there's no save device and chose
// to proceed anyway - sticks for the rest of the session, so future
// autoSaveUpdate() calls silently skip instead of prompting again.
saveMarkTemporary();
}
static void autoSaveWriteComplete(errorret_t result, void *user) {
AUTO_SAVE.saving = false;
if(errorIsOk(result)) return;
errorCatch(errorPrint(result));
// The write failed - most likely no save medium is present right now
// (a GameCube with no memory card inserted, for example). Pause world
// simulation and force the same prompt the initial boot scene uses so
// the player can insert a card and retry, or explicitly accept a
// temporary, unsaved session.
AUTO_SAVE.blocking = true;
uiInitialNoCardOpen(autoSaveNoCardResult, NULL);
}
void autoSaveQueue(void) {
AUTO_SAVE.pending = true;
}
void autoSaveUpdate(void) {
if(AUTO_SAVE.blocking) return;
if(!AUTO_SAVE.pending) return;
if(saveIsBusy()) return;
AUTO_SAVE.pending = false;
// Already opted out of saving for this session - nothing to do, and
// definitely don't reprompt every time an autosave is queued.
if(saveIsTemporary()) return;
AUTO_SAVE.saving = true;
saveWriteSlot(SAVE_ACTIVE_SLOT, autoSaveWriteComplete, NULL);
}
bool_t autoSaveIsSaving(void) {
return AUTO_SAVE.saving;
}
bool_t autoSaveIsBlocking(void) {
return AUTO_SAVE.blocking;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "error/error.h"
typedef struct {
/** True from autoSaveQueue() until the queued write starts. */
bool_t pending;
/** True while the queued write is actually in flight. */
bool_t saving;
/**
* True while the forced no-save-device prompt is up after a queued
* write failed - see autoSaveUpdate(). World simulation must pause
* while this is true (see rpgUpdate()).
*/
bool_t blocking;
} autosave_t;
extern autosave_t AUTO_SAVE;
/**
* Queues an autosave to run on a future engine tick. Safe to call as
* often as needed (e.g. after every map transition or story event) -
* repeated calls before the queued save starts just collapse into one.
*/
void autoSaveQueue(void);
/**
* Pumps the queued autosave: starts the write once nothing else is
* using the save system, and if that write fails (e.g. a GameCube with
* no memory card inserted), forces the same no-save-device prompt the
* initial boot scene uses and pauses world simulation until the player
* resolves it. Must be called every engine frame, after saveUpdate().
*/
void autoSaveUpdate(void);
/**
* True while a queued autosave's write is actually in progress. Intended
* for UI to show a "Saving" indicator.
*
* @return true if an autosave write is currently in flight.
*/
bool_t autoSaveIsSaving(void);
/**
* True while a failed autosave is forcing the no-save-device prompt and
* waiting on the player's decision. Intended for the world simulation to
* pause while this is true.
*
* @return true if an autosave is currently blocking on player input.
*/
bool_t autoSaveIsBlocking(void);
+124 -59
View File
@@ -9,19 +9,60 @@
#include "save/savestream.h" #include "save/savestream.h"
#include "util/memory.h" #include "util/memory.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "error/error.h"
save_t SAVE; save_t SAVE;
static void _saveEagerLoadComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
}
errorret_t saveInit(void) { errorret_t saveInit(void) {
memoryZero(&SAVE, sizeof(save_t)); memoryZero(&SAVE, sizeof(save_t));
SAVE.meta.deadzone = SAVE_META_DEADZONE_DEFAULT;
SAVE.meta.language = SAVE_META_LANGUAGE_DEFAULT;
#ifdef saveInitPlatform #ifdef saveInitPlatform
errorChain(saveInitPlatform()); // A missing/unreachable save medium is expected, recoverable state,
// not a reason to fail booting the whole game - log it and carry on
// with SAVE.available false instead of chaining the error upward.
errorret_t result = saveInitPlatform();
SAVE.available = errorIsOk(result);
if(!SAVE.available) errorCatch(errorPrint(result));
#else
SAVE.available = false;
#endif
// Eagerly pull meta + every slot into memory up front - cheap and
// harmless (nothing consumes loaded slot data automatically; there's no
// continue-game flow yet). PSP opts out entirely (see
// saveSkipEagerLoadPlatform) since its only read path is now the native
// savedata dialog, and running that on every boot would defeat the whole
// point of folding meta into it instead of a separate instant-write file.
#ifndef saveSkipEagerLoadPlatform
if(SAVE.available) {
saveLoadMeta(_saveEagerLoadComplete, NULL);
for(uint8_t i = 0; i < SAVE_SLOT_COUNT_MAX; i++) {
saveLoadSlot(i, _saveEagerLoadComplete, NULL);
}
}
#endif #endif
errorOk(); errorOk();
} }
bool_t saveIsAvailable(void) {
return SAVE.available && !SAVE.temporary;
}
void saveMarkTemporary(void) {
SAVE.temporary = true;
}
bool_t saveIsTemporary(void) {
return SAVE.temporary;
}
errorret_t saveDispose(void) { errorret_t saveDispose(void) {
#ifdef saveDisposePlatform #ifdef saveDisposePlatform
errorChain(saveDisposePlatform()); errorChain(saveDisposePlatform());
@@ -29,80 +70,104 @@ errorret_t saveDispose(void) {
errorOk(); errorOk();
} }
errorret_t saveLoad(const uint8_t slot) { errorret_t saveUpdate(void) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX"); #ifdef savePlatformUpdate
errorChain(savePlatformUpdate());
savefile_t *file = &SAVE.files[slot];
file->exists = false;
savestream_t stream;
memoryZero(&stream, sizeof(savestream_t));
#ifdef saveStreamOpenReadPlatform
errorChain(saveStreamOpenReadPlatform(&stream, slot));
#endif #endif
if(!stream.found) errorOk();
errorret_t ret = saveFileLoad(&stream, file);
#ifdef saveStreamClosePlatform
saveStreamClosePlatform(&stream);
#endif
if(errorIsNotOk(ret)) return ret;
errorChain(saveStreamVerifyChecksumImpl(&stream, slot));
file->exists = true;
errorOk(); errorOk();
} }
errorret_t saveWrite(const uint8_t slot) { bool_t saveIsBusy(void) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX"); #ifdef saveIsBusyPlatform
return saveIsBusyPlatform();
#else
return false;
#endif
}
savefile_t *file = &SAVE.files[slot]; void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
assertNotNull(onComplete, "onComplete cannot be NULL");
savestream_t stream; SAVE.slots[slot].exists = false;
memoryZero(&stream, sizeof(savestream_t));
#ifdef saveStreamOpenWritePlatform // Some platforms (PSP's native save dialog) can't complete within this
errorChain(saveStreamOpenWritePlatform(&stream, slot)); // call - they take over entirely and invoke onComplete later, from
// saveUpdate(), once their own multi-frame flow finishes. Those
// platforms never define the sync saveSlotLoadPlatform() at all, so the
// fallback below must live in the #else, not just after an early return.
#ifdef saveSlotAsyncLoadPlatform
saveSlotAsyncLoadPlatform(slot, onComplete, user);
#else
errorret_t ret = saveSlotLoadPlatform(slot, &SAVE.slots[slot]);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
}
void saveWriteSlot(const uint8_t slot, savecallback_t onComplete, void *user) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
assertNotNull(onComplete, "onComplete cannot be NULL");
// See saveLoadSlot() - some platforms take over and complete later.
#ifdef saveSlotAsyncWritePlatform
saveSlotAsyncWritePlatform(slot, onComplete, user);
#else
errorret_t ret = saveSlotWritePlatform(slot, &SAVE.slots[slot]);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
}
errorret_t saveDeleteSlot(const uint8_t slot) {
assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
#ifdef saveSlotDeletePlatform
errorret_t deleteRet = saveSlotDeletePlatform(slot);
SAVE.available = errorIsOk(deleteRet);
errorChain(deleteRet);
#endif #endif
errorret_t ret = saveFileWrite(&stream, file); SAVE.slots[slot].exists = false;
if(errorIsOk(ret)) {
ret = saveStreamFinalizeWriteImpl(&stream);
}
#ifdef saveStreamClosePlatform
saveStreamClosePlatform(&stream);
#endif
if(errorIsNotOk(ret)) return ret;
file->exists = true;
errorOk(); errorOk();
} }
errorret_t saveDelete(const uint8_t slot) { bool_t saveSlotExists(const uint8_t slot) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX"); assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
return SAVE.slots[slot].exists;
}
#ifdef saveDeletePlatform saveslot_t * saveGetSlot(const uint8_t slot) {
errorChain(saveDeletePlatform(slot)); assertTrue(slot < SAVE_SLOT_COUNT_MAX, "slot exceeds SAVE_SLOT_COUNT_MAX");
return &SAVE.slots[slot];
}
void saveLoadMeta(savecallback_t onComplete, void *user) {
assertNotNull(onComplete, "onComplete cannot be NULL");
SAVE.meta.exists = false;
#ifdef saveMetaAsyncLoadPlatform
saveMetaAsyncLoadPlatform(onComplete, user);
#else
errorret_t ret = saveMetaLoadPlatform(&SAVE.meta);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif #endif
SAVE.files[slot].exists = false;
errorOk();
} }
bool_t saveExists(const uint8_t slot) { void saveWriteMeta(savecallback_t onComplete, void *user) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX"); assertNotNull(onComplete, "onComplete cannot be NULL");
return SAVE.files[slot].exists;
#ifdef saveMetaAsyncWritePlatform
saveMetaAsyncWritePlatform(onComplete, user);
#else
errorret_t ret = saveMetaWritePlatform(&SAVE.meta);
SAVE.available = errorIsOk(ret);
onComplete(ret, user);
#endif
} }
savefile_t * saveGet(const uint8_t slot) { savemeta_t * saveGetMeta(void) {
assertTrue(slot < SAVE_FILE_COUNT_MAX, "slot exceeds SAVE_FILE_COUNT_MAX"); return &SAVE.meta;
return &SAVE.files[slot];
} }
+151 -24
View File
@@ -7,25 +7,97 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "savefile.h" #include "saveslot.h"
#include "savemeta.h"
#include "save/saveplatform.h" #include "save/saveplatform.h"
typedef struct { typedef struct {
/** Per-slot save file data; indexed 0 to SAVE_FILE_COUNT_MAX - 1. */ /** Per-slot save data; indexed 0 to SAVE_SLOT_COUNT_MAX - 1. */
savefile_t files[SAVE_FILE_COUNT_MAX]; saveslot_t slots[SAVE_SLOT_COUNT_MAX];
/** Device-wide preferences - see savemeta.h. */
savemeta_t meta;
/** Platform-specific save system state (paths, card handles, etc.). */ /** Platform-specific save system state (paths, card handles, etc.). */
saveplatform_t platform; saveplatform_t platform;
/**
* True if the save medium (memory card/stick/disk) was reachable the
* last time it was checked - at saveInit(), and refreshed by every
* subsequent load/write attempt. Starting the game with no card/stick
* inserted, or one being removed mid-session, are both expected
* conditions here, not fatal errors - see saveIsAvailable().
*/
bool_t available;
/**
* True once the player has explicitly acknowledged there's no save
* device and chosen to continue anyway (see the initial scene's "no
* card" prompt) - sticky for the rest of the session, folded into
* saveIsAvailable() so every existing/future call site that already
* gates on that automatically refuses to save/load from here on. See
* saveMarkTemporary()/saveIsTemporary().
*/
bool_t temporary;
/**
* Scratch error state used by platforms whose save/load completes
* asynchronously (see saveIsBusy()) to construct a result to hand to a
* savecallback_t from inside saveUpdate(), rather than from a direct
* errorThrow() return - mirrors network_t.errorState for the same reason.
*/
errorstate_t errorState;
} save_t; } save_t;
extern save_t SAVE; extern save_t SAVE;
/** /**
* Initializes the save system. * Initializes the save system. Never fails the way saveWriteSlot()/
* saveWriteMeta() can - if the platform's save medium isn't reachable
* (e.g. no memory card/stick inserted), that's logged and reflected in
* saveIsAvailable() rather than treated as fatal, since the game should
* still be playable without save support.
* *
* @return An error code if initialization fails. * On most platforms, this also eagerly loads meta and every slot from
* disk immediately - cheap and harmless, since nothing consumes the
* loaded slot data automatically (there's no "continue game" flow yet;
* rpgInit() always hardcodes a fresh game regardless of what's loaded).
* PSP skips this (see saveSkipEagerLoadPlatform) - its only read path is
* the native sceUtilitySavedata dialog now that meta lives inside the
* same payload as the save slot, and running that multi-frame dialog on
* every single boot would reintroduce the exact UX problem a lightweight
* settings-only file used to avoid.
*
* @return An error code only for unexpected platform failures.
*/ */
errorret_t saveInit(void); errorret_t saveInit(void);
/**
* Checks whether the save medium was reachable as of the last load/write
* attempt (or saveInit(), if none has been attempted yet), AND the
* session hasn't been marked temporary (see saveMarkTemporary()).
* Intended for UI to decide whether to offer saving/loading at all, or to
* explain why it isn't available right now - e.g. "No memory card
* inserted".
*
* @return true if saving/loading can be attempted right now.
*/
bool_t saveIsAvailable(void);
/**
* Marks this session as temporary - the player explicitly acknowledged
* there's no save device and chose to continue anyway. One-way: nothing
* currently re-probes the save medium mid-session (see saveInit()'s doc
* comment), so there's no legitimate way to un-stick this once set short
* of restarting the game.
*/
void saveMarkTemporary(void);
/**
* True once saveMarkTemporary() has been called this session. Distinct
* from saveIsAvailable() so UI can give a more specific message (e.g.
* "this session is temporary" rather than the generic "no save device
* found") once the player has already made that choice.
*
* @return true if this session has been marked temporary.
*/
bool_t saveIsTemporary(void);
/** /**
* Disposes of the save system. * Disposes of the save system.
* *
@@ -34,41 +106,96 @@ errorret_t saveInit(void);
errorret_t saveDispose(void); errorret_t saveDispose(void);
/** /**
* Loads the save file for a given slot from persistent storage. * Updates the save manager, pumping any in-progress async save/load and
* dispatching its callback once complete. No-op on platforms where every
* operation always completes synchronously (see saveIsBusy()). Must be
* called every engine frame for platforms that need it (PSP's native save
* dialog spans multiple frames).
* *
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1). * @return An error code indicating success or failure.
* @return An error code if the load fails.
*/ */
errorret_t saveLoad(const uint8_t slot); errorret_t saveUpdate(void);
/** /**
* Writes the save file for a given slot to persistent storage. * True while an async save/load is in progress (e.g. PSP's native save
* dialog is open). Calling any save/load function again while this is true
* is undefined behavior - wait for the previous call's callback first.
* *
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1). * @return True if a save/load request is currently in progress.
* @return An error code if the write fails.
*/ */
errorret_t saveWrite(const uint8_t slot); bool_t saveIsBusy(void);
/** /**
* Deletes the save file for a given slot from persistent storage. * Loads the save slot for a given index from persistent storage. Slow/
* async on some platforms (PSP's native save dialog spans multiple
* frames) - on others (Linux, Dolphin) onComplete is invoked before this
* call returns. See saveIsBusy().
* *
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1). * @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @param onComplete Callback invoked with the result once loading finishes.
* @param user User data passed through to onComplete.
*/
void saveLoadSlot(const uint8_t slot, savecallback_t onComplete, void *user);
/**
* Writes the save slot for a given index to persistent storage. Slow/
* async on some platforms (PSP's native save dialog spans multiple
* frames) - on others (Linux, Dolphin) onComplete is invoked before this
* call returns. See saveIsBusy().
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @param onComplete Callback invoked with the result once writing finishes.
* @param user User data passed through to onComplete.
*/
void saveWriteSlot(const uint8_t slot, savecallback_t onComplete, void *user);
/**
* Deletes the save slot for a given index from persistent storage.
*
* @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return An error code if the delete fails. * @return An error code if the delete fails.
*/ */
errorret_t saveDelete(const uint8_t slot); errorret_t saveDeleteSlot(const uint8_t slot);
/** /**
* Checks whether a save file exists for a given slot. * Checks whether a save slot has data for a given index.
* *
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1). * @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return true if a save file exists for the slot, false otherwise. * @return true if the slot has data, false otherwise.
*/ */
bool_t saveExists(const uint8_t slot); bool_t saveSlotExists(const uint8_t slot);
/** /**
* Gets a pointer to the save file data for a given slot. * Gets a pointer to the save slot data for a given index.
* *
* @param slot The save slot index (0 to SAVE_FILE_COUNT_MAX - 1). * @param slot The save slot index (0 to SAVE_SLOT_COUNT_MAX - 1).
* @return A pointer to the savefile_t for the given slot. * @return A pointer to the saveslot_t for the given slot.
*/ */
savefile_t * saveGet(const uint8_t slot); saveslot_t * saveGetSlot(const uint8_t slot);
/**
* Loads device-wide meta (preferences) from persistent storage. Callback-
* based on every platform, same as saveLoadSlot() - on PSP specifically,
* loading meta means loading the same combined savedata payload as the
* active slot, which is unavoidably async there.
*
* @param onComplete Callback invoked with the result once loading finishes.
* @param user User data passed through to onComplete.
*/
void saveLoadMeta(savecallback_t onComplete, void *user);
/**
* Writes device-wide meta (preferences) to persistent storage.
*
* @param onComplete Callback invoked with the result once writing finishes.
* @param user User data passed through to onComplete.
*/
void saveWriteMeta(savecallback_t onComplete, void *user);
/**
* Gets a pointer to the live meta data. Modify fields directly, then call
* saveWriteMeta() to persist them.
*
* @return A pointer to the meta.
*/
savemeta_t * saveGetMeta(void);
-30
View File
@@ -1,30 +0,0 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
/** Save file format version. Increment on breaking change. */
#define SAVE_FILE_VERSION 1
/** Magic bytes that identify a Dusk save file. */
#define SAVE_FILE_HEADER "DSK"
/** Byte length of the magic header (excludes the null terminator). */
#define SAVE_FILE_HEADER_SIZE (sizeof(SAVE_FILE_HEADER) - 1)
/** Maximum number of independent save slots supported. */
#define SAVE_FILE_COUNT_MAX 3
typedef struct {
/** Magic header bytes read from the file; must equal SAVE_FILE_HEADER. */
char_t header[SAVE_FILE_HEADER_SIZE];
/** Format version read from the file; used to branch on older layouts. */
uint32_t version;
/** Runtime flag - true if this slot was successfully loaded or written. */
bool_t exists;
} savefile_t;
+63
View File
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
/** Save meta format version. Increment on breaking change. */
#define SAVE_META_VERSION 1
/** Magic bytes that identify a Dusk save meta blob. */
#define SAVE_META_HEADER "DSM"
/** Byte length of the magic header (excludes the null terminator). */
#define SAVE_META_HEADER_SIZE (sizeof(SAVE_META_HEADER) - 1)
/**
* Default gamepad deadzone for meta that's never actually been loaded from
* disk yet (see saveInit(), which stamps this on first boot) - the save
* meta is the single source of truth for this value (see
* savemeta_t.deadzone); nothing else stores or defaults it.
*/
#define SAVE_META_DEADZONE_DEFAULT 0.1f
/**
* Default language index for meta that's never actually been loaded from
* disk yet - index 0 into LOCALE_LIST (see locale/localemanager.h), i.e.
* LOCALE_EN_US.
*/
#define SAVE_META_LANGUAGE_DEFAULT 0
/**
* Device/user-wide preferences, independent of any individual game save
* slot (see saveslot.h) - there's exactly one of these, not one per slot,
* since a setting like gamepad deadzone shouldn't reset or diverge just
* because the player started a new game in a different slot.
*/
typedef struct {
/** Magic header bytes read from the blob; must equal SAVE_META_HEADER. */
char_t header[SAVE_META_HEADER_SIZE];
/** Format version read from the blob; used to branch on older layouts. */
uint32_t version;
/** Runtime flag - true if meta was successfully loaded or written. */
bool_t exists;
/**
* User-configured gamepad deadzone (0.0f-1.0f) - the save meta is the
* only place this lives; read it directly via saveGetMeta()->deadzone
* rather than caching it anywhere else.
*/
float_t deadzone;
/**
* Index into LOCALE_LIST (see locale/localemanager.h) for the player's
* preferred UI language - the save meta is the only place this lives;
* read it directly via saveGetMeta()->language rather than caching it
* anywhere else. Never reorder/remove entries from LOCALE_LIST, only
* append, since this index must keep meaning the same locale across
* versions.
*/
uint8_t language;
} savemeta_t;
+89
View File
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "dusk.h"
/** Save slot format version. Increment on breaking change. */
#define SAVE_SLOT_VERSION 1
/** Magic bytes that identify a Dusk save slot. */
#define SAVE_SLOT_HEADER "DSK"
/** Byte length of the magic header (excludes the null terminator). */
#define SAVE_SLOT_HEADER_SIZE (sizeof(SAVE_SLOT_HEADER) - 1)
/**
* Maximum number of independent save slots supported. Platform-overridable
* via a compiler define (not a header #ifndef alone, since this header is
* included before any platform header gets a chance to react) - see PSP's
* CMakeLists.txt, which overrides this to 1.
*/
#ifndef SAVE_SLOT_COUNT_MAX
#define SAVE_SLOT_COUNT_MAX 3
#endif
/**
* The save slot actually used for gameplay right now - there's no slot
* select/multi-save UX yet (SAVE_SLOT_COUNT_MAX > 1 exists for later), so
* every part of the game that needs "the" save slot (the game menu's Save
* button, etc.) reads/writes this one slot.
*/
#define SAVE_ACTIVE_SLOT 0
/** Maximum length of a saved player name, including the null terminator. */
#define SAVE_PLAYER_NAME_MAX 32
/**
* Maximum number of global entities whose "collected" state can be
* tracked - see rpg/entity/global/globalitemstore.h. Bounded/fixed here
* rather than tied to ENTITY_GLOBAL_LIST_COUNT, since saveslot.h is a
* leaf header with no dependency on the entity system (and no reason to
* take one just for a size constant).
*/
#define SAVE_GLOBAL_ITEM_COUNT_MAX 64
/**
* Maximum number of story flags the save format can hold - see
* rpg/story/storyflag.h. Bounded/fixed here (with real headroom over the
* current flag count) rather than tied to STORY_FLAG_COUNT, since
* saveslot.h is a leaf header with no dependency on generated story
* content, matching SAVE_GLOBAL_ITEM_COUNT_MAX's reasoning.
*/
#define SAVE_STORY_FLAG_COUNT_MAX 128
/** Per-slot game progress - the state a "save file" traditionally means. */
typedef struct {
/** Magic header bytes read from the slot; must equal SAVE_SLOT_HEADER. */
char_t header[SAVE_SLOT_HEADER_SIZE];
/** Format version read from the slot; used to branch on older layouts. */
uint32_t version;
/** Runtime flag - true if this slot was successfully loaded or written. */
bool_t exists;
/** The player's saved name. */
char_t playerName[SAVE_PLAYER_NAME_MAX];
/** Per-global-ID "already collected" flags - see globalitemstore.h. */
bool_t globalItemCollected[SAVE_GLOBAL_ITEM_COUNT_MAX];
/**
* Story flag values, indexed by storyflag_t - the save slot is the only
* place these live; read/write via storyFlagGet()/storyFlagSet() (see
* rpg/story/storyflag.h), not directly.
*/
uint8_t storyFlags[SAVE_STORY_FLAG_COUNT_MAX];
} saveslot_t;
/**
* Callback invoked when an async saveWriteSlot()/saveLoadSlot()/
* saveWriteMeta()/saveLoadMeta() request completes. Declared here (rather
* than save.h) so platform save headers - which save.h's platform
* indirection pulls in before save.h finishes defining anything else - can
* reference it without a circular include.
*
* @param result Whether the request succeeded.
* @param user User data passed through from the original call.
*/
typedef void (*savecallback_t)(errorret_t result, void *user);
+86 -23
View File
@@ -45,12 +45,23 @@ errorret_t saveStreamWriteBytesImpl(
errorOk(); errorOk();
} }
errorret_t saveStreamFinalizeWriteImpl(savestream_t *stream) { errorret_t saveStreamTellImpl(savestream_t *stream, size_t *out) {
#ifdef saveStreamTellPlatform
errorChain(saveStreamTellPlatform(stream, out));
#else
*out = 0;
#endif
errorOk();
}
errorret_t saveStreamFinalizeWriteImpl(
savestream_t *stream, const size_t headerPosition, const size_t headerSize
) {
uint32_t finalCRC = cryptCRC32End(stream->checksum); uint32_t finalCRC = cryptCRC32End(stream->checksum);
uint32_t leChecksum = endianLittleToHost32(finalCRC); uint32_t leChecksum = endianLittleToHost32(finalCRC);
#ifdef saveStreamSeekPlatform #ifdef saveStreamSeekPlatform
errorChain(saveStreamSeekPlatform(stream, SAVE_FILE_HEADER_SIZE)); errorChain(saveStreamSeekPlatform(stream, headerPosition + headerSize));
#endif #endif
errorChain(saveStreamWriteBytesRawImpl( errorChain(saveStreamWriteBytesRawImpl(
@@ -60,27 +71,25 @@ errorret_t saveStreamFinalizeWriteImpl(savestream_t *stream) {
} }
errorret_t saveStreamVerifyChecksumImpl( errorret_t saveStreamVerifyChecksumImpl(
savestream_t *stream, const uint8_t slot savestream_t *stream, const char_t *sectionLabel
) { ) {
uint32_t computed = cryptCRC32End(stream->checksum); uint32_t computed = cryptCRC32End(stream->checksum);
if(computed != stream->expectedChecksum) { if(computed != stream->expectedChecksum) {
errorThrow("Save slot %u has invalid checksum", (uint32_t)slot); errorThrow("%s has invalid checksum", sectionLabel);
} }
errorOk(); errorOk();
} }
errorret_t saveStreamReadHeaderImpl( errorret_t saveStreamReadHeaderImpl(
savestream_t *stream, char_t header[SAVE_FILE_HEADER_SIZE] savestream_t *stream, char_t *header, const char_t *expectedHeader,
const size_t headerSize
) { ) {
errorChain(saveStreamReadBytesRawImpl(stream, header, SAVE_FILE_HEADER_SIZE)); errorChain(saveStreamReadBytesRawImpl(stream, header, headerSize));
if( for(size_t i = 0; i < headerSize; i++) {
header[0] != SAVE_FILE_HEADER[0] || if(header[i] != expectedHeader[i]) {
header[1] != SAVE_FILE_HEADER[1] || errorThrow("Save data has invalid header");
header[2] != SAVE_FILE_HEADER[2] }
) {
errorThrow("Save file has invalid header");
} }
uint32_t leChecksum; uint32_t leChecksum;
@@ -91,11 +100,9 @@ errorret_t saveStreamReadHeaderImpl(
} }
errorret_t saveStreamWriteHeaderImpl( errorret_t saveStreamWriteHeaderImpl(
savestream_t *stream, const char_t header[SAVE_FILE_HEADER_SIZE] savestream_t *stream, const char_t *header, const size_t headerSize
) { ) {
errorChain(saveStreamWriteBytesRawImpl( errorChain(saveStreamWriteBytesRawImpl(stream, header, headerSize));
stream, header, SAVE_FILE_HEADER_SIZE
));
uint32_t placeholder = 0; uint32_t placeholder = 0;
errorChain(saveStreamWriteBytesRawImpl( errorChain(saveStreamWriteBytesRawImpl(
@@ -327,14 +334,70 @@ errorret_t saveStreamWriteDateImpl(
errorOk(); errorOk();
} }
errorret_t saveFileLoad(savestream_t *stream, savefile_t *file) { errorret_t saveMetaSerializeRead(savestream_t *stream, savemeta_t *meta) {
saveFileReadHeader(stream, file->header); saveFileReadHeader(
saveFileReadVersion(stream, &file->version); stream, meta->header, SAVE_META_HEADER, SAVE_META_HEADER_SIZE
);
saveFileReadVersion(stream, &meta->version);
saveFileReadFloat(stream, &meta->deadzone);
saveFileReadUInt8(stream, &meta->language);
errorChain(saveStreamVerifyChecksumImpl(stream, "Save meta"));
meta->exists = true;
errorOk(); errorOk();
} }
errorret_t saveFileWrite(savestream_t *stream, savefile_t *file) { errorret_t saveMetaSerializeWrite(savestream_t *stream, savemeta_t *meta) {
saveFileWriteHeader(stream, file->header); memoryCopy(meta->header, SAVE_META_HEADER, SAVE_META_HEADER_SIZE);
saveFileWriteVersion(stream, &file->version); meta->version = SAVE_META_VERSION;
size_t headerPosition;
errorChain(saveStreamTellImpl(stream, &headerPosition));
saveFileWriteHeader(stream, meta->header, SAVE_META_HEADER_SIZE);
saveFileWriteVersion(stream, &meta->version);
saveFileWriteFloat(stream, &meta->deadzone);
saveFileWriteUInt8(stream, &meta->language);
errorChain(saveStreamFinalizeWriteImpl(
stream, headerPosition, SAVE_META_HEADER_SIZE
));
meta->exists = true;
errorOk();
}
errorret_t saveSlotSerializeRead(savestream_t *stream, saveslot_t *slot) {
saveFileReadHeader(
stream, slot->header, SAVE_SLOT_HEADER, SAVE_SLOT_HEADER_SIZE
);
saveFileReadVersion(stream, &slot->version);
saveFileReadString(stream, slot->playerName, SAVE_PLAYER_NAME_MAX);
for(size_t i = 0; i < SAVE_GLOBAL_ITEM_COUNT_MAX; i++) {
saveFileReadBool(stream, &slot->globalItemCollected[i]);
}
for(size_t i = 0; i < SAVE_STORY_FLAG_COUNT_MAX; i++) {
saveFileReadUInt8(stream, &slot->storyFlags[i]);
}
errorChain(saveStreamVerifyChecksumImpl(stream, "Save slot"));
slot->exists = true;
errorOk();
}
errorret_t saveSlotSerializeWrite(savestream_t *stream, saveslot_t *slot) {
memoryCopy(slot->header, SAVE_SLOT_HEADER, SAVE_SLOT_HEADER_SIZE);
slot->version = SAVE_SLOT_VERSION;
size_t headerPosition;
errorChain(saveStreamTellImpl(stream, &headerPosition));
saveFileWriteHeader(stream, slot->header, SAVE_SLOT_HEADER_SIZE);
saveFileWriteVersion(stream, &slot->version);
saveFileWriteString(stream, slot->playerName, SAVE_PLAYER_NAME_MAX);
for(size_t i = 0; i < SAVE_GLOBAL_ITEM_COUNT_MAX; i++) {
saveFileWriteBool(stream, &slot->globalItemCollected[i]);
}
for(size_t i = 0; i < SAVE_STORY_FLAG_COUNT_MAX; i++) {
saveFileWriteUInt8(stream, &slot->storyFlags[i]);
}
errorChain(saveStreamFinalizeWriteImpl(
stream, headerPosition, SAVE_SLOT_HEADER_SIZE
));
slot->exists = true;
errorOk(); errorOk();
} }
+74 -31
View File
@@ -7,7 +7,8 @@
#pragma once #pragma once
#include "error/error.h" #include "error/error.h"
#include "savefile.h" #include "saveslot.h"
#include "savemeta.h"
#include "save/saveplatform.h" #include "save/saveplatform.h"
#include "time/timeepoch.h" #include "time/timeepoch.h"
@@ -67,49 +68,72 @@ errorret_t saveStreamWriteBytesImpl(
); );
/** /**
* Finalizes a write stream: computes the final CRC32, seeks to the * Gets the current read/write position within the stream. Used to capture
* checksum field in the header, and writes it in little-endian order. * a section's start position before writing its header, so its checksum
* can be backfilled at the right offset once the section's body is known -
* required now that a single stream can hold multiple self-contained
* sections back-to-back (meta + N save slots), not just one.
*
* @param stream Active stream.
* @param out Receives the current position.
* @return An error if the platform can't report a position.
*/
errorret_t saveStreamTellImpl(savestream_t *stream, size_t *out);
/**
* Finalizes a write stream: computes the final CRC32, seeks back to the
* checksum field just after this section's header, and writes it in
* little-endian order.
* *
* @param stream Active write stream. * @param stream Active write stream.
* @param headerPosition Byte offset where this section's header started
* (see saveStreamTellImpl), captured before writing the header.
* @param headerSize Byte length of this section's magic header.
* @return An error if the seek or write fails. * @return An error if the seek or write fails.
*/ */
errorret_t saveStreamFinalizeWriteImpl(savestream_t *stream); errorret_t saveStreamFinalizeWriteImpl(
savestream_t *stream, const size_t headerPosition, const size_t headerSize
);
/** /**
* Verifies that the CRC32 accumulated during loading matches the value * Verifies that the CRC32 accumulated during loading matches the value
* stored in the file header. * stored in this section's header.
* *
* @param stream Active read stream (loading must be complete). * @param stream Active read stream (loading must be complete).
* @param slot Slot index used in the error message on mismatch. * @param sectionLabel Human-readable label used in the error message on
* mismatch (e.g. "save meta", "save slot").
* @return An error if the checksum does not match. * @return An error if the checksum does not match.
*/ */
errorret_t saveStreamVerifyChecksumImpl( errorret_t saveStreamVerifyChecksumImpl(
savestream_t *stream, const uint8_t slot savestream_t *stream, const char_t *sectionLabel
); );
/** /**
* Reads and validates the magic header, then reads the stored CRC32 and * Reads and validates a section's magic header, then reads its stored
* resets the running accumulator. * CRC32 and resets the running accumulator.
* *
* @param stream Active read stream. * @param stream Active read stream.
* @param header Buffer of SAVE_FILE_HEADER_SIZE bytes to receive the header. * @param header Buffer of headerSize bytes to receive the header.
* @param expectedHeader The magic bytes this section must match.
* @param headerSize Byte length of the magic header.
* @return An error if the header is missing or invalid. * @return An error if the header is missing or invalid.
*/ */
errorret_t saveStreamReadHeaderImpl( errorret_t saveStreamReadHeaderImpl(
savestream_t *stream, char_t header[SAVE_FILE_HEADER_SIZE] savestream_t *stream, char_t *header, const char_t *expectedHeader,
const size_t headerSize
); );
/** /**
* Writes the magic header and a zero CRC32 placeholder, then resets the * Writes a section's magic header and a zero CRC32 placeholder, then
* running accumulator. * resets the running accumulator.
* *
* @param stream Active write stream. * @param stream Active write stream.
* @param header Buffer of SAVE_FILE_HEADER_SIZE bytes to write. * @param header Buffer of headerSize bytes to write.
* @param headerSize Byte length of the magic header.
* @return An error if the write fails. * @return An error if the write fails.
*/ */
errorret_t saveStreamWriteHeaderImpl( errorret_t saveStreamWriteHeaderImpl(
savestream_t *stream, savestream_t *stream, const char_t *header, const size_t headerSize
const char_t header[SAVE_FILE_HEADER_SIZE]
); );
/** /**
@@ -377,29 +401,49 @@ errorret_t saveStreamWriteDateImpl(
); );
/** /**
* Reads the contents of a save slot from the stream into the save file * Reads a self-contained save meta section (header, version, fields,
* struct. Use saveFileRead* macros to deserialize fields one at a time. * checksum verification) from the stream.
* *
* @param stream Active read stream for this slot. * @param stream Active read stream, positioned at the section's start.
* @param file Save file struct to populate. * @param meta Meta struct to populate.
* @return An error code if loading fails. * @return An error code if loading fails.
*/ */
errorret_t saveFileLoad(savestream_t *stream, savefile_t *file); errorret_t saveMetaSerializeRead(savestream_t *stream, savemeta_t *meta);
/** /**
* Writes the contents of the save file struct into the stream. * Writes a self-contained save meta section (header, version, fields,
* Use saveFileWrite* macros to serialize fields one at a time. * checksum) to the stream.
* *
* @param stream Active write stream for this slot. * @param stream Active write stream, positioned at the section's start.
* @param file Save file struct to serialize. * @param meta Meta struct to serialize.
* @return An error code if writing fails. * @return An error code if writing fails.
*/ */
errorret_t saveFileWrite(savestream_t *stream, savefile_t *file); errorret_t saveMetaSerializeWrite(savestream_t *stream, savemeta_t *meta);
#define saveFileReadHeader(stream, header) \ /**
errorChain(saveStreamReadHeaderImpl(stream, header)) * Reads a self-contained save slot section (header, version, fields,
#define saveFileWriteHeader(stream, header) \ * checksum verification) from the stream.
errorChain(saveStreamWriteHeaderImpl(stream, header)) *
* @param stream Active read stream, positioned at the section's start.
* @param slot Slot struct to populate.
* @return An error code if loading fails.
*/
errorret_t saveSlotSerializeRead(savestream_t *stream, saveslot_t *slot);
/**
* Writes a self-contained save slot section (header, version, fields,
* checksum) to the stream.
*
* @param stream Active write stream, positioned at the section's start.
* @param slot Slot struct to serialize.
* @return An error code if writing fails.
*/
errorret_t saveSlotSerializeWrite(savestream_t *stream, saveslot_t *slot);
#define saveFileReadHeader(stream, header, expected, size) \
errorChain(saveStreamReadHeaderImpl(stream, header, expected, size))
#define saveFileWriteHeader(stream, header, size) \
errorChain(saveStreamWriteHeaderImpl(stream, header, size))
#define saveFileReadVersion(stream, out) \ #define saveFileReadVersion(stream, out) \
errorChain(saveStreamReadVersionImpl(stream, out)) errorChain(saveStreamReadVersionImpl(stream, out))
@@ -465,4 +509,3 @@ errorret_t saveFileWrite(savestream_t *stream, savefile_t *file);
errorChain(saveStreamReadDateImpl(stream, out)) errorChain(saveStreamReadDateImpl(stream, out))
#define saveFileWriteDate(stream, input) \ #define saveFileWriteDate(stream, input) \
errorChain(saveStreamWriteDateImpl(stream, input)) errorChain(saveStreamWriteDateImpl(stream, input))
+1
View File
@@ -10,5 +10,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
) )
# Subdirs # Subdirs
add_subdirectory(initial)
add_subdirectory(overworld) add_subdirectory(overworld)
add_subdirectory(battle) add_subdirectory(battle)
+9
View File
@@ -0,0 +1,9 @@
# 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
sceneinitial.c
)
+93
View File
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "scene/scene.h"
#include "assert/assert.h"
#include "util/memory.h"
#include "error/error.h"
#include "save/save.h"
#include "ui/frame/initial/uiinitialnocard.h"
#include "ui/frame/initial/uiinitialcreatesave.h"
static void sceneInitialCheckSave(void);
static void sceneInitialCreateSaveWriteComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) errorCatch(errorPrint(result));
sceneSet(SCENE_TYPE_OVERWORLD);
}
static void sceneInitialCreateSaveResult(const bool_t create, void *user) {
if(!create) {
sceneSet(SCENE_TYPE_OVERWORLD);
return;
}
saveWriteSlot(SAVE_ACTIVE_SLOT, sceneInitialCreateSaveWriteComplete, NULL);
}
static void sceneInitialNoCardResult(const bool_t retry, void *user) {
if(retry) {
sceneInitialCheckSave();
return;
}
// The player explicitly acknowledged there's no save device and chose
// to proceed anyway - stick with that for the rest of the session (see
// saveMarkTemporary()'s doc comment for why this can't be un-set later).
saveMarkTemporary();
sceneSet(SCENE_TYPE_OVERWORLD);
}
static void sceneInitialLoadComplete(errorret_t result, void *user) {
if(errorIsNotOk(result) || !saveIsAvailable()) {
errorCatch(result);
uiInitialNoCardOpen(sceneInitialNoCardResult, NULL);
return;
}
if(saveSlotExists(SAVE_ACTIVE_SLOT)) {
sceneSet(SCENE_TYPE_OVERWORLD);
return;
}
uiInitialCreateSaveOpen(sceneInitialCreateSaveResult, NULL);
}
static void sceneInitialCheckSave(void) {
if(!saveIsAvailable()) {
uiInitialNoCardOpen(sceneInitialNoCardResult, NULL);
return;
}
if(saveIsBusy()) return;
saveLoadSlot(SAVE_ACTIVE_SLOT, sceneInitialLoadComplete, NULL);
}
errorret_t sceneInitialInit(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null");
memoryZero(&sceneData->initial, sizeof(sceneinitial_t));
sceneInitialCheckSave();
errorOk();
}
errorret_t sceneInitialUpdate(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null");
errorOk();
}
errorret_t sceneInitialRender(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null");
errorOk();
}
errorret_t sceneInitialDispose(scenedata_t *sceneData) {
assertNotNull(sceneData, "Scene data cannot be null");
errorOk();
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "scene/scenebase.h"
// No per-scene state needed - the save globals and the two modal UI
// elements (see ui/frame/initial/) carry everything this scene cares
// about. A byte placeholder keeps the struct non-empty for portability.
typedef struct {
uint8_t reserved;
} sceneinitial_t;
/**
* Initialises the initial (boot) scene - kicks off the save
* availability/existence check that decides which prompt, if any, to
* show before proceeding to the overworld.
*
* @param sceneData The scene data used for this scene.
* @return An error if the init failed, or errorOk() if it succeeded.
*/
errorret_t sceneInitialInit(scenedata_t *sceneData);
/**
* Updates the initial scene. Currently a no-op - all the work happens in
* save callbacks and the two modal UI elements' own button handling.
*
* @param sceneData The scene data used for this scene.
* @return An error if the update failed, or errorOk() if it succeeded.
*/
errorret_t sceneInitialUpdate(scenedata_t *sceneData);
/**
* Renders the initial scene. Currently a no-op - the modals draw
* themselves via the global UI element pipeline.
*
* @param sceneData The scene data used for this scene.
* @return An error if the render failed, or errorOk() if it succeeded.
*/
errorret_t sceneInitialRender(scenedata_t *sceneData);
/**
* Disposes the initial scene.
*
* @param sceneData The scene data used for this scene.
* @return An error if the dispose failed, or errorOk() if it succeeded.
*/
errorret_t sceneInitialDispose(scenedata_t *sceneData);
+3 -3
View File
@@ -17,7 +17,7 @@
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
#include "display/texture/texture.h" #include "display/texture/texture.h"
#include "rpg/overworld/chunk.h" #include "rpg/overworld/map.h"
#include "rpg/entity/entity.h" #include "rpg/entity/entity.h"
#include "rpg/rpgcamera.h" #include "rpg/rpgcamera.h"
@@ -183,7 +183,7 @@ errorret_t sceneOverworldDrawEntity(
errorret_t sceneOverworldDrawChunksBase(const sceneoverworld_t *overworld) { errorret_t sceneOverworldDrawChunksBase(const sceneoverworld_t *overworld) {
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) { for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = CHUNK_ORDER[i]; chunk_t *chunk = MAP.chunkOrder[i];
if(chunk == NULL) continue; if(chunk == NULL) continue;
if(!sceneOverworldChunkShouldRender(overworld, chunk)) continue; if(!sceneOverworldChunkShouldRender(overworld, chunk)) continue;
if(chunk->meshCount == 0) continue; if(chunk->meshCount == 0) continue;
@@ -220,7 +220,7 @@ errorret_t sceneOverworldDrawChunksBase(const sceneoverworld_t *overworld) {
errorret_t sceneOverworldDrawChunksProps(const sceneoverworld_t *overworld) { errorret_t sceneOverworldDrawChunksProps(const sceneoverworld_t *overworld) {
for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) { for(chunkindex_t i = 0; i < MAP_CHUNK_COUNT; i++) {
chunk_t *chunk = CHUNK_ORDER[i]; chunk_t *chunk = MAP.chunkOrder[i];
if(chunk == NULL) continue; if(chunk == NULL) continue;
if(!sceneOverworldChunkShouldRender(overworld, chunk)) continue; if(!sceneOverworldChunkShouldRender(overworld, chunk)) continue;
+7
View File
@@ -10,6 +10,13 @@
scenecallbacks_t SCENE_TYPES[SCENE_TYPE_COUNT] = { scenecallbacks_t SCENE_TYPES[SCENE_TYPE_COUNT] = {
[SCENE_TYPE_NULL] = { 0 }, [SCENE_TYPE_NULL] = { 0 },
[SCENE_TYPE_INITIAL] = {
.init = sceneInitialInit,
.update = sceneInitialUpdate,
.render = sceneInitialRender,
.dispose = sceneInitialDispose
},
[SCENE_TYPE_OVERWORLD] = { [SCENE_TYPE_OVERWORLD] = {
.init = sceneOverworldInit, .init = sceneOverworldInit,
.update = sceneOverworldUpdate, .update = sceneOverworldUpdate,
+3
View File
@@ -7,10 +7,12 @@
#pragma once #pragma once
#include "scene/scenebase.h" #include "scene/scenebase.h"
#include "scene/initial/sceneinitial.h"
#include "scene/overworld/sceneoverworld.h" #include "scene/overworld/sceneoverworld.h"
#include "scene/battle/scenebattle.h" #include "scene/battle/scenebattle.h"
typedef union scenedata_u { typedef union scenedata_u {
sceneinitial_t initial;
sceneoverworld_t overworld; sceneoverworld_t overworld;
scenebattle_t battle; scenebattle_t battle;
} scenedata_t; } scenedata_t;
@@ -27,6 +29,7 @@ typedef struct {
typedef enum { typedef enum {
SCENE_TYPE_NULL, SCENE_TYPE_NULL,
SCENE_TYPE_INITIAL,
SCENE_TYPE_OVERWORLD, SCENE_TYPE_OVERWORLD,
SCENE_TYPE_BATTLE, SCENE_TYPE_BATTLE,
+1
View File
@@ -13,3 +13,4 @@ add_subdirectory(game)
add_subdirectory(settings) add_subdirectory(settings)
add_subdirectory(battle) add_subdirectory(battle)
add_subdirectory(backpack) add_subdirectory(backpack)
add_subdirectory(initial)
+2 -7
View File
@@ -22,7 +22,7 @@ void uiBackpackTabChanged(
const uint8_t index, const uint8_t index,
const uimenuitem_t *item const uimenuitem_t *item
) { ) {
const itemtypeid_t type = (itemtypeid_t)(index + 1); const itemtype_t type = (itemtype_t)(index + 1);
const inventory_t *inventory = backpackGetInventory(type); const inventory_t *inventory = backpackGetInventory(type);
errorCatch(uiItemListSetItemStacks( errorCatch(uiItemListSetItemStacks(
@@ -41,16 +41,11 @@ void uiBackpackTabSelected(
errorret_t uiBackpackInit(void) { errorret_t uiBackpackInit(void) {
memoryZero(&UI_BACKPACK, sizeof(uibackpack_t)); memoryZero(&UI_BACKPACK, sizeof(uibackpack_t));
assertTrue(
ITEM_TYPE_COUNT - 1 <= UI_BACKPACK_TAB_COUNT,
"Item type count exceeds UI_BACKPACK_TAB_COUNT"
);
MENU_BEGIN( MENU_BEGIN(
&UI_BACKPACK.tabsMenu, UI_BACKPACK.tabs, &UI_BACKPACK.tabsMenu, UI_BACKPACK.tabs,
uiBackpackTabSelected, NULL, uiBackpackTabChanged uiBackpackTabSelected, NULL, uiBackpackTabChanged
); );
for(uint32_t i = 0; i < ITEM_TYPE_COUNT - 1; i++) { for(uint8_t i = 0; i < UI_BACKPACK_TAB_COUNT; i++) {
stringFormat( stringFormat(
UI_BACKPACK.tabLabels[i], UI_BACKPACK_TAB_LABEL_MAX - 1, UI_BACKPACK.tabLabels[i], UI_BACKPACK_TAB_LABEL_MAX - 1,
"Category %u", i + 1 "Category %u", i + 1
+1 -1
View File
@@ -11,7 +11,7 @@
#include "ui/widget/uiitemlist.h" #include "ui/widget/uiitemlist.h"
#include "rpg/item/item.h" #include "rpg/item/item.h"
#define UI_BACKPACK_TAB_COUNT (ITEM_TYPE_COUNT_MAX - 1) #define UI_BACKPACK_TAB_COUNT (ITEM_TYPE_COUNT - 1)
#define UI_BACKPACK_TAB_LABEL_MAX 32 #define UI_BACKPACK_TAB_LABEL_MAX 32
#define UI_BACKPACK_ITEM_LIST_COLUMNS 4 #define UI_BACKPACK_ITEM_LIST_COLUMNS 4
#define UI_BACKPACK_ITEM_LIST_ROWS 5 #define UI_BACKPACK_ITEM_LIST_ROWS 5
+139 -20
View File
@@ -7,21 +7,95 @@
#include "uigamemenu.h" #include "uigamemenu.h"
#include "ui/frame/uiframe.h" #include "ui/frame/uiframe.h"
#include "ui/frame/uiconfirm.h"
#include "ui/frame/settings/uisettings.h" #include "ui/frame/settings/uisettings.h"
#include "ui/frame/backpack/uibackpack.h" #include "ui/frame/backpack/uibackpack.h"
#include "ui/rpg/textbox/uitextboxmain.h"
#include "util/memory.h" #include "util/memory.h"
#include "display/spritebatch/spritebatch.h" #include "display/spritebatch/spritebatch.h"
#include "display/screen/screen.h" #include "display/screen/screen.h"
#include "display/text/text.h"
#include "display/color.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "locale/localemanager.h" #include "locale/localemanager.h"
#include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/locale/assetlocaleloader.h"
#include "rpg/overworld/map.h" #include "save/save.h"
#include "error/error.h"
#include "util/string.h"
#define UI_GAME_MENU_INDEX_CHARACTERS 0 #define UI_GAME_MENU_INDEX_CHARACTERS 0
#define UI_GAME_MENU_INDEX_ITEMS 1 #define UI_GAME_MENU_INDEX_ITEMS 1
#define UI_GAME_MENU_INDEX_SETTINGS 2 #define UI_GAME_MENU_INDEX_SETTINGS 2
#define UI_GAME_MENU_INDEX_SAVE 3
static void uiGameMenuSaveWriteComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) {
// Generously sized - stringFormat asserts (crashes) rather than
// truncating if the message doesn't fit, so this must comfortably fit
// the longest platform save-error message plus this prefix.
char_t msg[256];
stringFormat(
msg, sizeof(msg), UI_GAME_MENU.saveFailedFormat, result.state->message
);
errorCatch(result);
uiTextboxMainSetText(msg);
return;
}
uiTextboxMainSetText(UI_GAME_MENU.saveSuccessText);
}
static void uiGameMenuSaveCreateConfirmed(const bool_t confirmed, void *user) {
if(!confirmed) {
uiTextboxMainSetText(UI_GAME_MENU.saveCancelledText);
return;
}
saveWriteSlot(SAVE_ACTIVE_SLOT, uiGameMenuSaveWriteComplete, NULL);
}
// Determines whether there's actually save data to overwrite (not just
// whether the medium is present) by attempting a real load first - this is
// what lets a fresh memory card/stick, with no prior save on it yet, be
// told apart from one that already has our data on it. Cheap either way
// (a single sector/file read), and correct on every platform without any
// platform-specific UI code - saveSlotExists() already reflects each
// platform's own notion of "found something."
static void uiGameMenuSaveCheckComplete(errorret_t result, void *user) {
if(errorIsNotOk(result)) {
char_t msg[256];
stringFormat(
msg, sizeof(msg), UI_GAME_MENU.saveCheckFailedFormat,
result.state->message
);
errorCatch(result);
uiTextboxMainSetText(msg);
return;
}
if(saveSlotExists(SAVE_ACTIVE_SLOT)) {
saveWriteSlot(SAVE_ACTIVE_SLOT, uiGameMenuSaveWriteComplete, NULL);
return;
}
uiConfirmOpen(
UI_GAME_MENU.saveCreateConfirmText,
uiGameMenuSaveCreateConfirmed,
NULL
);
}
static void uiGameMenuSave(void) {
if(saveIsTemporary()) {
uiTextboxMainSetText(UI_GAME_MENU.saveTemporaryText);
return;
}
if(!saveIsAvailable()) {
uiTextboxMainSetText(UI_GAME_MENU.saveUnavailableText);
return;
}
if(saveIsBusy()) return;// A save/load dialog (e.g. on PSP) is already up.
saveLoadSlot(SAVE_ACTIVE_SLOT, uiGameMenuSaveCheckComplete, NULL);
}
uigamemenu_t UI_GAME_MENU; uigamemenu_t UI_GAME_MENU;
@@ -32,6 +106,7 @@ void uiGameMenuSelected(
) { ) {
if(index == UI_GAME_MENU_INDEX_ITEMS) uiBackpackOpen(); if(index == UI_GAME_MENU_INDEX_ITEMS) uiBackpackOpen();
if(index == UI_GAME_MENU_INDEX_SETTINGS) uiSettingsOpen(); if(index == UI_GAME_MENU_INDEX_SETTINGS) uiSettingsOpen();
if(index == UI_GAME_MENU_INDEX_SAVE) uiGameMenuSave();
} }
errorret_t uiGameMenuInit(void) { errorret_t uiGameMenuInit(void) {
@@ -58,6 +133,62 @@ errorret_t uiGameMenuInit(void) {
UI_GAME_MENU.settingsLabel, UI_GAME_MENU.settingsLabel,
UI_GAME_MENU_LABEL_MAX UI_GAME_MENU_LABEL_MAX
)); ));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save",
0,
UI_GAME_MENU.saveLabel,
UI_GAME_MENU_LABEL_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_success",
0,
UI_GAME_MENU.saveSuccessText,
UI_GAME_MENU_MESSAGE_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_cancelled",
0,
UI_GAME_MENU.saveCancelledText,
UI_GAME_MENU_MESSAGE_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_unavailable",
0,
UI_GAME_MENU.saveUnavailableText,
UI_GAME_MENU_MESSAGE_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_temporary",
0,
UI_GAME_MENU.saveTemporaryText,
UI_GAME_MENU_MESSAGE_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_create_confirm",
0,
UI_GAME_MENU.saveCreateConfirmText,
UI_GAME_MENU_MESSAGE_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_failed_format",
0,
UI_GAME_MENU.saveFailedFormat,
UI_GAME_MENU_MESSAGE_MAX
));
errorChain(assetLocaleGetString(
&LOCALE.entry->data.locale,
"ui.game_menu.save_check_failed_format",
0,
UI_GAME_MENU.saveCheckFailedFormat,
UI_GAME_MENU_MESSAGE_MAX
));
MENU_BEGIN( MENU_BEGIN(
&UI_GAME_MENU.menu, UI_GAME_MENU.items, uiGameMenuSelected, NULL, NULL &UI_GAME_MENU.menu, UI_GAME_MENU.items, uiGameMenuSelected, NULL, NULL
@@ -65,6 +196,7 @@ errorret_t uiGameMenuInit(void) {
MENU_BUTTON(UI_GAME_MENU.charactersLabel); MENU_BUTTON(UI_GAME_MENU.charactersLabel);
MENU_BUTTON(UI_GAME_MENU.itemsLabel); MENU_BUTTON(UI_GAME_MENU.itemsLabel);
MENU_BUTTON(UI_GAME_MENU.settingsLabel); MENU_BUTTON(UI_GAME_MENU.settingsLabel);
MENU_BUTTON(UI_GAME_MENU.saveLabel);
MENU_END(UI_GAME_MENU.items, 1); MENU_END(UI_GAME_MENU.items, 1);
@@ -80,25 +212,12 @@ errorret_t uiGameMenuDraw(void) {
const float_t y = (float_t)SCREEN.scanY; const float_t y = (float_t)SCREEN.scanY;
errorChain(uiFrameDraw(x, y, width, height)); errorChain(uiFrameDraw(x, y, width, height));
const float_t contentX = x + UI_FRAME_START_X;
const float_t contentY = y + UI_FRAME_START_Y;
const float_t contentWidth = width - (UI_FRAME_START_X * 2);
const float_t contentHeight = height - (UI_FRAME_START_Y * 2);
// Map display name header - stopgap placement until this gets a proper
// HUD element of its own.
const float_t nameRowHeight = (float_t)FONT_DEFAULT.tileset->tileHeight;
errorChain(textDraw(
contentX, contentY, MAP.displayName, COLOR_WHITE, &FONT_DEFAULT
));
errorChain(uiMenuDraw( errorChain(uiMenuDraw(
&UI_GAME_MENU.menu, &UI_GAME_MENU.menu,
contentX, x + UI_FRAME_START_X,
contentY + nameRowHeight + UI_FRAME_PADDING_Y, y + UI_FRAME_START_Y,
contentWidth, width - (UI_FRAME_START_X * 2),
contentHeight - nameRowHeight - UI_FRAME_PADDING_Y height - (UI_FRAME_START_Y * 2)
)); ));
errorChain(spriteBatchFlush()); errorChain(spriteBatchFlush());
+10 -1
View File
@@ -9,9 +9,10 @@
#include "error/error.h" #include "error/error.h"
#include "ui/widget/uimenu.h" #include "ui/widget/uimenu.h"
#define UI_GAME_MENU_ITEM_COUNT 3 #define UI_GAME_MENU_ITEM_COUNT 4
#define UI_GAME_MENU_WIDTH 150.0f #define UI_GAME_MENU_WIDTH 150.0f
#define UI_GAME_MENU_LABEL_MAX 32 #define UI_GAME_MENU_LABEL_MAX 32
#define UI_GAME_MENU_MESSAGE_MAX 192
typedef struct { typedef struct {
uimenu_t menu; uimenu_t menu;
@@ -19,6 +20,14 @@ typedef struct {
char_t charactersLabel[UI_GAME_MENU_LABEL_MAX]; char_t charactersLabel[UI_GAME_MENU_LABEL_MAX];
char_t itemsLabel[UI_GAME_MENU_LABEL_MAX]; char_t itemsLabel[UI_GAME_MENU_LABEL_MAX];
char_t settingsLabel[UI_GAME_MENU_LABEL_MAX]; char_t settingsLabel[UI_GAME_MENU_LABEL_MAX];
char_t saveLabel[UI_GAME_MENU_LABEL_MAX];
char_t saveSuccessText[UI_GAME_MENU_MESSAGE_MAX];
char_t saveCancelledText[UI_GAME_MENU_MESSAGE_MAX];
char_t saveUnavailableText[UI_GAME_MENU_MESSAGE_MAX];
char_t saveTemporaryText[UI_GAME_MENU_MESSAGE_MAX];
char_t saveCreateConfirmText[UI_GAME_MENU_MESSAGE_MAX];
char_t saveFailedFormat[UI_GAME_MENU_MESSAGE_MAX];
char_t saveCheckFailedFormat[UI_GAME_MENU_MESSAGE_MAX];
} uigamemenu_t; } uigamemenu_t;
extern uigamemenu_t UI_GAME_MENU; extern uigamemenu_t UI_GAME_MENU;

Some files were not shown because too many files have changed in this diff Show More