Props were decoration: the guard and guests walked straight through benches, bins, bollards and planters. They now block movement and steer guests around. Collision - _add_prop_model() fits a StaticBody3D from the model's mesh bounds in holder-local space: a cylinder for bollard/bin/hydrant, a box otherwise, so placement rotation and variant scale carry over. - Fallback primitives go through the same fit, so a missing .glb cannot silently remove collision. - Each prop also publishes a NavigationObstacle3D; NpcAvatar enables agent avoidance and moves with the safe velocity from velocity_computed. The navmesh stays a flat walkable plane and avoidance does the steering. Placement - PROP_PLACEMENTS groups furniture by entrance, mid-block and curb zones, with a static placement_problems() validator for bounds, overlap/min-spacing, duplicates, and the door/steps/guest-lane exclusions. - variant_for(index) gives deterministic yaw jitter, subtle scale, and optional clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant). Verification - New windowed smoke_props.gd: guard walks into a bench and stops; a guest headed beyond the bench routes around it and never enters the collider. - Headless 182 -> 187: placement validation and its failure modes, variant determinism/range, collider + obstacle on every placed prop, fallback collider. - Other smokes (PLAN verbs, crowd, emergency, timeline) still pass; Python tooling 53/53; screenshots recaptured; compare 9 good / 0 bad. - Version 0.4.0. TODO: collision/placement section moved to DONE; prop pipeline checks remain.
654 lines
29 KiB
GDScript
654 lines
29 KiB
GDScript
class_name WorldBuilder
|
|
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
|
|
|
const Materials := preload("res://src/game/materials.gd")
|
|
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
|
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
|
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
|
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
|
## 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 MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
|
|
"newspaper_box", "fire_hydrant"]
|
|
|
|
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] = []
|
|
|
|
func _init(root_node: Node3D) -> void:
|
|
root = root_node
|
|
|
|
func build() -> void:
|
|
_build_ground()
|
|
_build_building()
|
|
_build_skyline_backdrop()
|
|
_build_marquee()
|
|
_build_steps()
|
|
_build_sidewalk()
|
|
_build_street()
|
|
_build_crosswalk()
|
|
_build_street_furniture()
|
|
_build_street_detail()
|
|
_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"
|
|
# Main facade (textured brick)
|
|
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.80, 0.76, 0.74), false, T_BRICK, Vector2(22, 10), "brick")
|
|
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
|
|
_add_box(building, Vector3(40, 0.8, 0.35), Vector3(0, 0.4, -3.85), Color(0.58, 0.56, 0.53), false, null, Vector2.ONE, "stone")
|
|
# Entrance recess + door recess frame
|
|
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
|
|
# Door frame columns and lintel, so the entrance is framed rather than painted on.
|
|
for side in [-1.55, 1.55]:
|
|
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
|
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
|
# Door frame and dark door
|
|
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal")
|
|
# Door glass (semi-transparent)
|
|
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
|
|
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
|
|
# Door handles: two slim metal bars where the guard reaches for the lock.
|
|
for side in [-0.42, 0.42]:
|
|
_add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
|
|
# Door lock indicator (green = open, red = locked), placed beside the handle
|
|
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
|
|
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
|
_build_facade_windows(building)
|
|
var col := BoxShape3D.new()
|
|
col.size = Vector3(40, 18, 4)
|
|
var so := building.create_shape_owner(building)
|
|
building.shape_owner_add_shape(so, col)
|
|
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
|
root.add_child(building)
|
|
|
|
## Regular window bands above the entrance. Panes are dark glass by day and
|
|
## light up after dark, which gives the facade depth at night.
|
|
func _build_facade_windows(parent: Node3D) -> void:
|
|
var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0]
|
|
for y in [9.2, 12.4]:
|
|
for x in columns:
|
|
# 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.
|
|
_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")
|
|
|
|
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 := 8.0
|
|
var tread := 0.6
|
|
var height := 0.16
|
|
for i in 4:
|
|
var y: float = i * height
|
|
var z: float = 4.0 * 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 = 4 * height + 0.1
|
|
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
|
|
var landing_col := BoxShape3D.new()
|
|
landing_col.size = Vector3(9, 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"
|
|
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.74, 0.74, 0.72), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
|
|
var col := BoxShape3D.new()
|
|
col.size = Vector3(30, 0.36, 8)
|
|
var so := sidewalk.create_shape_owner(sidewalk)
|
|
sidewalk.shape_owner_add_shape(so, col)
|
|
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8)))
|
|
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.
|
|
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.52, 0.53, 0.57), false, T_ASPHALT, Vector2(30, 7), "asphalt")
|
|
# Center dashes along the street (x varied so they form a lane line, not a stack).
|
|
for i in range(-40, 41, 4):
|
|
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
|
|
# Curb between the sidewalk and the roadway.
|
|
_add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
|
|
var col := BoxShape3D.new()
|
|
col.size = Vector3(120, 0.2, 26)
|
|
var so := street.create_shape_owner(street)
|
|
street.shape_owner_add_shape(so, col)
|
|
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
|
|
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 verts := PackedVector3Array([
|
|
Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6),
|
|
Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0),
|
|
])
|
|
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"
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_texture = T_SKYLINE
|
|
mat.texture_repeat = true
|
|
mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
|
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
|
mat.alpha_scissor_threshold = 0.5
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
# Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
|
|
# distant skyline sits closer to a hazy daytime city.
|
|
mat.albedo_color = Color(2.6, 2.7, 3.0, 1.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
|
|
mesh.size = Vector2(400, 60)
|
|
# 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 := 30.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)
|
|
|
|
var _skyline_mat: StandardMaterial3D = null
|
|
|
|
func _build_marquee() -> void:
|
|
## Striped awning above the door, then the neon sign strip + label.
|
|
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"
|
|
## Neon-style marquee strip above the door: emissive bars + a label.
|
|
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)
|
|
marquee_label = Label3D.new()
|
|
marquee_label.name = "MarqueeLabel"
|
|
marquee_label.text = "STEP LINE"
|
|
marquee_label.position = Vector3(0, 7.3, -3.55)
|
|
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
|
|
strip_parent.add_child(marquee_label)
|
|
root.add_child(strip_parent)
|
|
|
|
func _build_street_furniture() -> void:
|
|
# Street lamps along the sidewalk (bulbs + omni lights, off by day).
|
|
for side in [-7.5, 7.5]:
|
|
var lamp := Node3D.new()
|
|
lamp.name = "Lamp_%d" % int(side)
|
|
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
|
|
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
|
|
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
|
|
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
|
|
bulb.material_override = bulb_mat
|
|
bulb.material_override.emission_energy_multiplier = 1.5
|
|
_lamp_bulb_mats.append(bulb_mat)
|
|
var omni := OmniLight3D.new()
|
|
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
|
|
omni.omni_range = 12.0
|
|
omni.light_energy = 0.0
|
|
omni.light_color = Color(1, 0.93, 0.75)
|
|
lamp.add_child(omni)
|
|
_lamp_omni.append(omni)
|
|
root.add_child(lamp)
|
|
_street_lamps.append(bulb)
|
|
# Planters beside the steps were flat boxes that clipped the treads; the
|
|
# generated planter_box props replace them in _build_street_detail().
|
|
# Low-poly trees along the street front.
|
|
var trunk_color := Color(0.35, 0.25, 0.18)
|
|
var leaf_color := Color(0.16, 0.42, 0.2)
|
|
# Flank the walkway; keep the sight line to the door clear.
|
|
for i in 6:
|
|
var side := -1.0 if i % 2 == 0 else 1.0
|
|
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
|
var z := 4.0 + float((i * 7) % 5)
|
|
var tree := Node3D.new()
|
|
tree.name = "Tree_%d" % (i + 1)
|
|
var trunk := MeshInstance3D.new()
|
|
var trunk_mesh := CylinderMesh.new()
|
|
trunk_mesh.top_radius = 0.09
|
|
trunk_mesh.bottom_radius = 0.14
|
|
trunk_mesh.height = 1.4
|
|
trunk.mesh = trunk_mesh
|
|
trunk.material_override = _plain_material(trunk_color, "wood")
|
|
trunk.position = Vector3(x, 0.7, z)
|
|
tree.add_child(trunk)
|
|
var crown := MeshInstance3D.new()
|
|
var crown_mesh := CylinderMesh.new()
|
|
crown_mesh.top_radius = 0.05
|
|
crown_mesh.bottom_radius = 0.85
|
|
crown_mesh.height = 2.2
|
|
crown.mesh = crown_mesh
|
|
crown.material_override = _plain_material(leaf_color, "foliage")
|
|
crown.position = Vector3(x, 2.1, z)
|
|
tree.add_child(crown)
|
|
root.add_child(tree)
|
|
|
|
## 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"},
|
|
]
|
|
|
|
## 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)
|
|
for index in PROP_PLACEMENTS.size():
|
|
var entry: Dictionary = PROP_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)
|
|
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 := 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))
|
|
|
|
## 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 = 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 |