Phase 8 checkpoint
This commit is contained in:
@@ -133,6 +133,8 @@ cmake --build build --target demoSokoban # Demo: forklift sokoban activity
|
||||
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
|
||||
cmake --build build --target characters-all # All adult/baby/child/teenager/elder character assets
|
||||
cmake --build build --target character-assets-check # Validate age catalogs/LODs/resource tree
|
||||
# Optional: seed a detailed-nude source blend from the existing base parts
|
||||
cmake --build build --target init-nude-male
|
||||
```
|
||||
|
||||
### Parallel asset builds
|
||||
@@ -395,6 +397,10 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
|
||||
adult → age-band seed converter (rig scaling, mesh/shape-key
|
||||
deformation, garment/hair conversion, animation retargeting; contract:
|
||||
`assets/blender/characters/AGES-PLAN.md`)
|
||||
- `characters/make_nude_blend.py` - seeds the optional
|
||||
`nude-<stem>.blend` detailed-nude source files from the existing
|
||||
layer-0 body parts (`init-nude-<stem>` targets; the build appends the
|
||||
file after all clothing stages)
|
||||
- `characters/validate_age_assets.py` - structural validation of the
|
||||
generated age catalogs/LODs/resource tree
|
||||
3. **Build**: CMake processes assets during build
|
||||
|
||||
@@ -51,9 +51,13 @@ Pipeline per stem (`male`, `female`, `baby-male`, `child-female`, ...):
|
||||
`process_clothes.py` fits them to the body (weight transfer from the
|
||||
reference body parts, seam fixes), `combine_clothes.py` bakes the
|
||||
combined meshes for garment combinations (see §3).
|
||||
3. `consolidate.py` appends everything into
|
||||
`edited-normal-<stem>-consolidated.blend` and leaves exactly one body
|
||||
armature.
|
||||
3. `consolidate.py` appends each clothing stage and leaves exactly one
|
||||
body armature. The clothes-only result is
|
||||
`edited-normal-<stem>-consolidated-clothes.blend`. If
|
||||
`nude-<stem>.blend` exists, a final consolidation appends its
|
||||
detailed-nude parts; otherwise the clothes-only blend is copied.
|
||||
Either way the final output is
|
||||
`edited-normal-<stem>-consolidated.blend`.
|
||||
4. `export_models2.py` exports the consolidated blend through
|
||||
blender2ogre: one `.mesh` per catalog mesh object, the shared
|
||||
`.skeleton`, `.material` files, the prefixed texture atlases,
|
||||
@@ -158,45 +162,71 @@ Resolution per slot (`resolveMesh`), given the record's
|
||||
`combine_clothes.py` — a new garment only needs its own entry, the
|
||||
combinations are generated.
|
||||
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
|
||||
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
|
||||
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
|
||||
the plain layer-0 meshes are simplified composition bases meant to
|
||||
disappear under garments and lack anatomical detail. Author at most
|
||||
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
|
||||
slots without one keep the composition base, so the variants can be
|
||||
added one slot at a time. Tag it via `clothing_tags =
|
||||
"detailed-nude"` on a layer-0 mesh.
|
||||
- **No layer-1/2 garment resolved (nude slot)**: the layer-0 entry
|
||||
tagged **`detailed-nude`** when one exists for the slot (phase
|
||||
6.C.1) — the plain layer-0 meshes are simplified composition bases
|
||||
meant to disappear under garments and lack anatomical detail. This
|
||||
applies to `outfitLevel 0` and to `outfitLevel 1/2` records whose
|
||||
garment selections are empty or point at a missing catalog entry.
|
||||
Slots without a detailed variant keep the composition base, so the
|
||||
variants can be authored one slot at a time. Tag it via
|
||||
`clothing_tags = "detailed-nude"` on a layer-0 mesh.
|
||||
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
|
||||
duplicates are penalised), then any entry.
|
||||
|
||||
### Authoring a `detailed-nude` part (whole-body-part replacement)
|
||||
### Detailed-nude source files (`nude-<stem>.blend`)
|
||||
|
||||
Detailed-nude parts live in an optional, separate source blend per
|
||||
age/sex stem: `nude-<stem>.blend` (`nude-male.blend`,
|
||||
`nude-baby-female.blend`, ...). The file can contain up to three
|
||||
complete body parts, one per slot: `top`, `bottom`, `feet`. It is
|
||||
optional and may be populated one slot at a time; slots without a part
|
||||
fall back to the composition bases. The normal build appends the file
|
||||
**after** all clothing stages, so `combine_clothes.py` never sees these
|
||||
parts and cannot delete or combine them.
|
||||
|
||||
A detailed-nude mesh is a COMPLETE standalone body part that replaces
|
||||
the simplified composition base at runtime when the character wears
|
||||
nothing. It is NOT built from the base part by the pipeline and no
|
||||
geometry is ever deleted from it:
|
||||
the simplified composition base at runtime when the slot has no
|
||||
clothes. It is NOT built from the base part by the pipeline and no
|
||||
geometry is ever deleted from it. The plain base part must stay in
|
||||
`edited-normal-<stem>.blend` — garments still need it as their
|
||||
combination target.
|
||||
|
||||
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
|
||||
copies of the base meshes a garment explicitly names in its
|
||||
`ref_part` property. No garment references your new mesh, so it
|
||||
passes through the pipeline untouched, and the plain base part must
|
||||
stay in the blend — garments still need it as their combination
|
||||
target.
|
||||
- The replacement happens at mesh-resolution time only: outfitLevel 0
|
||||
shows the detailed part, outfitLevel >= 1 goes back to the
|
||||
composition bases/combined meshes.
|
||||
#### Seeding the file from the existing base parts
|
||||
|
||||
Steps:
|
||||
The quickest start is the seeding helper, which copies the existing
|
||||
layer-0 `BodyTop` / `BodyBottom` / `BodyFeet` parts into a new
|
||||
`nude-<stem>.blend`, renames them `*_detailed`, strips clothing
|
||||
metadata and tags them `detailed-nude`:
|
||||
|
||||
1. Model the part as a NEW mesh object in the base body blend
|
||||
`edited-normal-<sex>.blend`, next to the existing base parts
|
||||
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
|
||||
sex at most (`top`/`bottom`/`feet`); the engine takes the first
|
||||
`detailed-nude` entry it finds for the slot.
|
||||
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
|
||||
max 4 bone influences per vertex. (`consolidate.py` re-parents it
|
||||
to the armature named by its `sex` property and adds an Armature
|
||||
modifier if missing, but the weights are your job.)
|
||||
```bash
|
||||
# Recommended: goes through the Blender lock.
|
||||
cmake --build build-vscode --target init-nude-male # adult male
|
||||
cmake --build build-vscode --target init-nude-child-female
|
||||
```
|
||||
|
||||
Or run the helper directly (stop parallel asset builds first):
|
||||
|
||||
```bash
|
||||
blender -b -Y -P assets/blender/characters/make_nude_blend.py -- \
|
||||
--age adult --sex male
|
||||
```
|
||||
|
||||
The helper refuses to overwrite an existing file unless `--force` is
|
||||
passed. It includes the body armature for editing and skinning; the
|
||||
pipeline keeps one body armature during consolidation. If you
|
||||
regenerate an age seed with `--force`, its base boundaries can change;
|
||||
re-seed or adjust the matching `nude-<stem>.blend` afterwards.
|
||||
|
||||
#### Editing requirements
|
||||
|
||||
1. Keep or replace the `BodyTop_detailed` / `BodyBottom_detailed` /
|
||||
`BodyFeet_detailed` meshes. At most one `detailed-nude` entry per
|
||||
slot per stem; the engine takes the first entry it finds.
|
||||
2. Skin each part to the SAME `mixamorig:` armature with the same bind
|
||||
pose, max 4 bone influences per vertex. (`consolidate.py`
|
||||
re-parents it to the armature named by its `sex` property and adds
|
||||
an Armature modifier if missing, but the weights are your job.)
|
||||
3. Match the boundary loops of the base part it replaces (neck, waist,
|
||||
wrists, ankles) vertex-for-vertex in position: the consolidate
|
||||
step's seam fixer unifies shape-key offsets and normals for
|
||||
@@ -205,17 +235,38 @@ Steps:
|
||||
against the face/hands/neighbouring slots.
|
||||
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
|
||||
this directory: `transfer_shape_keys.py`,
|
||||
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
|
||||
`propagate_shape_keys.py`, `add_missing_shape_keys.py`. The seeding
|
||||
helper already does this from `Body_shapes`, and the final
|
||||
consolidation runs `add_missing_shape_keys.py` as a safety net, so a
|
||||
manually authored part also receives the missing keys automatically.
|
||||
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
|
||||
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
|
||||
6. Give object AND mesh data a unique name (e.g.
|
||||
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
|
||||
file name.
|
||||
7. Rebuild (`edited-blends` target or full build). No CMake or code
|
||||
changes are needed: consolidate/export discover the part by its
|
||||
custom properties and emit `body_part_<mesh data name>.json`
|
||||
automatically, and the runtime starts using it for nude characters
|
||||
of that slot.
|
||||
The seeding helper sets these automatically.
|
||||
6. Give object AND mesh data unique names (e.g. `BodyTop_detailed`);
|
||||
the mesh data name becomes the exported `.mesh` name prefixed with
|
||||
the stem (`male_BodyTop_detailed.mesh`).
|
||||
7. Rebuild the character (`character-<stem>`, `characters-all`, or the
|
||||
full build). No CMake edits are needed: `CMakeLists.txt` detects
|
||||
`nude-<stem>.blend`, appends it after the clothing consolidation and
|
||||
exports the parts as layer-0 catalog entries. If the file was added
|
||||
while CMake was already configured, the `CONFIGURE_DEPENDS` glob
|
||||
re-runs CMake on the next build (or run `cmake -B build-vscode -S .`
|
||||
manually).
|
||||
8. Verify with `cmake --build build-vscode --target
|
||||
character-assets-check`: when a `nude-<stem>.blend` exists, the
|
||||
validator requires at least one `detailed-nude` catalog entry, checks
|
||||
that every entry is layer 0 with no `garments` and matching
|
||||
age/sex, and warns about missing slots.
|
||||
|
||||
Do not add detailed-nude meshes to `edited-normal-<stem>.blend` for the
|
||||
new workflow. The separate file is what keeps them out of the clothing
|
||||
combination/removal path and lets them be replaced independently. Older
|
||||
assets with detailed-nude meshes already in the base blend still resolve
|
||||
at runtime; they simply do not get the separate-file workflow.
|
||||
|
||||
The current LOD2/3 whole-character impostors still use the default
|
||||
dressed outfit. Detailed-nude parts are visible at LOD0/LOD1; a nude
|
||||
impostor variant is future work.
|
||||
|
||||
The town population picks looks deterministically per NPC:
|
||||
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
|
||||
@@ -356,3 +407,10 @@ contract suite:
|
||||
./demoTaxi --headless --test-motion
|
||||
./demoTaxi --headless --test-detail
|
||||
```
|
||||
|
||||
Detailed-nude verification: seed a file with `init-nude-<stem>`, rebuild
|
||||
that stem, then set a character of that age/sex to outfit level Nude (or
|
||||
clear its layer-1/2 garment selections). The editor's "Resolved:" line
|
||||
and the slot mesh should show the `*_detailed` catalog entry; clothed
|
||||
characters must keep their selected garments. `--test-ages` also checks
|
||||
the runtime preference for every stem that has a `detailed-nude` entry.
|
||||
|
||||
@@ -28,6 +28,20 @@ function(character_source_blend AGE SEX OUT)
|
||||
PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(character_nude_source AGE SEX OUT)
|
||||
character_stem(${AGE} ${SEX} STEM)
|
||||
set(${OUT}
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/nude-${STEM}.blend"
|
||||
PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# Optional detailed-nude sources: one nude-<stem>.blend per age/sex.
|
||||
# CONFIGURE_DEPENDS makes CMake re-run when a file is added/removed, so the
|
||||
# per-stem pipeline below can switch between the plain clothes-consolidated
|
||||
# blend and the extra nude-append stage without a manual reconfigure.
|
||||
file(GLOB CHARACTER_NUDE_BLENDS CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/nude-*.blend")
|
||||
|
||||
function(character_glb AGE SEX OUT)
|
||||
character_stem(${AGE} ${SEX} STEM)
|
||||
set(${OUT} "${CMAKE_BINARY_DIR}/characters/${STEM}/normal-${STEM}.glb"
|
||||
@@ -471,6 +485,22 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
|
||||
foreach(SEX ${CHARACTER_SEXES})
|
||||
character_stem(${AGE} ${SEX} STEM)
|
||||
character_build_blend(${AGE} ${SEX} BASE_BUILD)
|
||||
character_nude_source(${AGE} ${SEX} NUDE_SRC)
|
||||
if(EXISTS "${NUDE_SRC}")
|
||||
set(NUDE_STATE 1)
|
||||
else()
|
||||
set(NUDE_STATE 0)
|
||||
endif()
|
||||
# The state file is rewritten only when its content changes. It is
|
||||
# a dependency of the final blend, so adding/removing a nude source
|
||||
# forces the final stage to re-run even when its output path stays
|
||||
# the same.
|
||||
configure_file(
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/nude_state.in"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/nude-${STEM}.state"
|
||||
@ONLY)
|
||||
set(CLOTHES_CONSOLIDATED
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-consolidated-clothes.blend")
|
||||
set(SHAPES_BLEND
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-shapes.blend")
|
||||
set(CONSOLIDATED
|
||||
@@ -490,7 +520,8 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
|
||||
set(SLOT_OUTPUT
|
||||
"edited-normal-${STEM}-consolidated-${SLOT}.blend")
|
||||
if(SLOT STREQUAL LAST_SLOT)
|
||||
set(SLOT_OUTPUT "edited-normal-${STEM}-consolidated.blend")
|
||||
set(SLOT_OUTPUT
|
||||
"edited-normal-${STEM}-consolidated-clothes.blend")
|
||||
endif()
|
||||
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
|
||||
"${WEIGHTED}"
|
||||
@@ -498,6 +529,46 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
endforeach()
|
||||
|
||||
# Final stage: the clothes pipeline always ends at
|
||||
# *-consolidated-clothes.blend. When an optional
|
||||
# nude-<stem>.blend exists, append its layer-0 detailed-nude parts
|
||||
# after all clothing stages (so combine_clothes.py never sees or
|
||||
# removes them); otherwise stage a plain copy as *-consolidated.blend.
|
||||
set(NUDE_STATE_FILE
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/nude-${STEM}.state")
|
||||
set(FINAL_DEPS "${CLOTHES_CONSOLIDATED}" "${NUDE_STATE_FILE}")
|
||||
if(EXISTS "${NUDE_SRC}")
|
||||
list(APPEND FINAL_DEPS "${NUDE_SRC}")
|
||||
add_custom_command(
|
||||
OUTPUT ${CONSOLIDATED}
|
||||
COMMAND ${BLENDER_LOCKED} -b -Y "${CLOTHES_CONSOLIDATED}"
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py --
|
||||
"${NUDE_SRC}"
|
||||
"${CONSOLIDATED}"
|
||||
COMMAND ${BLENDER_LOCKED} -b -Y "${CONSOLIDATED}"
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py --
|
||||
"${CONSOLIDATED}" "${CONSOLIDATED}"
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CONSOLIDATED}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${FINAL_DEPS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
COMMENT "Appending detailed nude parts and propagating shape keys from ${NUDE_SRC}"
|
||||
)
|
||||
else()
|
||||
add_custom_command(
|
||||
OUTPUT ${CONSOLIDATED}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
"${CLOTHES_CONSOLIDATED}" "${CONSOLIDATED}"
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CONSOLIDATED}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${FINAL_DEPS}
|
||||
COMMENT "Staging final character blend for ${STEM}"
|
||||
)
|
||||
endif()
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT ${LODS}
|
||||
COMMAND ${CMAKE_COMMAND} -E remove_directory ${OUTDIR}
|
||||
@@ -546,10 +617,31 @@ endforeach()
|
||||
|
||||
add_custom_target(characters-all DEPENDS ${ALL_CHARACTER_TARGETS})
|
||||
|
||||
# CMake lists use semicolons; the shell would split --ages on them. The
|
||||
# validator accepts commas, so pass a comma-separated list instead.
|
||||
string(REPLACE ";" "," CHARACTER_ASSETS_AGES "${CHARACTER_BUILD_AGES}")
|
||||
|
||||
# Authoring helper: seed nude-<stem>.blend from the existing layer-0
|
||||
# BodyTop/BodyBottom/BodyFeet parts. The helper refuses to overwrite an
|
||||
# existing file unless --force is passed manually; these targets are
|
||||
# opt-in and never part of the normal build.
|
||||
foreach(AGE ${CHARACTER_AGES})
|
||||
foreach(SEX ${CHARACTER_SEXES})
|
||||
character_stem(${AGE} ${SEX} STEM)
|
||||
add_custom_target(init-nude-${STEM}
|
||||
COMMAND ${BLENDER_LOCKED} -b -Y
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/make_nude_blend.py --
|
||||
--age ${AGE} --sex ${SEX}
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
COMMENT "Seeding nude-${STEM}.blend from the existing base parts")
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
add_custom_target(character-assets-check
|
||||
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/validate_age_assets.py
|
||||
--characters-dir ${CMAKE_BINARY_DIR}/characters
|
||||
--ages "${CHARACTER_BUILD_AGES}"
|
||||
--source-dir ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
--ages "${CHARACTER_ASSETS_AGES}"
|
||||
DEPENDS characters-all
|
||||
COMMENT "Validating generated character age assets")
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
nude=@NUDE_STATE@
|
||||
source=@NUDE_SRC@
|
||||
Binary file not shown.
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed an optional ``nude-<stem>.blend`` from the existing layer-0 parts.
|
||||
|
||||
``nude-<stem>.blend`` holds the optional anatomically detailed body parts
|
||||
for one age/sex band. This helper makes the first version for a stem by
|
||||
copying the current composition bases (``BodyTop`` / ``BodyBottom`` /
|
||||
``BodyFeet``) into a standalone blend, tagging them ``detailed-nude`` and
|
||||
giving them unique object/data names. Edit the resulting file in Blender;
|
||||
the normal character pipeline appends it after all clothing stages and the
|
||||
runtime prefers those parts whenever a slot has no layer-1/2 garment.
|
||||
|
||||
Run through CMake (recommended; participates in the Blender lock)::
|
||||
|
||||
cmake --build build-vscode --target init-nude-male
|
||||
|
||||
or directly::
|
||||
|
||||
blender -b -Y -P assets/blender/characters/make_nude_blend.py -- \
|
||||
--age adult --sex male [--force]
|
||||
|
||||
The helper never overwrites an existing file without ``--force``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
|
||||
import age_profiles # noqa: E402
|
||||
import add_missing_shape_keys as shape_keys # noqa: E402
|
||||
|
||||
SLOT_ORDER = ("top", "bottom", "feet")
|
||||
BASE_NAMES = {
|
||||
"top": "BodyTop",
|
||||
"bottom": "BodyBottom",
|
||||
"feet": "BodyFeet",
|
||||
}
|
||||
CLOTHING_PROPS = (
|
||||
"garments",
|
||||
"_combined_meta",
|
||||
"ref_layer",
|
||||
"ref_part",
|
||||
"garment_id",
|
||||
"ref_age",
|
||||
"ref_sex",
|
||||
"ref_clothing",
|
||||
"tags",
|
||||
"own_skeleton",
|
||||
"has_own_armature",
|
||||
"own_armature_name",
|
||||
)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print("[nude-seed] " + msg, flush=True)
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
if "--" in argv:
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
else:
|
||||
argv = []
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--age", required=True,
|
||||
choices=age_profiles.CANONICAL_AGES)
|
||||
p.add_argument("--sex", required=True, choices=("male", "female"))
|
||||
p.add_argument("--source", default="")
|
||||
p.add_argument("--output", default="")
|
||||
p.add_argument("--slots", default=",".join(SLOT_ORDER))
|
||||
p.add_argument("--force", action="store_true")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def object_name_score(obj):
|
||||
"""Prefer the canonical base name, then the shortest name.
|
||||
|
||||
Mirrors CharacterSlotSystem's layer-0 fallback: Blender duplicate
|
||||
suffixes are penalised so ``BodyTop`` beats ``BodyTop.001``.
|
||||
"""
|
||||
name = obj.name
|
||||
score = len(name)
|
||||
if name.startswith(BASE_NAMES.get(obj.get("slot", ""), "\0")):
|
||||
score -= 1000
|
||||
if "." in name:
|
||||
score += 1000
|
||||
return score
|
||||
|
||||
|
||||
def find_body_armature(stem):
|
||||
arm = bpy.data.objects.get(stem)
|
||||
if arm is not None and arm.type == 'ARMATURE':
|
||||
return arm
|
||||
candidates = [o for o in bpy.data.objects if o.type == 'ARMATURE']
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda o: len(o.data.bones))
|
||||
|
||||
|
||||
def find_base_part(slot, age, sex):
|
||||
candidates = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != 'MESH' or obj.data is None:
|
||||
continue
|
||||
if not all(k in obj for k in ("age", "sex", "slot")):
|
||||
continue
|
||||
if age_profiles.normalize_age(obj.get("age", "adult")) != age:
|
||||
continue
|
||||
if str(obj.get("sex", "")) != sex:
|
||||
continue
|
||||
if str(obj.get("slot", "")) != slot:
|
||||
continue
|
||||
if int(obj.get("layer", 0)) != 0:
|
||||
continue
|
||||
candidates.append(obj)
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates, key=object_name_score)
|
||||
|
||||
|
||||
def find_shape_source():
|
||||
"""Find the body reference mesh that carries the full shape-key set."""
|
||||
for name in ("Body_shapes", "Body"):
|
||||
obj = bpy.data.objects.get(name)
|
||||
if obj is not None and obj.type == 'MESH' and \
|
||||
obj.data.shape_keys is not None and \
|
||||
len(obj.data.shape_keys.key_blocks) > 1:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def build_shape_data(source_obj):
|
||||
"""Same payload add_missing_shape_keys.py builds for its propagation."""
|
||||
return {
|
||||
"names": [kb.name for kb in source_obj.data.shape_keys.key_blocks],
|
||||
"vertex_positions": {
|
||||
sk.name: [(v.co.x, v.co.y, v.co.z) for v in sk.data]
|
||||
for sk in source_obj.data.shape_keys.key_blocks
|
||||
},
|
||||
"polygons": [[v for v in poly.vertices]
|
||||
for poly in source_obj.data.polygons],
|
||||
"is_relative": source_obj.data.shape_keys.use_relative,
|
||||
"source_obj_name": source_obj.name,
|
||||
}
|
||||
|
||||
|
||||
def duplicate_part(src, slot, age, sex, armature):
|
||||
dup = src.copy()
|
||||
dup.data = src.data.copy()
|
||||
bpy.context.collection.objects.link(dup)
|
||||
dup.name = src.name + "_detailed"
|
||||
dup.data.name = dup.name
|
||||
|
||||
# Keep the source transform (the base parts are usually parented to the
|
||||
# armature with an identity object matrix).
|
||||
dup.parent = armature
|
||||
dup.matrix_parent_inverse = src.matrix_parent_inverse.copy()
|
||||
dup.matrix_world = src.matrix_world.copy()
|
||||
|
||||
for prop in CLOTHING_PROPS:
|
||||
if prop in dup:
|
||||
del dup[prop]
|
||||
dup["age"] = age
|
||||
dup["sex"] = sex
|
||||
dup["slot"] = slot
|
||||
dup["layer"] = 0
|
||||
dup["clothing_tags"] = "detailed-nude"
|
||||
|
||||
mod = next((m for m in dup.modifiers if m.type == 'ARMATURE'), None)
|
||||
if mod is None:
|
||||
mod = dup.modifiers.new(name="Armature", type='ARMATURE')
|
||||
mod.object = armature
|
||||
|
||||
if not dup.vertex_groups:
|
||||
log(f"WARNING: '{dup.name}' has no vertex groups; skin it before "
|
||||
f"building")
|
||||
if dup.data.shape_keys is None:
|
||||
log(f"WARNING: '{dup.name}' has no shape keys; copy them from "
|
||||
f"the base part or use transfer_shape_keys.py")
|
||||
return dup
|
||||
|
||||
|
||||
def purge_orphans():
|
||||
for _ in range(3):
|
||||
try:
|
||||
bpy.ops.outliner.orphans_purge(
|
||||
do_local_ids=True, do_linked_ids=True,
|
||||
do_recursive=True)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
age = age_profiles.normalize_age(args.age)
|
||||
sex = args.sex
|
||||
stem = age_profiles.stem(age, sex)
|
||||
source = args.source or os.path.join(
|
||||
_THIS_DIR, f"edited-normal-{stem}.blend")
|
||||
output = args.output or os.path.join(_THIS_DIR, f"nude-{stem}.blend")
|
||||
|
||||
if not os.path.exists(source):
|
||||
log(f"ERROR: source body blend not found: {source}")
|
||||
return 1
|
||||
if os.path.exists(output) and not args.force:
|
||||
log(f"SKIP: {output} already exists (use --force to reseed)")
|
||||
return 0
|
||||
|
||||
slots = [s.strip() for s in args.slots.split(",") if s.strip()]
|
||||
unknown = [s for s in slots if s not in SLOT_ORDER]
|
||||
if unknown:
|
||||
log(f"ERROR: unknown slots: {', '.join(unknown)}")
|
||||
return 1
|
||||
|
||||
log(f"opening {source}")
|
||||
bpy.ops.wm.open_mainfile(filepath=source)
|
||||
armature = find_body_armature(stem)
|
||||
if armature is None:
|
||||
log(f"ERROR: no body armature found in {source}")
|
||||
return 1
|
||||
log(f"armature: {armature.name} ({len(armature.data.bones)} bones)")
|
||||
|
||||
shape_source = find_shape_source()
|
||||
shape_data = build_shape_data(shape_source) if shape_source else None
|
||||
if shape_source:
|
||||
log(f"shape source: {shape_source.name} "
|
||||
f"({len(shape_data['names'])} keys)")
|
||||
else:
|
||||
log("WARNING: no Body_shapes/Body shape-key source; the detailed "
|
||||
"parts will only get keys already present on the base part")
|
||||
|
||||
duplicates = []
|
||||
for slot in slots:
|
||||
src = find_base_part(slot, age, sex)
|
||||
if src is None:
|
||||
log(f"WARNING: no layer-0 {slot} part found; skipping")
|
||||
continue
|
||||
dup = duplicate_part(src, slot, age, sex, armature)
|
||||
if shape_data:
|
||||
try:
|
||||
shape_keys.add_missing_shape_keys(shape_data, dup)
|
||||
except Exception as exc:
|
||||
log(f"WARNING: shape-key propagation failed for "
|
||||
f"{dup.name}: {exc}")
|
||||
duplicates.append(dup)
|
||||
log(f"{slot}: {src.name} -> {dup.name} "
|
||||
f"({len(dup.data.vertices)} verts)")
|
||||
|
||||
if not duplicates:
|
||||
log("ERROR: no layer-0 parts found for the requested slots")
|
||||
return 1
|
||||
|
||||
# Keep exactly the new parts and the body armature. The user can model
|
||||
# the detailed variants in this file without the rest of the body.
|
||||
keep = set(duplicates) | {armature}
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj not in keep:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
# A source-blend armature may carry the full animation library; the
|
||||
# detailed-nude file only needs the rest skeleton for skinning/editing.
|
||||
armature.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
try:
|
||||
bpy.data.actions.remove(action, do_unlink=True)
|
||||
except TypeError:
|
||||
bpy.data.actions.remove(action)
|
||||
except Exception:
|
||||
pass
|
||||
purge_orphans()
|
||||
|
||||
os.makedirs(os.path.dirname(output), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=output)
|
||||
log(f"saved {output}")
|
||||
log("next: edit the *_detailed parts, then rebuild "
|
||||
f"character-{stem} (or the full build)")
|
||||
log("requirements: same armature/bind pose, max 4 bone influences, "
|
||||
"shape keys, matching boundary loops")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
@@ -43,6 +43,9 @@ def warn(msg, warnings):
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--characters-dir", required=True)
|
||||
p.add_argument("--source-dir", default=THIS_DIR,
|
||||
help="character source directory containing optional "
|
||||
"nude-<stem>.blend files (default: %(default)s)")
|
||||
p.add_argument("--ages", default="",
|
||||
help="semicolon/comma separated age bands to check "
|
||||
"(default: all canonical bands)")
|
||||
@@ -69,6 +72,20 @@ def main():
|
||||
for sex in ("male", "female"):
|
||||
expected_stems.append(age_profiles.stem(age, sex))
|
||||
|
||||
# Catch misspelled detailed-nude source names (e.g. nude-adult-male.blend)
|
||||
# instead of silently ignoring them.
|
||||
all_stems = {age_profiles.stem(age, sex)
|
||||
for age in age_profiles.CANONICAL_AGES
|
||||
for sex in ("male", "female")}
|
||||
source_root = os.path.abspath(args.source_dir)
|
||||
for path in sorted(glob.glob(os.path.join(source_root, "nude-*.blend"))):
|
||||
name = os.path.basename(path)
|
||||
stem = name[len("nude-"):-len(".blend")]
|
||||
if stem not in all_stems:
|
||||
warn(f"unexpected detailed-nude source name: {name} "
|
||||
f"(expected nude-<stem>.blend, e.g. nude-male.blend)",
|
||||
warnings)
|
||||
|
||||
for stem in expected_stems:
|
||||
d = by_stem.get(stem)
|
||||
if d is None:
|
||||
@@ -89,7 +106,9 @@ def main():
|
||||
entries.append(e)
|
||||
ages = {age_profiles.normalize_age(e.get("age", ""))
|
||||
for e in entries}
|
||||
if ages != {stem.split("-")[0] if "-" in stem else "adult"}:
|
||||
stem_age = stem.split("-")[0] if "-" in stem else "adult"
|
||||
stem_sex = stem.rsplit("-", 1)[-1]
|
||||
if ages != {stem_age}:
|
||||
fail(f"{stem}: catalog ages {sorted(ages)} do not match stem",
|
||||
errors)
|
||||
slots = {}
|
||||
@@ -105,6 +124,67 @@ def main():
|
||||
if "hair" not in slots:
|
||||
warn(f"{stem}: no hair slot (bald band)", warnings)
|
||||
|
||||
# Optional detailed-nude source file: when present, the built
|
||||
# catalog must expose its layer-0 parts. Missing slots are only a
|
||||
# warning because a nude-<stem>.blend may be populated gradually.
|
||||
nude_path = os.path.join(os.path.abspath(args.source_dir),
|
||||
f"nude-{stem}.blend")
|
||||
if os.path.exists(nude_path):
|
||||
any_detailed = False
|
||||
for req in ("top", "bottom", "feet"):
|
||||
detailed = []
|
||||
for e in slots.get(req, []):
|
||||
tags = e.get("tags", [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(";")
|
||||
if t.strip()]
|
||||
if "detailed-nude" in tags:
|
||||
detailed.append(e)
|
||||
if not detailed:
|
||||
warn(f"{stem}: nude-{stem}.blend has no "
|
||||
f"detailed-nude {req} catalog entry (slot falls "
|
||||
f"back to the composition base)", warnings)
|
||||
continue
|
||||
any_detailed = True
|
||||
if len(detailed) > 1:
|
||||
fail(f"{stem}: multiple detailed-nude {req} entries: "
|
||||
+ ", ".join(e.get("mesh", "?")
|
||||
for e in detailed), errors)
|
||||
base_keys = set()
|
||||
for e in slots.get(req, []):
|
||||
tags = e.get("tags", [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(";")
|
||||
if t.strip()]
|
||||
if int(e.get("layer", 0)) == 0 and \
|
||||
"detailed-nude" not in tags:
|
||||
base_keys.update(e.get("shape_keys", []))
|
||||
for e in detailed:
|
||||
mesh = e.get("mesh", "?")
|
||||
if int(e.get("layer", 0)) != 0:
|
||||
fail(f"{stem}: detailed-nude {req} is not layer 0: "
|
||||
f"{mesh}", errors)
|
||||
if e.get("garments"):
|
||||
fail(f"{stem}: detailed-nude {req} carries "
|
||||
f"garments: {mesh}", errors)
|
||||
if age_profiles.normalize_age(
|
||||
e.get("age", "")) != stem_age:
|
||||
fail(f"{stem}: detailed-nude {req} age mismatch: "
|
||||
f"{mesh}", errors)
|
||||
if str(e.get("sex", "")) != stem_sex:
|
||||
fail(f"{stem}: detailed-nude {req} sex mismatch: "
|
||||
f"{mesh}", errors)
|
||||
missing_keys = base_keys - set(
|
||||
e.get("shape_keys", []))
|
||||
if missing_keys:
|
||||
warn(f"{stem}: detailed-nude {req} is missing "
|
||||
f"shape keys {sorted(missing_keys)}: {mesh}",
|
||||
warnings)
|
||||
if not any_detailed:
|
||||
fail(f"{stem}: nude-{stem}.blend exists but the built "
|
||||
f"catalog has no detailed-nude entries "
|
||||
f"(re-run CMake and the character target)", errors)
|
||||
|
||||
skel = os.path.join(d, stem + ".skeleton")
|
||||
if not os.path.exists(skel):
|
||||
fail(f"{stem}: missing body skeleton {stem}.skeleton", errors)
|
||||
|
||||
@@ -1765,6 +1765,8 @@ void EditorApp::populateInteriorNpcs()
|
||||
interiorGrid = e;
|
||||
});
|
||||
m_interiorNpcSystem->populate(interiorGrid);
|
||||
m_interiorNpcRetryFrames =
|
||||
m_interiorNpcSystem->needsRetry() ? 120 : 0;
|
||||
}
|
||||
|
||||
void EditorApp::captureVehicleStates()
|
||||
@@ -3066,6 +3068,15 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
|
||||
}
|
||||
stepProfile("npcPopulation");
|
||||
|
||||
/* A save/load can resolve the interior document before the
|
||||
* population registry is rebuilt: retry any NPCs whose records
|
||||
* were missing now that the population update has run. */
|
||||
if (m_interiorNpcRetryFrames > 0 &&
|
||||
m_interiorNpcSystem) {
|
||||
--m_interiorNpcRetryFrames;
|
||||
populateInteriorNpcs();
|
||||
}
|
||||
|
||||
/* --- NPC vehicle traffic (demo-taxi phase 5.4/6.A): fleet
|
||||
* seeding, clock-pure symbolic drive legs, distance-gated
|
||||
* spawn/despawn. Runs after the population (needs the
|
||||
|
||||
@@ -721,6 +721,9 @@ private:
|
||||
/* Phase 8.2 follow-up: schedule-driven NPCs in a generated
|
||||
* interior (the exterior detail tier skips indoor NPCs). */
|
||||
void populateInteriorNpcs();
|
||||
/* Deferred retry after a save/load: the population registry is
|
||||
* rebuilt one frame after the interior document is switched in. */
|
||||
int m_interiorNpcRetryFrames = 0;
|
||||
void applyVehicleStates(const Ogre::String &scenePath);
|
||||
bool performSceneSwitch(const Ogre::String &scenePath,
|
||||
const SceneSwitchOptions &opts);
|
||||
|
||||
@@ -7127,6 +7127,768 @@ struct FallTestListener : public Ogre::FrameListener {
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* --test-interiors (phase 8.5): generated-interior acceptance battery.
|
||||
*
|
||||
* Picks three enterable buildings in the streamed town (multi-storey,
|
||||
* exterior-stair and back-door capable where present) and checks the
|
||||
* full contract: interior footprint/stairs/arrival/exit wiring, two
|
||||
* visits returning a bit-identical cached document, a plan edit
|
||||
* invalidating the cache, and save/load resuming inside the SAME
|
||||
* generated interior with population + clock restored.
|
||||
*
|
||||
* Run: ./demoTaxi --headless --test-interiors
|
||||
*/
|
||||
struct InteriorsAcceptanceListener : public Ogre::FrameListener {
|
||||
EditorApp *app;
|
||||
int frame = 0;
|
||||
int phase = 0;
|
||||
bool failed = false;
|
||||
Ogre::String failReason;
|
||||
int deadline = 90000;
|
||||
int wait = 0;
|
||||
size_t current = 0;
|
||||
bool clockSet = false;
|
||||
bool secondEntry = false;
|
||||
bool travelIssued = false;
|
||||
|
||||
struct TestCase {
|
||||
std::string uid;
|
||||
std::string scheme;
|
||||
std::string doorA;
|
||||
std::string doorB;
|
||||
int floors = 0;
|
||||
bool exteriorStair = false;
|
||||
Ogre::Vector3 entryPos = Ogre::Vector3::ZERO;
|
||||
nlohmann::json firstDoc;
|
||||
uint64_t firstHash = 0;
|
||||
size_t firstCacheSize = 0;
|
||||
};
|
||||
std::vector<TestCase> cases;
|
||||
std::string baseScene;
|
||||
std::string savePath = "/tmp/opencode/interiors_acceptance_save.json";
|
||||
Ogre::Vector3 savedPlayerPos = Ogre::Vector3::ZERO;
|
||||
size_t savedPopulation = 0;
|
||||
float savedHours = 0.0f;
|
||||
Ogre::Vector3 expectedExit = Ogre::Vector3::ZERO;
|
||||
|
||||
explicit InteriorsAcceptanceListener(EditorApp *a)
|
||||
: app(a)
|
||||
{
|
||||
}
|
||||
|
||||
bool fail(const std::string &why)
|
||||
{
|
||||
if (failed)
|
||||
return true;
|
||||
failed = true;
|
||||
failReason = why;
|
||||
std::cerr << "[test] interiors FAIL: " << why << std::endl;
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
|
||||
Ogre::Vector3 worldPos(flecs::entity e)
|
||||
{
|
||||
if (!e.is_alive() || !e.has<TransformComponent>())
|
||||
return Ogre::Vector3::ZERO;
|
||||
const auto &t = e.get<TransformComponent>();
|
||||
const Ogre::Vector3 rp =
|
||||
t.node ? t.node->_getDerivedPosition() : t.position;
|
||||
JPH::DVec3 wp(rp.x, rp.y, rp.z);
|
||||
if (RenderOriginSystem *ro =
|
||||
RenderOriginSystem::getInstance())
|
||||
wp = ro->renderToWorld(rp);
|
||||
return Ogre::Vector3((float)wp.GetX(), (float)wp.GetY(),
|
||||
(float)wp.GetZ());
|
||||
}
|
||||
|
||||
flecs::entity exteriorBuilding(const std::string &uid)
|
||||
{
|
||||
flecs::entity found;
|
||||
app->getWorld()->each(
|
||||
[&](flecs::entity e, CellGridComponent &g) {
|
||||
if (g.gridUid == uid &&
|
||||
g.generationMode == "exteriorOnly")
|
||||
found = e;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
flecs::entity interiorGrid(const std::string &uid)
|
||||
{
|
||||
flecs::entity found;
|
||||
app->getWorld()->each(
|
||||
[&](flecs::entity e, CellGridComponent &g) {
|
||||
if (g.gridUid == uid &&
|
||||
g.generationMode == "interiorOnly")
|
||||
found = e;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
static void cellBounds(const LayoutLib::BuildingPlan &p, int &minX,
|
||||
int &maxX, int &minZ, int &maxZ)
|
||||
{
|
||||
minX = maxX = minZ = maxZ = -1;
|
||||
for (int y = 0; y < p.floors; ++y) {
|
||||
for (int z = 0; z < p.cellsZ; ++z) {
|
||||
for (int x = 0; x < p.cellsX; ++x) {
|
||||
if (!(p.at(x, y, z) &
|
||||
LayoutLib::PlanFlags::Floor))
|
||||
continue;
|
||||
if (minX < 0) {
|
||||
minX = maxX = x;
|
||||
minZ = maxZ = z;
|
||||
} else {
|
||||
minX = std::min(minX, x);
|
||||
maxX = std::max(maxX, x);
|
||||
minZ = std::min(minZ, z);
|
||||
maxZ = std::max(maxZ, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void cellBounds(const CellGridComponent &g, int &minX,
|
||||
int &maxX, int &minZ, int &maxZ)
|
||||
{
|
||||
minX = maxX = minZ = maxZ = -1;
|
||||
for (const Cell &c : g.cells) {
|
||||
if (!(c.flags & CellFlags::Floor))
|
||||
continue;
|
||||
if (minX < 0) {
|
||||
minX = maxX = c.x;
|
||||
minZ = maxZ = c.z;
|
||||
} else {
|
||||
minX = std::min(minX, c.x);
|
||||
maxX = std::max(maxX, c.x);
|
||||
minZ = std::min(minZ, c.z);
|
||||
maxZ = std::max(maxZ, c.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void collectCases()
|
||||
{
|
||||
cases.clear();
|
||||
bool haveMulti = false, haveExterior = false, haveBack = false;
|
||||
app->getWorld()->each(
|
||||
[&](flecs::entity e, CellGridComponent &g) {
|
||||
if (g.generationMode != "exteriorOnly" ||
|
||||
g.gridUid.rfind("town-", 0) != 0 ||
|
||||
g.cells.empty())
|
||||
return;
|
||||
std::string first, second;
|
||||
for (const auto &dc : g.doorConfigs) {
|
||||
if (dc.second.sceneSwitchPath
|
||||
.rfind("generated:", 0) != 0)
|
||||
continue;
|
||||
if (first.empty())
|
||||
first = dc.first;
|
||||
else if (second.empty() &&
|
||||
dc.first != first)
|
||||
second = dc.first;
|
||||
}
|
||||
if (first.empty())
|
||||
return;
|
||||
const bool multi = g.height > 1;
|
||||
bool exteriorStair = false;
|
||||
for (const Cell &c : g.cells)
|
||||
if (c.flags & CellFlags::ExteriorStair)
|
||||
exteriorStair = true;
|
||||
const bool back = !second.empty();
|
||||
const bool categorized =
|
||||
(multi && !haveMulti) ||
|
||||
(exteriorStair && !haveExterior) ||
|
||||
(back && !haveBack);
|
||||
if (!categorized && cases.size() >= 3)
|
||||
return;
|
||||
TestCase tc;
|
||||
tc.uid = g.gridUid;
|
||||
tc.scheme = "generated:" + g.gridUid;
|
||||
tc.doorA = first;
|
||||
tc.doorB = second.empty() ? first : second;
|
||||
tc.floors = g.height;
|
||||
tc.exteriorStair = exteriorStair;
|
||||
/* Front-door approach point (world space):
|
||||
* the shell spans [0, w*cs] from the node,
|
||||
* front face at local +Z (TownGenSystem
|
||||
* convention). Used to travel back into
|
||||
* streaming range before entering. */
|
||||
const auto &t = e.get<TransformComponent>();
|
||||
const Ogre::Vector3 rp =
|
||||
t.node ? t.node->_getDerivedPosition() :
|
||||
t.position;
|
||||
const Ogre::Quaternion rq =
|
||||
t.node ? t.node->_getDerivedOrientation() :
|
||||
t.rotation;
|
||||
const float cs = g.cellSize;
|
||||
const Ogre::Vector3 shellCenter(
|
||||
(g.width - 1) * cs * 0.5f, 0.0f,
|
||||
(g.depth - 1) * cs * 0.5f);
|
||||
const Ogre::Vector3 approach =
|
||||
rp + rq * shellCenter +
|
||||
rq * Ogre::Vector3(
|
||||
0.0f, 0.0f,
|
||||
g.depth * cs * 0.5f + 3.0f);
|
||||
JPH::DVec3 wp(approach.x, approach.y,
|
||||
approach.z);
|
||||
if (RenderOriginSystem *ro =
|
||||
RenderOriginSystem::getInstance())
|
||||
wp = ro->renderToWorld(approach);
|
||||
tc.entryPos = Ogre::Vector3((float)wp.GetX(),
|
||||
(float)wp.GetY(),
|
||||
(float)wp.GetZ());
|
||||
cases.push_back(tc);
|
||||
if (multi)
|
||||
haveMulti = true;
|
||||
if (exteriorStair)
|
||||
haveExterior = true;
|
||||
if (back)
|
||||
haveBack = true;
|
||||
});
|
||||
}
|
||||
|
||||
bool enter(const TestCase &tc, const std::string &door)
|
||||
{
|
||||
secondEntry = (phase == 2);
|
||||
if (!secondEntry &&
|
||||
!exteriorBuilding(tc.uid).is_alive()) {
|
||||
/* The streamed town only keeps pages around the
|
||||
* camera loaded: travel back into this building's
|
||||
* range first (a player would have walked up to
|
||||
* the door), then wait for the page to stream
|
||||
* in. */
|
||||
if (!travelIssued) {
|
||||
SceneSwitchOptions travel;
|
||||
travel.hasPosition = true;
|
||||
travel.posX = tc.entryPos.x;
|
||||
travel.posY = tc.entryPos.y;
|
||||
travel.posZ = tc.entryPos.z;
|
||||
if (!app->switchScene(baseScene, travel))
|
||||
return fail("travel switch refused");
|
||||
travelIssued = true;
|
||||
wait = 0;
|
||||
return true;
|
||||
}
|
||||
if (wait % 300 == 0) {
|
||||
const Ogre::Vector3 pp = worldPos(
|
||||
app->getPlayerCharacterEntity());
|
||||
std::string grids;
|
||||
app->getWorld()->each(
|
||||
[&](flecs::entity, CellGridComponent &g) {
|
||||
if (g.generationMode ==
|
||||
"exteriorOnly")
|
||||
grids += " " + g.gridUid;
|
||||
});
|
||||
std::cout << "[test] exterior grids:" << grids
|
||||
<< std::endl;
|
||||
TerrainSystem *ts =
|
||||
TerrainSystem::getInstance();
|
||||
if (ts && ts->getTerrainGroup()) {
|
||||
int loaded = 0;
|
||||
for (const auto &kv :
|
||||
ts->getTerrainGroup()
|
||||
->getTerrainSlots())
|
||||
if (kv.second &&
|
||||
kv.second->instance &&
|
||||
kv.second->instance
|
||||
->isLoaded())
|
||||
++loaded;
|
||||
std::cout << "[test] terrain slots "
|
||||
<< loaded << " streaming "
|
||||
<< ts->getStreamingActive()
|
||||
<< std::endl;
|
||||
}
|
||||
std::cout << "[test] interiors: travel wait "
|
||||
<< wait << " player "
|
||||
<< pp.x << "," << pp.y << "," << pp.z
|
||||
<< " target " << tc.entryPos.x << ","
|
||||
<< tc.entryPos.y << "," << tc.entryPos.z
|
||||
<< " pending "
|
||||
<< app->hasPendingSceneSwitch()
|
||||
<< std::endl;
|
||||
}
|
||||
if (++wait > 12000)
|
||||
return fail("building did not stream in "
|
||||
"for entry: " + tc.uid);
|
||||
return true;
|
||||
}
|
||||
if (!secondEntry) {
|
||||
/* Resolve once from the exterior (the building
|
||||
* entity is present here) and remember the
|
||||
* authoritative cached document. */
|
||||
GeneratedInteriorService *svc =
|
||||
app->getGeneratedInteriorService();
|
||||
nlohmann::json doc;
|
||||
if (!svc ||
|
||||
!svc->resolveGeneratedInterior(tc.scheme,
|
||||
baseScene, doc))
|
||||
return fail("could not resolve the interior "
|
||||
"document");
|
||||
TestCase &mut = cases[current];
|
||||
mut.firstDoc = doc;
|
||||
mut.firstHash =
|
||||
doc.value("interior", nlohmann::json())
|
||||
.value("planHash", (uint64_t)0);
|
||||
mut.firstCacheSize = svc->cache().size();
|
||||
}
|
||||
travelIssued = false;
|
||||
SceneSwitchOptions opts;
|
||||
opts.targetEntityName = "arrival:" + door;
|
||||
if (!app->switchScene(tc.scheme, opts))
|
||||
return fail("entry refused for " + tc.scheme);
|
||||
wait = 0;
|
||||
phase = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkInside(const TestCase &tc, const std::string &door)
|
||||
{
|
||||
flecs::entity interior = interiorGrid(tc.uid);
|
||||
if (!interior.is_alive())
|
||||
return true; /* still loading */
|
||||
const CellGridComponent &g = interior.get<CellGridComponent>();
|
||||
const LayoutLib::BuildingPlan *plan =
|
||||
TownGenSystem::findBuildingPlan(tc.uid);
|
||||
if (!plan)
|
||||
return fail("interior plan missing for " + tc.uid);
|
||||
if ((int)g.height != tc.floors)
|
||||
return fail("interior floors differ for " + tc.uid);
|
||||
int pMinX, pMaxX, pMinZ, pMaxZ;
|
||||
int gMinX, gMaxX, gMinZ, gMaxZ;
|
||||
cellBounds(*plan, pMinX, pMaxX, pMinZ, pMaxZ);
|
||||
cellBounds(g, gMinX, gMaxX, gMinZ, gMaxZ);
|
||||
if (pMinX != gMinX || pMaxX != gMaxX || pMinZ != gMinZ ||
|
||||
pMaxZ != gMaxZ)
|
||||
return fail("interior footprint differs from plan " +
|
||||
tc.uid);
|
||||
if (tc.floors > 1) {
|
||||
int stairs = 0;
|
||||
for (const Cell &c : g.cells)
|
||||
if (c.flags & CellFlags::StairWell)
|
||||
++stairs;
|
||||
if (stairs == 0)
|
||||
return fail("multi-storey interior has no "
|
||||
"stairs for " + tc.uid);
|
||||
}
|
||||
flecs::entity marker;
|
||||
app->getWorld()->each(
|
||||
[&](flecs::entity e, EntityNameComponent &n) {
|
||||
if (n.name == "arrival:" + door)
|
||||
marker = e;
|
||||
});
|
||||
if (!marker.is_alive() ||
|
||||
!marker.has<TransformComponent>())
|
||||
return fail("arrival marker missing for " + door);
|
||||
const auto &mt = marker.get<TransformComponent>();
|
||||
const Ogre::Vector3 mp =
|
||||
mt.node ? mt.node->_getDerivedPosition() : mt.position;
|
||||
const float half = g.cellSize * 0.5f;
|
||||
if (mp.x < gMinX * g.cellSize - half - 0.3f ||
|
||||
mp.x > gMaxX * g.cellSize + half + 0.3f ||
|
||||
mp.z < gMinZ * g.cellSize - half - 0.3f ||
|
||||
mp.z > gMaxZ * g.cellSize + half + 0.3f ||
|
||||
mp.y < -0.5f || mp.y > g.height * g.cellHeight + 1.0f)
|
||||
return fail("arrival marker is outside the interior " +
|
||||
door);
|
||||
for (const auto &dc : g.doorConfigs) {
|
||||
if (dc.second.sceneSwitchPath.rfind("generated:", 0) == 0)
|
||||
continue;
|
||||
if (!dc.second.hasSceneSwitchPose ||
|
||||
dc.second.sceneSwitchPath != baseScene)
|
||||
return fail("exit door is not pre-wired to the "
|
||||
"source scene for " + tc.uid);
|
||||
}
|
||||
|
||||
InteriorNpcSystem *ins = app->getInteriorNpcSystem();
|
||||
if (!ins)
|
||||
return fail("no InteriorNpcSystem");
|
||||
const size_t eligible = ins->eligibleCount();
|
||||
const size_t spawned = ins->spawnedCount();
|
||||
if (spawned != std::min<size_t>(eligible, 12))
|
||||
return fail("inside NPC count " +
|
||||
std::to_string(spawned) + " != eligible " +
|
||||
std::to_string(eligible));
|
||||
for (flecs::entity_t id : ins->spawned()) {
|
||||
flecs::entity npc = app->getWorld()->entity(id);
|
||||
if (!npc.is_alive() ||
|
||||
!npc.has<TransformComponent>())
|
||||
return fail("inside NPC entity missing");
|
||||
const auto &t = npc.get<TransformComponent>();
|
||||
const Ogre::Vector3 p =
|
||||
t.node ? t.node->_getDerivedPosition() :
|
||||
t.position;
|
||||
bool anchored = false;
|
||||
interior.children([&](flecs::entity c) {
|
||||
if (!c.has<RoomComponent>())
|
||||
return true;
|
||||
const auto &r = c.get<RoomComponent>();
|
||||
if (r.roomType == "stairwell")
|
||||
return true;
|
||||
if (p.x >= r.minX * g.cellSize - 0.8f &&
|
||||
p.x <= r.maxX * g.cellSize + 0.8f &&
|
||||
p.z >= r.minZ * g.cellSize - 0.8f &&
|
||||
p.z <= r.maxZ * g.cellSize + 0.8f &&
|
||||
p.y >= r.minY * g.cellHeight - 0.3f &&
|
||||
p.y <= r.maxY * g.cellHeight + 0.3f)
|
||||
anchored = true;
|
||||
return true;
|
||||
});
|
||||
if (!anchored)
|
||||
return fail("inside NPC is not at a room "
|
||||
"anchor for " + tc.uid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkCachedDocument(const TestCase &tc, bool first)
|
||||
{
|
||||
GeneratedInteriorService *svc =
|
||||
app->getGeneratedInteriorService();
|
||||
if (!svc)
|
||||
return fail("no GeneratedInteriorService");
|
||||
nlohmann::json doc;
|
||||
if (!svc->resolveGeneratedInterior(tc.scheme, baseScene, doc))
|
||||
return fail("could not resolve the interior document");
|
||||
const uint64_t hash =
|
||||
doc.value("interior", nlohmann::json())
|
||||
.value("planHash", (uint64_t)0);
|
||||
(void)first;
|
||||
if (doc != tc.firstDoc || hash != tc.firstHash)
|
||||
return fail("second visit did not return the cached "
|
||||
"document for " + tc.uid);
|
||||
if (svc->cache().size() != tc.firstCacheSize)
|
||||
return fail("cache grew on the second visit for " +
|
||||
tc.uid);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool exitToExterior(const TestCase &tc, const std::string &door)
|
||||
{
|
||||
flecs::entity interior = interiorGrid(tc.uid);
|
||||
if (!interior.is_alive())
|
||||
return fail("interior vanished before exit");
|
||||
const auto &g = interior.get<CellGridComponent>();
|
||||
auto it = g.doorConfigs.find(door);
|
||||
if (it == g.doorConfigs.end() ||
|
||||
!it->second.hasSceneSwitchPose)
|
||||
return fail("no exit pose for " + door);
|
||||
expectedExit = Ogre::Vector3(it->second.sceneSwitchPosX,
|
||||
it->second.sceneSwitchPosY,
|
||||
it->second.sceneSwitchPosZ);
|
||||
SceneSwitchOptions back;
|
||||
back.hasPosition = true;
|
||||
back.posX = expectedExit.x;
|
||||
back.posY = expectedExit.y;
|
||||
back.posZ = expectedExit.z;
|
||||
back.hasRotation = true;
|
||||
back.rotation = Ogre::Quaternion(
|
||||
Ogre::Degree(it->second.sceneSwitchYawDeg),
|
||||
Ogre::Vector3::UNIT_Y);
|
||||
if (!app->switchScene(baseScene, back))
|
||||
return fail("exit switch refused");
|
||||
wait = 0;
|
||||
phase = 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkExteriorReturn()
|
||||
{
|
||||
if (app->isInsideGeneratedInterior() ||
|
||||
app->hasPendingSceneSwitch())
|
||||
return true;
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive() ||
|
||||
!player.has<TransformComponent>())
|
||||
return true;
|
||||
const Ogre::Vector3 p = worldPos(player);
|
||||
if ((p - expectedExit).length() > 1.2f)
|
||||
return fail("player did not land at the approach pose " +
|
||||
Ogre::StringConverter::toString(p) +
|
||||
" want " +
|
||||
Ogre::StringConverter::toString(
|
||||
expectedExit));
|
||||
if (app->getCurrentBaseScene() != baseScene)
|
||||
return fail("base scene changed on exit");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool frameRenderingQueued(const Ogre::FrameEvent &) override
|
||||
{
|
||||
++frame;
|
||||
if (failed || phase == 99) {
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
if (frame > deadline)
|
||||
return fail("timeout in phase " +
|
||||
std::to_string(phase));
|
||||
if (!clockSet) {
|
||||
if (GameClockSystem *clock = GameClockSystem::getInstance()) {
|
||||
clock->setPaused(true);
|
||||
clock->setTime(12, 12.0f);
|
||||
}
|
||||
clockSet = true;
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 0: {
|
||||
collectCases();
|
||||
bool multi = false, ext = false, back = false;
|
||||
for (const auto &tc : cases) {
|
||||
if (tc.floors > 1)
|
||||
multi = true;
|
||||
if (tc.exteriorStair)
|
||||
ext = true;
|
||||
if (tc.doorA != tc.doorB)
|
||||
back = true;
|
||||
}
|
||||
if (cases.size() < 3 || !multi) {
|
||||
if (wait % 300 == 0) {
|
||||
int grids = 0, exteriorGrids = 0, doors = 0;
|
||||
app->getWorld()->each(
|
||||
[&](flecs::entity, CellGridComponent &g) {
|
||||
++grids;
|
||||
if (g.generationMode == "exteriorOnly") {
|
||||
++exteriorGrids;
|
||||
doors += (int)g.doorConfigs.size();
|
||||
}
|
||||
});
|
||||
std::cout << "[test] interiors: waiting, "
|
||||
<< cases.size() << " cases (multi "
|
||||
<< multi << ", ext " << ext
|
||||
<< ", back " << back << ") grids "
|
||||
<< grids << " exterior "
|
||||
<< exteriorGrids << " doors " << doors
|
||||
<< " scene " << app->getCurrentBaseScene()
|
||||
<< std::endl;
|
||||
}
|
||||
if (++wait > 12000)
|
||||
return fail("not enough enterable "
|
||||
"buildings appeared");
|
||||
return true;
|
||||
}
|
||||
if (!ext || !back)
|
||||
std::cout << "[test] interiors: optional case "
|
||||
"missing (exteriorStair="
|
||||
<< ext << ", backDoor=" << back
|
||||
<< ")" << std::endl;
|
||||
baseScene = app->getCurrentBaseScene();
|
||||
std::cout << "[test] interiors: " << cases.size()
|
||||
<< " buildings selected" << std::endl;
|
||||
current = 0;
|
||||
wait = 0;
|
||||
phase = 7;
|
||||
return true;
|
||||
}
|
||||
case 7: {
|
||||
if (app->hasPendingSceneSwitch())
|
||||
return true;
|
||||
return enter(cases[current], cases[current].doorA);
|
||||
}
|
||||
case 1: {
|
||||
if (!app->isInsideGeneratedInterior())
|
||||
return true;
|
||||
flecs::entity interior = interiorGrid(cases[current].uid);
|
||||
if (!interior.is_alive())
|
||||
return true;
|
||||
if (!checkInside(cases[current], cases[current].doorA))
|
||||
return true;
|
||||
if (failed)
|
||||
return true;
|
||||
if (current == 0 && !secondEntry) {
|
||||
/* Save/load inside: record state, then reload. */
|
||||
savedPlayerPos = worldPos(
|
||||
app->getPlayerCharacterEntity());
|
||||
if (NpcPopulationSystem *pop =
|
||||
NpcPopulationSystem::getInstance())
|
||||
savedPopulation =
|
||||
pop->populationCharacterIds()
|
||||
.size();
|
||||
if (GameClockSystem *clock =
|
||||
GameClockSystem::getInstance())
|
||||
savedHours =
|
||||
clock->getAbsoluteHours();
|
||||
app->saveGame(savePath, "interiors-acceptance");
|
||||
app->loadGame(savePath);
|
||||
wait = 0;
|
||||
phase = 3;
|
||||
return true;
|
||||
}
|
||||
return exitToExterior(cases[current],
|
||||
cases[current].doorA);
|
||||
}
|
||||
case 2: {
|
||||
if (app->isInsideGeneratedInterior() ||
|
||||
app->hasPendingSceneSwitch())
|
||||
return true;
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive() ||
|
||||
!player.has<TransformComponent>())
|
||||
return true;
|
||||
if (!checkExteriorReturn())
|
||||
return true;
|
||||
if (failed)
|
||||
return true;
|
||||
if (secondEntry) {
|
||||
phase = 5;
|
||||
return true;
|
||||
}
|
||||
/* The exit switch reloads the exterior; the town
|
||||
* streams the building's page in around the camera.
|
||||
* Wait for it before resolving. */
|
||||
if (!exteriorBuilding(cases[current].uid).is_alive()) {
|
||||
if (++wait > 6000)
|
||||
return fail("exterior building did not "
|
||||
"stream back in");
|
||||
return true;
|
||||
}
|
||||
wait = 0;
|
||||
/* Second visit: bit-identical cached document, and a
|
||||
* second (back) door when the layout has one. */
|
||||
if (!checkCachedDocument(cases[current], false))
|
||||
return true;
|
||||
if (failed)
|
||||
return true;
|
||||
return enter(cases[current], cases[current].doorB);
|
||||
}
|
||||
case 3: {
|
||||
/* Deferred load-game interior re-entry. */
|
||||
if (!app->isInsideGeneratedInterior())
|
||||
return true;
|
||||
InteriorNpcSystem *ins = app->getInteriorNpcSystem();
|
||||
if (ins && ins->needsRetry()) {
|
||||
if (++wait > 600)
|
||||
return fail("interior NPC records were "
|
||||
"not rebuilt after load");
|
||||
return true;
|
||||
}
|
||||
if (!checkInside(cases[0], cases[0].doorA))
|
||||
return true;
|
||||
const Ogre::Vector3 p = worldPos(
|
||||
app->getPlayerCharacterEntity());
|
||||
if ((p - savedPlayerPos).length() > 0.8f)
|
||||
return fail("load did not resume at the saved "
|
||||
"pose");
|
||||
if (NpcPopulationSystem *pop =
|
||||
NpcPopulationSystem::getInstance()) {
|
||||
if (pop->populationCharacterIds().size() !=
|
||||
savedPopulation)
|
||||
return fail("population was not restored "
|
||||
"on load");
|
||||
}
|
||||
if (GameClockSystem *clock =
|
||||
GameClockSystem::getInstance()) {
|
||||
if (std::fabs(clock->getAbsoluteHours() -
|
||||
savedHours) > 0.05f)
|
||||
return fail("clock was not restored on "
|
||||
"load");
|
||||
}
|
||||
if (app->getCurrentBaseScene() != baseScene)
|
||||
return fail("base scene changed on load");
|
||||
/* The exterior entities are gone while inside, so
|
||||
* resolve against the plan registry directly: the
|
||||
* reloaded town must have regenerated the building
|
||||
* bit-identically (same plan hash). */
|
||||
const LayoutLib::BuildingPlan *plan =
|
||||
TownGenSystem::findBuildingPlan(cases[0].uid);
|
||||
if (!plan)
|
||||
return fail("interior plan missing after load");
|
||||
const std::string &uid = cases[0].uid;
|
||||
const std::string addr =
|
||||
uid.rfind("town-", 0) == 0 ? uid.substr(5) : uid;
|
||||
const uint64_t hash = InteriorScene::planHash(*plan, addr);
|
||||
if (hash != cases[0].firstHash)
|
||||
return fail("exterior regenerated differently on "
|
||||
"load");
|
||||
return exitToExterior(cases[0], cases[0].doorA);
|
||||
}
|
||||
case 4: {
|
||||
/* Not used (kept for phase numbering). */
|
||||
phase = 5;
|
||||
return true;
|
||||
}
|
||||
case 5: {
|
||||
/* All three buildings visited twice. */
|
||||
++current;
|
||||
wait = 0;
|
||||
if (current < cases.size()) {
|
||||
phase = 7;
|
||||
return true;
|
||||
}
|
||||
phase = 6;
|
||||
return true;
|
||||
}
|
||||
case 6: {
|
||||
/* Plan edit: the cache key includes the plan hash, so
|
||||
* the document must change (the same path a lot-param
|
||||
* regeneration takes). */
|
||||
/* Use the last visited building: its exterior entity
|
||||
* is still streamed in (we just exited at its door),
|
||||
* while cases[0]'s page may already be unloaded. */
|
||||
const TestCase &tc = cases.back();
|
||||
if (!exteriorBuilding(tc.uid).is_alive()) {
|
||||
if (++wait > 6000)
|
||||
return fail("exterior building did not "
|
||||
"stream back in");
|
||||
return true;
|
||||
}
|
||||
wait = 0;
|
||||
const LayoutLib::BuildingPlan *old =
|
||||
TownGenSystem::findBuildingPlan(tc.uid);
|
||||
if (!old)
|
||||
return fail("plan missing for cache invalidation");
|
||||
LayoutLib::BuildingPlan edited = *old;
|
||||
bool toggled = false;
|
||||
for (int y = 0; y < edited.floors && !toggled; ++y)
|
||||
for (int z = 0; z < edited.cellsZ && !toggled;
|
||||
++z)
|
||||
for (int x = 0; x < edited.cellsX;
|
||||
++x) {
|
||||
if (!(edited.at(x, y, z) &
|
||||
LayoutLib::PlanFlags::
|
||||
Floor))
|
||||
continue;
|
||||
edited.at(x, y, z) ^=
|
||||
LayoutLib::PlanFlags::
|
||||
Ceiling;
|
||||
toggled = true;
|
||||
break;
|
||||
}
|
||||
if (!toggled)
|
||||
return fail("plan has no floor cell to edit");
|
||||
TownGenSystem::registerBuildingPlan(tc.uid, edited);
|
||||
GeneratedInteriorService *svc =
|
||||
app->getGeneratedInteriorService();
|
||||
nlohmann::json doc;
|
||||
if (!svc ||
|
||||
!svc->resolveGeneratedInterior(tc.scheme, baseScene,
|
||||
doc))
|
||||
return fail("could not resolve the edited plan");
|
||||
const uint64_t hash =
|
||||
doc.value("interior", nlohmann::json())
|
||||
.value("planHash", (uint64_t)0);
|
||||
if (hash == tc.firstHash || doc == tc.firstDoc)
|
||||
return fail("plan edit did not invalidate the "
|
||||
"interior cache");
|
||||
std::cout << "[test] interiors: cache invalidation ok"
|
||||
<< std::endl;
|
||||
phase = 99;
|
||||
std::cout << "[test] PASS: 3 interiors entered/exited, "
|
||||
"cached docs stable, save/load inside, "
|
||||
"cache invalidation"
|
||||
<< std::endl;
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* --test-stolen-car (phase 8.2 follow-up): carjacked NPC car ownership.
|
||||
*
|
||||
@@ -14498,6 +15260,7 @@ int main(int argc, char *argv[])
|
||||
bool testFurniture = false;
|
||||
bool testStairs = false;
|
||||
bool testFall = false;
|
||||
bool testInteriors = false;
|
||||
bool testInterior = false;
|
||||
bool testStolenCar = false;
|
||||
bool testTownInterior = false;
|
||||
@@ -14591,6 +15354,8 @@ int main(int argc, char *argv[])
|
||||
testStairs = true;
|
||||
} else if (arg == "--test-fall") {
|
||||
testFall = true;
|
||||
} else if (arg == "--test-interiors") {
|
||||
testInteriors = true;
|
||||
} else if (arg == "--test-interior") {
|
||||
testInterior = true;
|
||||
} else if (arg == "--test-stolen-car") {
|
||||
@@ -14724,6 +15489,9 @@ int main(int argc, char *argv[])
|
||||
/* The real-town interior probe needs the streamed town. */
|
||||
if (testTownInterior && !sceneArgGiven)
|
||||
sceneFile = "demo_scene_exterior.json";
|
||||
/* The phase 8.5 acceptance battery needs it too. */
|
||||
if (testInteriors && !sceneArgGiven)
|
||||
sceneFile = "demo_scene_exterior.json";
|
||||
/* The detail-tier test too. */
|
||||
/* The detail-tier test too. */
|
||||
if (testDetail && !sceneArgGiven)
|
||||
@@ -14973,6 +15741,11 @@ int main(int argc, char *argv[])
|
||||
if (testFall)
|
||||
app.getRoot()->addFrameListener(&fallListener);
|
||||
|
||||
/* Phase 8.5: generated-interior acceptance battery. */
|
||||
InteriorsAcceptanceListener interiorsListener(&app);
|
||||
if (testInteriors)
|
||||
app.getRoot()->addFrameListener(&interiorsListener);
|
||||
|
||||
/* Phase 8.2: in-memory generated interior switching. */
|
||||
InteriorTestListener interiorListener(&app, sceneFile);
|
||||
if (testInterior)
|
||||
|
||||
@@ -803,8 +803,12 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
|
||||
return flecs::entity::null();
|
||||
|
||||
CharacterRecord *c = findCharacter(id);
|
||||
if (!c)
|
||||
if (!c) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"CharacterRegistry: spawnCharacter " + std::to_string(id) +
|
||||
" failed: record missing");
|
||||
return flecs::entity::null();
|
||||
}
|
||||
|
||||
/* Singleton: despawn any existing copy first */
|
||||
flecs::entity existing = findSpawnedEntity(id);
|
||||
@@ -832,6 +836,10 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
|
||||
inst = prefabSys.createInstance(
|
||||
c->prefabPath, flecs::entity::null(), pos,
|
||||
c->firstName + " " + c->lastName, m_uiSystem);
|
||||
if (!inst.is_alive())
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"CharacterRegistry: prefab spawn failed for " +
|
||||
c->prefabPath);
|
||||
if (inst.is_alive() && inst.has<TransformComponent>()) {
|
||||
auto &t = inst.get_mut<TransformComponent>();
|
||||
t.position = c->position;
|
||||
|
||||
@@ -58,7 +58,13 @@ GeneratedInteriorService::findBuilding(const std::string &gridUid) const
|
||||
{
|
||||
flecs::entity found;
|
||||
m_world.each([&](flecs::entity e, CellGridComponent &grid) {
|
||||
if (!found.is_alive() && grid.gridUid == gridUid)
|
||||
/* Only the EXTERIOR shell supplies the entrance poses and
|
||||
* the world transform; while inside, the interior grid
|
||||
* carries the same uid and must not be mistaken for it
|
||||
* (it would produce a document with origin-space exit
|
||||
* poses). */
|
||||
if (!found.is_alive() && grid.gridUid == gridUid &&
|
||||
grid.generationMode == "exteriorOnly")
|
||||
found = e;
|
||||
});
|
||||
return found;
|
||||
|
||||
@@ -105,6 +105,7 @@ int InteriorNpcSystem::populate(flecs::entity interiorGrid)
|
||||
if (eligibleInBuilding(pop, motion, id, address))
|
||||
eligible.push_back(id);
|
||||
m_eligible = eligible.size();
|
||||
m_missing = 0;
|
||||
if (eligible.empty()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"InteriorNpcSystem: no scheduled NPC is inside '" +
|
||||
@@ -119,11 +120,24 @@ int InteriorNpcSystem::populate(flecs::entity interiorGrid)
|
||||
for (size_t i = 0; i < eligible.size() && spawned < (int)kMaxInteriorNpcs;
|
||||
++i) {
|
||||
const uint64_t id = eligible[i];
|
||||
if (registry.findSpawnedEntity(id).is_alive())
|
||||
continue; /* already materialized */
|
||||
flecs::entity npc = registry.spawnCharacter(id);
|
||||
if (!npc.is_alive())
|
||||
if (!registry.findCharacter(id)) {
|
||||
/* The population system rebuilds its registry records a
|
||||
* frame after a save/load (applyLoadedSaveState clears the
|
||||
* registry). Defer this NPC to the retry pass rather than
|
||||
* treating the interior as empty. */
|
||||
++m_missing;
|
||||
continue;
|
||||
}
|
||||
/* spawnCharacter() replaces any already-materialized copy
|
||||
* (persistent save spawns and previous interior visits alike),
|
||||
* so the count and the interior tag always match. */
|
||||
flecs::entity npc = registry.spawnCharacter(id);
|
||||
if (!npc.is_alive()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"InteriorNpcSystem: spawn failed for " +
|
||||
std::to_string(id));
|
||||
continue;
|
||||
}
|
||||
|
||||
const RoomAnchor &anchor = anchors[i % anchors.size()];
|
||||
/* Spread roommates in a 3x3 pattern inside the centre cell
|
||||
|
||||
@@ -43,6 +43,17 @@ public:
|
||||
{
|
||||
return m_eligible;
|
||||
}
|
||||
/** Eligible ids whose registry record did not exist yet (the
|
||||
* population system rebuilds those records a frame later after a
|
||||
* save/load; EditorApp retries populate then). */
|
||||
size_t missingRecords() const
|
||||
{
|
||||
return m_missing;
|
||||
}
|
||||
bool needsRetry() const
|
||||
{
|
||||
return m_missing > 0;
|
||||
}
|
||||
const std::vector<flecs::entity_t> &spawned() const
|
||||
{
|
||||
return m_spawned;
|
||||
@@ -53,4 +64,5 @@ private:
|
||||
EditorApp *m_app;
|
||||
std::vector<flecs::entity_t> m_spawned;
|
||||
size_t m_eligible = 0;
|
||||
size_t m_missing = 0;
|
||||
};
|
||||
|
||||
@@ -934,7 +934,34 @@ void TownGenSystem::update()
|
||||
if (terrain.is_alive() && terrain.has<TerrainComponent>())
|
||||
graph = &terrain.get<TerrainComponent>().roadGraph;
|
||||
|
||||
{
|
||||
static int s_dbgFrames = 0;
|
||||
if ((!graph || graph->townAreas.empty()) &&
|
||||
s_dbgFrames < 40) {
|
||||
++s_dbgFrames;
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TownGenSystem: early-out active=" +
|
||||
std::to_string((int)active) + " rs=" +
|
||||
std::to_string((int)(rs != nullptr)) +
|
||||
" terrainAlive=" +
|
||||
std::to_string((int)terrain.is_alive()) +
|
||||
" graph=" +
|
||||
std::to_string((int)(graph != nullptr)) +
|
||||
" areas=" +
|
||||
(graph ? std::to_string(
|
||||
graph->townAreas.size()) :
|
||||
std::string("-")) +
|
||||
" nodes=" +
|
||||
(graph ? std::to_string(graph->nodes.size()) :
|
||||
std::string("-")));
|
||||
}
|
||||
}
|
||||
|
||||
if (!graph || graph->townAreas.empty()) {
|
||||
if (m_streamTerrainId != 0)
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TownGenSystem: no graph/town areas; "
|
||||
"dropping streamed town");
|
||||
clearStreamedPages();
|
||||
m_streamTerrainId = 0;
|
||||
return;
|
||||
@@ -943,6 +970,11 @@ void TownGenSystem::update()
|
||||
/* Scene switches rebuild the terrain entity; the containers of
|
||||
* the old scene died with it, so restart the bookkeeping. */
|
||||
if (m_streamTerrainId != terrain.id()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TownGenSystem: terrain rebuilt; restarting "
|
||||
"streamed-town bookkeeping (" +
|
||||
std::to_string(graph->townAreas.size()) +
|
||||
" area(s))");
|
||||
clearStreamedPages();
|
||||
m_streamTerrainId = terrain.id();
|
||||
}
|
||||
@@ -1034,6 +1066,11 @@ void TownGenSystem::buildStreamedPageArea(const RoadGraph &graph,
|
||||
if (result.buildings.empty())
|
||||
return;
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TownGenSystem[" + area.name + " p" + std::to_string(px) +
|
||||
"_" + std::to_string(py) + "]: generating " +
|
||||
std::to_string(result.buildings.size()) + " building(s)");
|
||||
|
||||
const TownGen::DistrictParams &dp =
|
||||
TownGen::districtParams(p.district);
|
||||
flecs::entity container = createTownContainer(
|
||||
|
||||
Reference in New Issue
Block a user