From 4cca2af2dd6fbf9dcd6b16bc0f9fa2493725d6d5 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 1 Nov 2026 19:03:20 +0100 Subject: [PATCH] Extract per-venue geometry specs into WorldBuilder Add the Hollywood, Joke Shop and TV Studio tables for facade, window, stair, street, nav, guard-spawn and feature data. Unknown ids fall back to the boulevard. Use the specs for the core dimensions and gates, with a headless test for the distinct venue data. Headless 230/230. --- MEMORY.md | 1 + TODO.md | 9 +- src/game/world_builder.gd | 217 +++++++++++++++++++++++++---------- src/test/tests/test_world.gd | 33 ++++++ 4 files changed, 198 insertions(+), 62 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 6a5275c..6ff9aab 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -246,6 +246,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o helmet is lifted 2 cm above the brow. `test_npc_character` guards head/back gear in Godot. Blender's GLB export is byte-nondeterministic for unchanged assets (padding/index order), so do not commit a full no-op rebuild; rebuild only the assets whose geometry changed. +- **Venue geometry (v0.6.1):** `WorldBuilder.VENUE_SPECS` is the per-venue table; `WorldBuilder.new(root, id)` / `set_venue()` select it, unknown ids fall back to `hollywood_building`. Dimensions, window/pilaster rows, stairs, street, nav bounds, guard spawn and feature flags are read from the spec. Joke Shop and TV Studio currently use empty `prop_placements`; bespoke alley/studio dressing and the `GameManager` rebuild-on-venue-switch are follow-ups. - **Portrait capture:** `capture_realism.gd` faces the **Rig** toward the camera (using the inverse look direction) and hides the blue `FocusCircle`; the old code used the wrong yaw and left the focus disc over the face. An explicit `--portrait-type=N` now overrides the hooded diff --git a/TODO.md b/TODO.md index 571c15a..e084551 100644 --- a/TODO.md +++ b/TODO.md @@ -9,10 +9,11 @@ Today all three venues share the Hollywood `WorldBuilder` geometry, so the Joke concepts are only compared on lighting (noted in `tools/scene_concepts.py` `SHOTS`). They also have no stage in the committed `docs/screenshots/` gallery, only in `capture_realism.gd`. -- [ ] Extract a geometry spec per venue instead of branching inline: a `scenario.` (or - `WorldBuilder`) table of facade width/height, door style, window rows, awning/marquee - flags, street furniture set, palette and props, defaulting to the Hollywood values so - nothing regresses. +- [x] Extract a geometry spec per venue instead of branching inline: `WorldBuilder.VENUE_SPECS` + now holds facade/stair/street/nav dimensions, window/pilaster rows, guard spawn, feature + flags and prop placements for Hollywood, Joke Shop and TV Studio. Unknown ids fall back to + Hollywood; `test_venue_geometry_specs_are_distinct_and_defaulted` checks the narrow Joke Shop + facade, disabled skyline/marquee, studio bulb-canopy flag and fallback. - [ ] Joke Shop: narrow brick alley (10–12 m wide), one warm wall lantern as the key light, recessed door with a step, shuttered window, dumpster/AC clutter, no skyline ring — the alley concept has depth, not a horizon. diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 015ae91..d81204a 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -59,18 +59,99 @@ var library_nodes: Array[Node3D] = [] ## Total triangle budget for the Blender library pieces placed in the world. const LIBRARY_TRIANGLE_BUDGET := 60000 -func _init(root_node: Node3D) -> void: +## Per-venue geometry data. The Hollywood values are the fallback: every field +## here defaults to the long-standing boulevard layout, so adding a venue cannot +## silently regress the original scene. Joke Shop and TV Studio override the +## facade/street measurements and feature flags; their bespoke dressing lands in +## the following tasks. +const VENUE_SPECS := { + "hollywood_building": { + "display_name": "Hollywood Boulevard Building", + "facade_width": 40.0, "facade_height": 18.0, "facade_depth": 4.0, + "facade_center_z": -6.0, + "window_columns": [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0], + "pilaster_columns": [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3], + "window_rows": [9.2, 12.4], + "stair_width": 8.0, "stair_count": 4, "stair_tread": 0.6, "stair_height": 0.16, + "sidewalk_width": 30.0, "sidewalk_depth": 8.0, + "street_width": 120.0, "street_depth": 26.0, + "nav_min": Vector2(-18.0, -0.6), "nav_max": Vector2(18.0, 20.0), + "guard_spawn": Vector3(0.0, 0.1, -2.0), + "features": { + "marquee": true, "skyline": true, "handrail": true, "sedan": true, + "wall_lanterns": true, "palms": true, "street_trees": true, + "crosswalk": true, "bulb_canopy": false, + }, + }, + "joke_shop": { + "display_name": "The Joke Shop", + "facade_width": 12.0, "facade_height": 12.0, "facade_depth": 5.0, + "facade_center_z": -6.5, + "window_columns": [-3.8, 3.8], + "pilaster_columns": [-5.2, 5.2], + "window_rows": [8.2], + "stair_width": 5.0, "stair_count": 2, "stair_tread": 0.65, "stair_height": 0.16, + "sidewalk_width": 22.0, "sidewalk_depth": 6.0, + "street_width": 75.0, "street_depth": 18.0, + "nav_min": Vector2(-9.0, -0.6), "nav_max": Vector2(9.0, 14.0), + "guard_spawn": Vector3(0.0, 0.1, -1.6), + "features": { + "marquee": false, "skyline": false, "handrail": false, "sedan": false, + "wall_lanterns": true, "palms": false, "street_trees": false, + "crosswalk": false, "bulb_canopy": false, + }, + "prop_placements": [], + }, + "tv_studio": { + "display_name": "Late Night TV Studio", + "facade_width": 30.0, "facade_height": 15.0, "facade_depth": 4.0, + "facade_center_z": -6.0, + "window_columns": [-11.0, -7.2, 7.2, 11.0], + "pilaster_columns": [-13.2, -9.0, -5.0, 5.0, 9.0, 13.2], + "window_rows": [10.5], + "stair_width": 7.0, "stair_count": 3, "stair_tread": 0.6, "stair_height": 0.16, + "sidewalk_width": 28.0, "sidewalk_depth": 7.0, + "street_width": 100.0, "street_depth": 24.0, + "nav_min": Vector2(-14.0, -0.6), "nav_max": Vector2(14.0, 18.0), + "guard_spawn": Vector3(0.0, 0.1, -1.8), + "features": { + "marquee": false, "skyline": true, "handrail": true, "sedan": true, + "wall_lanterns": false, "palms": false, "street_trees": true, + "crosswalk": true, "bulb_canopy": true, + }, + "prop_placements": [], + }, +} +const DEFAULT_VENUE := "hollywood_building" + +var venue_id := DEFAULT_VENUE +var spec: Dictionary = VENUE_SPECS[DEFAULT_VENUE] + +func _init(root_node: Node3D, id: String = DEFAULT_VENUE) -> void: root = root_node + set_venue(id) + +## Select the geometry spec before `build()`. Unknown ids fall back to the +## boulevard so old callers and tests keep working. +func set_venue(id: String) -> void: + venue_id = id if VENUE_SPECS.has(id) else DEFAULT_VENUE + spec = VENUE_SPECS[venue_id] + +func _feature(name: String) -> bool: + return bool((spec.get("features", {}) as Dictionary).get(name, false)) func build() -> void: _build_ground() _build_building() - _build_skyline_backdrop() - _build_marquee() + if _feature("skyline"): + _build_skyline_backdrop() + if _feature("marquee"): + _build_marquee() _build_steps() _build_sidewalk() _build_street() - _build_crosswalk() + if _feature("crosswalk"): + _build_crosswalk() _build_street_furniture() _build_street_detail() _build_library_dressing() @@ -111,41 +192,46 @@ func _build_ground() -> void: func _build_building() -> void: var building := StaticBody3D.new() building.name = "Building" + var w := float(spec["facade_width"]) + var h := float(spec["facade_height"]) + var d := float(spec["facade_depth"]) + var cz := float(spec["facade_center_z"]) + var front := cz + d * 0.5 # Main facade (textured brick) - _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.86, 0.85, 0.83), false, T_BRICK, Vector2(22, 10), "brick") + _add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), Color(0.86, 0.85, 0.83), false, T_BRICK, Vector2(w * 0.55, h * 0.55), "brick") # Stone base course so the facade has a footing instead of meeting the steps dead-on. - _add_box(building, Vector3(40, 0.8, 0.5), Vector3(0, 0.4, -3.78), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone") + _add_box(building, Vector3(w, 0.8, 0.5), Vector3(0, 0.4, front + 0.22), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone") # Facade depth, not a flat wall: a projecting plinth, a string course between # the window bands, a cornice under the roofline and pilasters at the bays. - _add_box(building, Vector3(40, 0.34, 0.22), Vector3(0, 0.93, -3.9), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") - _add_box(building, Vector3(40, 0.28, 0.28), Vector3(0, 7.6, -3.86), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") - _add_box(building, Vector3(40, 0.5, 0.5), Vector3(0, 17.7, -3.75), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone") - _add_box(building, Vector3(40, 0.22, 0.2), Vector3(0, 10.9, -3.9), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone") - for bay in [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]: - _add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(bay, 12.6, -3.9), + _add_box(building, Vector3(w, 0.34, 0.22), Vector3(0, 0.93, front + 0.1), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") + _add_box(building, Vector3(w, 0.28, 0.28), Vector3(0, 7.6, front + 0.14), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") + _add_box(building, Vector3(w, 0.5, 0.5), Vector3(0, h - 0.3, front + 0.25), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone") + _add_box(building, Vector3(w, 0.22, 0.2), Vector3(0, 10.9, front + 0.1), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone") + for bay in spec.get("pilaster_columns", [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]): + _add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(float(bay), 12.6, front + 0.1), Color(0.8, 0.78, 0.76), true, T_BRICK, Vector2(0.3, 4.7), "brick") # Entrance recess + door recess frame - _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone") + _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, front + 0.1), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone") # Door frame columns and lintel, so the entrance is framed rather than painted on. for side in [-1.55, 1.55]: - _add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") - _add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") + _add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") + _add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") # Door frame and dark door - _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal") + _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, front + 0.02), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal") # Door glass (semi-transparent) - var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55)) + var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, front + 0.1), Color(0.6, 0.75, 0.9, 0.55)) glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55)) # Door handles: two slim metal bars where the guard reaches for the lock. for side in [-0.42, 0.42]: - _add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal") + _add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, front + 0.21), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal") # Door lock indicator (green = open, red = locked), placed beside the handle - door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) + door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, front + 0.18), Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) # Blender glazed double door in front of the recessed frame. var door_model := _load_prop_scene("double_door") if door_model != null: door_model.name = "LibraryDoubleDoor" - door_model.position = Vector3(0, 0.0, -3.84) + door_model.position = Vector3(0, 0.0, front + 0.16) building.add_child(door_model) library_nodes.append(door_model) _apply_library_ao(door_model, "double_door") @@ -154,18 +240,21 @@ func _build_building() -> void: _lamp_bulb_mats.append(material) _build_facade_windows(building) var col := BoxShape3D.new() - col.size = Vector3(40, 18, 4) + col.size = Vector3(w, h, d) var so := building.create_shape_owner(building) building.shape_owner_add_shape(so, col) - building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6))) + building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, h * 0.5, cz))) root.add_child(building) ## Regular window bands above the entrance. Panes are dark glass by day and ## light up after dark, which gives the facade depth at night. func _build_facade_windows(parent: Node3D) -> void: - var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0] - for y in [9.2, 12.4]: - for x in columns: + var columns: Array = spec.get("window_columns", [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0]) + var rows: Array = spec.get("window_rows", [9.2, 12.4]) + for value in rows: + var y := float(value) + for x_value in columns: + var x := float(x_value) # Recessed reveal, then the pane: gives the window a dark frame edge. _add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99), Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone") @@ -188,12 +277,13 @@ func _build_steps() -> void: # The ground and building provide the actual collision boundaries. var steps := StaticBody3D.new() steps.name = "Steps" - var step_w := 8.0 - var tread := 0.6 - var height := 0.16 - for i in 4: + var step_w := float(spec.get("stair_width", 8.0)) + var tread := float(spec.get("stair_tread", 0.6)) + var height := float(spec.get("stair_height", 0.16)) + var count := int(spec.get("stair_count", 4)) + for i in count: var y: float = i * height - var z: float = 4.0 * tread - i * tread + var z: float = float(count) * tread - i * tread var w: float = step_w - i * 0.35 _add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) # Add collision shape to each tread @@ -203,10 +293,11 @@ func _build_steps() -> void: steps.shape_owner_add_shape(owner_id, tread_col) steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z))) # Landing at the top with collision - var landing_y: float = 4 * height + 0.1 - _add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45)) + var landing_y: float = count * height + 0.1 + var landing_w := step_w + 1.0 + _add_box(steps, Vector3(landing_w, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45)) var landing_col := BoxShape3D.new() - landing_col.size = Vector3(9, 0.2, 3) + landing_col.size = Vector3(landing_w, 0.2, 3) var landing_owner := steps.create_shape_owner(steps) steps.shape_owner_add_shape(landing_owner, landing_col) steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5))) @@ -215,12 +306,14 @@ func _build_steps() -> void: func _build_sidewalk() -> void: var sidewalk := StaticBody3D.new() sidewalk.name = "Sidewalk" - _add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(9, 2.4), "concrete") + var w := float(spec.get("sidewalk_width", 30.0)) + var d := float(spec.get("sidewalk_depth", 8.0)) + _add_box(sidewalk, Vector3(w, 0.18, d), Vector3(0, 0.09, d * 0.5), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(w * 0.3, d * 0.3), "concrete") var col := BoxShape3D.new() - col.size = Vector3(30, 0.36, 8) + col.size = Vector3(w, 0.36, d) var so := sidewalk.create_shape_owner(sidewalk) sidewalk.shape_owner_add_shape(so, col) - sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8))) + sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, d * 0.5))) root.add_child(sidewalk) func _build_street() -> void: @@ -230,20 +323,24 @@ func _build_street() -> void: # white looked like more pavement. # The street keeps its own material so night mode can switch to the wet # asphalt map (glossier, with lamp reflections) and back. - _street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7)) - var street_mesh := _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), - Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(30, 7), "asphalt") + var street_w := float(spec.get("street_width", 120.0)) + var street_d := float(spec.get("street_depth", 26.0)) + var curb_z := float(spec.get("sidewalk_depth", 8.0)) + 4.1 + var street_z := curb_z + street_d * 0.5 - 3.1 + _street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(street_w * 0.25, street_d * 0.27)) + var street_mesh := _add_box(street, Vector3(street_w, 0.1, street_d), Vector3(0, 0.05, street_z), + Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(street_w * 0.25, street_d * 0.27), "asphalt") street_mesh.material_override = _street_mat # Center dashes along the street (x varied so they form a lane line, not a stack). - for i in range(-40, 41, 4): - _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete") + for i in range(-int(street_w / 3.0), int(street_w / 3.0) + 1, 4): + _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, street_z), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete") # Curb between the sidewalk and the roadway. - _add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete") + _add_box(street, Vector3(street_w, 0.22, 0.5), Vector3(0, 0.11, curb_z), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete") var col := BoxShape3D.new() - col.size = Vector3(120, 0.2, 26) + col.size = Vector3(street_w, 0.2, street_d) var so := street.create_shape_owner(street) street.shape_owner_add_shape(so, col) - street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22))) + street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, street_z))) root.add_child(street) func _build_navmesh() -> void: @@ -256,9 +353,11 @@ func _build_navmesh() -> void: mesh.agent_max_climb = 0.8 mesh.agent_max_slope = 45.0 mesh.cell_size = 0.25 + var nav_min: Vector2 = spec.get("nav_min", Vector2(-18.0, -0.6)) + var nav_max: Vector2 = spec.get("nav_max", Vector2(18.0, 20.0)) var verts := PackedVector3Array([ - Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6), - Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0), + Vector3(nav_min.x, 0.0, nav_min.y), Vector3(nav_max.x, 0.0, nav_min.y), + Vector3(nav_max.x, 0.0, nav_max.y), Vector3(nav_min.x, 0.0, nav_max.y), ]) mesh.set_vertices(verts) mesh.add_polygon(PackedInt32Array([0, 1, 2, 3])) @@ -377,7 +476,7 @@ func _build_marquee() -> void: ## a parked sedan, and wall lanterns. Kept separate so the placement validator ## only sees grounded sidewalk furniture. func _build_library_dressing() -> void: - if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"): + if _feature("handrail") and ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"): var rail := _load_prop_scene("handrail") if rail != null: rail.name = "LibraryHandrail" @@ -386,7 +485,7 @@ func _build_library_dressing() -> void: root.add_child(rail) library_nodes.append(rail) _apply_library_ao(rail, "handrail") - if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): + if _feature("sedan") and ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0) sedan.name = "ParkedSedan" if not library_nodes.has(sedan): @@ -394,7 +493,7 @@ func _build_library_dressing() -> void: for material in _emissive_materials(sedan): if not _lamp_bulb_mats.has(material): _lamp_bulb_mats.append(material) - if ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"): + if _feature("wall_lanterns") and ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"): for side in [-4.9, 4.9]: var lantern := _load_prop_scene("wall_lantern") if lantern == null: @@ -440,12 +539,13 @@ func _build_street_furniture() -> void: _collect_emissive(holder) # Blender library street trees replace the cone trees. Flank the walkway and # keep the sight line to the door clear. - for i in 6: - var side := -1.0 if i % 2 == 0 else 1.0 - var x := side * (6.5 + float(int(i / 2)) * 3.0) - var z := 4.0 + float((i * 7) % 5) - _add_prop_model(root, "street_tree", Vector3(x, 0.18, z), - float((i * 37) % 360)) + if _feature("street_trees"): + for i in 6: + var side := -1.0 if i % 2 == 0 else 1.0 + var x := side * (6.5 + float(int(i / 2)) * 3.0) + var z := 4.0 + float((i * 7) % 5) + _add_prop_model(root, "street_tree", Vector3(x, 0.18, z), + float((i * 37) % 360)) ## Total triangles across the placed Blender library pieces, for the draw budget ## test. Counts indices when present, otherwise the vertex stream. @@ -585,8 +685,9 @@ func _build_street_detail() -> void: var detail := Node3D.new() detail.name = "StreetDetail" root.add_child(detail) - for index in PROP_PLACEMENTS.size(): - var entry: Dictionary = PROP_PLACEMENTS[index] + var placements: Array = spec.get("prop_placements", PROP_PLACEMENTS) + for index in placements.size(): + var entry: Dictionary = placements[index] var base_rot := float(entry.get("rot", 0.0)) var variant := variant_for(index, base_rot) var holder := _add_prop_model(detail, str(entry["prop"]), @@ -821,7 +922,7 @@ func _build_guard_spawn() -> void: # The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground # But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet # Then the head will be at y=1.7 (1.6 offset + 0.1 base) - marker.position = Vector3(0, 0.1, -2.0) + marker.position = spec.get("guard_spawn", Vector3(0, 0.1, -2.0)) root.add_child(marker) func _lock_indicator_material(color: Color) -> StandardMaterial3D: diff --git a/src/test/tests/test_world.gd b/src/test/tests/test_world.gd index 8a782ad..70b8b4d 100644 --- a/src/test/tests/test_world.gd +++ b/src/test/tests/test_world.gd @@ -19,6 +19,39 @@ func test_navmesh_built() -> bool: root.free() return a and b +func test_venue_geometry_specs_are_distinct_and_defaulted() -> bool: + var hollywood := WorldBuilder.new(Node3D.new()) + var joke := WorldBuilder.new(Node3D.new(), "joke_shop") + var studio := WorldBuilder.new(Node3D.new(), "tv_studio") + var unknown := WorldBuilder.new(Node3D.new(), "not_a_venue") + var a := TestUtils.check(hollywood.venue_id == "hollywood_building", + "the default venue is Hollywood") + var b := TestUtils.check(float(hollywood.spec["facade_width"]) > float(joke.spec["facade_width"]), + "the Joke Shop alley is narrower than the boulevard") + var c := TestUtils.check(not bool(joke.spec["features"]["skyline"]) + and not bool(joke.spec["features"]["marquee"]), + "the Joke Shop disables the skyline and marquee") + var d := TestUtils.check(bool(studio.spec["features"]["bulb_canopy"]) + and not bool(studio.spec["features"]["marquee"]), + "the studio uses a bulb canopy instead of the marquee") + var e := TestUtils.check(unknown.venue_id == "hollywood_building", + "an unknown venue falls back to Hollywood") + var root := Node3D.new() + var builder := WorldBuilder.new(root, "joke_shop") + builder.build() + var body := root.find_child("Building", true, false) + var facade := body.get_child(0) as MeshInstance3D if body != null else null + var f := TestUtils.check(facade != null and (facade.mesh as BoxMesh).size.x < 20.0, + "the Joke Shop builds the narrow facade") + var g := TestUtils.check(root.find_child("SkylineBackdrop", true, false) == null, + "the Joke Shop builds no skyline ring") + hollywood.root.free() + joke.root.free() + studio.root.free() + unknown.root.free() + root.free() + return a and b and c and d and e and f and g + func test_phase_lighting_config_covered() -> bool: var ok := true for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]: