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
+123 -5
View File
@@ -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": <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
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('<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:
f.write(buf)
print(
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])))
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):