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.
This commit is contained in:
2026-08-04 07:37:48 -05:00
parent a84137b5ff
commit 4b0388a0e1
23 changed files with 510 additions and 85 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -2179,5 +2179,27 @@
0 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
}
] ]
} }
@@ -14,6 +14,26 @@
#include "asset/loader/assetloader.h" #include "asset/loader/assetloader.h"
#include "asset/asset.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) { 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.");
@@ -146,6 +166,62 @@ errorret_t assetChunkLoaderSync(assetloading_t *loading) {
out->meshOffsets[m][2] = endianLittleToHostFloat(out->meshOffsets[m][2]); 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); memoryFree(data);
loading->loading.chunk.data = NULL; loading->loading.chunk.data = NULL;
+28 -1
View File
@@ -9,7 +9,7 @@
#include "asset/assetfile.h" #include "asset/assetfile.h"
#include "rpg/overworld/chunk.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 assetloading_s assetloading_t;
typedef struct assetentry_s assetentry_t; typedef struct assetentry_s assetentry_t;
@@ -33,12 +33,39 @@ typedef struct {
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;
/** /**
+13 -1
View File
@@ -10,6 +10,7 @@
#include "util/memory.h" #include "util/memory.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/map.h"
#include "rpg/overworld/maparea.h" #include "rpg/overworld/maparea.h"
#include "rpg/overworld/chunk.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) { if(chunkIndex != 0xFF) {
chunk_t *next = mapGetChunk(chunkIndex); 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++) { 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
);
}
} }
} }
} }
+4 -1
View File
@@ -142,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.
+2
View File
@@ -14,3 +14,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
tileshape.c tileshape.c
) )
add_subdirectory(global)
+9
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;
@@ -28,6 +30,13 @@ 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;
/** /**
@@ -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
+112 -40
View File
@@ -14,9 +14,20 @@
#include "event/event.h" #include "event/event.h"
#include "util/string.h" #include "util/string.h"
#include "rpg/entity/global/entityglobal.h" #include "rpg/entity/global/entityglobal.h"
#include "rpg/entity/item/entityitem.h"
#include "rpg/overworld/maparea.h"
map_t MAP; 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() { errorret_t mapInit() {
memoryZero(&MAP, sizeof(map_t)); memoryZero(&MAP, sizeof(map_t));
MAP.loaded = true; MAP.loaded = true;
@@ -105,7 +116,7 @@ errorret_t mapDispose() {
void mapChunkUnload(chunk_t *chunk) { void mapChunkUnload(chunk_t *chunk) {
mapChunkLoadQueueRemove(chunk); mapChunkLoadQueueRemove(chunk);
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; mapChunkLoadingSlotClear(chunk);
for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) { for(uint8_t i = 0; i < CHUNK_ENTITY_COUNT_MAX; i++) {
if(chunk->entities[i] == 0xFF) continue; if(chunk->entities[i] == 0xFF) continue;
@@ -119,6 +130,12 @@ void mapChunkUnload(chunk_t *chunk) {
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities)); 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) { if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded); eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded);
eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError); eventUnsubscribe(&chunk->dcfEntry->onError, mapChunkLoadError);
@@ -139,7 +156,7 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
if(!mapIsLoaded()) errorThrow("No map loaded"); if(!mapIsLoaded()) errorThrow("No map loaded");
mapChunkLoadQueueRemove(chunk); mapChunkLoadQueueRemove(chunk);
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; mapChunkLoadingSlotClear(chunk);
if(chunk->dcfEntry != NULL) { if(chunk->dcfEntry != NULL) {
eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded); eventUnsubscribe(&chunk->dcfEntry->onLoaded, mapChunkLoaded);
@@ -149,6 +166,16 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
} }
memorySet(chunk->entities, 0xFF, sizeof(chunk->entities)); 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; chunk->meshCount = 0;
char_t name[64]; char_t name[64];
@@ -178,44 +205,48 @@ errorret_t mapChunkLoad(chunk_t *chunk) {
} }
void mapChunkLoadNext() { void mapChunkLoadNext() {
if(MAP.loadingChunk != NULL) return; for(uint32_t slot = 0; slot < MAP_CHUNK_LOAD_CONCURRENCY; slot++) {
if(MAP.loadQueueCount == 0) return; if(MAP.loadingChunks[slot] != NULL) continue;
if(MAP.loadQueueCount == 0) return;
chunk_t *chunk = MAP.loadQueue[0]; chunk_t *chunk = MAP.loadQueue[0];
for(uint32_t i = 1; i < MAP.loadQueueCount; i++) { for(uint32_t i = 1; i < MAP.loadQueueCount; i++) {
MAP.loadQueue[i - 1] = MAP.loadQueue[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) { void mapChunkLoadQueueRemove(chunk_t *chunk) {
@@ -372,7 +403,7 @@ void mapChunkLoadError(void *params, void *user) {
chunk->dcfEntry = NULL; chunk->dcfEntry = NULL;
memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles)); memorySet(chunk->tiles, 0x00, sizeof(chunk->tiles));
if(MAP.loadingChunk == chunk) MAP.loadingChunk = NULL; mapChunkLoadingSlotClear(chunk);
mapChunkLoadNext(); mapChunkLoadNext();
} }
@@ -433,6 +464,47 @@ void mapChunkLoaded(void *params, void *user) {
// this chunk_t is displaying it. Released in mapChunkUnload instead. // this chunk_t is displaying it. Released in mapChunkUnload instead.
chunk->meshCount = meshCount; 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(); mapChunkLoadNext();
} }
+8 -6
View File
@@ -12,6 +12,10 @@
#define MAP_FILE_PATH_MAX 128 #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 { typedef struct map_s {
bool_t loaded; bool_t loaded;
@@ -19,11 +23,9 @@ typedef struct map_s {
chunk_t *chunkOrder[MAP_CHUNK_COUNT]; chunk_t *chunkOrder[MAP_CHUNK_COUNT];
chunkpos_t chunkPosition; 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]; chunk_t *loadQueue[MAP_CHUNK_COUNT];
uint32_t loadQueueCount; uint32_t loadQueueCount;
chunk_t *loadingChunk; chunk_t *loadingChunks[MAP_CHUNK_LOAD_CONCURRENCY];
} map_t; } map_t;
extern map_t MAP; extern map_t MAP;
@@ -80,9 +82,9 @@ void mapChunkUnload(chunk_t* chunk);
errorret_t mapChunkLoad(chunk_t* chunk); errorret_t mapChunkLoad(chunk_t* chunk);
/** /**
* Starts loading the next queued chunk, if no chunk is currently mid-load. * Starts loading queued chunks until MAP_CHUNK_LOAD_CONCURRENCY chunks are
* Called after mapChunkLoad enqueues a chunk, and again after the * mid-load. Called after mapChunkLoad enqueues a chunk, and again after a
* currently-loading chunk finishes (or is unloaded) to advance the queue. * mid-load chunk finishes (or is unloaded) to advance the queue.
*/ */
void mapChunkLoadNext(); void mapChunkLoadNext();
+18
View File
@@ -10,6 +10,7 @@
#include "util/math.h" #include "util/math.h"
#include "util/memory.h" #include "util/memory.h"
#include "rpg/overworld/map.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];
@@ -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
);
+4 -30
View File
@@ -7,12 +7,9 @@
#include "rpg.h" #include "rpg.h"
#include "entity/entity.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/map.h"
#include "rpg/overworld/maparea.h" #include "rpg/overworld/maparea.h"
#include "rpg/cutscene/cutscenesystem.h" #include "rpg/cutscene/cutscenesystem.h"
#include "rpg/cutscene/scene/testcutscene.h"
#include "rpg/item/backpack.h" #include "rpg/item/backpack.h"
#include "rpg/battle/party.h" #include "rpg/battle/party.h"
#include "ui/rpg/textbox/uitextboxminilist.h" #include "ui/rpg/textbox/uitextboxminilist.h"
@@ -21,14 +18,9 @@
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "console/console.h"
#include "ui/rpg/uiemoji.h" #include "ui/rpg/uiemoji.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));
@@ -43,7 +35,9 @@ errorret_t rpgInit(void) {
// Init world // Init world
errorChain(mapPositionSet((chunkpos_t){ 0, 0, 0 })); 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(); uint8_t entIndex = entityGetAvailable();
assertTrue(entIndex != 0xFF, "No available entity slots!."); assertTrue(entIndex != 0xFF, "No available entity slots!.");
entity_t *ent = &ENTITIES[entIndex]; entity_t *ent = &ENTITIES[entIndex];
@@ -52,31 +46,11 @@ errorret_t rpgInit(void) {
RPG_CAMERA.mode = RPG_CAMERA_MODE_FOLLOW_ENTITY; RPG_CAMERA.mode = RPG_CAMERA_MODE_FOLLOW_ENTITY;
RPG_CAMERA.followEntity.followEntityId = ent->id; RPG_CAMERA.followEntity.followEntityId = ent->id;
mapSpawnEntity(3, (worldpos_t){ 8, 8, 1 }); // Starting inventory.
// 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.
backpackAdd(ITEM_ID_POTION, 5); backpackAdd(ITEM_ID_POTION, 5);
backpackAdd(ITEM_ID_POTATO, 3); backpackAdd(ITEM_ID_POTATO, 3);
backpackAdd(ITEM_ID_APPLE, 8); 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! // All Good!
errorOk(); errorOk();
} }
+123 -5
View File
@@ -14,6 +14,16 @@ JSON input (assetsraw/chunks/chunk_X_Y_Z.json):
], ],
"meshes": [ "meshes": [
{ "file": "house_5_3.dmf", "pos": [x, y, z] } { "file": "house_5_3.dmf", "pos": [x, y, z] }
],
"entities": [
{ "type": "global", "globalId": <int>, "pos": [x, y, z] },
{ "type": "item", "itemId": <int>, "quantity": <int>, "pos": [x, y, z] }
],
"areas": [
{
"min": [x, y, z], "max": [x, y, z],
"callbackId": <int>, "notify": <int>, "trigger": <int>
}
] ]
} }
@@ -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 Mesh files are located by searching under assets/meshes/ and referenced by
path from the assets root in the DCF. 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: Output DCF is derived automatically:
assetsraw/chunks/chunk_X_Y_Z.json -> assets/chunks/X_Y_Z.dcf 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) 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, 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; }) 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: for each model:
null-terminated string (relative asset path to .json model) null-terminated string (relative asset path to .json model)
float32[3] (x, y, z offset) 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: DMF format:
Bytes 0-3: DMF\\x00 Bytes 0-3: DMF\\x00
@@ -74,6 +114,11 @@ WORLD_LAYER_HEIGHT = 1.0 / math.sqrt(2)
CHUNK_MESH_COUNT_MAX = 10 CHUNK_MESH_COUNT_MAX = 10
CHUNK_MESH_NAME_MAX = 64 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 # 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). # 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' FILE_MAGIC = b'DCF'
DMF_MAGIC = b'DMF\x00' DMF_MAGIC = b'DMF\x00'
VERSION_OUT = 4 VERSION_OUT = 5
DMF_VERSION = 1 DMF_VERSION = 1
@@ -163,11 +208,29 @@ def write_dmf(path, vertex_bytes):
print(f' Wrote DMF {path}: {vert_count} vertices, {len(buf)} 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.""" """Write a current-version DCF referencing the given DMF asset paths."""
mesh_count = len(mesh_names) mesh_count = len(mesh_names)
if mesh_offsets is None: if mesh_offsets is None:
mesh_offsets = [(0.0, 0.0, 0.0)] * mesh_count 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 = bytearray()
buf += FILE_MAGIC buf += FILE_MAGIC
@@ -183,11 +246,34 @@ def write_dcf(dcf_path, tiles, mesh_names, mesh_offsets=None):
) )
buf += encoded + b'\x00' buf += encoded + b'\x00'
buf += struct.pack('<3f', offset[0], offset[1], offset[2]) buf += struct.pack('<3f', offset[0], offset[1], offset[2])
buf += struct.pack('<B', len(entity_spawns))
for spawn in entity_spawns:
kind = spawn['kind']
x, y, z = spawn['pos']
if kind == ENTITY_SPAWN_KIND_GLOBAL:
a, b = spawn['globalId'], 0
else:
a, b = spawn['itemId'], spawn['quantity']
buf += struct.pack('<BHB3h', kind, a, b, x, y, z)
buf += struct.pack('<B', len(area_spawns))
for area in area_spawns:
minX, minY, minZ = area['min']
maxX, maxY, maxZ = area['max']
buf += struct.pack(
'<6hHBB',
minX, minY, minZ, maxX, maxY, maxZ,
area['callbackId'], area['notify'], area['trigger']
)
with open(dcf_path, 'wb') as f: with open(dcf_path, 'wb') as f:
f.write(buf) f.write(buf)
print( print(
f' Wrote DCF {dcf_path}: ' f' Wrote DCF {dcf_path}: '
f'version {VERSION_OUT}, {mesh_count} mesh(es), {len(buf)} bytes' f'version {VERSION_OUT}, {mesh_count} mesh(es), '
f'{len(entity_spawns)} entity spawn(s), {len(area_spawns)} '
f'area(s), {len(buf)} bytes'
) )
@@ -323,7 +409,39 @@ def from_json(json_path, dcf_path):
mesh_offsets.append((float(pos[0]), float(pos[1]), float(pos[2]))) mesh_offsets.append((float(pos[0]), float(pos[1]), float(pos[2])))
print(f' Resolved {filename} -> {rel}') print(f' Resolved {filename} -> {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): def process_json(json_path):