From 4b0388a0e1e3f969a032ed7764a7c4ff8a5b4d76 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Tue, 4 Aug 2026 07:37:48 -0500 Subject: [PATCH] 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. --- assets/chunks/-1_0_0.dcf | Bin 2235 -> 2237 bytes assets/chunks/0_0_0.dcf | Bin 2146 -> 2184 bytes assets/chunks/0_1_0.dcf | Bin 2234 -> 2236 bytes assets/chunks/1_0_0.dcf | Bin 2322 -> 2324 bytes assets/chunks/1_1_0.dcf | Bin 2234 -> 2236 bytes assets/chunks/2_0_0.dcf | Bin 2102 -> 2104 bytes assetsraw/chunks/chunk_0_0_0.json | 22 +++ .../asset/loader/chunk/assetchunkloader.c | 76 +++++++++ .../asset/loader/chunk/assetchunkloader.h | 29 +++- src/dusk/rpg/entity/entity.c | 14 +- src/dusk/rpg/entity/entity.h | 5 +- src/dusk/rpg/overworld/CMakeLists.txt | 2 + src/dusk/rpg/overworld/chunk.h | 9 ++ src/dusk/rpg/overworld/global/CMakeLists.txt | 9 ++ src/dusk/rpg/overworld/global/mapareaglobal.h | 17 ++ .../rpg/overworld/global/mapareaglobaldefs.h | 17 ++ .../rpg/overworld/global/mapareagloballist.h | 22 +++ src/dusk/rpg/overworld/map.c | 152 +++++++++++++----- src/dusk/rpg/overworld/map.h | 14 +- src/dusk/rpg/overworld/maparea.c | 18 +++ src/dusk/rpg/overworld/maparea.h | 27 +++- src/dusk/rpg/rpg.c | 34 +--- tools/asset/chunk/__main__.py | 128 ++++++++++++++- 23 files changed, 510 insertions(+), 85 deletions(-) create mode 100644 src/dusk/rpg/overworld/global/CMakeLists.txt create mode 100644 src/dusk/rpg/overworld/global/mapareaglobal.h create mode 100644 src/dusk/rpg/overworld/global/mapareaglobaldefs.h create mode 100644 src/dusk/rpg/overworld/global/mapareagloballist.h diff --git a/assets/chunks/-1_0_0.dcf b/assets/chunks/-1_0_0.dcf index 70bb62209e75de265f3e91e145eb939fa13cea15..268ee8d551f8f2ad4dad2d788cc446e57c9210b8 100644 GIT binary patch delta 75 zcmdljxL1(V#o3L4m4ShQak3#(|KtfQ3X>18CSapCA7C|L1WE`16%;_}8Jj1tXtOXe GFaQ8#V;5Hd delta 72 zcmdlhxLc6Z#o3L4g@J*Aak3#(|KtfQ3X>18CSapCA7C|L1WE`16%;_}8Jj1tXtMwS D9wQf2 diff --git a/assets/chunks/0_0_0.dcf b/assets/chunks/0_0_0.dcf index eccdb75949aa81127f031acf4528f6750656d4fd..79abf59926faf5818dd28aa4e50aae65303dd2e3 100644 GIT binary patch delta 61 zcmaDP&>_g_;_Sx2%D}+DIC&#u`^E{E*wvUAm>C#2fRK@a5r}vgm_X9pAW;SZ22KVp Ipae4;07Bpf*8l(j delta 22 dcmeAWd?di>;_Sx2!oa}5IC&#u`^E{E*a1K122B6} diff --git a/assets/chunks/0_1_0.dcf b/assets/chunks/0_1_0.dcf index 5dddccd4dbe4e937da5c98a8a5e7e7ef7522b38a..7742f4e81e964740dffc42c15c7f1860d1a74710 100644 GIT binary patch delta 25 gcmdlbxJQuF#o3L4m4ShQadIJ3`^E`c983%h08Y0BcK`qY delta 22 dcmdlZxJ!`J#o3L4g@J*AadIJ3`^E`c8~{E^1#|!a diff --git a/assets/chunks/1_0_0.dcf b/assets/chunks/1_0_0.dcf index f9f7654c958f4a2fbe56df7be1117e7cf60ba87f..f86048feebebec81d005201cef7e4ff797347054 100644 GIT binary patch delta 21 ccmbOvG)0Kj#o3L4b+Rv0_r?iL983%h06y&nTmS$7 delta 18 ZcmbOtG)ain#o3L4WwI|*_r?iL8~`rV1z7+9 diff --git a/assets/chunks/1_1_0.dcf b/assets/chunks/1_1_0.dcf index ef39549c63a6943638f69beab62f9f7534694aa8..1c99c441f799128ab5ae0b03163c42b36db7cf42 100644 GIT binary patch delta 27 icmdlbxJQuF#o3L4m4ShQakC?{IOApo##!u43=9BLLInE& delta 24 fcmdlZxJ!`J#o3L4g@J*AakC?{IOApo##!tDLq-Jn diff --git a/assets/chunks/2_0_0.dcf b/assets/chunks/2_0_0.dcf index d26e7bb528801010fb62e7275a921da5084cf6c8..3f0a7a65f1628f3d430bc3421408bc1a866b8d00 100644 GIT binary patch delta 25 hcmdlcutR{=#o3L4btCV3rin5foA)qPu`)3*003Sj27CYj delta 22 ecmdlXuuXu~#o3L4Wh3u;rin5foA)qPu>t@}rv`Zd diff --git a/assetsraw/chunks/chunk_0_0_0.json b/assetsraw/chunks/chunk_0_0_0.json index 06fce022..a8332113 100644 --- a/assetsraw/chunks/chunk_0_0_0.json +++ b/assetsraw/chunks/chunk_0_0_0.json @@ -2179,5 +2179,27 @@ 0 ] } + ], + "entities": [ + { + "type": "global", + "globalId": 3, + "pos": [8, 8, 1] + }, + { + "type": "item", + "itemId": 1, + "quantity": 1, + "pos": [12, 2, 0] + } + ], + "areas": [ + { + "min": [11, 3, 0], + "max": [16, 9, 10], + "callbackId": 1, + "notify": 3, + "trigger": 6 + } ] } \ No newline at end of file diff --git a/src/dusk/asset/loader/chunk/assetchunkloader.c b/src/dusk/asset/loader/chunk/assetchunkloader.c index 4046ef12..467987a8 100644 --- a/src/dusk/asset/loader/chunk/assetchunkloader.c +++ b/src/dusk/asset/loader/chunk/assetchunkloader.c @@ -14,6 +14,26 @@ #include "asset/loader/assetloader.h" #include "asset/asset.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) { assertNotNull(loading, "Loading cannot be NULL"); assertNotMainThread("Should be called from an async thread."); @@ -146,6 +166,62 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) { 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; diff --git a/src/dusk/asset/loader/chunk/assetchunkloader.h b/src/dusk/asset/loader/chunk/assetchunkloader.h index db591a7f..ef871ab6 100644 --- a/src/dusk/asset/loader/chunk/assetchunkloader.h +++ b/src/dusk/asset/loader/chunk/assetchunkloader.h @@ -9,7 +9,7 @@ #include "asset/assetfile.h" #include "rpg/overworld/chunk.h" -#define ASSET_CHUNK_FILE_VERSION 4 +#define ASSET_CHUNK_FILE_VERSION 5 typedef struct assetloading_s assetloading_t; typedef struct assetentry_s assetentry_t; @@ -33,12 +33,39 @@ typedef struct { uint8_t modelIndex; } assetchunkloaderloading_t; +typedef enum { + CHUNK_ENTITY_SPAWN_KIND_GLOBAL, + CHUNK_ENTITY_SPAWN_KIND_ITEM +} chunkentityspawnkind_t; + +typedef struct { + chunkentityspawnkind_t kind; + uint16_t globalId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_GLOBAL. + uint16_t itemId; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM. + uint8_t itemQuantity; // Valid when kind == CHUNK_ENTITY_SPAWN_KIND_ITEM. + worldpos_t position; +} chunkentityspawn_t; + +typedef struct { + worldpos_t min; + worldpos_t max; + uint16_t callbackId; // Index into MAP_AREA_CALLBACK_LIST. + uint8_t notify; + uint8_t trigger; +} chunkareaspawn_t; + typedef struct { tile_t *tiles; uint8_t meshCount; char_t modelNames[CHUNK_MESH_COUNT_MAX][CHUNK_MESH_NAME_MAX]; vec3 meshOffsets[CHUNK_MESH_COUNT_MAX]; assetentry_t *modelEntries[CHUNK_MESH_COUNT_MAX]; + + uint8_t entitySpawnCount; + chunkentityspawn_t entitySpawns[CHUNK_ENTITY_SPAWN_COUNT_MAX]; + + uint8_t areaSpawnCount; + chunkareaspawn_t areaSpawns[CHUNK_AREA_COUNT_MAX]; } assetchunkoutput_t; /** diff --git a/src/dusk/rpg/entity/entity.c b/src/dusk/rpg/entity/entity.c index 4b2b4a65..12fd2eec 100644 --- a/src/dusk/rpg/entity/entity.c +++ b/src/dusk/rpg/entity/entity.c @@ -10,6 +10,7 @@ #include "util/memory.h" #include "time/time.h" #include "util/math.h" +#include "console/console.h" #include "rpg/overworld/map.h" #include "rpg/overworld/maparea.h" #include "rpg/overworld/chunk.h" @@ -292,7 +293,10 @@ 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) { chunk_t *next = mapGetChunk(chunkIndex); @@ -300,8 +304,16 @@ void entitySetChunk(entity_t *entity, const uint8_t chunkIndex) { for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) { if(next->entities[i] != 0xFF) continue; next->entities[i] = entity->id; + entity->chunkIndex = chunkIndex; break; } + if(entity->chunkIndex != chunkIndex) { + consolePrint( + "entitySetChunk: chunk %u has no free entity slots, entity %u " + "left untracked", + chunkIndex, entity->id + ); + } } } } diff --git a/src/dusk/rpg/entity/entity.h b/src/dusk/rpg/entity/entity.h index e184b3b3..dc5e75b0 100644 --- a/src/dusk/rpg/entity/entity.h +++ b/src/dusk/rpg/entity/entity.h @@ -142,7 +142,10 @@ uint8_t entityGetAvailable(); /** * 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 chunkIndex Index of the chunk to assign to, or 0xFF for none. diff --git a/src/dusk/rpg/overworld/CMakeLists.txt b/src/dusk/rpg/overworld/CMakeLists.txt index a6c4f934..24c8e3f5 100644 --- a/src/dusk/rpg/overworld/CMakeLists.txt +++ b/src/dusk/rpg/overworld/CMakeLists.txt @@ -14,3 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} tileshape.c ) +add_subdirectory(global) + diff --git a/src/dusk/rpg/overworld/chunk.h b/src/dusk/rpg/overworld/chunk.h index 38cc9abe..48180256 100644 --- a/src/dusk/rpg/overworld/chunk.h +++ b/src/dusk/rpg/overworld/chunk.h @@ -12,6 +12,8 @@ #define CHUNK_MESH_COUNT_MAX 10 #define CHUNK_MESH_NAME_MAX 64 #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; @@ -28,6 +30,13 @@ typedef struct chunk_s { assetentry_t *modelEntries[CHUNK_MESH_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; /** diff --git a/src/dusk/rpg/overworld/global/CMakeLists.txt b/src/dusk/rpg/overworld/global/CMakeLists.txt new file mode 100644 index 00000000..27caaac2 --- /dev/null +++ b/src/dusk/rpg/overworld/global/CMakeLists.txt @@ -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 +) diff --git a/src/dusk/rpg/overworld/global/mapareaglobal.h b/src/dusk/rpg/overworld/global/mapareaglobal.h new file mode 100644 index 00000000..6f31dcd4 --- /dev/null +++ b/src/dusk/rpg/overworld/global/mapareaglobal.h @@ -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 diff --git a/src/dusk/rpg/overworld/global/mapareaglobaldefs.h b/src/dusk/rpg/overworld/global/mapareaglobaldefs.h new file mode 100644 index 00000000..2f6d49b3 --- /dev/null +++ b/src/dusk/rpg/overworld/global/mapareaglobaldefs.h @@ -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 diff --git a/src/dusk/rpg/overworld/global/mapareagloballist.h b/src/dusk/rpg/overworld/global/mapareagloballist.h new file mode 100644 index 00000000..9768582f --- /dev/null +++ b/src/dusk/rpg/overworld/global/mapareagloballist.h @@ -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 diff --git a/src/dusk/rpg/overworld/map.c b/src/dusk/rpg/overworld/map.c index 20ad8de1..c4f44824 100644 --- a/src/dusk/rpg/overworld/map.c +++ b/src/dusk/rpg/overworld/map.c @@ -14,9 +14,20 @@ #include "event/event.h" #include "util/string.h" #include "rpg/entity/global/entityglobal.h" +#include "rpg/entity/item/entityitem.h" +#include "rpg/overworld/maparea.h" map_t MAP; +// Clears chunk's mid-load slot, if it currently holds one. +static void mapChunkLoadingSlotClear(chunk_t *chunk) { + for(uint32_t i = 0; i < MAP_CHUNK_LOAD_CONCURRENCY; i++) { + if(MAP.loadingChunks[i] != chunk) continue; + MAP.loadingChunks[i] = NULL; + return; + } +} + errorret_t mapInit() { memoryZero(&MAP, sizeof(map_t)); MAP.loaded = true; @@ -105,7 +116,7 @@ errorret_t mapDispose() { void mapChunkUnload(chunk_t *chunk) { mapChunkLoadQueueRemove(chunk); - if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; + mapChunkLoadingSlotClear(chunk); for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) { if(chunk->entities[i] == 0xFF) continue; @@ -119,6 +130,12 @@ void mapChunkUnload(chunk_t *chunk) { memorySet(chunk->entities, 0xFF, sizeof(chunk->entities)); + 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)); + if(chunk->dcfEntry != NULL) { eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded); eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError); @@ -139,7 +156,7 @@ errorret_t mapChunkLoad(chunk_t *chunk) { if(!mapIsLoaded()) errorThrow("No map loaded"); mapChunkLoadQueueRemove(chunk); - if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; + mapChunkLoadingSlotClear(chunk); if(chunk->dcfEntry != NULL) { eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded); @@ -149,6 +166,16 @@ errorret_t mapChunkLoad(chunk_t *chunk) { } 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]; @@ -178,44 +205,48 @@ errorret_t mapChunkLoad(chunk_t *chunk) { } void mapChunkLoadNext() { - if(MAP.loadingChunk != NULL) return; - if(MAP.loadQueueCount == 0) return; + 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]; + 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); } - MAP.loadQueueCount--; - MAP.loadingChunk = 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. - if(entry->state == ASSET_ENTRY_STATE_LOADED) { - mapChunkLoaded(entry, chunk); - return; - } - if(entry->state == ASSET_ENTRY_STATE_ERROR) { - mapChunkLoadError(entry, chunk); - return; - } - - eventSubscribe(&entry->onLoaded, mapChunkLoaded, chunk); - eventSubscribe(&entry->onError, mapChunkLoadError, chunk); } void mapChunkLoadQueueRemove(chunk_t *chunk) { @@ -372,7 +403,7 @@ void mapChunkLoadError(void *params, void *user) { chunk->dcfEntry = NULL; memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles)); - if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; + mapChunkLoadingSlotClear(chunk); mapChunkLoadNext(); } @@ -433,6 +464,47 @@ void mapChunkLoaded(void *params, void *user) { // this chunk_t is displaying it. Released in mapChunkUnload instead. chunk->meshCount = meshCount; - if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; + // 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(); } diff --git a/src/dusk/rpg/overworld/map.h b/src/dusk/rpg/overworld/map.h index f8b09bf8..154f32f6 100644 --- a/src/dusk/rpg/overworld/map.h +++ b/src/dusk/rpg/overworld/map.h @@ -12,6 +12,10 @@ #define MAP_FILE_PATH_MAX 128 +// 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 { bool_t loaded; @@ -19,11 +23,9 @@ typedef struct map_s { chunk_t *chunkOrder[MAP_CHUNK_COUNT]; chunkpos_t chunkPosition; - // Only one chunk may be mid-load (asset locked & awaiting onLoaded/ - // onError) at any given time - everything else waits here in FIFO order. chunk_t *loadQueue[MAP_CHUNK_COUNT]; uint32_t loadQueueCount; - chunk_t *loadingChunk; + chunk_t *loadingChunks[MAP_CHUNK_LOAD_CONCURRENCY]; } map_t; extern map_t MAP; @@ -80,9 +82,9 @@ void mapChunkUnload(chunk_t* chunk); errorret_t mapChunkLoad(chunk_t* chunk); /** - * Starts loading the next queued chunk, if no chunk is currently mid-load. - * Called after mapChunkLoad enqueues a chunk, and again after the - * currently-loading chunk finishes (or is unloaded) to advance the queue. + * 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(); diff --git a/src/dusk/rpg/overworld/maparea.c b/src/dusk/rpg/overworld/maparea.c index 95efe628..6c1a798b 100644 --- a/src/dusk/rpg/overworld/maparea.c +++ b/src/dusk/rpg/overworld/maparea.c @@ -10,6 +10,7 @@ #include "util/math.h" #include "util/memory.h" #include "rpg/overworld/map.h" +#include "rpg/overworld/global/mapareaglobal.h" maparea_t MAP_AREAS[MAP_AREA_COUNT_MAX]; @@ -148,3 +149,20 @@ void mapAreaCheckEntity(entity_t *entity) { 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 + ); +} diff --git a/src/dusk/rpg/overworld/maparea.h b/src/dusk/rpg/overworld/maparea.h index 836f712a..ec385169 100644 --- a/src/dusk/rpg/overworld/maparea.h +++ b/src/dusk/rpg/overworld/maparea.h @@ -158,4 +158,29 @@ void mapAreaCheckEntity(entity_t *entity); * @param entity Pointer to the entity associated with the callback. * @param trigger Which MAP_TRIGGER_* condition invoked the callback. */ -void mapAreaNoopCallback(entity_t *entity, const uint8_t trigger); \ No newline at end of file +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 +); \ No newline at end of file diff --git a/src/dusk/rpg/rpg.c b/src/dusk/rpg/rpg.c index 6e04ae7b..08a33309 100644 --- a/src/dusk/rpg/rpg.c +++ b/src/dusk/rpg/rpg.c @@ -7,12 +7,9 @@ #include "rpg.h" #include "entity/entity.h" -#include "rpg/entity/npc/npcpath.h" -#include "rpg/entity/item/entityitem.h" #include "rpg/overworld/map.h" #include "rpg/overworld/maparea.h" #include "rpg/cutscene/cutscenesystem.h" -#include "rpg/cutscene/scene/testcutscene.h" #include "rpg/item/backpack.h" #include "rpg/battle/party.h" #include "ui/rpg/textbox/uitextboxminilist.h" @@ -21,14 +18,9 @@ #include "util/memory.h" #include "util/string.h" #include "assert/assert.h" -#include "console/console.h" #include "ui/rpg/uiemoji.h" -void rpgTestAreaCallback(entity_t *entity, const uint8_t trigger) { - consolePrint("rpgTestAreaCallback: trigger=%u", trigger); -} - errorret_t rpgInit(void) { memoryZero(ENTITIES, sizeof(ENTITIES)); memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); @@ -43,7 +35,9 @@ errorret_t rpgInit(void) { // Init world errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 })); - // TEST: Create some entities. + // The player is the one entity that isn't sourced from map/chunk data - + // every other entity (NPCs, items) and map area comes from the loaded + // chunks' own spawn data (see rpg/overworld/map.c mapChunkLoaded). uint8_t entIndex = entityGetAvailable(); assertTrue(entIndex != 0xFF, "No available entity slots!."); entity_t *ent = &ENTITIES[entIndex]; @@ -52,31 +46,11 @@ errorret_t rpgInit(void) { RPG_CAMERA.mode = RPG_CAMERA_MODE_FOLLOW_ENTITY; RPG_CAMERA.followEntity.followEntityId = ent->id; - mapSpawnEntity(3, (worldpos_t){ 8, 8, 1 }); - - // TEST: Place an item entity. - uint8_t itemEntIndex = entityGetAvailable(); - assertTrue(itemEntIndex != 0xFF, "No available entity slots!."); - entity_t *itemEnt = &ENTITIES[itemEntIndex]; - entityInit(itemEnt, ENTITY_TYPE_ITEM); - entityItemSet(itemEnt, ITEM_ID_POTION, 1); - entityPositionSet(itemEnt, (worldpos_t){ 12, 2, 0 }); - - // TEST: Give the player a starting assortment of items. + // Starting inventory. backpackAdd(ITEM_ID_POTION, 5); backpackAdd(ITEM_ID_POTATO, 3); backpackAdd(ITEM_ID_APPLE, 8); - // TEST: Create a test map area. - uint8_t areaIndex = mapAreaAdd( - (worldpos_t){ 11, 3, 0 }, - (worldpos_t){ 16, 9, 10 }, - rpgTestAreaCallback, - MAP_AREA_NOTIFY_ALL, - MAP_TRIGGER_ENTER | MAP_TRIGGER_EXIT - ); - assertTrue(areaIndex != 0xFF, "No available map area slots!."); - // All Good! errorOk(); } diff --git a/tools/asset/chunk/__main__.py b/tools/asset/chunk/__main__.py index 60ccec06..1aa767b1 100644 --- a/tools/asset/chunk/__main__.py +++ b/tools/asset/chunk/__main__.py @@ -14,6 +14,16 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json): ], "meshes": [ { "file": "house_5_3.dmf", "pos": [x, y, z] } + ], + "entities": [ + { "type": "global", "globalId": , "pos": [x, y, z] }, + { "type": "item", "itemId": , "quantity": , "pos": [x, y, z] } + ], + "areas": [ + { + "min": [x, y, z], "max": [x, y, z], + "callbackId": , "notify": , "trigger": + } ] } @@ -25,10 +35,27 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json): Mesh files are located by searching under assets/meshes/ and referenced by path from the assets root in the DCF. + "entities" spawns things into the world when this chunk loads. A "global" + entity is spawned via mapSpawnEntity() - globalId indexes + ENTITY_GLOBAL_LIST (src/dusk/rpg/entity/global/entitygloballist.h) and is + deduped automatically if already spawned, so it's safe to declare on a + chunk that streams in more than once. An "item" entity has no persistent + identity - it respawns fresh every time this chunk (re)loads, including + after being picked up, since nothing tracks "already collected" yet. + itemId is a raw ITEM_ID_* value (see src/dusk/rpg/item/item.json for the + name -> id mapping, same convention as the tile "type" ints above). + + "areas" declares map trigger regions (see rpg/overworld/maparea.h) owned + by this chunk - they're removed when the chunk unloads and re-added if it + streams back in. callbackId indexes MAP_AREA_CALLBACK_LIST + (src/dusk/rpg/overworld/global/mapareagloballist.h; 0 is reserved and + invalid). notify is bitwise MAP_AREA_NOTIFY_PLAYER(1)|NOTIFY_NPC(2). + trigger is bitwise MAP_TRIGGER_STEP(1)|ENTER(2)|EXIT(4). + Output DCF is derived automatically: assetsraw/chunks/chunk_X_Y_Z.json -> assets/chunks/X_Y_Z.dcf -Version 4 DCF format (after 8-byte header): +Version 5 DCF format (after 8-byte header): tile_t tiles[CHUNK_WIDTH * CHUNK_HEIGHT] (one per x/y column) each tile: uint32_t shape, uint8_t z, 3 padding bytes (8 bytes total, matching the C tile_t struct's layout: { tileshape_t shape; uint8_t z; }) @@ -36,6 +63,19 @@ Version 4 DCF format (after 8-byte header): for each model: null-terminated string (relative asset path to .json model) float32[3] (x, y, z offset) + uint8_t entitySpawnCount + for each entity spawn: + uint8_t kind (0 = global entity, 1 = item entity) + uint16_t a (globalId if kind 0, itemId if kind 1) + uint8_t b (unused if kind 0, quantity if kind 1) + int16_t x, y, z (world position, 3 fields) + uint8_t areaSpawnCount + for each area spawn: + int16_t minX, minY, minZ (3 fields) + int16_t maxX, maxY, maxZ (3 fields) + uint16_t callbackId + uint8_t notify + uint8_t trigger DMF format: Bytes 0-3: DMF\\x00 @@ -74,6 +114,11 @@ WORLD_LAYER_HEIGHT = 1.0 / math.sqrt(2) CHUNK_MESH_COUNT_MAX = 10 CHUNK_MESH_NAME_MAX = 64 +CHUNK_ENTITY_SPAWN_COUNT_MAX = 8 +CHUNK_AREA_COUNT_MAX = 4 + +ENTITY_SPAWN_KIND_GLOBAL = 0 +ENTITY_SPAWN_KIND_ITEM = 1 # Matches sizeof(tile_t) on the C side: uint32_t shape + uint8_t z, padded # to 8 bytes ({ tileshape_t shape; uint8_t z; } with 4-byte enum alignment). @@ -106,7 +151,7 @@ TILE_SHAPE_RAMP_SOUTHWEST_INNER = 13 FILE_MAGIC = b'DCF' DMF_MAGIC = b'DMF\x00' -VERSION_OUT = 4 +VERSION_OUT = 5 DMF_VERSION = 1 @@ -163,11 +208,29 @@ def write_dmf(path, vertex_bytes): print(f' Wrote DMF {path}: {vert_count} vertices, {len(buf)} bytes') -def write_dcf(dcf_path, tiles, mesh_names, mesh_offsets=None): +def write_dcf( + dcf_path, tiles, mesh_names, mesh_offsets=None, + entity_spawns=None, area_spawns=None +): """Write a current-version DCF referencing the given DMF asset paths.""" mesh_count = len(mesh_names) if mesh_offsets is None: mesh_offsets = [(0.0, 0.0, 0.0)] * mesh_count + if entity_spawns is None: + entity_spawns = [] + if area_spawns is None: + area_spawns = [] + + if len(entity_spawns) > CHUNK_ENTITY_SPAWN_COUNT_MAX: + raise ValueError( + f"Too many entity spawns ({len(entity_spawns)}) - max " + f"{CHUNK_ENTITY_SPAWN_COUNT_MAX}" + ) + if len(area_spawns) > CHUNK_AREA_COUNT_MAX: + raise ValueError( + f"Too many area spawns ({len(area_spawns)}) - max " + f"{CHUNK_AREA_COUNT_MAX}" + ) buf = bytearray() buf += FILE_MAGIC @@ -183,11 +246,34 @@ def write_dcf(dcf_path, tiles, mesh_names, mesh_offsets=None): ) buf += encoded + b'\x00' buf += struct.pack('<3f', offset[0], offset[1], offset[2]) + + buf += struct.pack(' {rel}') - write_dcf(dcf_path, bytes(tiles), model_names, mesh_offsets) + entity_spawns = [] + for spawn in data.get('entities', []): + pos = tuple(int(v) for v in spawn['pos']) + if spawn['type'] == 'global': + entity_spawns.append({ + 'kind': ENTITY_SPAWN_KIND_GLOBAL, + 'globalId': int(spawn['globalId']), + 'pos': pos, + }) + elif spawn['type'] == 'item': + entity_spawns.append({ + 'kind': ENTITY_SPAWN_KIND_ITEM, + 'itemId': int(spawn['itemId']), + 'quantity': int(spawn['quantity']), + 'pos': pos, + }) + else: + raise ValueError(f"Unknown entity spawn type: {spawn['type']}") + + area_spawns = [] + for area in data.get('areas', []): + area_spawns.append({ + 'min': tuple(int(v) for v in area['min']), + 'max': tuple(int(v) for v in area['max']), + 'callbackId': int(area['callbackId']), + 'notify': int(area['notify']), + 'trigger': int(area['trigger']), + }) + + write_dcf( + dcf_path, bytes(tiles), model_names, mesh_offsets, + entity_spawns, area_spawns + ) def process_json(json_path):