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.
This commit is contained in:
zwitschi committed 2026-11-01 19:03:20 +01:00
1 parent 99f390078d
commit 4cca2af2dd
4 files changed
+198 -62

No files matched your search

+159 -58
View File
@@ -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: