Files
security-game/src/game/world_builder.gd
T
Gig Line Dev 37f925c7ad
Full build / Import + headless tests (push) Successful in 37s
Full build / Full desktop build + optional release (push) Skipped
Rebuild the Joke Shop as the Sunset Strip block
- Replace the alley with the 27 m Comedy Store-inspired hall, signature canopy, circular blade sign, red curtain bays, west entrance, low-rise neighbours, dry hillside and opposite frontage.
- Add day/night/across-street concept frames and real-site mapping notes.
- Expose the Joke Shop day stage in the realism captures and refresh the gallery shots.
- Render multiline HUD/wand strings correctly and list carried items in the wand panel.
- Add the Joke Shop layout/budget tests and update docs, roadmap and changelog.
2026-11-02 14:10:07 +01:00

2344 lines
107 KiB
GDScript

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")
## 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)},
Rules.Phase.MIDDAY: {"texture": T_SKYLINE_DAY, "tint": Color(1.02, 1.04, 1.08)},
Rules.Phase.EVENING: {"texture": T_SKYLINE_DUSK, "tint": Color(0.9, 0.92, 1.0)},
Rules.Phase.LATE: {"texture": T_SKYLINE_NIGHT, "tint": Color(1.5, 1.55, 1.65)},
}
## 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"]
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_LIMESTONE,
"facade_tint": Color(0.88, 0.86, 0.82), "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",
"facade_width": 30.0, "facade_height": 15.0, "facade_depth": 4.0,
"facade_center_z": -6.0, "facade_texture": T_CONCRETE,
"facade_tint": Color(0.58, 0.57, 0.55), "facade_role": "stone",
"window_columns": [-11.0, -7.2, 7.2, 11.0],
"pilaster_columns": [-13.2, -9.0, -5.0, 5.0, 9.0, 13.2],
"window_rows": [10.5],
"stair_width": 7.0, "stair_count": 3, "stair_tread": 0.6, "stair_height": 0.16,
"sidewalk_width": 28.0, "sidewalk_depth": 7.0,
"street_width": 100.0, "street_depth": 24.0,
"nav_min": Vector2(-14.0, -0.6), "nav_max": Vector2(14.0, 18.0),
"guard_spawn": Vector3(0.0, 0.1, -1.8),
"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,
},
"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)
_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)
var signature := Color(0.58, 0.56, 0.52)
var red := Color(0.42, 0.035, 0.045)
# Main hall. The black stucco is the real building's dominant surface; the
# concrete texture keeps it matte instead of turning it into glossy paint.
_add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), black,
false, T_CONCRETE, 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")
# Signature scribbles on the fascia and the wall. 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")
_add_box(west, Vector3(9.2, 0.22, 1.3), Vector3(-22.6, 3.0, west_front + 0.65),
Color(0.48, 0.10, 0.08), true, T_AWNING, Vector2(3, 0.5), "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.
var facade := _add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz),
stone, false, T_LIMESTONE, Vector2(w * 0.22, h * 0.18), "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)
_add_box(parent, Vector3(2.62, 5.55, 0.06), Vector3(x, 8.2, front + 0.73),
red, true, null, Vector2.ONE, "cloth")
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)
_add_box(walk, Vector3(30.0, 0.035, 1.75), Vector3(0, 0.198, 9.15),
Color(0.20, 0.18, 0.17), false, null, Vector2.ONE, "stone")
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("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 float repeats = 2.0;
void fragment() {
vec4 panorama = texture(sky_texture, vec2(UV.x * repeats, UV.y));
ALBEDO = panorama.rgb * tint.rgb;
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)
_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)
_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)
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)
_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)
_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"])
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