Files
security-game/src/game/world_builder.gd
T
zwitschi a51dae2c33
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped
White the Joke Shop canopy signature band to match the reference
2026-11-03 20:07:01 +01:00

2394 lines
110 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
const Materials := preload("res://src/game/materials.gd")
const Rules := preload("res://src/core/rules.gd")
const PICK_DIR := "res://assets/textures/ai/game_ready/v2/picks/"
const T_BRICK := preload(PICK_DIR + "brick_facade.png")
const T_CONCRETE := preload(PICK_DIR + "sidewalk_pavers.png")
const T_ASPHALT := preload(PICK_DIR + "asphalt.png")
const T_AWNING := preload(PICK_DIR + "awning_canvas.png")
const T_LIMESTONE := preload(PICK_DIR + "stone_steps.png")
const T_ASHLAR := preload(PICK_DIR + "limestone_ashlar.png")
const T_STUCCO := preload(PICK_DIR + "dark_stucco.png")
## One skyline panorama per shift phase (realism pass): hazy daylight, warm dusk
## with lit windows, and a night city. The old single strip was a black silhouette.
const T_SKYLINE_DAY := preload(PICK_DIR + "skyline_day.png")
const T_SKYLINE_DUSK := preload(PICK_DIR + "skyline_dusk.png")
const T_SKYLINE_NIGHT := preload(PICK_DIR + "skyline_night.png")
## Height of each backdrop plane (metres); the bases sit on the ground plane.
const SKYLINE_HEIGHT := 90.0
const SKYLINE_BY_PHASE := {
Rules.Phase.EARLY: {"texture": T_SKYLINE_DAY, "tint": Color(0.98, 1.0, 1.04),
"haze": Color(0.62, 0.71, 0.82), "haze_strength": 0.36},
Rules.Phase.MIDDAY: {"texture": T_SKYLINE_DAY, "tint": Color(1.02, 1.04, 1.08),
"haze": Color(0.66, 0.75, 0.85), "haze_strength": 0.34},
Rules.Phase.EVENING: {"texture": T_SKYLINE_DUSK, "tint": Color(0.9, 0.92, 1.0),
"haze": Color(0.55, 0.43, 0.40), "haze_strength": 0.30},
Rules.Phase.LATE: {"texture": T_SKYLINE_NIGHT, "tint": Color(1.5, 1.55, 1.65),
"haze": Color(0.07, 0.09, 0.14), "haze_strength": 0.22},
}
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## Surfaces use the shared PBR profiles in `src/game/materials.gd`; street
## furniture comes from the generated GLB props (tools/model_pipeline.py) with a
## procedural fallback so a headless checkout without imports still builds.
## Generated prop models, one `.glb` per street-furniture piece.
const MODEL_DIR := "res://assets/models/generated/"
const BLENDER_PROP_DIR := "res://assets/models/blender/props/"
const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"]
## Library props loaded in place of the generated set, plus new street dressing.
const LIBRARY_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant", "street_lamp", "street_tree", "traffic_cone",
"palm_tree", "round_planter", "a_frame_sign", "crowd_barrier", "stanchion",
"security_podium"]
## Library pieces that are mounted above head height, part of the building shell or
## purely decorative, so they intentionally publish no walk-blocking body. Anything
## else in `library_nodes` must carry a `StaticBody3D` + `NavigationObstacle3D`
## (guarded by `test_library_dressing_collision_matches_the_documented_exemptions`).
const NON_BLOCKING_LIBRARY := ["marquee", "wall_lantern", "handrail", "double_door"]
var root: Node3D = null
## Door lock indicator mesh near the handle; toggled by GameManager.
var door_indicator: MeshInstance3D = null
var _door_indicator_mat: StandardMaterial3D = null
## Street lamps and signage so night mode can switch them on.
var _street_lamps: Array[MeshInstance3D] = []
var _lamp_omni: Array[OmniLight3D] = []
## Low-energy practical washes on the hero facade; kept separate from the street
## lamps so a close omni does not blow out the limestone at night.
var _accent_omni: Array[OmniLight3D] = []
var _ovation_sign: Label3D = null
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
var marquee_label: Label3D = null
var _marquee_emissive: Array[StandardMaterial3D] = []
## Facade windows that light up after dark.
var _window_mats: Array[StandardMaterial3D] = []
## Names of the generated props that actually loaded (procedural fallbacks are
## not listed), so tests and the debug overlay can report real model usage.
var loaded_models: Array[String] = []
var fallback_props: Array[String] = []
## Props that came from the Blender library rather than the generated set.
var library_props: Array[String] = []
var library_nodes: Array[Node3D] = []
## Total triangle budget for the Blender library pieces placed in the world.
const LIBRARY_TRIANGLE_BUDGET := 60000
## 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 — El Capitan Entertainment Centre",
## The real 6840 Hollywood Blvd building: a 29.6 m wide, 14.5 m high
## Neoclassical temple facade with six Ionic columns, eight stone steps,
## a recessed central entrance and two street-level side doors.
"architecture": "masonic",
"facade_width": 29.6, "facade_height": 14.5, "facade_depth": 8.0,
"facade_center_z": -8.0, "facade_texture": T_ASHLAR,
# Photo target: warm cream limestone, mid value ~0.78 (the old tint was
# a flat cool 0.88 that read as plaster in the day capture).
"facade_tint": Color(0.85, 0.81, 0.74), "facade_role": "stone",
"window_columns": [], "window_rows": [],
"stair_width": 12.0, "stair_count": 8, "stair_tread": 0.38, "stair_height": 0.10,
"landing_depth": 4.0, "landing_center_z": -1.8,
"sidewalk_width": 34.0, "sidewalk_depth": 12.0,
"street_width": 120.0, "street_depth": 26.0, "street_front_z": 12.0,
"nav_min": Vector2(-18.0, -0.6), "nav_max": Vector2(18.0, 20.0),
"guard_spawn": Vector3(0.0, 1.0, -2.0),
"features": {
"marquee": false, "skyline": true, "handrail": false, "sedan": false,
"wall_lanterns": true, "palms": true, "street_trees": false,
"street_lamps": true, "crosswalk": true, "bulb_canopy": false,
"masonic_facade": true, "walk_of_fame": true, "neighbours": true,
"opposite_block": true, "hollywood_lane_markings": true,
},
"street_lamp_positions": [
Vector3(-13.2, 0.18, 10.9), Vector3(13.2, 0.18, 10.9),
],
"prop_placements": [
{"zone": "entrance", "prop": "bollard", "x": -7.2, "z": 5.5},
{"zone": "entrance", "prop": "bollard", "x": -8.6, "z": 5.5},
{"zone": "entrance", "prop": "bollard", "x": 7.2, "z": 5.5},
{"zone": "entrance", "prop": "bollard", "x": 8.6, "z": 5.5},
{"zone": "entrance", "prop": "trash_bin", "x": -9.8, "z": 5.8, "accessory": "bag"},
{"zone": "entrance", "prop": "trash_bin", "x": 9.8, "z": 5.8},
{"zone": "mid_block", "prop": "bench", "x": -10.9, "z": 8.1, "rot": 180.0},
{"zone": "mid_block", "prop": "bench", "x": 10.9, "z": 8.1, "rot": 180.0},
{"zone": "mid_block", "prop": "planter_box", "x": -12.1, "z": 9.5},
{"zone": "mid_block", "prop": "planter_box", "x": 12.1, "z": 9.5},
{"zone": "curb", "prop": "newspaper_box", "x": -11.4, "z": 11.2, "rot": -12.0},
{"zone": "curb", "prop": "newspaper_box", "x": -10.7, "z": 11.2, "rot": -12.0, "accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 10.2, "z": 11.3, "accessory": "cone"},
{"zone": "mid_block", "prop": "palm_tree", "x": -13.2, "z": 9.7, "rot": 10.0},
{"zone": "mid_block", "prop": "palm_tree", "x": 13.2, "z": 9.7, "rot": -10.0},
{"zone": "mid_block", "prop": "round_planter", "x": -12.8, "z": 7.0},
{"zone": "mid_block", "prop": "round_planter", "x": 12.8, "z": 7.0},
{"zone": "curb", "prop": "a_frame_sign", "x": -9.8, "z": 7.0, "rot": 15.0},
{"zone": "curb", "prop": "traffic_cone", "x": 9.6, "z": 10.0},
{"zone": "curb", "prop": "crowd_barrier", "x": 3.0, "z": 10.9},
{"zone": "curb", "prop": "stanchion", "x": -2.6, "z": 10.9},
{"zone": "entrance", "prop": "security_podium", "x": 8.0, "z": 6.1, "rot": -15.0},
],
},
"joke_shop": {
"display_name": "The Joke Shop",
## A fictional stand-in for The Comedy Store at 8433 Sunset Blvd. The
## playable frontage is 27 m: the real parcel's 47 m footprint includes
## the rear rooms and the hillside lot, which are not walkable here.
"architecture": "comedy_store",
"facade_width": 27.0, "facade_height": 8.4, "facade_depth": 12.0,
"facade_center_z": -8.0, "facade_texture": T_CONCRETE,
"facade_tint": Color(0.13, 0.13, 0.14), "facade_role": "concrete",
"window_columns": [], "window_rows": [], "pilaster_columns": [],
"stair_width": 4.4, "stair_count": 1, "stair_tread": 1.2, "stair_height": 0.14,
"sidewalk_width": 38.0, "sidewalk_depth": 14.0, "curb_z": 11.9,
"street_width": 90.0, "street_depth": 24.0, "street_front_z": 12.0,
"nav_min": Vector2(-15.0, -0.6), "nav_max": Vector2(15.0, 14.0),
"guard_spawn": Vector3(-4.8, 0.1, 1.4),
"sedan_position": Vector3(9.4, 0.1, 15.0), "sedan_rotation": 90.0,
"street_lamp_positions": [
Vector3(-13.4, 0.18, 10.8), Vector3(13.4, 0.18, 10.8),
],
"features": {
"marquee": false, "skyline": false, "handrail": false, "sedan": true,
"wall_lanterns": false, "palms": false, "street_trees": false,
"street_lamps": true, "crosswalk": true, "bulb_canopy": false,
"comedy_store": true, "sunset_hillside": true, "sunset_neighbours": true,
"sunset_across_street": true, "hollywood_lane_markings": true,
},
"prop_placements": [
{"zone": "entrance", "prop": "security_podium", "x": 6.8, "z": 5.8, "rot": -15.0},
{"zone": "entrance", "prop": "crowd_barrier", "x": 2.2, "z": 9.0},
{"zone": "entrance", "prop": "stanchion", "x": -3.0, "z": 9.4},
{"zone": "entrance", "prop": "stanchion", "x": 3.0, "z": 9.4},
{"zone": "mid_block", "prop": "palm_tree", "x": -13.0, "z": 8.0, "rot": 8.0},
{"zone": "mid_block", "prop": "palm_tree", "x": 13.0, "z": 8.0, "rot": -8.0},
{"zone": "mid_block", "prop": "trash_bin", "x": -11.5, "z": 7.2, "accessory": "bag"},
{"zone": "mid_block", "prop": "trash_bin", "x": 10.5, "z": 7.2},
{"zone": "mid_block", "prop": "planter_box", "x": -12.0, "z": 9.7},
{"zone": "mid_block", "prop": "round_planter", "x": 8.5, "z": 7.3},
{"zone": "curb", "prop": "a_frame_sign", "x": 5.0, "z": 7.8, "rot": 18.0},
{"zone": "curb", "prop": "newspaper_box", "x": 11.0, "z": 10.6, "rot": -12.0},
{"zone": "curb", "prop": "newspaper_box", "x": 11.7, "z": 10.6, "rot": -12.0},
{"zone": "curb", "prop": "fire_hydrant", "x": -13.2, "z": 10.9,
"accessory": "cone"},
],
},
"tv_studio": {
"display_name": "Late Night TV Studio",
## Same real building as the Hollywood shift (the Jimmy Kimmel Live!
## starting point), restaged as a late-night arrival: the six-column
## Masonic Temple facade with a bulb canopy, sedan and barrier line
## instead of the day-time marquee.
"architecture": "masonic",
"facade_width": 30.0, "facade_height": 14.5, "facade_depth": 4.0,
"facade_center_z": -6.0, "facade_texture": T_ASHLAR,
"facade_tint": Color(0.85, 0.81, 0.74), "facade_role": "stone",
"window_columns": [], "window_rows": [], "pilaster_columns": [],
"stair_width": 12.0, "stair_count": 8, "stair_tread": 0.38, "stair_height": 0.10,
"landing_depth": 4.0, "landing_center_z": -1.8,
"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, 1.0, -2.0),
"sedan_position": Vector3(5.4, 0.1, 8.8), "sedan_rotation": 90.0,
"features": {
"marquee": false, "skyline": true, "handrail": true, "sedan": true,
"wall_lanterns": false, "palms": false, "street_trees": true,
"street_lamps": true, "crosswalk": true, "bulb_canopy": true,
"masonic_facade": true,
},
"prop_placements": [
{"zone": "entrance", "prop": "stanchion", "x": -2.8, "z": 4.4},
{"zone": "entrance", "prop": "stanchion", "x": 2.8, "z": 4.4},
{"zone": "entrance", "prop": "crowd_barrier", "x": -5.2, "z": 4.2},
{"zone": "entrance", "prop": "crowd_barrier", "x": 5.2, "z": 4.2},
{"zone": "curb", "prop": "security_podium", "x": 4.6, "z": 5.9, "rot": -15.0},
],
},
}
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()
if _feature("sunset_hillside"):
_build_sunset_hillside()
if _feature("masonic_facade"):
_build_masonic_building()
elif _feature("comedy_store"):
_build_comedy_store_building()
else:
_build_building()
if _feature("skyline"):
_build_skyline_backdrop()
if _feature("marquee"):
_build_marquee()
if _feature("bulb_canopy"):
_build_bulb_canopy()
if not _feature("comedy_store"):
_build_steps()
_build_sidewalk()
if _feature("walk_of_fame"):
_build_walk_of_fame()
if _feature("neighbours"):
_build_neighbour_blocks()
if _feature("opposite_block"):
_build_opposite_block()
if _feature("sunset_neighbours"):
_build_sunset_neighbours()
if _feature("sunset_across_street"):
_build_sunset_across_street()
_build_street()
if _feature("hollywood_lane_markings"):
_build_hollywood_lane_markings()
if _feature("crosswalk"):
_build_crosswalk()
_build_street_furniture()
_build_street_detail()
_build_library_dressing()
if venue_id == "joke_shop" and not _feature("comedy_store"):
_build_alley_dressing()
_build_guard_spawn()
_build_navmesh()
set_night_mode(false)
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1),
role: String = "concrete") -> MeshInstance3D:
var mesh := BoxMesh.new()
mesh.size = size
var mat: StandardMaterial3D
if texture:
mat = Materials.textured(texture, color, uv_scale, role)
else:
mat = Materials.flat(color, role)
mesh.material = mat
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.position = pos
mi.cast_shadow = (1 if cast_shadow else 0) as GeometryInstance3D.ShadowCastingSetting
parent.add_child(mi)
return mi
func _build_ground() -> void:
var ground := StaticBody3D.new()
ground.name = "Ground"
# Large enough that the ground meets the distant skyline ring (no gap of sky
# under the buildings when looking down the boulevard).
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26), false, null, Vector2.ONE, "asphalt")
var col := BoxShape3D.new()
col.size = Vector3(500, 1, 500)
var shape_owner := ground.create_shape_owner(ground)
ground.shape_owner_add_shape(shape_owner, col)
root.add_child(ground)
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 (brick on the boulevard/alley, stone on the studio)
var facade_texture: Texture2D = spec.get("facade_texture", T_BRICK)
var facade_tint: Color = spec.get("facade_tint", Color(0.86, 0.85, 0.83))
var facade_role := str(spec.get("facade_role", "brick"))
_add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), facade_tint, false, facade_texture, Vector2(w * 0.55, h * 0.55), facade_role)
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
_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(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, facade_texture, Vector2(0.3, 4.7), facade_role)
# Entrance recess + door recess frame
_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, 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, 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, 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, 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, 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, front + 0.16)
building.add_child(door_model)
library_nodes.append(door_model)
door_model.set_meta("library_prop", "double_door")
_apply_library_ao(door_model, "double_door")
for material in _emissive_materials(door_model):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
_build_facade_windows(building)
var col := BoxShape3D.new()
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, h * 0.5, cz)))
root.add_child(building)
## The Joke Shop is a fictional stand-in for The Comedy Store on the Sunset
## Strip: a low black hall with a deep signature-covered canopy, a circular
## blade sign, red velvet curtains and a dry hillside of houses behind it.
## The real venue's plan is an irregular L across several parcels; the game
## keeps the 27 m street frontage and compresses the rear rooms.
func _build_comedy_store_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
var black := Color(0.105, 0.10, 0.105)
var black_deep := Color(0.045, 0.045, 0.05)
# The canopy carries the real club's white celebrity signatures over the black
# fascia; keep them near-white so they read as chalk/paint from the sidewalk.
var signature := Color(0.90, 0.88, 0.80)
var red := Color(0.42, 0.035, 0.045)
# Main hall. The black stucco is the real building's dominant surface: a flat
# matte charcoal tile now replaces the grey paver texture so the wall reads as
# dark plaster, not masonry. The 0.105 tint keeps the effective value near the
# reference's flat black while the fine grain stops it looking like glossy paint.
_add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), black,
false, T_STUCCO, Vector2(w * 0.42, h * 0.42), "concrete")
# Low plinth and a thin parapet cap so the roofline reads against the hill.
_add_box(building, Vector3(w, 0.30, 0.34), Vector3(0, 0.15, front + 0.05),
Color(0.07, 0.07, 0.075), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(w, 0.42, 0.48), Vector3(0, h + 0.10, front + 0.06),
Color(0.08, 0.08, 0.085), true, null, Vector2.ONE, "painted_metal")
# Roof plant behind the blade sign: the real hall has a taller rear volume.
_add_box(building, Vector3(12.0, 1.5, 4.6), Vector3(5.5, h + 0.75, cz - 2.6),
black, false, null, Vector2.ONE, "concrete")
# Deep sloping canopy over the whole frontage. The outer edge drops about a
# metre, as in the reference photographs.
var canopy := _add_box(building, Vector3(w - 0.6, 0.22, 4.9),
Vector3(0, 5.65, front + 2.35), black, true, T_CONCRETE,
Vector2(w * 0.35, 1.5), "concrete")
canopy.name = "SignatureCanopy"
canopy.rotation_degrees.x = 11.0
# Outer fascia: the signature band that faces the street.
_add_box(building, Vector3(w - 0.2, 0.92, 0.20),
Vector3(0, 4.98, front + 4.76), black_deep, true, null, Vector2.ONE, "concrete")
for side in [-1.0, 1.0]:
_add_box(building, Vector3(0.20, 0.92, 4.9),
Vector3(side * (w * 0.5 - 0.22), 4.98, front + 2.35),
black_deep, true, null, Vector2.ONE, "concrete")
# White signature scribbles on the fascia. Keeping them as a few texture-free
# labels reads as handwriting at gameplay distance and costs no extra draw
# calls compared with stamping dozens of decals.
var scribbles := "Richard Pryor Robin Williams George Carlin\n" \
+ "Dave Chappelle Bill Burr Ali Wong Marc Maron\n" \
+ "Joan Rivers Sam Kinison Andrew Dice Clay Mitzi Shore"
for side in [-1.0, 1.0]:
var names := Label3D.new()
names.name = "CanopySignatures_%s" % ("west" if side < 0.0 else "east")
names.text = scribbles
names.position = Vector3(side * 6.6, 4.98, front + 4.88)
names.font_size = 22
names.pixel_size = 0.0043
names.modulate = signature
names.outline_size = 0
names.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
building.add_child(names)
var wall_names := Label3D.new()
wall_names.name = "WallSignatures"
wall_names.text = "The Comedy Store family\nEvery name written in chalk\nand paint since 1972"
wall_names.position = Vector3(-3.8, 3.55, front + 0.18)
wall_names.font_size = 20
wall_names.pixel_size = 0.0042
wall_names.modulate = Color(0.42, 0.40, 0.38)
wall_names.outline_size = 0
wall_names.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
building.add_child(wall_names)
# Front-of-house name on the fascia, matching the concept's white lettering.
var fascia_name := Label3D.new()
fascia_name.name = "FasciaName"
fascia_name.text = "THE JOKE SHOP"
fascia_name.position = Vector3(0.0, 4.96, front + 4.90)
fascia_name.font_size = 64
fascia_name.pixel_size = 0.0042
fascia_name.modulate = Color(0.92, 0.90, 0.84)
fascia_name.outline_size = 0
building.add_child(fascia_name)
# Warm bulbs under the outer fascia. They are off in daylight and lit by
# `set_night_mode` through `_marquee_emissive`.
for index in 11:
var bulb := MeshInstance3D.new()
var bulb_mesh := SphereMesh.new()
bulb_mesh.radius = 0.075
bulb_mesh.height = 0.15
bulb_mesh.radial_segments = 8
bulb_mesh.rings = 4
bulb.mesh = bulb_mesh
bulb.position = Vector3(-11.0 + float(index) * 2.2, 5.48, front + 4.78)
var bulb_mat := Materials.emissive(Color(1.0, 0.83, 0.52), 0.2)
bulb.material_override = bulb_mat
_marquee_emissive.append(bulb_mat)
building.add_child(bulb)
# Recessed entrance at the west end, with a low step and a red velvet door
# curtain. The real door sits under the same deep canopy, not in the centre.
var door_x := -8.8
_add_box(building, Vector3(4.2, 3.8, 0.18), Vector3(door_x, 1.9, front + 0.06),
black_deep, false, null, Vector2.ONE, "concrete")
_add_box(building, Vector3(2.45, 3.25, 0.12), Vector3(door_x, 1.63, front + 0.18),
red, true, null, Vector2.ONE, "cloth")
_add_box(building, Vector3(2.62, 0.16, 0.18), Vector3(door_x, 2.35, front + 0.26),
Color(0.24, 0.02, 0.03), false, null, Vector2.ONE, "cloth")
for side in [-1.0, 1.0]:
_add_box(building, Vector3(0.16, 3.5, 0.30),
Vector3(door_x + side * 1.32, 1.75, front + 0.14),
Color(0.19, 0.18, 0.17), true, null, Vector2.ONE, "painted_metal")
_add_box(building, Vector3(3.0, 0.20, 0.32), Vector3(door_x, 3.55, front + 0.14),
Color(0.19, 0.18, 0.17), true, null, Vector2.ONE, "painted_metal")
for side in [-0.42, 0.42]:
_add_box(building, Vector3(0.06, 0.44, 0.06),
Vector3(door_x + side, 1.45, front + 0.29),
Color(0.75, 0.70, 0.55), false, null, Vector2.ONE, "metal")
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24),
Vector3(door_x + 1.85, 1.70, front + 0.22), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Step and collision in front of the curtain.
var step := _add_box(building, Vector3(4.4, 0.14, 1.25),
Vector3(door_x, 0.07, front + 0.92), Color(0.24, 0.23, 0.21),
true, T_CONCRETE, Vector2(2.0, 0.6), "concrete")
step.name = "EntranceStep"
var step_body := StaticBody3D.new()
step_body.name = "EntranceStepCollision"
var step_shape := BoxShape3D.new()
step_shape.size = Vector3(4.4, 0.14, 1.25)
var step_owner := step_body.create_shape_owner(step_body)
step_body.shape_owner_add_shape(step_owner, step_shape)
step_body.shape_owner_set_transform(step_owner,
Transform3D(Basis(), Vector3(door_x, 0.07, front + 0.92)))
root.add_child(step_body)
# Red curtains hang between the entrance and the signature wall. Three
# narrow folds and a tieback keep them from reading as flat red slabs.
for x in [-3.2, 1.9, 7.0, 11.2]:
for fold in [-0.27, 0.0, 0.27]:
var fold_color := red.lightened(0.10 if fold > 0.0 else 0.0)
_add_box(building, Vector3(0.24, 3.10, 0.18),
Vector3(x + fold, 2.70, front + 4.56 + absf(fold) * 0.08),
fold_color, true, null, Vector2.ONE, "cloth")
_add_box(building, Vector3(0.96, 0.16, 0.22),
Vector3(x, 2.13, front + 4.60), Color(0.24, 0.02, 0.03),
false, null, Vector2.ONE, "cloth")
_add_box(building, Vector3(1.06, 0.52, 0.20),
Vector3(x, 1.34, front + 4.54), Color(0.34, 0.025, 0.035),
false, null, Vector2.ONE, "cloth")
# Small warm sconces along the black wall. Their bulbs are emissive and each
# gets a low-energy practical so the facade is not a black hole after dark.
for x in [-12.4, -6.2, 3.0, 9.4]:
_add_box(building, Vector3(0.34, 0.62, 0.20), Vector3(x, 3.55, front + 0.18),
Color(0.17, 0.16, 0.14), false, null, Vector2.ONE, "painted_metal")
var sconce := _add_box(building, Vector3(0.22, 0.34, 0.14),
Vector3(x, 3.55, front + 0.30), Color(1.0, 0.82, 0.52),
false, null, Vector2.ONE, "metal")
var sconce_mat := Materials.emissive(Color(1.0, 0.78, 0.45), 0.2)
sconce.material_override = sconce_mat
_marquee_emissive.append(sconce_mat)
var sconce_light := OmniLight3D.new()
sconce_light.name = "JokeSconce_%d" % int(x)
sconce_light.position = Vector3(x, 3.35, front + 0.75)
sconce_light.omni_range = 5.0
sconce_light.omni_attenuation = 1.0
sconce_light.light_energy = 0.0
sconce_light.light_color = Color(1.0, 0.73, 0.42)
building.add_child(sconce_light)
_accent_omni.append(sconce_light)
# Small open sign above the entrance.
var open_sign := Label3D.new()
open_sign.name = "EntranceCopy"
open_sign.text = "OPEN FOR COMEDY\nFOOD AND DRINKS"
open_sign.position = Vector3(door_x, 4.15, front + 0.22)
open_sign.font_size = 28
open_sign.pixel_size = 0.0035
open_sign.modulate = Color(0.82, 0.80, 0.72)
open_sign.outline_size = 0
open_sign.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
building.add_child(open_sign)
# Circular blade sign on the roof. It is deliberately the same silhouette as
# the concept art: a white disc, black rim and black three-line wordmark.
var sign_x := -10.8
# The pole stands on the roof just behind its front edge; the disc projects
# forward but is not floating over the canopy.
var sign_z := front + 0.35
var pole := MeshInstance3D.new()
var pole_mesh := CylinderMesh.new()
pole_mesh.top_radius = 0.10
pole_mesh.bottom_radius = 0.13
pole_mesh.height = 2.7
pole_mesh.radial_segments = 12
pole.mesh = pole_mesh
pole.position = Vector3(sign_x, h + 1.2, sign_z)
pole.material_override = Materials.flat(Color(0.12, 0.12, 0.13), "metal")
building.add_child(pole)
var rim := MeshInstance3D.new()
var rim_mesh := CylinderMesh.new()
rim_mesh.top_radius = 2.12
rim_mesh.bottom_radius = 2.12
rim_mesh.height = 0.16
rim_mesh.radial_segments = 48
rim.mesh = rim_mesh
rim.position = Vector3(sign_x, h + 2.55, sign_z - 0.03)
rim.rotation_degrees.x = 90.0
rim.material_override = Materials.flat(Color(0.06, 0.06, 0.07), "painted_metal")
building.add_child(rim)
var disc := MeshInstance3D.new()
var disc_mesh := CylinderMesh.new()
disc_mesh.top_radius = 1.96
disc_mesh.bottom_radius = 1.96
disc_mesh.height = 0.10
disc_mesh.radial_segments = 48
disc.mesh = disc_mesh
disc.position = Vector3(sign_x, h + 2.55, sign_z + 0.05)
disc.rotation_degrees.x = 90.0
# Slight self-illumination so the blade reads as a lit sign after dark; the
# emission is low in daylight and switched with the other marquee materials.
var disc_mat := Materials.emissive(Color(0.92, 0.90, 0.83), 0.22)
disc.material_override = disc_mat
building.add_child(disc)
var sign_label := Label3D.new()
sign_label.name = "ComedySignLabel"
sign_label.text = "THE\nJOKE\nSHOP"
sign_label.position = Vector3(sign_x, h + 2.55, sign_z + 0.12)
sign_label.font_size = 150
sign_label.pixel_size = 0.0048
sign_label.modulate = Color(0.06, 0.06, 0.07)
sign_label.outline_size = 0
sign_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
sign_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
building.add_child(sign_label)
# Night practicals: one light on the blade sign, three under the canopy and
# one at the entrance. Their energies are set in `set_night_mode`.
for data in [
["JokeSign", Vector3(sign_x, h + 2.5, sign_z + 1.2), 9.0, Color(1.0, 0.94, 0.78)],
["JokeCanopy_west", Vector3(-7.5, 4.55, front + 2.6), 8.0, Color(1.0, 0.78, 0.48)],
["JokeCanopy_mid", Vector3(0.0, 4.55, front + 2.6), 8.0, Color(1.0, 0.78, 0.48)],
["JokeCanopy_east", Vector3(7.5, 4.55, front + 2.6), 8.0, Color(1.0, 0.78, 0.48)],
["JokeEntrance", Vector3(door_x, 3.35, front + 1.1), 7.0, Color(1.0, 0.72, 0.42)],
["JokeGround_west", Vector3(-7.0, 0.7, front + 6.0), 10.0, Color(1.0, 0.68, 0.38)],
["JokeGround_mid", Vector3(0.0, 0.7, front + 6.0), 10.0, Color(1.0, 0.68, 0.38)],
["JokeGround_east", Vector3(7.0, 0.7, front + 6.0), 10.0, Color(1.0, 0.68, 0.38)],
]:
var omni := OmniLight3D.new()
omni.name = str(data[0])
omni.position = data[1]
omni.omni_range = float(data[2])
omni.omni_attenuation = 1.0
omni.light_energy = 0.0
omni.light_color = data[3]
building.add_child(omni)
_accent_omni.append(omni)
# Solid collision volume for the hall.
var col := BoxShape3D.new()
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, h * 0.5, cz)))
root.add_child(building)
## A window with a dark reveal, a pane that lights at night and a sill. Used by
## the Sunset neighbours and the hillside houses, which have no bespoke facade
## geometry of their own.
func _add_sunset_window(parent: Node3D, center: Vector3, size: Vector2) -> void:
_add_box(parent, Vector3(size.x + 0.24, size.y + 0.24, 0.10),
center, Color(0.16, 0.15, 0.14), false, null, Vector2.ONE, "stone")
var pane := _add_box(parent, Vector3(size.x, size.y, 0.08),
center + Vector3(0.0, 0.0, 0.09), Color(0.1, 0.12, 0.16),
false, null, Vector2.ONE, "glass")
var pane_mat := Materials.window_pane()
pane.material_override = pane_mat
_window_mats.append(pane_mat)
_add_box(parent, Vector3(size.x + 0.36, 0.10, 0.20),
center + Vector3(0.0, -size.y * 0.5 - 0.10, 0.08),
Color(0.52, 0.49, 0.44), false, null, Vector2.ONE, "stone")
## Sunset Strip neighbours: a cream Mediterranean block west and a white
## commercial block east. Only their street-facing volumes are modelled; the
## playable map never leaves the sidewalk in front of the club.
func _build_sunset_neighbours() -> void:
var neighbours := Node3D.new()
neighbours.name = "SunsetNeighbours"
root.add_child(neighbours)
# West: 15 m cream stucco block with a terracotta parapet and small balconies.
var west := StaticBody3D.new()
west.name = "WestMediterraneanBlock"
var west_w := 15.0
var west_h := 11.0
var west_front := -3.2
_add_box(west, Vector3(west_w, west_h, 12.0),
Vector3(-21.0, west_h * 0.5, -9.2), Color(0.80, 0.73, 0.60),
false, T_CONCRETE, Vector2(6, 5), "concrete")
_add_box(west, Vector3(west_w + 0.4, 1.0, 12.4),
Vector3(-21.0, west_h + 0.35, -9.2), Color(0.42, 0.18, 0.11),
true, null, Vector2.ONE, "stone")
_add_box(west, Vector3(west_w, 0.26, 0.55),
Vector3(-21.0, 3.35, west_front + 0.15), Color(0.44, 0.40, 0.33),
true, null, Vector2.ONE, "stone")
for row in 3:
var y := 2.2 + float(row) * 2.9
for column in 4:
var x := -26.6 + float(column) * 3.6
_add_sunset_window(west, Vector3(x, y, west_front + 0.10),
Vector2(1.5, 1.9) if row > 0 else Vector2(1.8, 2.3))
# Ground-floor shopfront and a red canvas awning.
_add_box(west, Vector3(8.6, 2.4, 0.12), Vector3(-22.6, 1.5, west_front + 0.08),
Color(0.08, 0.10, 0.12), false, null, Vector2.ONE, "glass")
# Keep the cream stripe: tinting the whole atlas dark red collapsed the
# canvas into a flat band at portrait distance.
_add_box(west, Vector3(9.2, 0.22, 1.3), Vector3(-22.6, 3.0, west_front + 0.65),
Color(0.62, 0.62, 0.62), true, T_AWNING, Vector2(4, 1), "awning")
# Balcony rails on the upper floor.
for x in [-24.8, -21.2, -17.6]:
_add_box(west, Vector3(2.4, 0.08, 0.85), Vector3(x, 5.6, west_front + 0.55),
Color(0.31, 0.29, 0.26), true, null, Vector2.ONE, "painted_metal")
_add_box(west, Vector3(2.4, 0.62, 0.05), Vector3(x, 5.9, west_front + 0.95),
Color(0.24, 0.23, 0.21), false, null, Vector2.ONE, "painted_metal")
var west_col := BoxShape3D.new()
west_col.size = Vector3(west_w, west_h, 12.0)
var west_owner := west.create_shape_owner(west)
west.shape_owner_add_shape(west_owner, west_col)
west.shape_owner_set_transform(west_owner,
Transform3D(Basis(), Vector3(-21.0, west_h * 0.5, -9.2)))
neighbours.add_child(west)
# East: 15 m white block with a taller parapet, ribbon windows and a lit
# ground-floor lobby.
var east := StaticBody3D.new()
east.name = "EastCommercialBlock"
var east_w := 15.0
var east_h := 12.4
var east_front := -3.0
_add_box(east, Vector3(east_w, east_h, 12.0),
Vector3(21.0, east_h * 0.5, -9.0), Color(0.84, 0.83, 0.79),
false, T_CONCRETE, Vector2(6, 6), "concrete")
_add_box(east, Vector3(east_w + 0.3, 0.55, 12.2),
Vector3(21.0, east_h + 0.18, -9.0), Color(0.57, 0.55, 0.51),
true, null, Vector2.ONE, "stone")
for row in 3:
var y := 2.8 + float(row) * 3.2
for column in 5:
var x := 14.6 + float(column) * 3.2
_add_sunset_window(east, Vector3(x, y, east_front + 0.10),
Vector2(2.1, 1.35))
_add_box(east, Vector3(10.0, 3.0, 0.12), Vector3(21.0, 1.65, east_front + 0.08),
Color(0.08, 0.11, 0.14), false, null, Vector2.ONE, "glass")
_add_box(east, Vector3(10.4, 0.18, 1.1), Vector3(21.0, 3.4, east_front + 0.55),
Color(0.11, 0.11, 0.12), true, null, Vector2.ONE, "painted_metal")
var east_col := BoxShape3D.new()
east_col.size = Vector3(east_w, east_h, 12.0)
var east_owner := east.create_shape_owner(east)
east.shape_owner_add_shape(east_owner, east_col)
east.shape_owner_set_transform(east_owner,
Transform3D(Basis(), Vector3(21.0, east_h * 0.5, -9.0)))
neighbours.add_child(east)
## Dry Hollywood Hills slope immediately behind the club, with a few stucco
## houses and palms. The plane starts at the rear wall so it cannot poke through
## the playable frontage, and the houses sit on the computed slope height.
func _build_sunset_hillside() -> void:
var hillside := Node3D.new()
hillside.name = "SunsetHillside"
root.add_child(hillside)
# Hand-built 45-degree ramp instead of a rotated PlaneMesh: the front edge
# meets the building's rear wall and the face points squarely at the street,
# so the slope reads as a hill from ground level instead of disappearing
# edge-on. The rear ridge is high enough to sit above the roofline.
var plane := MeshInstance3D.new()
plane.name = "DryHillside"
var verts := PackedVector3Array([
Vector3(-55.0, 0.0, -14.0), Vector3(55.0, 0.0, -14.0),
Vector3(55.0, 28.0, -30.0), Vector3(-55.0, 28.0, -30.0),
])
var normals := PackedVector3Array()
var slope_normal := Vector3(0.0, 1.0, 1.0).normalized()
for i in 4:
normals.append(slope_normal)
var uvs := PackedVector2Array([
Vector2(0.0, 0.0), Vector2(14.0, 0.0),
Vector2(14.0, 7.0), Vector2(0.0, 7.0),
])
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_TEX_UV] = uvs
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array([0, 2, 1, 0, 3, 2])
var plane_mesh := ArrayMesh.new()
plane_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
plane.mesh = plane_mesh
# The hillside is background scenery. Like the skyline panorama it is
# unshaded and fog-free: scene fog at 50 m washed a lit slope into the sky,
# which read as a hole behind the roofline.
var hill_shader := Shader.new()
hill_shader.code = """
shader_type spatial;
render_mode unshaded, cull_disabled, fog_disabled;
uniform sampler2D hill_texture : source_color, repeat_enable, filter_linear_mipmap;
uniform vec4 tint : source_color = vec4(1.0);
void fragment() {
ALBEDO = texture(hill_texture, UV * vec2(14.0, 7.0)).rgb * tint.rgb;
}
"""
var hill_mat := ShaderMaterial.new()
hill_mat.shader = hill_shader
hill_mat.set_shader_parameter("hill_texture", T_CONCRETE)
hill_mat.set_shader_parameter("tint", Color(0.48, 0.42, 0.29))
_hill_mat = hill_mat
plane.material_override = hill_mat
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
hillside.add_child(plane)
# A handful of low-poly shrubs break up the flat slope.
for index in 14:
var x := -40.0 + float(index) * 6.1
var z := -17.0 - float((index * 7) % 13)
var height := _sunset_hill_height(z)
var shrub := MeshInstance3D.new()
var shrub_mesh := SphereMesh.new()
shrub_mesh.radius = 0.9 + float(index % 3) * 0.25
shrub_mesh.height = 1.2 + float(index % 3) * 0.3
shrub_mesh.radial_segments = 7
shrub_mesh.rings = 4
shrub.mesh = shrub_mesh
shrub.position = Vector3(x, height + 0.5, z)
shrub.scale = Vector3(1.4, 0.7, 1.0)
shrub.material_override = Materials.flat(Color(0.25, 0.26, 0.15), "concrete")
shrub.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
hillside.add_child(shrub)
# Houses at three heights along the slope.
var houses := [
{"x": -23.0, "z": -18.0, "w": 8.0, "h": 4.8, "d": 7.0},
{"x": -5.0, "z": -22.0, "w": 10.0, "h": 5.2, "d": 8.0},
{"x": 15.0, "z": -20.0, "w": 7.0, "h": 4.2, "d": 6.0},
{"x": 31.0, "z": -27.0, "w": 9.0, "h": 5.0, "d": 7.0},
]
for house in houses:
var hx := float(house["x"])
var hz := float(house["z"])
var hh := float(house["h"])
var base := _sunset_hill_height(hz)
_add_box(hillside, Vector3(float(house["w"]), hh, float(house["d"])),
Vector3(hx, base + hh * 0.5, hz), Color(0.78, 0.73, 0.63),
false, T_CONCRETE, Vector2(3, 2.5), "concrete")
_add_box(hillside, Vector3(float(house["w"]) + 0.4, 0.35, float(house["d"]) + 0.4),
Vector3(hx, base + hh + 0.12, hz), Color(0.36, 0.20, 0.13),
true, null, Vector2.ONE, "stone")
# Front windows face the street (positive Z side of the house).
for offset in [-1.7, 1.7]:
_add_sunset_window(hillside,
Vector3(hx + offset, base + hh * 0.58, hz + float(house["d"]) * 0.5 + 0.08),
Vector2(1.35, 1.1))
# A porch bulb and small warm practical: the reference night shows a few
# scattered house lights, not a uniformly black hill.
var porch := _add_box(hillside, Vector3(0.22, 0.28, 0.14),
Vector3(hx, base + 1.45, hz + float(house["d"]) * 0.5 + 0.12),
Color(1.0, 0.82, 0.55), false, null, Vector2.ONE, "metal")
var porch_mat := Materials.emissive(Color(1.0, 0.78, 0.46), 0.2)
porch.material_override = porch_mat
_marquee_emissive.append(porch_mat)
var porch_light := OmniLight3D.new()
porch_light.name = "HillPorch"
porch_light.position = Vector3(hx, base + 1.35, hz + float(house["d"]) * 0.5 + 0.8)
porch_light.omni_range = 5.0
porch_light.omni_attenuation = 1.0
porch_light.light_energy = 0.0
porch_light.light_color = Color(1.0, 0.72, 0.42)
hillside.add_child(porch_light)
_accent_omni.append(porch_light)
# Tall palms on the slope; the library palm gives the same silhouette as the
# street trees without another bespoke asset.
for data in [
[-32.0, -17.0, 0.78], [9.0, -20.0, 0.9], [36.0, -24.0, 0.72],
]:
var px := float(data[0])
var pz := float(data[1])
var palm := _add_prop_model(hillside, "palm_tree",
Vector3(px, _sunset_hill_height(pz) - 0.1, pz), 20.0)
palm.scale = Vector3.ONE * float(data[2])
palm.name = "HillsidePalm_%d" % int(px)
func _sunset_hill_height(z: float) -> float:
return 0.24 + maxf(0.0, -14.0 - z) * tan(deg_to_rad(60.0))
## The opposite side of Sunset: a low retail row, a rooftop billboard and palms.
## It is background context; the navigable area stops at the near kerb.
func _build_sunset_across_street() -> void:
var across := Node3D.new()
across.name = "SunsetAcrossStreet"
root.add_child(across)
_add_box(across, Vector3(90.0, 0.18, 6.0), Vector3(0.0, 0.09, 39.0),
Color(0.53, 0.52, 0.50), false, T_CONCRETE, Vector2(26, 2), "concrete")
_add_box(across, Vector3(90.0, 0.22, 0.5), Vector3(0.0, 0.11, 36.0),
Color(0.60, 0.59, 0.56), false, null, Vector2.ONE, "stone")
var buildings := [
{"x": -34.0, "w": 22.0, "h": 9.5, "d": 10.0, "color": Color(0.66, 0.58, 0.47)},
{"x": -10.0, "w": 24.0, "h": 12.5, "d": 10.0, "color": Color(0.74, 0.72, 0.66)},
{"x": 14.0, "w": 20.0, "h": 8.5, "d": 10.0, "color": Color(0.57, 0.50, 0.44)},
{"x": 34.0, "w": 16.0, "h": 10.5, "d": 10.0, "color": Color(0.70, 0.66, 0.59)},
]
for index in buildings.size():
var entry: Dictionary = buildings[index]
var bx := float(entry["x"])
var bw := float(entry["w"])
var bh := float(entry["h"])
var bz := 47.0
_add_box(across, Vector3(bw, bh, float(entry["d"])),
Vector3(bx, bh * 0.5, bz), entry["color"], false, T_CONCRETE,
Vector2(8, 4), "concrete")
_add_box(across, Vector3(bw + 0.35, 0.45, 10.3),
Vector3(bx, bh + 0.18, bz), Color(0.34, 0.29, 0.23),
true, null, Vector2.ONE, "stone")
for row in 2:
var y := 3.3 + float(row) * 3.1
for column in int(bw / 3.3):
var x := bx - bw * 0.5 + 1.9 + float(column) * 3.3
_add_sunset_window(across, Vector3(x, y, 41.94), Vector2(1.8, 1.4))
# Ground-floor shop glass and a projecting awning.
_add_box(across, Vector3(bw - 1.2, 2.5, 0.12),
Vector3(bx, 1.55, 41.92), Color(0.08, 0.10, 0.13),
false, null, Vector2.ONE, "glass")
_add_box(across, Vector3(bw - 0.8, 0.16, 1.1),
Vector3(bx, 3.05, 42.45), Color(0.26, 0.23, 0.22),
true, null, Vector2.ONE, "awning")
# Rooftop billboard on the central block.
_add_box(across, Vector3(14.0, 6.0, 0.30), Vector3(-10.0, 15.5, 42.3),
Color(0.13, 0.13, 0.14), false, null, Vector2.ONE, "painted_metal")
for side in [-5.0, 5.0]:
_add_box(across, Vector3(0.22, 7.0, 0.22),
Vector3(-10.0 + side, 12.0, 42.4), Color(0.22, 0.21, 0.20),
false, null, Vector2.ONE, "metal")
var billboard := Label3D.new()
billboard.name = "SunsetBillboard"
billboard.text = "SUNSET\nLIVE"
billboard.position = Vector3(-10.0, 15.5, 42.12)
billboard.rotation.y = PI
billboard.font_size = 120
billboard.pixel_size = 0.005
billboard.modulate = Color(0.86, 0.31, 0.45)
billboard.outline_size = 0
billboard.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
across.add_child(billboard)
var club_sign := Label3D.new()
club_sign.name = "AcrossNeon"
club_sign.text = "COMEDY"
club_sign.position = Vector3(14.0, 7.0, 41.82)
club_sign.rotation.y = PI
club_sign.font_size = 110
club_sign.pixel_size = 0.0045
club_sign.modulate = Color(0.95, 0.38, 0.58)
club_sign.outline_size = 12
club_sign.outline_modulate = Color(0.22, 0.03, 0.10)
across.add_child(club_sign)
for side in [-1.0, 1.0]:
var palm := _add_prop_model(across, "palm_tree",
Vector3(side * 26.0, 0.18, 37.8), 90.0 if side < 0.0 else -90.0)
palm.name = "AcrossPalm_%s" % ("west" if side < 0.0 else "east")
## Regular window bands above the entrance. Panes are dark glass by day and
## light up after dark, which gives the facade depth at night.
## Real-world Hollywood Boulevard landmark: the El Capitan Entertainment
## Centre (the 1921 Hollywood Masonic Temple). The facade is a six-column
## Neoclassical portico with a recessed entrance, eight stone steps, a
## crosscut-grille transom, red event banners and the Masonic parapet motto.
## The two neighbours (El Capitan Theatre and the retail block) and the
## opposite Ovation Hollywood frontage give the block its real context.
func _build_masonic_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
var stone := Color(0.86, 0.84, 0.80)
var stone_dark := Color(0.61, 0.59, 0.55)
var stone_light := Color(0.93, 0.91, 0.87)
# Main stone mass. The real building is brick/concrete clad in a light
# limestone-like render; granite/limestone keeps the block consistent with
# the existing PBR surface library.
# Ashlar courses are ~0.55 m tall: the 768 px tile covers ~4.4 m of wall
# height, so the facade repeats it 3.2× vertically and 6.4× across.
var facade := _add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz),
stone, false, T_ASHLAR, Vector2(w * 0.216, h * 0.22), "stone")
facade.name = "MasonicFacade"
# Rusticated base and string courses.
_add_box(building, Vector3(w + 0.3, 1.0, d + 0.3), Vector3(0, 0.5, cz + 0.12),
stone_dark, true, T_LIMESTONE, Vector2(w * 0.22, 0.8), "stone")
for y in [1.12, 1.48, 1.84, 2.20]:
_add_box(building, Vector3(w, 0.055, 0.16), Vector3(0, y, front + 0.03),
stone_light, false, null, Vector2.ONE, "stone")
# Six engaged Ionic columns across the centre of the facade.
var columns := Node3D.new()
columns.name = "IonicColonnade"
building.add_child(columns)
for index in 6:
var x := -12.4 + float(index) * 4.96
_add_masonic_column(columns, x, front)
# Entablature, frieze and decorated parapet.
_add_box(building, Vector3(w + 0.35, 0.5, 0.7), Vector3(0, 11.55, front + 0.22),
stone_light, true, T_LIMESTONE, Vector2(w * 0.18, 0.55), "stone")
_add_box(building, Vector3(w, 1.15, 0.45), Vector3(0, 12.35, front + 0.16),
stone, true, T_LIMESTONE, Vector2(w * 0.18, 0.8), "stone")
_add_box(building, Vector3(w + 0.55, 0.42, 0.9), Vector3(0, 13.16, front + 0.25),
stone_light, true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(w + 0.2, 0.65, 0.45), Vector3(0, 13.70, front + 0.12),
stone, true, T_LIMESTONE, Vector2(w * 0.18, 0.45), "stone")
_add_box(building, Vector3(w + 0.4, 0.16, 0.58), Vector3(0, 14.10, front + 0.16),
stone_light, true, null, Vector2.ONE, "stone")
# A low balustrade above the cornice, matching the reference roofline.
var balustrade := Node3D.new()
balustrade.name = "ParapetBalustrade"
building.add_child(balustrade)
for index in 15:
var x := -14.0 + float(index) * 2.0
_add_box(balustrade, Vector3(0.16, 0.62, 0.18), Vector3(x, 13.96, front + 0.34),
stone_light, true, null, Vector2.ONE, "stone")
_add_box(balustrade, Vector3(w, 0.12, 0.24), Vector3(0, 14.34, front + 0.34),
stone_light, true, null, Vector2.ONE, "stone")
# Two carved medallions flank the Masonic motto.
for x in [-10.5, 10.5]:
var disc := CylinderMesh.new()
disc.top_radius = 0.44
disc.bottom_radius = 0.44
disc.height = 0.12
disc.radial_segments = 20
var medallion := MeshInstance3D.new()
medallion.mesh = disc
medallion.position = Vector3(x, 12.40, front + 0.61)
medallion.rotation_degrees.x = 90.0
medallion.material_override = Materials.limestone(stone_dark, Vector2(0.8, 0.8))
building.add_child(medallion)
var frieze := Label3D.new()
frieze.name = "FriezeInscription"
frieze.text = "FREEMASONRY BUILDS ITS TEMPLES\nAMONG THE NATIONS AND IN THE HEARTS OF MEN"
frieze.position = Vector3(0, 12.45, front + 0.65)
frieze.font_size = 68
frieze.pixel_size = 0.0080
frieze.modulate = Color(0.25, 0.23, 0.21)
frieze.outline_size = 0
frieze.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
frieze.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
building.add_child(frieze)
marquee_label = frieze
# The continuous transom behind the banners: dark crosscut iron grilles.
var transom := Node3D.new()
transom.name = "TransomGrilles"
building.add_child(transom)
for index in 5:
var x := -9.92 + float(index) * 4.96
_add_masonic_lattice(transom, x, 8.55, front + 0.20, 3.85, 2.1)
# Three red event banners in the central bays, like the reference facade.
var banners := Node3D.new()
banners.name = "EventBanners"
building.add_child(banners)
var banner_texts := ["EL", "CAPITAN", "LIVE"]
for index in 3:
var x := -4.96 + float(index) * 4.96
_add_masonic_banner(banners, x, front, banner_texts[index])
# Central recessed double entrance and two street-level side doors.
_add_masonic_door(building, 0.0, 4.2, 5.0, front)
for side in [-1.0, 1.0]:
_add_masonic_door(building, side * 9.92, 2.5, 4.4, front)
# Display cases in the two remaining bays.
for x in [-4.96, 4.96]:
_add_box(building, Vector3(2.2, 3.2, 0.18), Vector3(x, 3.1, front + 0.16),
Color(0.10, 0.10, 0.10), true, null, Vector2.ONE, "painted_metal")
_add_box(building, Vector3(1.7, 2.2, 0.08), Vector3(x, 3.35, front + 0.27),
Color(0.07, 0.08, 0.11), false, null, Vector2.ONE, "glass")
_add_box(building, Vector3(2.4, 0.18, 0.28), Vector3(x, 4.75, front + 0.14),
stone_light, true, null, Vector2.ONE, "stone")
# Door lock indicator beside the central handle.
door_indicator = _add_box(building, Vector3(0.16, 0.16, 0.22),
Vector3(2.55, 3.32, front + 0.30), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Warm practical wash for the columns and entrance after dark. The lights
# are off in daylight and set by `set_night_mode`, like the street lamps.
for x in [-10.5, -5.5, 5.5, 10.5]:
var wash := OmniLight3D.new()
wash.name = "FacadeWash_%d" % int(x)
wash.position = Vector3(x, 7.2, front + 2.2)
wash.omni_range = 16.0 if Materials.modern_light_falloff() else 10.0
wash.omni_attenuation = 1.0
wash.light_energy = 0.0
wash.light_color = Color(1.0, 0.82, 0.56)
building.add_child(wash)
_accent_omni.append(wash)
# A second, dimmer row higher up keeps the upper column shafts and capitals
# from disappearing after dark.
for x in [-10.5, -5.5, 5.5, 10.5]:
var high := OmniLight3D.new()
high.name = "UpperWash_%d" % int(x)
high.position = Vector3(x, 10.4, front + 1.8)
high.omni_range = 15.0
high.omni_attenuation = 1.0
high.light_energy = 0.0
high.light_color = Color(1.0, 0.78, 0.50)
building.add_child(high)
_accent_omni.append(high)
# Stone collision volume behind the recessed doors.
var col := BoxShape3D.new()
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, h * 0.5, cz)))
root.add_child(building)
func _add_masonic_column(parent: Node3D, x: float, front: float) -> void:
var z := front + 0.50
var stone := Color(0.9, 0.88, 0.84)
_add_box(parent, Vector3(1.12, 0.48, 1.08), Vector3(x, 1.24, z),
Color(0.78, 0.76, 0.72), true, T_LIMESTONE, Vector2(0.8, 0.5), "stone")
_add_box(parent, Vector3(0.94, 0.34, 0.94), Vector3(x, 1.65, z),
stone, true, null, Vector2.ONE, "stone")
var shaft := CylinderMesh.new()
shaft.top_radius = 0.34
shaft.bottom_radius = 0.43
shaft.height = 8.9
shaft.radial_segments = 14
shaft.rings = 4
var mesh := MeshInstance3D.new()
mesh.name = "Column_%d" % int(round(x))
mesh.mesh = shaft
mesh.position = Vector3(x, 6.27, z)
mesh.material_override = Materials.limestone(stone, Vector2(1.4, 4.2))
mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
parent.add_child(mesh)
var capital := CylinderMesh.new()
capital.top_radius = 0.54
capital.bottom_radius = 0.40
capital.height = 0.42
capital.radial_segments = 14
var cap := MeshInstance3D.new()
cap.mesh = capital
cap.position = Vector3(x, 10.93, z)
cap.material_override = Materials.limestone(stone, Vector2(1.0, 0.6))
cap.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
parent.add_child(cap)
_add_box(parent, Vector3(1.12, 0.20, 1.10), Vector3(x, 11.22, z),
stone, true, null, Vector2.ONE, "stone")
## Dark crosscut iron grille panel. The bars are deliberately simple boxes: at
## gameplay distance they read as the reference transom without a texture cost.
func _add_masonic_lattice(parent: Node3D, x: float, y: float, z: float,
width: float, height: float) -> void:
_add_box(parent, Vector3(width, height, 0.08), Vector3(x, y, z),
Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "painted_metal")
var columns := 6
for index in columns:
var bar_x := x - width * 0.5 + (float(index) + 0.5) * width / float(columns)
_add_box(parent, Vector3(0.055, height, 0.05), Vector3(bar_x, y, z + 0.07),
Color(0.23, 0.22, 0.20), false, null, Vector2.ONE, "metal")
for index in 4:
var bar_y := y - height * 0.5 + (float(index) + 0.5) * height / 4.0
_add_box(parent, Vector3(width, 0.055, 0.05), Vector3(x, bar_y, z + 0.07),
Color(0.23, 0.22, 0.20), false, null, Vector2.ONE, "metal")
func _add_masonic_banner(parent: Node3D, x: float, front: float, text: String) -> void:
var red := Color(0.58, 0.055, 0.065)
# The reference night photo keeps the event banners readable against the dark
# stone: a low warm self-light that the day/night cycle brightens (not a
# glowing neon box). Registered with `_marquee_emissive` in `set_night_mode`.
var cloth := _add_box(parent, Vector3(2.62, 5.55, 0.06), Vector3(x, 8.2, front + 0.73),
red, true, null, Vector2.ONE, "cloth")
cloth.material_override = Materials.emissive(red, 0.14)
_marquee_emissive.append(cloth.material_override)
for y in [5.55, 10.85]:
_add_box(parent, Vector3(2.82, 0.10, 0.12), Vector3(x, y, front + 0.75),
Color(0.78, 0.62, 0.28), true, null, Vector2.ONE, "metal")
_add_box(parent, Vector3(0.10, 5.55, 0.10), Vector3(x - 1.28, 8.2, front + 0.78),
Color(0.78, 0.62, 0.28), false, null, Vector2.ONE, "metal")
_add_box(parent, Vector3(0.10, 5.55, 0.10), Vector3(x + 1.28, 8.2, front + 0.78),
Color(0.78, 0.62, 0.28), false, null, Vector2.ONE, "metal")
var label := Label3D.new()
label.name = "Banner_%s" % text
label.text = "\n".join(text.split(""))
label.position = Vector3(x, 8.2, front + 0.80)
label.font_size = 112
label.pixel_size = 0.0044
label.modulate = Color(1.0, 0.83, 0.47)
label.outline_size = 0
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
parent.add_child(label)
func _add_masonic_door(parent: Node3D, x: float, width: float, height: float,
front: float) -> void:
var stone_light := Color(0.93, 0.91, 0.87)
var bottom := 1.0
# Recessed stone surround and dark timber/iron door.
_add_box(parent, Vector3(width + 0.55, height + 0.35, 0.18),
Vector3(x, bottom + height * 0.5, front + 0.06),
Color(0.33, 0.31, 0.28), true, null, Vector2.ONE, "stone")
_add_box(parent, Vector3(width, height, 0.14),
Vector3(x, bottom + height * 0.5, front + 0.14),
Color(0.075, 0.065, 0.060), true, null, Vector2.ONE, "wood")
for side in [-1.0, 1.0]:
_add_box(parent, Vector3(0.10, height - 0.25, 0.08),
Vector3(x + side * width * 0.28, bottom + height * 0.5, front + 0.22),
Color(0.33, 0.25, 0.16), false, null, Vector2.ONE, "wood")
_add_box(parent, Vector3(0.055, 0.55, 0.055),
Vector3(x + side * 0.22, bottom + 2.55, front + 0.24),
Color(0.72, 0.65, 0.45), false, null, Vector2.ONE, "metal")
# Lit transom above the door (the reference has a continuous grille band).
var pane := _add_box(parent, Vector3(width * 0.88, 0.72, 0.08),
Vector3(x, bottom + height + 0.38, front + 0.15), Color(1, 1, 1))
var pane_mat := Materials.window_pane()
pane.material_override = pane_mat
_window_mats.append(pane_mat)
for index in 3:
_add_box(parent, Vector3(width * 0.88, 0.045, 0.04),
Vector3(x, bottom + height + 0.10 + float(index) * 0.27, front + 0.20),
Color(0.20, 0.19, 0.18), false, null, Vector2.ONE, "metal")
## Neighbouring El Capitan Theatre (6838) and the retail block east of the
## temple. They are deliberately simpler than the hero facade but preserve the
## street wall, marquee scale and the vertical theatre sign.
func _build_neighbour_blocks() -> void:
var group := Node3D.new()
group.name = "NeighbourBlocks"
root.add_child(group)
# West neighbour: El Capitan Theatre.
var theatre := StaticBody3D.new()
theatre.name = "ElCapitanTheatre"
_add_box(theatre, Vector3(17.6, 18.5, 7.0), Vector3(-24.1, 9.25, -7.5),
Color(0.82, 0.74, 0.61), false, T_LIMESTONE, Vector2(6.0, 8.0), "stone")
_add_box(theatre, Vector3(18.0, 0.34, 7.4), Vector3(-24.1, 6.4, -7.5),
Color(0.63, 0.54, 0.43), true, null, Vector2.ONE, "stone")
_add_box(theatre, Vector3(18.0, 0.48, 7.4), Vector3(-24.1, 18.2, -7.5),
Color(0.56, 0.47, 0.38), true, null, Vector2.ONE, "stone")
for x in [-30.0, -26.8, -23.6, -20.4, -17.2]:
for y in [8.8, 12.6]:
_add_box(theatre, Vector3(2.4, 2.4, 0.10), Vector3(x, y, -3.95),
Color(0.09, 0.10, 0.12), false, null, Vector2.ONE, "glass")
_add_box(theatre, Vector3(2.7, 0.18, 0.18), Vector3(x, y - 1.32, -3.90),
Color(0.72, 0.64, 0.52), true, null, Vector2.ONE, "stone")
# The theatre marquee projects over the sidewalk.
_add_box(theatre, Vector3(10.5, 0.42, 2.3), Vector3(-23.4, 5.65, -3.30),
Color(0.45, 0.035, 0.045), true, null, Vector2.ONE, "painted_metal")
var marquee_trim := Materials.emissive(Color(1.0, 0.70, 0.36), 1.2)
for index in 8:
var bulb := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.06
sphere.height = 0.12
bulb.mesh = sphere
bulb.position = Vector3(-28.0 + float(index) * 1.35, 5.40, -2.20)
bulb.material_override = marquee_trim
theatre.add_child(bulb)
if not _marquee_emissive.has(marquee_trim):
_marquee_emissive.append(marquee_trim)
var marquee_glow := OmniLight3D.new()
marquee_glow.name = "MarqueeGlow"
marquee_glow.position = Vector3(-23.4, 5.3, -2.4)
marquee_glow.omni_range = 18.0 if Materials.modern_light_falloff() else 12.0
marquee_glow.omni_attenuation = 1.0
marquee_glow.light_energy = 0.0
marquee_glow.light_color = Color(1.0, 0.70, 0.38)
theatre.add_child(marquee_glow)
_accent_omni.append(marquee_glow)
_add_box(theatre, Vector3(10.5, 0.08, 2.3), Vector3(-23.4, 5.87, -3.30),
Color(0.95, 0.84, 0.58), false, null, Vector2.ONE, "stone")
var theatre_label := Label3D.new()
theatre_label.name = "ElCapitanMarqueeLabel"
theatre_label.text = "EL CAPITAN"
theatre_label.position = Vector3(-23.4, 5.66, -2.12)
theatre_label.font_size = 110
theatre_label.pixel_size = 0.0040
theatre_label.modulate = Color(1.0, 0.78, 0.46)
theatre_label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
theatre.add_child(theatre_label)
# Vertical blade sign at the temple-side corner.
_add_box(theatre, Vector3(0.92, 7.2, 0.26), Vector3(-16.1, 8.9, -3.18),
Color(0.46, 0.035, 0.045), true, null, Vector2.ONE, "painted_metal")
var blade := Label3D.new()
blade.name = "ElCapitanBladeSign"
blade.text = "EL\nCAP\nITAN"
blade.position = Vector3(-16.1, 8.9, -3.02)
blade.font_size = 82
blade.pixel_size = 0.0042
blade.modulate = Color(1.0, 0.88, 0.62)
blade.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
blade.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
theatre.add_child(blade)
var theatre_col := BoxShape3D.new()
theatre_col.size = Vector3(17.6, 18.5, 7.0)
var theatre_owner := theatre.create_shape_owner(theatre)
theatre.shape_owner_add_shape(theatre_owner, theatre_col)
theatre.shape_owner_set_transform(theatre_owner,
Transform3D(Basis(), Vector3(-24.1, 9.25, -7.5)))
group.add_child(theatre)
# East neighbour: the modern retail block beside the temple.
var retail := StaticBody3D.new()
retail.name = "EastRetailBlock"
_add_box(retail, Vector3(16.0, 13.2, 6.2), Vector3(24.6, 6.6, -7.0),
Color(0.76, 0.74, 0.69), false, T_LIMESTONE, Vector2(6.0, 6.0), "stone")
_add_box(retail, Vector3(16.2, 0.36, 6.6), Vector3(24.6, 13.0, -7.0),
Color(0.61, 0.58, 0.52), true, null, Vector2.ONE, "stone")
_add_box(retail, Vector3(15.2, 4.4, 0.12), Vector3(24.6, 2.4, -3.88),
Color(0.08, 0.09, 0.11), false, null, Vector2.ONE, "glass")
for x in [18.2, 21.8, 25.4, 29.0, 31.0]:
_add_box(retail, Vector3(0.18, 4.4, 0.22), Vector3(x, 2.4, -3.82),
Color(0.16, 0.16, 0.17), true, null, Vector2.ONE, "metal")
for x in [18.2, 22.0, 25.8, 29.6]:
for y in [7.4, 10.2]:
_add_box(retail, Vector3(2.0, 1.8, 0.10), Vector3(x, y, -3.92),
Color(0.10, 0.11, 0.13), false, null, Vector2.ONE, "glass")
_add_box(retail, Vector3(2.3, 0.15, 0.17), Vector3(x, y - 1.0, -3.88),
Color(0.66, 0.63, 0.56), true, null, Vector2.ONE, "stone")
var retail_label := Label3D.new()
retail_label.name = "EastRetailSign"
retail_label.text = "ZARA"
retail_label.position = Vector3(24.6, 5.4, -3.74)
retail_label.font_size = 94
retail_label.pixel_size = 0.0044
retail_label.modulate = Color(0.10, 0.10, 0.10)
retail.add_child(retail_label)
var retail_col := BoxShape3D.new()
retail_col.size = Vector3(16.0, 13.2, 6.2)
var retail_owner := retail.create_shape_owner(retail)
retail.shape_owner_add_shape(retail_owner, retail_col)
retail.shape_owner_set_transform(retail_owner,
Transform3D(Basis(), Vector3(24.6, 6.6, -7.0)))
group.add_child(retail)
## A flat, front-facing arch panel used for the Ovation arcade. Both the stone
## surround and the dark opening use the same mesh at different sizes, which
## keeps the arch silhouette readable without a CSG or custom shader.
func _arch_panel_mesh(width: float, height: float, segments: int = 18) -> ArrayMesh:
var radius := width * 0.5
var spring := maxf(0.0, height - radius)
var polygon := PackedVector2Array()
polygon.append(Vector2(-radius, 0.0))
polygon.append(Vector2(radius, 0.0))
polygon.append(Vector2(radius, spring))
for index in range(1, segments):
var angle := PI * float(index) / float(segments)
polygon.append(Vector2(cos(angle) * radius, spring + sin(angle) * radius))
polygon.append(Vector2(-radius, spring))
var triangles := Geometry2D.triangulate_polygon(polygon)
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
for point in polygon:
vertices.append(Vector3(point.x, point.y, 0.0))
normals.append(Vector3(0.0, 0.0, -1.0))
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array(triangles)
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
## The south side of Hollywood Boulevard: the Ovation Hollywood frontage
## across the four-lane street, with its deep retail podium and sign band.
func _build_opposite_block() -> void:
var opposite := Node3D.new()
opposite.name = "OvationHollywood"
root.add_child(opposite)
# South sidewalk, separate from the north walk.
_add_box(opposite, Vector3(96, 0.18, 6.0), Vector3(0, 0.09, 41.0),
Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(28, 2.0), "concrete")
_add_box(opposite, Vector3(98, 0.22, 0.5), Vector3(0, 0.11, 38.1),
Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "stone")
# Deep retail mass with a shaded arcade at street level.
_add_box(opposite, Vector3(82, 21.0, 12.0), Vector3(0, 10.5, 50.5),
Color(0.79, 0.75, 0.68), false, T_LIMESTONE, Vector2(12, 6), "stone")
# Ground-floor arcade: seven tall arched openings with a stone surround. The
# concept reference shows the Ovation frontage as a deep shaded arcade rather
# than a blank glass strip.
var arch_xs := [-24.0, -16.0, -8.0, 0.0, 8.0, 16.0, 24.0]
for x in arch_xs:
var surround := MeshInstance3D.new()
surround.name = "OvationArchSurround"
surround.mesh = _arch_panel_mesh(6.6, 6.4)
surround.position = Vector3(x, 0.18, 44.28)
surround.material_override = Materials.limestone(Color(0.90, 0.86, 0.78), Vector2(1.2, 1.0))
opposite.add_child(surround)
var opening := MeshInstance3D.new()
opening.name = "OvationArchOpening"
opening.mesh = _arch_panel_mesh(5.5, 5.6)
opening.position = Vector3(x, 0.18, 44.24)
if int((x + 24.0) / 8.0) % 2 == 0:
var shop_pane := Materials.window_pane()
opening.material_override = shop_pane
_window_mats.append(shop_pane)
else:
opening.material_override = Materials.glass(Color(0.08, 0.09, 0.11, 0.94))
opposite.add_child(opening)
# Pilasters between the arcade bays and a string course above them break up
# the long frontage instead of leaving one flat grey wall.
for x in [-28.0, -20.0, -12.0, -4.0, 4.0, 12.0, 20.0, 28.0]:
_add_box(opposite, Vector3(0.9, 6.4, 0.45), Vector3(x, 3.4, 44.22),
Color(0.86, 0.82, 0.74), true, null, Vector2.ONE, "stone")
_add_box(opposite, Vector3(82, 0.35, 0.40), Vector3(0, 6.9, 44.20),
Color(0.84, 0.80, 0.72), true, null, Vector2.ONE, "stone")
_add_box(opposite, Vector3(82, 0.38, 0.90), Vector3(0, 20.82, 44.10),
Color(0.72, 0.68, 0.61), true, null, Vector2.ONE, "stone")
# Upper ribbon windows and the sign band.
for row_index in 3:
var row: float = [9.0, 12.2, 15.4][row_index]
for x in range(-34, 38, 8):
var window := _add_box(opposite, Vector3(5.0, 1.25, 0.12),
Vector3(float(x), row, 44.30), Color(0.09, 0.10, 0.12),
false, null, Vector2.ONE, "glass")
if (row_index + int((x + 34) / 8)) % 3 != 1:
var pane_mat := Materials.window_pane()
window.material_override = pane_mat
_window_mats.append(pane_mat)
_add_box(opposite, Vector3(82, 0.45, 0.70), Vector3(0, 18.0, 44.15),
Color(0.60, 0.56, 0.49), true, null, Vector2.ONE, "stone")
var ovation := Label3D.new()
ovation.name = "OvationSign"
ovation.text = "OVATION HOLLYWOOD"
ovation.position = Vector3(0, 16.4, 44.05)
ovation.font_size = 150
ovation.pixel_size = 0.0052
ovation.modulate = Color(0.16, 0.14, 0.12)
ovation.outline_size = 0
ovation.outline_modulate = Color(0.90, 0.87, 0.80, 0.8)
# The sign faces north across the boulevard, so its front points -Z.
ovation.rotation.y = PI
opposite.add_child(ovation)
_ovation_sign = ovation
# Practical wash across the boulevard so the opposite frontage reads after
# dark instead of becoming a black slab.
for x in [-24.0, 0.0, 24.0]:
var wash := OmniLight3D.new()
wash.name = "OvationWash_%d" % int(x)
wash.position = Vector3(x, 6.0, 40.2)
wash.omni_range = 18.0 if Materials.modern_light_falloff() else 12.0
wash.omni_attenuation = 1.0
wash.light_energy = 0.0
wash.light_color = Color(1.0, 0.84, 0.62)
opposite.add_child(wash)
_accent_omni.append(wash)
# South-side palms frame the far sidewalk.
for side in [-1.0, 1.0]:
var palm := _add_prop_model(opposite, "palm_tree",
Vector3(side * 18.0, 0.18, 41.2), 90.0 if side < 0.0 else -90.0)
palm.name = "SouthPalm_%s" % ("west" if side < 0.0 else "east")
## Hollywood Walk of Fame: dark terrazzo band, brass star discs and coral
## five-point stars along the entrance sidewalk.
func _build_walk_of_fame() -> void:
var walk := Node3D.new()
walk.name = "WalkOfFame"
root.add_child(walk)
# Reuse the paver map as a dark terrazzo: a flat colour read as a black
# strip at close range, while the texture supplies the small aggregate.
_add_box(walk, Vector3(30.0, 0.035, 1.75), Vector3(0, 0.198, 9.15),
Color(0.34, 0.30, 0.27), false, T_CONCRETE, Vector2(18, 1.05), "concrete")
var star_mesh := _star_mesh(0.40, 0.17)
var brass := Materials.flat(Color(0.70, 0.55, 0.24), "metal")
var coral := Materials.flat(Color(0.72, 0.30, 0.28), "stone")
for index in 10:
var x := -13.5 + float(index) * 3.0
_add_cylinder_mesh(walk, 0.55, 0.02, Vector3(x, 0.214, 9.15),
Color(0.58, 0.49, 0.30), "metal")
var star := MeshInstance3D.new()
star.name = "WalkStar_%d" % index
star.mesh = star_mesh
star.position = Vector3(x, 0.226, 9.15)
star.material_override = coral if index % 2 == 0 else brass
walk.add_child(star)
func _star_mesh(outer: float, inner: float) -> ArrayMesh:
var vertices := PackedVector3Array()
var normals := PackedVector3Array()
var indices := PackedInt32Array()
for index in 10:
var angle := -PI * 0.5 + float(index) * PI / 5.0
var radius := outer if index % 2 == 0 else inner
vertices.append(Vector3(cos(angle) * radius, 0.0, sin(angle) * radius))
normals.append(Vector3.UP)
vertices.append(Vector3.ZERO)
normals.append(Vector3.UP)
var center := 10
for index in 10:
indices.append(center)
indices.append(index)
indices.append((index + 1) % 10)
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
## Four-lane boulevard markings: double-yellow centre, broken white lane lines,
## and the red-painted kerb from the reference photographs.
func _build_hollywood_lane_markings() -> void:
var street_w := float(spec.get("street_width", 120.0))
var street_d := float(spec.get("street_depth", 26.0))
var front := float(spec.get("street_front_z", 12.0))
var center_z := front + street_d * 0.5
var yellow := Color(0.86, 0.70, 0.16)
for offset in [-0.14, 0.14]:
_add_box(root, Vector3(street_w * 0.92, 0.02, 0.09),
Vector3(0, 0.115, center_z + offset), yellow, false, null, Vector2.ONE, "concrete")
var white := Color(0.88, 0.87, 0.80)
for offset in [-6.5, 6.5]:
for x in range(-55, 56, 6):
_add_box(root, Vector3(2.6, 0.02, 0.08),
Vector3(float(x), 0.116, center_z + offset),
white, false, null, Vector2.ONE, "concrete")
# Red kerb paint: the painted face of the concrete kerb.
_add_box(root, Vector3(street_w, 0.13, 0.055),
Vector3(0, 0.19, front - 0.26), Color(0.58, 0.075, 0.055),
false, null, Vector2.ONE, "painted_metal")
func _build_facade_windows(parent: Node3D) -> void:
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")
var pane := _add_box(parent, Vector3(1.7, 2.2, 0.12),
Vector3(x, y, -3.94), Color(1, 1, 1))
var pane_mat := Materials.window_pane()
pane.material_override = pane_mat
_window_mats.append(pane_mat)
# Sill below the frame and glazing bars, so the pane reads as a window
# rather than a dark hole.
_add_box(parent, Vector3(2.1, 0.12, 0.3), Vector3(x, y - 1.3, -3.86),
Color(0.55, 0.53, 0.50), false, null, Vector2.ONE, "stone")
_add_box(parent, Vector3(0.06, 2.2, 0.05), Vector3(x, y, -3.9),
Color(0.24, 0.23, 0.22), false, null, Vector2.ONE, "stone")
_add_box(parent, Vector3(1.7, 0.06, 0.05), Vector3(x, y + 0.25, -3.9),
Color(0.24, 0.23, 0.22), false, null, Vector2.ONE, "stone")
func _build_steps() -> void:
# Steps have collision so NPCs and the guard can walk up the entrance.
# The ground and building provide the actual collision boundaries.
var steps := StaticBody3D.new()
steps.name = "Steps"
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 = float(count) * tread - i * tread
var w: float = step_w - i * 0.35
var tread_box := _add_box(steps, Vector3(w, height, tread),
Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
if _feature("masonic_facade"):
tread_box.material_override = Materials.limestone(
Color(0.70, 0.67, 0.61), Vector2(w * 0.55, 0.55))
# Add collision shape to each tread
var tread_col := BoxShape3D.new()
tread_col.size = Vector3(w, height, tread)
var owner_id := steps.create_shape_owner(steps)
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. The depth/centre are spec-driven so the
# eight-step Masonic entrance reaches the recessed doors.
var landing_y: float = count * height + 0.1
var landing_w := step_w + 1.0
var landing_depth := float(spec.get("landing_depth", 3.0))
var landing_center_z := float(spec.get("landing_center_z", -1.5))
var landing_box := _add_box(steps, Vector3(landing_w, 0.2, landing_depth),
Vector3(0, landing_y, landing_center_z), Color(0.5, 0.47, 0.45))
if _feature("masonic_facade"):
landing_box.material_override = Materials.limestone(
Color(0.70, 0.67, 0.61), Vector2(landing_w * 0.55, 1.0))
var landing_col := BoxShape3D.new()
landing_col.size = Vector3(landing_w, 0.2, landing_depth)
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, landing_center_z)))
root.add_child(steps)
func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk"
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(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, d * 0.5)))
root.add_child(sidewalk)
func _build_street() -> void:
var street := StaticBody3D.new()
street.name = "Street"
# The roadway reads darker than the sidewalk; the raw asphalt texture tinted
# 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.
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("curb_z", float(spec.get("sidewalk_depth", 8.0)) + 4.1))
# The default venues use the legacy 3.1 m overlap; the Hollywood block has an
# explicit street frontage so the kerb, sidewalk and red paint line up.
var street_front := float(spec.get("street_front_z", curb_z - 3.1))
var street_z := street_front + street_d * 0.5
_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
# Legacy centre dashes. The Hollywood block replaces these with the proper
# four-lane markings after the street is built.
if not _feature("hollywood_lane_markings"):
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(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(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, street_z)))
root.add_child(street)
func _build_navmesh() -> void:
## Flat navigation mesh over the walkable floor: landing strip, sidewalk,
## and street front. Hand-authored polygons; no runtime bake needed.
var region := NavigationRegion3D.new()
region.name = "NavRegion"
var mesh := NavigationMesh.new()
mesh.agent_radius = 0.4
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(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]))
region.navigation_mesh = mesh
root.add_child(region)
func _build_skyline_backdrop() -> void:
## Distant skyline surrounding the block. The texture's sky is transparent
## (alpha scissor), so only building silhouettes show. Four planes form a
## ring: a single plane placed behind the building reads as a floating black
## band from the sidewalk.
var backdrop := Node3D.new()
backdrop.name = "SkylineBackdrop"
# A shader, not a StandardMaterial3D: the panorama already has its own
# atmospheric haze baked in, and the scene fog at 200 m washed the towers out
# to a white band (Godot 4.3 has no `fog_enabled` on StandardMaterial3D, so the
# `fog_disabled` render mode does the job). Two UV repeats over the 400 m ring:
# each repeat is ~200 x 90 m, close to the 2:1 panorama aspect, so the towers
# keep their proportions instead of stretching into a wide band.
var mat := ShaderMaterial.new()
var shader := Shader.new()
shader.code = SKYLINE_SHADER
mat.shader = shader
mat.set_shader_parameter("sky_texture", SKYLINE_BY_PHASE[_phase]["texture"])
mat.set_shader_parameter("tint", SKYLINE_BY_PHASE[_phase]["tint"])
mat.set_shader_parameter("haze_color", SKYLINE_BY_PHASE[_phase]["haze"])
mat.set_shader_parameter("haze_strength", SKYLINE_BY_PHASE[_phase]["haze_strength"])
mat.set_shader_parameter("repeats", 2.0)
_skyline_mat = mat
var mesh := PlaneMesh.new()
# PlaneMesh defaults to a horizontal (XZ) plane; the skyline must stand up.
mesh.orientation = PlaneMesh.FACE_Z
# 90 m tall: at the 200 m ring distance the towers subtend a skyline band
# instead of a thin strip on the horizon.
mesh.size = Vector2(400, SKYLINE_HEIGHT)
# Far enough that the skyline reads as background and its top edge stays out
# of the middle of the sky.
const DIST := 200.0
# The texture keeps its buildings in the lower part of the image, so the plane
# center sits at half its height: the building bases meet the ground.
const CENTER_Y := SKYLINE_HEIGHT / 2.0
var placements := [
[Vector3(0, CENTER_Y, -DIST), 0.0],
[Vector3(0, CENTER_Y, DIST), 0.0],
[Vector3(-DIST, CENTER_Y, 0), PI / 2.0],
[Vector3(DIST, CENTER_Y, 0), PI / 2.0],
]
for placement in placements:
var plane := MeshInstance3D.new()
plane.mesh = mesh
plane.material_override = mat
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
plane.position = placement[0]
plane.rotation.y = placement[1]
backdrop.add_child(plane)
root.add_child(backdrop)
## Backdrop shader: unshaded, alpha-scissored, and fog-free so the panorama's own
## haze shows instead of the scene fog bleaching it.
const SKYLINE_SHADER := """
shader_type spatial;
render_mode unshaded, cull_disabled, fog_disabled;
uniform sampler2D sky_texture : source_color, repeat_enable, filter_linear_mipmap;
uniform vec4 tint : source_color = vec4(1.0);
uniform vec4 haze_color : source_color = vec4(0.62, 0.71, 0.82, 1.0);
uniform float haze_strength = 0.36;
uniform float repeats = 2.0;
void fragment() {
vec4 panorama = texture(sky_texture, vec2(UV.x * repeats, UV.y));
// Atmospheric perspective: blend the lower part of the panorama toward
// the horizon colour so the towers sit in the distance instead of reading
// as a crisp cardboard cut-out.
float world_y = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).y;
float horizon = clamp(1.0 - world_y / 42.0, 0.0, 1.0);
ALBEDO = mix(panorama.rgb * tint.rgb, haze_color.rgb, horizon * haze_strength);
ALPHA = panorama.a * tint.a;
ALPHA_SCISSOR_THRESHOLD = 0.5;
}
"""
var _skyline_mat: ShaderMaterial = null
var _hill_mat: ShaderMaterial = null
var _street_mat: StandardMaterial3D = null
var _phase: int = Rules.Phase.EARLY
func _build_marquee() -> void:
## Blender canopy + sign box above the door; the procedural striped awning and
## neon bars remain only as a fallback when the library asset is missing.
var library := _load_prop_scene("marquee")
if library != null:
library.name = "LibraryMarquee"
library.position = Vector3(0, 5.62, -3.9)
root.add_child(library)
library_nodes.append(library)
library.set_meta("library_prop", "marquee")
_apply_library_ao(library, "marquee")
for material in _emissive_materials(library):
if not _marquee_emissive.has(material):
_marquee_emissive.append(material)
else:
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning"
var strip_parent := Node3D.new()
strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]:
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat
_marquee_emissive.append(mat)
root.add_child(strip_parent)
marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel"
marquee_label.text = "GIG LINE"
marquee_label.position = Vector3(0, 6.36, -1.9)
marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 160
marquee_label.pixel_size = 0.004
marquee_label.outline_size = 24
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false
root.add_child(marquee_label)
## Venue dressing that is not part of the sidewalk prop table: a stair handrail,
## a parked sedan, and wall lanterns. Kept separate so the placement validator
## only sees grounded sidewalk furniture.
func _build_library_dressing() -> void:
if _feature("handrail") and ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
var rail := _load_prop_scene("handrail")
if rail != null:
rail.name = "LibraryHandrail"
rail.position = Vector3(3.35, 0.2, 3.95)
rail.rotation_degrees.y = 90.0
root.add_child(rail)
library_nodes.append(rail)
rail.set_meta("library_prop", "handrail")
_apply_library_ao(rail, "handrail")
if _feature("sedan") and ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
var sedan_pos: Vector3 = spec.get("sedan_position", Vector3(6.0, 0.1, 17.0))
var sedan_rot := float(spec.get("sedan_rotation", 180.0))
var sedan := _add_prop_model(root, "sedan", sedan_pos, sedan_rot)
sedan.name = "ParkedSedan"
if venue_id == "tv_studio":
_apply_car_paint(sedan)
if not library_nodes.has(sedan):
library_nodes.append(sedan)
sedan.set_meta("library_prop", "sedan")
for material in _emissive_materials(sedan):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
if _feature("wall_lanterns") and ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"):
if venue_id == "joke_shop":
_add_wall_lantern(Vector3(3.2, 3.3, -3.72), true)
elif _feature("masonic_facade"):
for side in [-13.6, 13.6]:
_add_wall_lantern(Vector3(side, 3.4, -3.62), true)
else:
for side in [-4.9, 4.9]:
_add_wall_lantern(Vector3(side, 3.3, -3.78), false)
## The TV studio entrance canopy: a dark metal slab with a row of warm bulbs,
## replacing the boulevard marquee.
func _build_bulb_canopy() -> void:
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
var canopy := _add_box(root, Vector3(7.2, 0.22, 2.2),
Vector3(0, 5.7, front + 1.0), Color(0.16, 0.16, 0.18),
true, null, Vector2.ONE, "metal")
canopy.name = "BulbCanopy"
for x in [-3.0, 3.0]:
_add_box(root, Vector3(0.12, 5.5, 0.12),
Vector3(x, 2.75, front + 1.95), Color(0.20, 0.20, 0.22),
true, null, Vector2.ONE, "metal")
for x in [-2.8, -1.4, 0.0, 1.4, 2.8]:
var bulb := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.075
sphere.height = 0.15
bulb.mesh = sphere
var mat := _emissive_material(Color(1.0, 0.78, 0.42))
bulb.material_override = mat
bulb.position = Vector3(x, 5.53, front + 1.0)
root.add_child(bulb)
if not _marquee_emissive.has(mat):
_marquee_emissive.append(mat)
for x in [-2.2, 2.2]:
var omni := OmniLight3D.new()
omni.position = Vector3(x, 5.15, front + 1.4)
omni.omni_range = 12.0 if Materials.modern_light_falloff() else 8.0
omni.omni_attenuation = 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.36)
root.add_child(omni)
_lamp_omni.append(omni)
## Glossy black paint for the studio's parked car. The library sedan keeps its
## tintable slots; the studio overrides the body meshes with a single paint.
func _apply_car_paint(holder: Node3D) -> void:
var paint := StandardMaterial3D.new()
paint.resource_name = "StudioCarPaint"
paint.albedo_color = Color(0.018, 0.020, 0.024)
paint.metallic = 0.85
paint.roughness = 0.12
paint.metallic_specular = 0.8
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi != null:
mi.material_override = paint
## One wall lantern plus an optional warm light. The Joke Shop uses a single
## lantern as its key light; the boulevard keeps two unlit visual lanterns.
func _add_wall_lantern(pos: Vector3, with_light: bool) -> void:
var lantern := _load_prop_scene("wall_lantern")
if lantern == null:
return
lantern.name = "WallLantern"
lantern.position = pos
root.add_child(lantern)
library_nodes.append(lantern)
lantern.set_meta("library_prop", "wall_lantern")
_apply_library_ao(lantern, "wall_lantern")
for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
if with_light:
var omni := OmniLight3D.new()
omni.name = "LanternGlow"
omni.position = pos + Vector3(0.0, -0.1, 0.45)
omni.omni_range = 14.0 if Materials.modern_light_falloff() else 9.0
omni.omni_attenuation = 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.38)
root.add_child(omni)
_lamp_omni.append(omni)
## The Joke Shop alley: two brick side walls frame a 12 m passage, with
## shutters, a recessed door step, a dumpster and wall-mounted AC boxes.
func _build_alley_dressing() -> void:
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
var height := float(spec["facade_height"])
var wall_x := float(spec["facade_width"]) * 0.5 + 0.35
for side in [-1.0, 1.0]:
var wall := _add_box(root, Vector3(0.7, height, 15.0),
Vector3(side * wall_x, height * 0.5, front + 7.5), Color(0.72, 0.70, 0.68),
true, T_BRICK, Vector2(8, 6), "brick")
wall.name = "AlleyWall"
_add_box(root, Vector3(0.9, 0.35, 15.0),
Vector3(side * wall_x, 0.18, front + 7.5), Color(0.44, 0.42, 0.40),
true, null, Vector2.ONE, "stone")
# Shutters over the two upper windows.
for x in [-3.8, 3.8]:
_add_box(root, Vector3(2.0, 2.5, 0.12), Vector3(x, 8.2, front + 0.22),
Color(0.18, 0.24, 0.21), true, null, Vector2.ONE, "painted_metal")
for slat in range(-4, 5):
_add_box(root, Vector3(1.9, 0.07, 0.04),
Vector3(x, 8.2 + slat * 0.28, front + 0.30),
Color(0.24, 0.31, 0.27), false, null, Vector2.ONE, "painted_metal")
# The recessed door gets a low stone step into the alley.
_add_box(root, Vector3(3.4, 0.16, 0.55), Vector3(0, 0.08, front + 0.75),
Color(0.48, 0.45, 0.42), true, null, Vector2.ONE, "stone")
# Dumpster and its lid; a solid body so guests and the guard walk around it.
var dump := StaticBody3D.new()
dump.name = "AlleyDumpster"
dump.position = Vector3(-4.2, 0, front + 9.2)
_add_box(dump, Vector3(1.9, 1.15, 1.25), Vector3(0, 0.58, 0),
Color(0.16, 0.28, 0.21), true, null, Vector2.ONE, "painted_metal")
_add_box(dump, Vector3(2.0, 0.08, 1.35), Vector3(0, 1.18, -0.02),
Color(0.11, 0.19, 0.15), true, null, Vector2.ONE, "painted_metal")
var dump_col := BoxShape3D.new()
dump_col.size = Vector3(1.9, 1.2, 1.3)
var owner_id := dump.create_shape_owner(dump)
dump.shape_owner_add_shape(owner_id, dump_col)
dump.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, 0.6, 0)))
root.add_child(dump)
# AC boxes on the inside faces of the walls, above head height.
for side in [-1.0, 1.0]:
for z in [front + 3.0, front + 7.0]:
_add_box(root, Vector3(0.55, 0.8, 1.1),
Vector3(side * (wall_x - 0.5), 5.8, z),
Color(0.56, 0.57, 0.55), true, null, Vector2.ONE, "metal")
_add_box(root, Vector3(0.08, 0.55, 0.8),
Vector3(side * (wall_x - 0.78), 5.8, z),
Color(0.22, 0.23, 0.22), false, null, Vector2.ONE, "metal")
## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode.
func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]:
var out: Array[StandardMaterial3D] = []
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var standard := mi.mesh.surface_get_material(index) as StandardMaterial3D
if standard != null and standard.emission_enabled \
and standard.emission_energy_multiplier > 0.0:
out.append(standard)
return out
func _build_street_furniture() -> void:
# Blender library street lamps (bulbs + omni lights, off by day). The lamp
# arm points to local -X, so the left side is rotated 180 degrees to face
# the sidewalk.
if _feature("street_lamps"):
var lamp_positions: Array = spec.get("street_lamp_positions", [])
if lamp_positions.is_empty():
for side in [-7.5, 7.5]:
lamp_positions.append(Vector3(side, 0.18, 8.5))
for index in lamp_positions.size():
var lamp_pos: Vector3 = lamp_positions[index]
var holder := _add_prop_model(root, "street_lamp", lamp_pos,
180.0 if lamp_pos.x < 0.0 else 0.0)
holder.name = "Lamp_%d" % index
var omni := OmniLight3D.new()
omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
omni.omni_attenuation = 1.0
omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75)
holder.add_child(omni)
_lamp_omni.append(omni)
_collect_emissive(holder)
# Blender library street trees replace the cone trees. Flank the walkway and
# keep the sight line to the door clear.
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 in the whole venue, including procedural architecture. Used by
## the map audit to catch duplicate/hidden geometry after a rebuild.
func world_triangle_count() -> int:
var total := 0
for node in root.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi == null or mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var arrays := mi.mesh.surface_get_arrays(index)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.size() > 0:
total += indices.size() / 3
else:
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
total += vertices.size() / 3
return total
func world_mesh_count() -> int:
return root.find_children("*", "MeshInstance3D", true, false).size()
## Total triangles across the placed Blender library pieces, for the draw budget
## test. Counts indices when present, otherwise the vertex stream.
func library_triangle_count() -> int:
var total := 0
for holder in library_nodes:
if not is_instance_valid(holder):
continue
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var arrays := mi.mesh.surface_get_arrays(index)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.size() > 0:
total += indices.size() / 3
else:
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
total += vertices.size() / 3
return total
## Apply the baked AO map that sits next to a library prop GLB.
func _apply_library_ao(model: Node3D, prop: String) -> void:
var texture := Materials.load_ao(BLENDER_PROP_DIR + prop + "_ao.png")
if texture == null:
return
for node in model.find_children("*", "MeshInstance3D", true, false):
Materials.apply_ao(node as MeshInstance3D, texture)
## Register a library prop's emissive surfaces so night mode can switch them on.
func _collect_emissive(holder: Node3D) -> void:
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if not _street_lamps.has(mi):
_street_lamps.append(mi)
for material in _emissive_materials(holder):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
## Street-furniture placement table. Positions are in world space on the
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
## furniture instead of a scattered list. `accessory` names a small procedural
## addition (bag, crate, cone) for one-off variation.
const PROP_PLACEMENTS := [
# Entrance: bollards flank the foot of the steps, bins catch people who
# stop at the door.
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
# Mid-block: benches face the boulevard, planters break up the long run.
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
# Curb: newspaper boxes and a hydrant along the roadway edge.
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
"accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"},
# Blender library dressing: palms frame the block, planters and a queue
# barrier sit outside the guest lane.
{"zone": "mid_block", "prop": "palm_tree", "x": -13.0, "z": 10.2, "rot": 10.0},
{"zone": "mid_block", "prop": "palm_tree", "x": 13.0, "z": 10.2, "rot": -10.0},
{"zone": "mid_block", "prop": "round_planter", "x": -12.6, "z": 6.2},
{"zone": "mid_block", "prop": "round_planter", "x": 12.6, "z": 6.2},
{"zone": "curb", "prop": "a_frame_sign", "x": -6.9, "z": 6.5, "rot": 15.0},
{"zone": "curb", "prop": "traffic_cone", "x": 9.6, "z": 9.8},
{"zone": "curb", "prop": "crowd_barrier", "x": 3.0, "z": 10.8},
{"zone": "curb", "prop": "stanchion", "x": -2.6, "z": 10.8},
{"zone": "entrance", "prop": "security_podium", "x": 3.2, "z": 5.6, "rot": -15.0},
]
## The door, steps, and the lane guests walk in through stay clear of props.
const PROP_EXCLUSIONS := [
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
]
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
## Two prop centres closer than this read as one blob (newspaper boxes are
## deliberately lined up, so this stays below their 0.7 m spacing).
const PROP_MIN_SPACING := 0.6
static func _variant_seed(index: int) -> int:
return index * 7919 + 13
## Deterministic per-placement variation. The same index always gives the same
## rotation and scale, so the street is reproducible while repeats differ.
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = _variant_seed(index)
return {
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
"scale": rng.randf_range(0.96, 1.04),
"roll": rng.randf(),
}
## Static validation used by tests: reject duplicate, out-of-bounds, or
## overlapping placements, and any prop inside the door/steps/approach lane.
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
var problems: Array[String] = []
var seen := {}
for i in placements.size():
var entry: Dictionary = placements[i]
var prop := str(entry.get("prop", ""))
var x := float(entry.get("x", 0.0))
var z := float(entry.get("z", 0.0))
var key := "%s@%.2f,%.2f" % [prop, x, z]
if seen.has(key):
problems.append("duplicate placement %s" % key)
seen[key] = true
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
for zone in PROP_EXCLUSIONS:
if x > float(zone["x0"]) and x < float(zone["x1"]) \
and z > float(zone["z0"]) and z < float(zone["z1"]):
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
for j in range(i):
var other: Dictionary = placements[j]
var dx := x - float(other.get("x", 0.0))
var dz := z - float(other.get("z", 0.0))
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
prop, str(other.get("prop", "")), x, z])
return problems
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
## variants are fixed, so the street looks the same on every run.
func _build_street_detail() -> void:
var detail := Node3D.new()
detail.name = "StreetDetail"
root.add_child(detail)
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"]),
Vector3(float(entry["x"]), 0.18, float(entry["z"])),
float(variant["rotation"]), index)
holder.scale = Vector3.ONE * float(variant["scale"])
var accessory := str(entry.get("accessory", ""))
if accessory != "":
_add_prop_accessory(holder, accessory)
## Place one generated prop. Falls back to simple primitives when the `.glb` is
## missing (fresh checkout before `godot --import`) so the world still builds.
## A static collider is fitted from the model bounds (cylinder for round props,
## box for everything else), so props block the guard and NPCs.
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
var holder := Node3D.new()
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
holder.position = pos
holder.rotation_degrees.y = rot_y_deg
var model := _load_prop_scene(prop)
if model != null:
holder.add_child(model)
loaded_models.append(prop)
if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"):
library_props.append(prop)
library_nodes.append(holder)
holder.set_meta("library_prop", prop)
_apply_library_ao(model, prop)
else:
var fallback := _fallback_prop(prop)
holder.add_child(fallback)
fallback_props.append(prop)
model = fallback
parent.add_child(holder)
_add_prop_collision(holder, prop, model)
return holder
## Fit a simple collider to the prop's meshes (in holder-local space, so the
## placement scale and rotation carry over).
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
var bounds := _model_bounds(model, holder)
if bounds.size == Vector3.ZERO:
return
var body := StaticBody3D.new()
body.name = "Collision"
var collider := CollisionShape3D.new()
collider.name = "Shape"
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
if prop in round_props:
var cylinder := CylinderShape3D.new()
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
cylinder.height = bounds.size.y
collider.shape = cylinder
else:
var box := BoxShape3D.new()
box.size = bounds.size
collider.shape = box
collider.position = bounds.get_center()
body.add_child(collider)
holder.add_child(body)
# Publish the footprint to NavigationServer so guest avoidance steers around
# the prop even though the navmesh itself is a flat walkable plane.
var obstacle := NavigationObstacle3D.new()
obstacle.name = "Avoidance"
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
obstacle.height = maxf(bounds.size.y, 0.5)
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
holder.add_child(obstacle)
## AABB of every mesh under `model`, expressed relative to `ancestor`.
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
var bounds := AABB()
var found := false
for node in model.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
var xform := _transform_to_ancestor(mi, ancestor)
var box: AABB = xform * mi.mesh.get_aabb()
bounds = box if not found else bounds.merge(box)
found = true
return bounds
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
var xform := Transform3D.IDENTITY
var current := node
while current != null and current != ancestor:
if current is Node3D:
xform = (current as Node3D).transform * xform
current = current.get_parent()
return xform
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
## newspaper boxes, a cone by the hydrant.
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
match kind:
"bag":
var bag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.22
sphere.height = 0.5
bag.mesh = sphere
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
bag.position = Vector3(0.38, 0.22, 0.06)
holder.add_child(bag)
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
"crate":
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
"cone":
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
Color(0.95, 0.36, 0.10), "painted_metal")
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
Color(0.92, 0.92, 0.9), "painted_metal")
func _load_prop_scene(prop: String) -> Node3D:
var path := BLENDER_PROP_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
path = MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
return null
var res: Resource = ResourceLoader.load(path)
if res is PackedScene:
var node: Node = (res as PackedScene).instantiate()
if node is Node3D:
return node as Node3D
node.queue_free()
return null
## Rough primitive stand-in per prop, used only when the model is unavailable.
func _fallback_prop(prop: String) -> Node3D:
var node := Node3D.new()
match prop:
"bollard":
_add_cylinder_mesh(node, 0.09, 0.92, Vector3(0, 0.46, 0), Color(0.16, 0.17, 0.19), "metal")
"bench":
_add_box(node, Vector3(1.8, 0.08, 0.5), Vector3(0, 0.46, 0), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
_add_box(node, Vector3(1.8, 0.42, 0.07), Vector3(0, 0.72, -0.22), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
for x in [-0.72, 0.72]:
_add_box(node, Vector3(0.09, 0.46, 0.46), Vector3(x, 0.23, 0), Color(0.16, 0.17, 0.19), true, null, Vector2.ONE, "metal")
"trash_bin":
_add_cylinder_mesh(node, 0.27, 0.78, Vector3(0, 0.39, 0), Color(0.22, 0.30, 0.24), "painted_metal")
_add_cylinder_mesh(node, 0.29, 0.06, Vector3(0, 0.81, 0), Color(0.16, 0.17, 0.19), "metal")
"planter_box":
_add_box(node, Vector3(0.96, 0.54, 0.96), Vector3(0, 0.27, 0), Color(0.52, 0.51, 0.48), true, null, Vector2.ONE, "stone")
_add_cylinder_mesh(node, 0.30, 0.55, Vector3(0, 0.86, 0), Color(0.18, 0.42, 0.20), "foliage")
"newspaper_box":
_add_box(node, Vector3(0.42, 0.72, 0.34), Vector3(0, 0.49, 0), Color(0.16, 0.27, 0.45), true, null, Vector2.ONE, "painted_metal")
_add_box(node, Vector3(0.44, 0.10, 0.36), Vector3(0, 0.90, 0), Color(0.85, 0.72, 0.28), true, null, Vector2.ONE, "painted_metal")
"fire_hydrant":
_add_cylinder_mesh(node, 0.15, 0.62, Vector3(0, 0.31, 0), Color(0.6, 0.12, 0.12), "painted_metal")
_add_box(node, Vector3(0.52, 0.10, 0.12), Vector3(0, 0.42, 0), Color(0.75, 0.74, 0.70), true, null, Vector2.ONE, "metal")
_:
_add_box(node, Vector3(0.5, 0.5, 0.5), Vector3(0, 0.25, 0), Color(0.5, 0.5, 0.5))
return node
func _add_cylinder_mesh(parent: Node3D, radius: float, height: float,
pos: Vector3, color: Color, role: String = "metal") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := CylinderMesh.new()
mesh.top_radius = radius
mesh.bottom_radius = radius
mesh.height = height
mesh.radial_segments = 12
mi.mesh = mesh
mi.position = pos
mi.material_override = Materials.flat(color, role)
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
parent.add_child(mi)
return mi
func _build_crosswalk() -> void:
# White crosswalk stripes in front of the entrance on the street.
for i in 8:
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 17.0), Color(0.92, 0.92, 0.92))
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 19.0), Color(0.92, 0.92, 0.92))
## Apply a shift phase: skyline panorama, dry/wet street, and night lighting.
## Called by GameManager on every phase change.
func set_phase(phase: int) -> void:
_phase = phase
var entry: Dictionary = SKYLINE_BY_PHASE.get(phase, SKYLINE_BY_PHASE[Rules.Phase.EARLY])
if _skyline_mat != null:
_skyline_mat.set_shader_parameter("sky_texture", entry["texture"])
_skyline_mat.set_shader_parameter("tint", entry["tint"])
_skyline_mat.set_shader_parameter("haze_color", entry["haze"])
_skyline_mat.set_shader_parameter("haze_strength", entry["haze_strength"])
var night: bool = phase == Rules.Phase.EVENING or phase == Rules.Phase.LATE
set_night_mode(night)
if _street_mat != null:
var wet: bool = phase == Rules.Phase.LATE
var dry: StandardMaterial3D = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7))
var damp: StandardMaterial3D = Materials.asphalt(Color(0.52, 0.55, 0.6), Vector2(30, 7), true)
var target: StandardMaterial3D = damp if wet else dry
_street_mat.albedo_texture = target.albedo_texture
_street_mat.normal_texture = target.normal_texture
_street_mat.roughness = target.roughness
_street_mat.metallic_specular = target.metallic_specular
## Switch street lamps and signage to night mode (emissive + lights on).
func set_night_mode(night: bool) -> void:
for mat in _lamp_bulb_mats:
mat.emission_energy_multiplier = (2.0 if night else 0.0)
for omni in _lamp_omni:
# Two lamps at full energy washed the whole sidewalk grey; keep visible
# pools of light instead of flat night-time brightness. The wall sconces
# sit close to the stone, so they need far less energy than street lamps.
var lamp_energy := 2.2
if omni.name == "LanternGlow":
lamp_energy = 3.0 if venue_id == "joke_shop" else 1.3
omni.light_energy = (lamp_energy if night else 0.0)
if _hill_mat != null:
# The unshaded hillside has no real lighting, so its tint follows the
# phase. Night keeps it as a dark silhouette behind the lit houses.
var hill_tint := Color(0.48, 0.42, 0.29)
if _phase == Rules.Phase.EVENING:
hill_tint = Color(0.34, 0.29, 0.25)
elif _phase == Rules.Phase.LATE:
hill_tint = Color(0.22, 0.21, 0.23)
_hill_mat.set_shader_parameter("tint", hill_tint)
for omni in _accent_omni:
# Keep the facade washes soft: the reference night reads as dark stone
# with warm pools at the sconces, not a flooded yellow wall.
var energy := 1.2
if omni.name.begins_with("FacadeWash"):
energy = 0.50
elif omni.name.begins_with("UpperWash"):
energy = 0.28
elif omni.name.begins_with("OvationWash"):
energy = 0.75
elif omni.name == "JokeSign":
energy = 1.25
elif omni.name.begins_with("JokeCanopy"):
energy = 2.8
elif omni.name == "JokeEntrance":
energy = 3.0
elif omni.name.begins_with("JokeSconce"):
energy = 1.8
elif omni.name.begins_with("JokeGround"):
energy = 1.35
elif omni.name == "HillPorch":
energy = 0.75
omni.light_energy = (energy if night else 0.0)
if _ovation_sign:
_ovation_sign.modulate = Color(0.90, 0.84, 0.70) if night else Color(0.16, 0.14, 0.12)
for mat in _marquee_emissive:
mat.emission_energy_multiplier = (2.0 if night else 0.2)
# Light most, not all, of the windows: a fully lit grid reads as a light box
# instead of an occupied building.
for i in _window_mats.size():
var lit := night and i % 3 != 2
_window_mats[i].emission_energy_multiplier = (0.65 if lit else 0.0)
if marquee_label:
marquee_label.modulate.a = (1.0 if night else 0.9)
func _emissive_material(color: Color) -> StandardMaterial3D:
return Materials.emissive(color)
func _plain_material(color: Color, role: String = "concrete") -> StandardMaterial3D:
return Materials.flat(color, role)
func _build_guard_spawn() -> void:
var marker := Marker3D.new()
marker.name = "GuardSpawn"
# Guard spawns on the landing at the top of the steps, facing the street.
# The landing is at y=4*height+0.1 where height=0.16, so y=0.76
# 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 = spec.get("guard_spawn", Vector3(0, 0.1, -2.0))
root.add_child(marker)
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = 1.5
return mat
## Mirror the door lock state visually (green open, red locked).
func set_door_locked(locked: bool) -> void:
if door_indicator == null:
return
var color := Color(0.9, 0.15, 0.15) if locked else Color(0.1, 0.9, 0.3)
if door_indicator.material_override is StandardMaterial3D:
(door_indicator.material_override as StandardMaterial3D).albedo_color = color
(door_indicator.material_override as StandardMaterial3D).emission = color