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") ## 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] = [] 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 Building", "facade_width": 40.0, "facade_height": 18.0, "facade_depth": 4.0, "facade_center_z": -6.0, "facade_texture": T_BRICK, "facade_tint": Color(0.86, 0.85, 0.83), "facade_role": "brick", "window_columns": [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0], "pilaster_columns": [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3], "window_rows": [9.2, 12.4], "stair_width": 8.0, "stair_count": 4, "stair_tread": 0.6, "stair_height": 0.16, "sidewalk_width": 30.0, "sidewalk_depth": 8.0, "street_width": 120.0, "street_depth": 26.0, "nav_min": Vector2(-18.0, -0.6), "nav_max": Vector2(18.0, 20.0), "guard_spawn": Vector3(0.0, 0.1, -2.0), "features": { "marquee": true, "skyline": true, "handrail": true, "sedan": true, "wall_lanterns": true, "palms": true, "street_trees": true, "street_lamps": true, "crosswalk": true, "bulb_canopy": false, }, }, "joke_shop": { "display_name": "The Joke Shop", "facade_width": 12.0, "facade_height": 12.0, "facade_depth": 5.0, "facade_center_z": -6.5, "facade_texture": T_BRICK, "facade_tint": Color(0.78, 0.66, 0.58), "facade_role": "brick", "window_columns": [-3.8, 3.8], "pilaster_columns": [-5.2, 5.2], "window_rows": [8.2], "stair_width": 5.0, "stair_count": 2, "stair_tread": 0.65, "stair_height": 0.16, "sidewalk_width": 22.0, "sidewalk_depth": 6.0, "street_width": 75.0, "street_depth": 18.0, "nav_min": Vector2(-9.0, -0.6), "nav_max": Vector2(9.0, 14.0), "guard_spawn": Vector3(0.0, 0.1, -1.6), "features": { "marquee": false, "skyline": false, "handrail": false, "sedan": false, "wall_lanterns": true, "palms": false, "street_trees": false, "street_lamps": false, "crosswalk": false, "bulb_canopy": false, }, "prop_placements": [], }, "tv_studio": { "display_name": "Late Night TV Studio", "facade_width": 30.0, "facade_height": 15.0, "facade_depth": 4.0, "facade_center_z": -6.0, "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() _build_building() if _feature("skyline"): _build_skyline_backdrop() if _feature("marquee"): _build_marquee() if _feature("bulb_canopy"): _build_bulb_canopy() _build_steps() _build_sidewalk() _build_street() if _feature("crosswalk"): _build_crosswalk() _build_street_furniture() _build_street_detail() _build_library_dressing() if venue_id == "joke_shop": _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) ## Regular window bands above the entrance. Panes are dark glass by day and ## light up after dark, which gives the facade depth at night. func _build_facade_windows(parent: Node3D) -> void: var columns: 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 _add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) # Add collision shape to each tread 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 var landing_y: float = count * height + 0.1 var landing_w := step_w + 1.0 _add_box(steps, Vector3(landing_w, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45)) var landing_col := BoxShape3D.new() landing_col.size = Vector3(landing_w, 0.2, 3) var landing_owner := steps.create_shape_owner(steps) steps.shape_owner_add_shape(landing_owner, landing_col) steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5))) 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("sidewalk_depth", 8.0)) + 4.1 var street_z := curb_z + street_d * 0.5 - 3.1 _street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(street_w * 0.25, street_d * 0.27)) var street_mesh := _add_box(street, Vector3(street_w, 0.1, street_d), Vector3(0, 0.05, street_z), Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(street_w * 0.25, street_d * 0.27), "asphalt") street_mesh.material_override = _street_mat # Center dashes along the street (x varied so they form a lane line, not a stack). for i in range(-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 _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) 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 = 0.0 if Materials.modern_light_falloff() else 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 = 0.0 if Materials.modern_light_falloff() else 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"): for side in [-7.5, 7.5]: var holder := _add_prop_model(root, "street_lamp", Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0) holder.name = "Lamp_%d" % int(side) 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 = 0.0 if Materials.modern_light_falloff() else 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 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. omni.light_energy = (1.8 if night else 0.0) 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 = (1.1 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