v0.3.0: rendering pass, generated props, selection model and Web build
Rendering and presentation - Shared surface-material module (src/game/materials.gd) with per-surface roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky energy. WorldBuilder rebuilt on those profiles with facade windows that light up, door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage. - NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses, hands and shoes, arm/leg walk swing. - Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box, hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a procedural fallback; ADR-0012. Selection and movement - Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the mouse wheel cycle nearby people initially, right click clears focus with a re-acquire grace, HUD shows the candidate count, Shift walks at half speed. ADR-0011. Windowed smoke extended to cover wheel cycling and clearing. QA tooling - screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list` subcommand; draft generation keeps existing AI references unless --force. Web export - New Web preset using the non-threaded Godot template (static hosting, no COOP/COEP). tools/build_releases.sh exports build/web and packages build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop artifacts. test_web_export.gd covers the preset and release wiring. Verification - Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass (including the windowed focus/wheel smoke). Web bundle serves index.html, index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server. - Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
This commit is contained in:
1 parent
f763cff744
commit
afa1d6beec
48 files changed
+2602
-147
No files matched your search
+59
-23
@@ -16,6 +16,7 @@ const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||
const Materials := preload("res://src/game/materials.gd")
|
||||
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
@@ -44,24 +45,34 @@ var _last_phase: int = -1
|
||||
## structure — phase-based lighting). Night phases switch street lamps on.
|
||||
const PHASE_LIGHTING := {
|
||||
Rules.Phase.EARLY: {
|
||||
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
|
||||
"sun_energy": 1.05, "sun_angle": -50.0, "sun_color": Color(1.0, 0.97, 0.90),
|
||||
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
||||
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
||||
"exposure": 0.88, "glow": 0.35, "fog_color": Color(0.80, 0.85, 0.92),
|
||||
"fog_density": 0.0028, "fog_energy": 0.75, "contrast": 1.04, "saturation": 1.06,
|
||||
"ambient_energy": 0.45,
|
||||
},
|
||||
Rules.Phase.MIDDAY: {
|
||||
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
|
||||
"sun_energy": 1.3, "sun_angle": -62.0, "sun_color": Color(1.0, 0.99, 0.96),
|
||||
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
||||
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
||||
"exposure": 0.85, "glow": 0.3, "fog_color": Color(0.84, 0.88, 0.94),
|
||||
"fog_density": 0.0022, "fog_energy": 0.8, "contrast": 1.05, "saturation": 1.05,
|
||||
"ambient_energy": 0.45,
|
||||
},
|
||||
Rules.Phase.EVENING: {
|
||||
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
||||
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
||||
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
||||
"exposure": 1.05, "glow": 0.7, "fog_color": Color(0.95, 0.68, 0.48),
|
||||
"fog_density": 0.0045, "fog_energy": 0.6, "contrast": 1.08, "saturation": 1.12,
|
||||
},
|
||||
Rules.Phase.LATE: {
|
||||
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
||||
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
||||
"exposure": 1.25, "glow": 0.95, "fog_color": Color(0.10, 0.13, 0.24),
|
||||
"fog_density": 0.0055, "fog_energy": 0.5, "contrast": 1.1, "saturation": 1.0,
|
||||
},
|
||||
}
|
||||
var hud: Hud = null
|
||||
@@ -103,12 +114,12 @@ var _venue_index := 0
|
||||
## RNG for venue door-staff lines so repeated interactions vary.
|
||||
var _door_rng := RandomNumberGenerator.new()
|
||||
|
||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
||||
const NEARBY_RADIUS := 12.0
|
||||
## Aim-free targeting lives on the guard (`GuardController.nearby_candidates`);
|
||||
## this file only reacts to the signals it emits and logs the selection.
|
||||
|
||||
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
||||
## events evict the oldest unfocused NPC so authored moments still spawn.
|
||||
const MAX_LIVE_NPCS := 16
|
||||
var _nearby_index := -1
|
||||
|
||||
func _ready() -> void:
|
||||
_door_rng.randomize()
|
||||
@@ -219,7 +230,6 @@ func _reset_shift_state() -> void:
|
||||
current_incident_npc_id = ""
|
||||
targeted_npc = null
|
||||
_focused_avatar = null
|
||||
_nearby_index = -1
|
||||
inspection_open = false
|
||||
wand_open = false
|
||||
_wand_cooldown = 0.0
|
||||
@@ -429,8 +439,9 @@ func _ensure_lighting() -> void:
|
||||
sky.sky_material = _sky
|
||||
env.sky = sky
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
env.ambient_light_energy = 0.5
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
# Tonemap, glow, fog and colour grading live in SurfaceMaterials so the
|
||||
# world builder and the environment tune-up stay in one place.
|
||||
Materials.configure_environment(env, PHASE_LIGHTING[Rules.Phase.EARLY])
|
||||
_env_node.environment = env
|
||||
scene_root.add_child(_env_node)
|
||||
|
||||
@@ -448,6 +459,8 @@ func _apply_phase_lighting() -> void:
|
||||
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
|
||||
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||
if _env_node and _env_node.environment:
|
||||
Materials.configure_environment(_env_node.environment, cfg)
|
||||
if world_builder:
|
||||
world_builder.set_night_mode(cfg.get("night", false))
|
||||
|
||||
@@ -642,12 +655,14 @@ func _on_guard_target(target: Node3D) -> void:
|
||||
targeted_npc = target
|
||||
_set_focused_avatar(target)
|
||||
_begin_incident(target)
|
||||
_refresh_nearby_hint()
|
||||
|
||||
func _on_guard_clear() -> void:
|
||||
targeted_npc = null
|
||||
_set_focused_avatar(null)
|
||||
hud.set_target_label("")
|
||||
hud.set_action_hint("")
|
||||
_refresh_nearby_hint()
|
||||
|
||||
func _set_focused_avatar(avatar: Node3D) -> void:
|
||||
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
||||
@@ -699,6 +714,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
_handle_key(event)
|
||||
|
||||
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
||||
if event.is_action_pressed("cycle_target_next"):
|
||||
_cycle_nearby_target(1)
|
||||
return
|
||||
if event.is_action_pressed("cycle_target_prev"):
|
||||
_cycle_nearby_target(-1)
|
||||
return
|
||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
if radial and radial.is_open:
|
||||
@@ -794,6 +815,10 @@ func _handle_cancel(from_mouse: bool) -> void:
|
||||
if _close_top_modal():
|
||||
return
|
||||
if from_mouse:
|
||||
# Right click with nothing open drops the current target, so the player can
|
||||
# break focus without walking or looking away.
|
||||
if targeted_npc != null:
|
||||
_clear_focus()
|
||||
return
|
||||
if settings_panel and settings_panel.visible:
|
||||
return
|
||||
@@ -974,30 +999,41 @@ func _cycle_venue() -> void:
|
||||
hud.set_wand_icon(false)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
|
||||
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
||||
## interact without precise aiming (accessibility).
|
||||
func _cycle_nearby_target() -> void:
|
||||
## Aim-free targeting: cycle through NPCs near the guard (Tab, mouse wheel).
|
||||
## Lets players interact without precise aiming (accessibility) and pins the
|
||||
## target so the focus cone does not drop someone the player is not looking at.
|
||||
func _cycle_nearby_target(direction: int = 1) -> void:
|
||||
if guard == null:
|
||||
return
|
||||
var nearby: Array = []
|
||||
for avatar in npcs:
|
||||
if not is_instance_valid(avatar):
|
||||
continue
|
||||
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
|
||||
nearby.append(avatar)
|
||||
if nearby.is_empty():
|
||||
var next_target: Node3D = guard.cycle_target(direction)
|
||||
if next_target == null:
|
||||
hud.log(Strings.lookup("hud.no_one_nearby"))
|
||||
_refresh_nearby_hint()
|
||||
return
|
||||
_nearby_index = (_nearby_index + 1) % nearby.size()
|
||||
var next_target: Node3D = nearby[_nearby_index]
|
||||
targeted_npc = next_target
|
||||
_set_focused_avatar(next_target)
|
||||
_begin_incident(next_target)
|
||||
guard.pin_target(next_target)
|
||||
var controller: Object = _targeted_controller()
|
||||
var who: String = next_target.name
|
||||
if controller and controller.profile and controller.profile.display_name != "":
|
||||
who = str(controller.profile.display_name)
|
||||
hud.log(Strings.fmt("hud.targeting", [who]))
|
||||
_refresh_nearby_hint()
|
||||
|
||||
## Drop the focus and hand selection back to the aim cone (right click).
|
||||
func _clear_focus() -> void:
|
||||
if guard:
|
||||
guard.clear_target()
|
||||
targeted_npc = null
|
||||
_set_focused_avatar(null)
|
||||
if hud:
|
||||
hud.set_target_label("")
|
||||
hud.set_action_hint("")
|
||||
_refresh_nearby_hint()
|
||||
|
||||
## How many people the aim-free ring can cycle through right now.
|
||||
func _refresh_nearby_hint() -> void:
|
||||
if hud == null or guard == null:
|
||||
return
|
||||
hud.set_nearby_hint(guard.nearby_candidates().size())
|
||||
|
||||
func _apply_interaction(level: int, action_name: String) -> void:
|
||||
if targeted_npc == null:
|
||||
|
||||
@@ -15,6 +15,19 @@ var targeted_npc: Node3D = null
|
||||
var input_locked := false
|
||||
var _miss_frames := 0
|
||||
const MISS_GRACE := 8
|
||||
## Loop `Shift` halves the walk speed for close work (inspection, a guest at the
|
||||
## door). It is a precision modifier, not a sprint (PLAN.md keeps movement plain).
|
||||
const SLOW_WALK_FACTOR := 0.45
|
||||
## Aim-free cycling (Tab / mouse wheel) has to survive the focus cone: without a
|
||||
## pin the cone would drop a target the player is not looking at within a few
|
||||
## frames.
|
||||
var target_pinned := false
|
||||
## After the player clears the focus, the cone stays quiet briefly so the person
|
||||
## they were looking at is not re-acquired on the very next frame.
|
||||
var focus_suppress_s := 0.0
|
||||
const FOCUS_SUPPRESS_S := 0.75
|
||||
## Radius used by aim-free cycling.
|
||||
const NEARBY_RADIUS := 12.0
|
||||
|
||||
@onready var camera: Camera3D = $Head/Camera3D
|
||||
@onready var head: Node3D = $Head
|
||||
@@ -33,6 +46,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if focus_suppress_s > 0.0:
|
||||
focus_suppress_s = maxf(0.0, focus_suppress_s - _delta)
|
||||
if input_locked:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
@@ -58,9 +73,12 @@ func _physics_process(_delta: float) -> void:
|
||||
var desired := (forward * input_dir.y + right * input_dir.x)
|
||||
if desired.length() > 1.0:
|
||||
desired = desired.normalized()
|
||||
desired *= move_speed
|
||||
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
|
||||
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
|
||||
var speed := move_speed
|
||||
if Input.is_action_pressed("walk_slow"):
|
||||
speed *= SLOW_WALK_FACTOR
|
||||
desired *= speed
|
||||
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * speed)
|
||||
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * speed)
|
||||
if is_on_floor():
|
||||
velocity.y = 0.0
|
||||
else:
|
||||
@@ -71,6 +89,19 @@ func _physics_process(_delta: float) -> void:
|
||||
func _update_target() -> void:
|
||||
if camera == null:
|
||||
return
|
||||
# Right after "clear target" the cone stays quiet for a moment, otherwise it
|
||||
# would re-acquire the person the player just dismissed.
|
||||
if focus_suppress_s > 0.0:
|
||||
if targeted_npc != null:
|
||||
targeted_npc = null
|
||||
cleared_target.emit()
|
||||
return
|
||||
# A pinned target (chosen with Tab or the mouse wheel) is not subject to the
|
||||
# cone until it is freed or the player clears it.
|
||||
if target_pinned:
|
||||
if targeted_npc != null and is_instance_valid(targeted_npc):
|
||||
return
|
||||
target_pinned = false
|
||||
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
||||
var heads: Array = []
|
||||
var avatars: Array = []
|
||||
@@ -121,3 +152,54 @@ func _head_visible(from: Vector3, to: Vector3) -> bool:
|
||||
return true
|
||||
n = n.get_parent()
|
||||
return false
|
||||
|
||||
## Avatars within cycling range, nearest first. Aim-free selection (Tab, mouse
|
||||
## wheel) walks this list so the order is predictable: closest person next.
|
||||
func nearby_candidates(max_range: float = NEARBY_RADIUS) -> Array:
|
||||
if get_tree() == null:
|
||||
return []
|
||||
var here := global_position
|
||||
var out: Array = []
|
||||
for node in get_tree().get_nodes_in_group("npc"):
|
||||
if not (node is Node3D) or not is_instance_valid(node):
|
||||
continue
|
||||
var avatar := node as Node3D
|
||||
if (avatar.global_position - here).length() <= max_range:
|
||||
out.append(avatar)
|
||||
out.sort_custom(func(a: Node3D, b: Node3D) -> bool:
|
||||
return (a.global_position - here).length_squared() < (b.global_position - here).length_squared())
|
||||
return out
|
||||
|
||||
## Next target in the aim-free ring. `direction` is +1 forward, -1 backward.
|
||||
## Returns null when nobody is in range (the caller logs "no one nearby").
|
||||
func cycle_target(direction: int = 1) -> Node3D:
|
||||
var list := nearby_candidates()
|
||||
if list.is_empty():
|
||||
target_pinned = false
|
||||
return null
|
||||
var index := list.find(targeted_npc)
|
||||
var next_index := 0
|
||||
if index >= 0:
|
||||
next_index = posmod(index + direction, list.size())
|
||||
elif direction < 0:
|
||||
next_index = list.size() - 1
|
||||
target_pinned = true
|
||||
return list[next_index]
|
||||
|
||||
## Pin a target chosen outside the cone and announce it like a cone pick.
|
||||
func pin_target(avatar: Node3D) -> void:
|
||||
target_pinned = avatar != null
|
||||
# An explicit pick overrides the post-clear suppression window.
|
||||
focus_suppress_s = 0.0
|
||||
if targeted_npc == avatar:
|
||||
return
|
||||
targeted_npc = avatar
|
||||
if avatar != null:
|
||||
picked_target.emit(avatar)
|
||||
else:
|
||||
cleared_target.emit()
|
||||
|
||||
## Drop the target and hand focus back to the focus cone.
|
||||
func clear_target() -> void:
|
||||
target_pinned = false
|
||||
focus_suppress_s = FOCUS_SUPPRESS_S
|
||||
@@ -0,0 +1,137 @@
|
||||
class_name SurfaceMaterials
|
||||
extends RefCounted
|
||||
## Shared surface recipes for the procedural world (ADR-0012).
|
||||
##
|
||||
## Godot's `StandardMaterial3D` defaults are flat: every surface comes out with
|
||||
## the same roughness and a plain albedo, which is what made the street read as
|
||||
## grey boxes. These helpers give each surface a physically plausible profile
|
||||
## (brick is rough, glass is smooth, metal is metallic) and keep the textures,
|
||||
## UV scaling, and texture filtering in one place so the world builder, the NPC
|
||||
## avatars, and the props all look like one scene.
|
||||
|
||||
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")
|
||||
|
||||
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
|
||||
## assert the profiles instead of hard-coding numbers twice.
|
||||
const PROFILES := {
|
||||
"brick": {"roughness": 0.88, "metallic": 0.0},
|
||||
"concrete": {"roughness": 0.94, "metallic": 0.0},
|
||||
"asphalt": {"roughness": 0.72, "metallic": 0.0},
|
||||
"awning": {"roughness": 0.80, "metallic": 0.0},
|
||||
"stone": {"roughness": 0.88, "metallic": 0.0},
|
||||
"metal": {"roughness": 0.32, "metallic": 0.75},
|
||||
"painted_metal": {"roughness": 0.48, "metallic": 0.35},
|
||||
"wood": {"roughness": 0.70, "metallic": 0.0},
|
||||
"cloth": {"roughness": 0.85, "metallic": 0.0},
|
||||
"skin": {"roughness": 0.62, "metallic": 0.0},
|
||||
"glass": {"roughness": 0.12, "metallic": 0.10},
|
||||
"rubber": {"roughness": 0.95, "metallic": 0.0},
|
||||
}
|
||||
|
||||
static func profile(role: String) -> Dictionary:
|
||||
return PROFILES.get(role, PROFILES["concrete"])
|
||||
|
||||
## Base textured surface with a named profile.
|
||||
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
|
||||
role: String = "concrete") -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_texture = texture
|
||||
mat.albedo_color = tint
|
||||
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
||||
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
|
||||
mat.roughness = float(profile(role).get("roughness", 0.9))
|
||||
mat.metallic = float(profile(role).get("metallic", 0.0))
|
||||
return mat
|
||||
|
||||
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D:
|
||||
return textured(T_BRICK, tint, uv_scale, "brick")
|
||||
|
||||
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
|
||||
return textured(T_CONCRETE, tint, uv_scale, "concrete")
|
||||
|
||||
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
|
||||
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt")
|
||||
# Damp asphalt at night picks up the street lamps and the marquee.
|
||||
mat.metallic_specular = 0.6
|
||||
return mat
|
||||
|
||||
static func awning(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(4, 1)) -> StandardMaterial3D:
|
||||
return textured(T_AWNING, tint, uv_scale, "awning")
|
||||
|
||||
## Flat-coloured surface with a profile (props, trim, limbs, clothing).
|
||||
static func flat(color: Color, role: String = "concrete") -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.roughness = float(profile(role).get("roughness", 0.9))
|
||||
mat.metallic = float(profile(role).get("metallic", 0.0))
|
||||
return mat
|
||||
|
||||
## Self-lit surface (marquee bars, lamp bulbs, lock indicator).
|
||||
static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = energy
|
||||
mat.roughness = 0.4
|
||||
return mat
|
||||
|
||||
## Dark glass for windows and the entrance door. The alpha keeps the interior
|
||||
## visible, and the low roughness gives the facade a highlight instead of a hole.
|
||||
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.roughness = float(profile("glass").get("roughness", 0.12))
|
||||
mat.metallic = 0.15
|
||||
mat.metallic_specular = 0.8
|
||||
return mat
|
||||
|
||||
## Window pane that can be lit from the inside at night. Solid dark glass reads
|
||||
## as a window at midday; alpha glass just looked like white paper on the brick.
|
||||
static func window_pane() -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.09, 0.12, 0.17)
|
||||
mat.roughness = 0.18
|
||||
mat.metallic = 0.35
|
||||
mat.metallic_specular = 0.6
|
||||
mat.emission_enabled = true
|
||||
mat.emission = Color(1.0, 0.86, 0.62)
|
||||
mat.emission_energy_multiplier = 0.0
|
||||
return mat
|
||||
|
||||
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
|
||||
## fog for depth, and a mild contrast/saturation lift. Phase values come from
|
||||
## `GameManager.PHASE_LIGHTING`.
|
||||
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
||||
if env == null:
|
||||
return
|
||||
env.background_mode = Environment.BG_SKY
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||
env.tonemap_exposure = float(cfg.get("exposure", 1.0))
|
||||
env.tonemap_white = 6.0
|
||||
env.ambient_light_energy = float(cfg.get("ambient_energy", 0.5))
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
# Glow makes the marquee, lamp bulbs, and windows read as light sources.
|
||||
env.glow_enabled = true
|
||||
env.glow_normalized = true
|
||||
env.glow_intensity = float(cfg.get("glow", 0.55))
|
||||
env.glow_strength = 1.0
|
||||
env.glow_bloom = 0.08
|
||||
env.glow_hdr_threshold = 1.0
|
||||
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
|
||||
# Aerial perspective: the skyline ring sits ~200 m out, so light fog gives the
|
||||
# distance its haze without hiding the street.
|
||||
env.fog_enabled = true
|
||||
env.fog_light_color = cfg.get("fog_color", Color(0.72, 0.78, 0.88))
|
||||
env.fog_light_energy = float(cfg.get("fog_energy", 0.7))
|
||||
env.fog_density = float(cfg.get("fog_density", 0.0035))
|
||||
env.fog_aerial_perspective = 0.25
|
||||
env.fog_sky_affect = 0.4
|
||||
env.adjustment_enabled = true
|
||||
env.adjustment_brightness = 1.0
|
||||
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
|
||||
env.adjustment_saturation = float(cfg.get("saturation", 1.08))
|
||||
+123
-33
@@ -5,6 +5,7 @@ extends CharacterBody3D
|
||||
|
||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const Materials := preload("res://src/game/materials.gd")
|
||||
|
||||
var npc: NPCController = null
|
||||
var spawn_position: Vector3 = Vector3.ZERO
|
||||
@@ -95,6 +96,7 @@ var _name_tag: Label3D = null
|
||||
var _photo_prop: MeshInstance3D = null
|
||||
var _sign_prop: MeshInstance3D = null
|
||||
var _legs: Array[MeshInstance3D] = []
|
||||
var _arms: Array[MeshInstance3D] = []
|
||||
var _theater_t := 0.0
|
||||
var _photo_prop_base_y := 0.0
|
||||
var _sign_prop_base_y := 0.0
|
||||
@@ -102,43 +104,105 @@ const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||
|
||||
## Which small accessory a role wears. This is look-only: the player must still
|
||||
## read intent from behaviour, never from the archetype name (ADR-0010).
|
||||
const ACCESSORIES := {
|
||||
Rules.NpcType.AUTHORIZED_STAFF: "cap",
|
||||
Rules.NpcType.RESPONDER: "cap",
|
||||
Rules.NpcType.DELIVERY: "cap",
|
||||
Rules.NpcType.TOURIST: "backpack",
|
||||
Rules.NpcType.INFLUENCER: "sunglasses",
|
||||
Rules.NpcType.TRESPASSER: "hood",
|
||||
Rules.NpcType.VIOLENT_THREAT: "hood",
|
||||
Rules.NpcType.MENTAL_CRISIS: "hood",
|
||||
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
|
||||
Rules.NpcType.PERFORMER: "none",
|
||||
Rules.NpcType.PROTESTER: "none",
|
||||
Rules.NpcType.INTOXICATED: "none",
|
||||
Rules.NpcType.CONFUSED_VISITOR: "none",
|
||||
}
|
||||
|
||||
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
|
||||
## individuals and the result is deterministic (tests rely on that).
|
||||
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
|
||||
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
|
||||
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
|
||||
shirt = shirt.darkened(frac * 0.12)
|
||||
# Jeans through khaki/chino, so not every avatar wears the same trousers.
|
||||
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
|
||||
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
|
||||
return {
|
||||
"shirt": shirt,
|
||||
"sleeves": shirt.darkened(0.16),
|
||||
"pants": pants,
|
||||
"hair": HAIR_COLORS[hair_index],
|
||||
"skin": SKIN_TONE.darkened(frac * 0.18),
|
||||
"accessory": ACCESSORIES.get(npc_type, "none"),
|
||||
"height_scale": 0.92 + 0.16 * frac,
|
||||
"blend": frac,
|
||||
}
|
||||
|
||||
func _build_body(npc_type: int) -> void:
|
||||
_rig = Node3D.new()
|
||||
_rig.name = "Rig"
|
||||
add_child(_rig)
|
||||
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
# Per-seed variation: slight height and brightness differences so crowds
|
||||
# read as individuals (deterministic LCG, no RNG side effects).
|
||||
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
||||
var height_scale := 0.92 + 0.16 * frac
|
||||
_rig.scale.y = height_scale
|
||||
var shader_delta := 0.12 * frac
|
||||
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
||||
var limbs_color := base_color.darkened(0.25)
|
||||
# Torso
|
||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||
# Legs
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
|
||||
# Arms
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||
var outfit := outfit_for(npc_type, _visual_seed)
|
||||
var shirt: Color = outfit["shirt"]
|
||||
var sleeves: Color = outfit["sleeves"]
|
||||
var pants: Color = outfit["pants"]
|
||||
var skin: Color = outfit["skin"]
|
||||
var hair: Color = outfit["hair"]
|
||||
_rig.scale.y = float(outfit["height_scale"])
|
||||
var limbs_color := sleeves
|
||||
# Torso, with a shoulder block and hips so the silhouette is not a tube.
|
||||
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
|
||||
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
|
||||
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
|
||||
# Legs and arms are tracked so the walk cycle can swing them.
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
|
||||
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
|
||||
# Hands and shoes give the limbs an end point at conversation distance.
|
||||
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
|
||||
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
|
||||
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
||||
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
|
||||
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
|
||||
_head = Node3D.new()
|
||||
_head.name = "Head"
|
||||
_head.position = Vector3(0, 1.62, 0)
|
||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
||||
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
|
||||
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
||||
_add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
|
||||
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
|
||||
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
|
||||
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
|
||||
_rig.add_child(_head)
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
||||
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||
_add_accessory(str(outfit["accessory"]), shirt)
|
||||
_build_focus_circle()
|
||||
_add_props(npc_type)
|
||||
|
||||
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
|
||||
func _add_accessory(kind: String, shirt: Color) -> void:
|
||||
match kind:
|
||||
"cap":
|
||||
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
|
||||
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
|
||||
"hood":
|
||||
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
|
||||
"backpack":
|
||||
_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")
|
||||
_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")
|
||||
"sunglasses":
|
||||
_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")
|
||||
"lanyard":
|
||||
_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")
|
||||
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
|
||||
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
|
||||
_:
|
||||
pass
|
||||
|
||||
func _build_focus_circle() -> void:
|
||||
_focus_circle = MeshInstance3D.new()
|
||||
_focus_circle.name = "FocusCircle"
|
||||
@@ -180,7 +244,8 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
||||
if _name_tag:
|
||||
_name_tag.visible = focused
|
||||
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
|
||||
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
mesh.radius = radius
|
||||
@@ -188,24 +253,34 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.rotation_degrees.z = z_rot
|
||||
mi.material_override = _material(color)
|
||||
mi.material_override = _material(color, role)
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
|
||||
role: String = "skin") -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := SphereMesh.new()
|
||||
mesh.radius = radius
|
||||
mesh.height = radius * 2.0
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
mi.material_override = _material(color, role)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
return mat
|
||||
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
|
||||
role: String = "cloth") -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color, role)
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
|
||||
return Materials.flat(color, role)
|
||||
|
||||
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||
func _add_props(npc_type: int) -> void:
|
||||
@@ -358,6 +433,11 @@ static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
||||
return 0.0
|
||||
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
||||
|
||||
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
|
||||
## can be checked without a scene tree.
|
||||
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
|
||||
return sin(phase + float(side) * PI) * amplitude
|
||||
|
||||
## Sway amount for a performer playing to the sidewalk.
|
||||
static func sway_for_pose(pose: String, phase: float) -> float:
|
||||
if pose != "perform":
|
||||
@@ -374,6 +454,10 @@ func _animate(delta: float) -> void:
|
||||
_bob_phase += delta * (6.0 + speed)
|
||||
_rig.position.y = sin(_bob_phase) * 0.035
|
||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||
for i in _legs.size():
|
||||
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
|
||||
for i in _arms.size():
|
||||
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
|
||||
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
||||
# Idle theater: seated, photographing, filming, or performing.
|
||||
_theater_t += delta
|
||||
@@ -393,10 +477,16 @@ func _animate(delta: float) -> void:
|
||||
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
||||
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
||||
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
||||
for arm in _arms:
|
||||
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
|
||||
else:
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||
for i in _legs.size():
|
||||
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
|
||||
for arm in _arms:
|
||||
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2)
|
||||
# The focus circle billboards via its material; no per-frame look_at needed.
|
||||
# Attention cue: head turns toward the guard when awareness was raised.
|
||||
if _head != null and npc.guard_noticed:
|
||||
|
||||
+18
-6
@@ -20,14 +20,26 @@ static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vect
|
||||
return ang
|
||||
|
||||
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
|
||||
var best_i := -1
|
||||
var best_score := 999.0
|
||||
var ranked := rank_indices(cam_origin, cam_forward, head_positions, max_range, max_angle)
|
||||
return ranked[0] if not ranked.is_empty() else -1
|
||||
|
||||
## Every candidate inside the cone, best first: smallest angle to the crosshair,
|
||||
## then nearest. The cone used to return position in the group, which made the
|
||||
## pick order depend on spawn order; ranking by angle matches what the player
|
||||
## sees and gives `Tab` / the mouse wheel a stable order too.
|
||||
static func rank_indices(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> Array:
|
||||
var scored: Array = []
|
||||
for i in head_positions.size():
|
||||
var pos: Vector3 = head_positions[i]
|
||||
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
||||
if score < 0.0:
|
||||
continue
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_i = i
|
||||
return best_i
|
||||
scored.append([score, (pos - cam_origin).length(), i])
|
||||
scored.sort_custom(func(a: Array, b: Array) -> bool:
|
||||
if not is_equal_approx(float(a[0]), float(b[0])):
|
||||
return float(a[0]) < float(b[0])
|
||||
return float(a[1]) < float(b[1]))
|
||||
var ranked: Array = []
|
||||
for entry in scored:
|
||||
ranked.append(int(entry[2]))
|
||||
return ranked
|
||||
+17
-1
@@ -26,6 +26,7 @@ const CARD_TEXTURES := {
|
||||
var top_label: Label
|
||||
var time_label: Label
|
||||
var target_label: Label
|
||||
var nearby_label: Label
|
||||
var hint_label: Label
|
||||
var log_label: Label
|
||||
var debug_label: Label
|
||||
@@ -85,6 +86,15 @@ func _init() -> void:
|
||||
target_label.add_theme_font_size_override("font_size", 16)
|
||||
vbox.add_child(target_label)
|
||||
|
||||
nearby_label = Label.new()
|
||||
nearby_label.add_theme_font_size_override("font_size", 12)
|
||||
nearby_label.modulate = Color(0.92, 0.95, 1.0)
|
||||
# A shadow keeps the hint readable over the bright sidewalk and the sky.
|
||||
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
|
||||
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||
vbox.add_child(nearby_label)
|
||||
|
||||
hint_label = Label.new()
|
||||
hint_label.modulate = Color(1, 0.9, 0.6)
|
||||
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
@@ -270,6 +280,12 @@ func _update_radio_cooldown(coordinator: Object) -> void:
|
||||
func set_target_label(text: String) -> void:
|
||||
target_label.text = text
|
||||
|
||||
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
|
||||
func set_nearby_hint(count: int) -> void:
|
||||
if nearby_label == null:
|
||||
return
|
||||
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
|
||||
|
||||
var _flashlight := false
|
||||
var _door_locked := false
|
||||
|
||||
@@ -308,7 +324,7 @@ func set_font_scale(scale: float) -> void:
|
||||
var clamped := clampf(scale, 0.75, 1.75)
|
||||
_font_scale = clamped
|
||||
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
|
||||
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
||||
nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
||||
for label in scaled:
|
||||
if label == null:
|
||||
continue
|
||||
|
||||
+179
-41
@@ -1,13 +1,21 @@
|
||||
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.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
## 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.
|
||||
@@ -19,6 +27,12 @@ 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
|
||||
@@ -33,19 +47,21 @@ func build() -> void:
|
||||
_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)) -> MeshInstance3D:
|
||||
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1),
|
||||
role: String = "concrete") -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
var mat: StandardMaterial3D
|
||||
if texture:
|
||||
mat.albedo_texture = texture
|
||||
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
||||
mat = Materials.textured(texture, color, uv_scale, role)
|
||||
else:
|
||||
mat = Materials.flat(color, role)
|
||||
mesh.material = mat
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
@@ -59,7 +75,7 @@ func _build_ground() -> void:
|
||||
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))
|
||||
_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)
|
||||
@@ -70,22 +86,27 @@ 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.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
||||
_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))
|
||||
_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))
|
||||
_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))
|
||||
var glass_mat := StandardMaterial3D.new()
|
||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
glass_mat.roughness = 0.15
|
||||
glass_mat.metallic = 0.1
|
||||
glass.material_override = glass_mat
|
||||
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)
|
||||
@@ -93,6 +114,24 @@ func _build_building() -> void:
|
||||
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.
|
||||
@@ -125,7 +164,7 @@ func _build_steps() -> void:
|
||||
func _build_sidewalk() -> void:
|
||||
var sidewalk := StaticBody3D.new()
|
||||
sidewalk.name = "Sidewalk"
|
||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4))
|
||||
_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)
|
||||
@@ -136,10 +175,14 @@ func _build_sidewalk() -> void:
|
||||
func _build_street() -> void:
|
||||
var street := StaticBody3D.new()
|
||||
street.name = "Street"
|
||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7))
|
||||
# Center dashes
|
||||
# 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(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
|
||||
_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)
|
||||
@@ -215,7 +258,7 @@ 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), false, T_AWNING)
|
||||
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()
|
||||
@@ -244,8 +287,8 @@ func _build_street_furniture() -> void:
|
||||
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))
|
||||
_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))
|
||||
_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
|
||||
@@ -253,17 +296,15 @@ func _build_street_furniture() -> void:
|
||||
_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 = 14.0
|
||||
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.
|
||||
for side in [-4.6, 4.6]:
|
||||
var planter := _add_box(root, Vector3(1.1, 0.7, 1.1), Vector3(side, 0.35, 1.0), Color(0.42, 0.34, 0.28))
|
||||
planter.name = "Planter_%d" % int(side)
|
||||
# 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)
|
||||
@@ -280,7 +321,7 @@ func _build_street_furniture() -> void:
|
||||
trunk_mesh.bottom_radius = 0.14
|
||||
trunk_mesh.height = 1.4
|
||||
trunk.mesh = trunk_mesh
|
||||
trunk.material_override = _plain_material(trunk_color)
|
||||
trunk.material_override = _plain_material(trunk_color, "wood")
|
||||
trunk.position = Vector3(x, 0.7, z)
|
||||
tree.add_child(trunk)
|
||||
var crown := MeshInstance3D.new()
|
||||
@@ -289,11 +330,108 @@ func _build_street_furniture() -> void:
|
||||
crown_mesh.bottom_radius = 0.85
|
||||
crown_mesh.height = 2.2
|
||||
crown.mesh = crown_mesh
|
||||
crown.material_override = _plain_material(leaf_color)
|
||||
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 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, and every placement is 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)
|
||||
# Bollards flank the foot of the steps.
|
||||
for x in [-5.6, -4.2, 4.2, 5.6]:
|
||||
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7))
|
||||
# Benches face the boulevard.
|
||||
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0)
|
||||
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0)
|
||||
# Bins where people leave the steps.
|
||||
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9))
|
||||
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9))
|
||||
# Planters break up the long sidewalk run.
|
||||
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8))
|
||||
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
|
||||
# Newspaper boxes and a hydrant along the curb.
|
||||
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
|
||||
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
|
||||
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
|
||||
|
||||
## Place one generated prop. Falls back to simple primitives when the `.glb` is
|
||||
## missing (fresh checkout before `godot --import`) so the world still builds.
|
||||
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
||||
rot_y_deg: float = 0.0) -> 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:
|
||||
holder.add_child(_fallback_prop(prop))
|
||||
fallback_props.append(prop)
|
||||
parent.add_child(holder)
|
||||
return holder
|
||||
|
||||
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:
|
||||
@@ -305,24 +443,24 @@ 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:
|
||||
omni.light_energy = (3.0 if night else 0.0)
|
||||
# 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:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = 1.0
|
||||
return mat
|
||||
return Materials.emissive(color)
|
||||
|
||||
func _plain_material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
return mat
|
||||
func _plain_material(color: Color, role: String = "concrete") -> StandardMaterial3D:
|
||||
return Materials.flat(color, role)
|
||||
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
|
||||
@@ -93,8 +93,53 @@ func _advance() -> void:
|
||||
if state_frames >= 5:
|
||||
if gm.radial.is_open:
|
||||
_fail("action wheel did not close on cancel")
|
||||
state = 4
|
||||
state_frames = 0
|
||||
4:
|
||||
# Aim-free selection (ADR-0011): the cycled target stays pinned, the
|
||||
# HUD reports how many people can be cycled, and the mouse wheel is
|
||||
# bound to the same ring.
|
||||
if state_frames >= 5:
|
||||
if gm.targeted_npc == null:
|
||||
_fail("target lost after the wheel closed")
|
||||
if not gm.guard.target_pinned:
|
||||
_fail("cycled target was not pinned")
|
||||
if gm.hud.nearby_label.text == "":
|
||||
_fail("nearby hint empty while people are in range")
|
||||
_wheel(true)
|
||||
state = 5
|
||||
state_frames = 0
|
||||
5:
|
||||
if state_frames >= 5:
|
||||
if gm.targeted_npc == null:
|
||||
_fail("mouse wheel dropped the target")
|
||||
elif not gm.guard.target_pinned:
|
||||
_fail("mouse wheel did not pin the cycled target")
|
||||
print("smoke: wheel cycled to %s" % gm.targeted_npc.name)
|
||||
# Right click with nothing open returns to cone focus.
|
||||
gm.call("_handle_cancel", true)
|
||||
state = 6
|
||||
state_frames = 0
|
||||
6:
|
||||
if state_frames >= 5:
|
||||
if gm.targeted_npc != null:
|
||||
_fail("right click did not clear the target")
|
||||
if gm.guard.target_pinned:
|
||||
_fail("clearing the target left it pinned")
|
||||
_finish()
|
||||
|
||||
## Synthesised mouse-wheel event, fed through the real handler so the input-map
|
||||
## binding is exercised as well as the cycling logic.
|
||||
func _wheel(down: bool) -> void:
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if down else MOUSE_BUTTON_WHEEL_UP
|
||||
ev.pressed = true
|
||||
ev.position = Vector2(640, 360)
|
||||
ev.global_position = ev.position
|
||||
if not ev.is_action_pressed("cycle_target_next"):
|
||||
_fail("cycle_target_next is not bound to the mouse wheel")
|
||||
gm.call("_handle_mouse_button", ev)
|
||||
|
||||
func _stage_target() -> void:
|
||||
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||
@@ -127,7 +172,7 @@ func _fail(message: String) -> void:
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: focus circle + action wheel opened and cancelled")
|
||||
print("SMOKE OK: focus circle + action wheel + aim-free cycling")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the generated prop models (tools/model_pipeline.py) and
|
||||
## their placement in the procedural world.
|
||||
|
||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||
const MODEL_DIR := "res://assets/models/generated/"
|
||||
|
||||
func test_prop_glb_files_exist() -> bool:
|
||||
var missing: Array[String] = []
|
||||
for prop in WorldBuilder.MODEL_PROPS:
|
||||
if not FileAccess.file_exists(MODEL_DIR + str(prop) + ".glb"):
|
||||
missing.append(str(prop))
|
||||
return TestUtils.check(missing.is_empty(), "all prop models built (missing: %s)" % str(missing))
|
||||
|
||||
func test_prop_glb_files_are_imported_scenes() -> bool:
|
||||
## `load()` only returns a PackedScene once Godot has imported the `.glb`;
|
||||
## a null here means the checkout is missing the import or the model is invalid.
|
||||
var problems: Array[String] = []
|
||||
for prop in WorldBuilder.MODEL_PROPS:
|
||||
var path := MODEL_DIR + str(prop) + ".glb"
|
||||
var packed := ResourceLoader.load(path) as PackedScene
|
||||
if packed == null:
|
||||
problems.append("%s not imported as a scene" % prop)
|
||||
elif not FileAccess.file_exists(path + ".import"):
|
||||
problems.append("%s has no .import file" % prop)
|
||||
return TestUtils.check(problems.is_empty(), "prop models import as scenes (%s)" % str(problems))
|
||||
|
||||
func test_prop_scenes_instantiate_with_meshes() -> bool:
|
||||
var problems: Array[String] = []
|
||||
for prop in WorldBuilder.MODEL_PROPS:
|
||||
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
|
||||
if packed == null:
|
||||
problems.append("%s: no scene" % prop)
|
||||
continue
|
||||
var instance := packed.instantiate()
|
||||
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
|
||||
if meshes.is_empty():
|
||||
problems.append("%s: no mesh instances" % prop)
|
||||
instance.free()
|
||||
return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems))
|
||||
|
||||
func test_world_places_generated_props() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var detail := root.get_node_or_null("StreetDetail")
|
||||
var a := TestUtils.check(detail != null, "StreetDetail exists")
|
||||
var placed: int = detail.get_child_count() if detail else 0
|
||||
var b := TestUtils.check(placed >= 10, "street furniture placed (%d)" % placed)
|
||||
var c := TestUtils.check(builder.loaded_models.size() >= WorldBuilder.MODEL_PROPS.size(),
|
||||
"generated models loaded instead of fallbacks")
|
||||
var d := TestUtils.check(builder.fallback_props.is_empty(),
|
||||
"no procedural fallback used (fallbacks: %s)" % str(builder.fallback_props))
|
||||
root.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_world_facade_windows_light_at_night() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var a := TestUtils.check(builder._window_mats.size() >= 8, "window panes built")
|
||||
builder.set_night_mode(true)
|
||||
var lit: bool = builder._window_mats[0].emission_energy_multiplier > 0.0
|
||||
builder.set_night_mode(false)
|
||||
var dark: bool = is_zero_approx(builder._window_mats[0].emission_energy_multiplier)
|
||||
root.free()
|
||||
return a and TestUtils.check(lit, "windows light up at night") and TestUtils.check(dark, "windows are dark by day")
|
||||
|
||||
func test_fallback_prop_is_usable() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
var fallback := builder._fallback_prop("bench")
|
||||
var meshes := fallback.find_children("*", "MeshInstance3D", true, false)
|
||||
var ok := TestUtils.check(meshes.size() >= 3, "fallback bench has geometry")
|
||||
fallback.free()
|
||||
root.free()
|
||||
return ok
|
||||
@@ -0,0 +1,77 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the shared surface/environment recipes (ADR-0012).
|
||||
|
||||
const Materials := preload("res://src/game/materials.gd")
|
||||
const GameManager := preload("res://src/game/game_manager.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
func test_every_profile_has_physical_values() -> bool:
|
||||
var ok := true
|
||||
for role in ["brick", "concrete", "asphalt", "awning", "stone", "metal",
|
||||
"painted_metal", "wood", "cloth", "skin", "glass", "rubber"]:
|
||||
var profile: Dictionary = Materials.profile(role)
|
||||
var roughness: float = float(profile.get("roughness", -1.0))
|
||||
var metallic: float = float(profile.get("metallic", -1.0))
|
||||
if roughness < 0.0 or roughness > 1.0:
|
||||
ok = false
|
||||
if metallic < 0.0 or metallic > 1.0:
|
||||
ok = false
|
||||
return TestUtils.check(ok, "all roles have roughness/metallic in 0..1")
|
||||
|
||||
func test_textured_recipes_keep_their_texture_and_profile() -> bool:
|
||||
var brick := Materials.brick()
|
||||
var concrete := Materials.concrete()
|
||||
var asphalt := Materials.asphalt()
|
||||
var awning := Materials.awning()
|
||||
var a := TestUtils.check(brick.albedo_texture != null, "brick keeps its texture")
|
||||
var b := TestUtils.check(concrete.uv1_scale.x > 1.0, "concrete tiles across the sidewalk")
|
||||
var c := TestUtils.check(asphalt.roughness < brick.roughness, "asphalt is smoother than brick")
|
||||
var d := TestUtils.check(awning.albedo_texture != null, "awning keeps its texture")
|
||||
var e := TestUtils.check(Materials.flat(Color.RED, "metal").metallic > 0.5, "metal role is metallic")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_glass_and_emissive_recipes() -> bool:
|
||||
var glass := Materials.glass()
|
||||
var pane := Materials.window_pane()
|
||||
var bar := Materials.emissive(Color(1.0, 0.35, 0.75), 2.5)
|
||||
var a := TestUtils.check(glass.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA,
|
||||
"glass is alpha blended")
|
||||
var b := TestUtils.check(glass.roughness < 0.2, "glass is smooth")
|
||||
var c := TestUtils.check(pane.emission_enabled, "window panes can light up")
|
||||
var d := TestUtils.check(is_equal_approx(pane.emission_energy_multiplier, 0.0),
|
||||
"window panes start dark")
|
||||
var e := TestUtils.check(is_equal_approx(bar.emission_energy_multiplier, 2.5),
|
||||
"emissive energy is applied")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_environment_tuning_applies_phase_values() -> bool:
|
||||
var env := Environment.new()
|
||||
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
|
||||
Materials.configure_environment(env, late)
|
||||
var a := TestUtils.check(env.tonemap_mode == Environment.TONE_MAPPER_ACES,
|
||||
"ACES tonemapping")
|
||||
var b := TestUtils.check(env.glow_enabled, "glow enabled for the marquee")
|
||||
var c := TestUtils.check(env.fog_enabled, "fog enabled for distance haze")
|
||||
var d := TestUtils.check(env.fog_density > 0.0, "fog has a density")
|
||||
var e := TestUtils.check(env.adjustment_enabled, "colour grading enabled")
|
||||
var f := TestUtils.check(
|
||||
env.fog_light_color.is_equal_approx(late["fog_color"]),
|
||||
"phase fog colour is used")
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_every_phase_has_environment_keys() -> bool:
|
||||
var ok := true
|
||||
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
|
||||
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
|
||||
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
|
||||
"contrast", "saturation"]:
|
||||
if not cfg.has(key):
|
||||
ok = false
|
||||
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
|
||||
|
||||
func test_night_phases_are_night() -> bool:
|
||||
var evening: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EVENING]
|
||||
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
|
||||
var early: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EARLY]
|
||||
return TestUtils.check(bool(evening["night"]) and bool(late["night"]) and not bool(early["night"]),
|
||||
"night flag matches the phase")
|
||||
@@ -0,0 +1,88 @@
|
||||
extends RefCounted
|
||||
## Headless tests for focus ranking, aim-free cycling helpers, and the NPC
|
||||
## outfit/gait helpers added in the control re-evaluation (ADR-0011).
|
||||
|
||||
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
func test_rank_orders_by_angle_then_distance() -> bool:
|
||||
var origin := Vector3.ZERO
|
||||
var forward := Vector3(0, 0, -1)
|
||||
# Index 2 is dead ahead and near, index 0 dead ahead but far, index 1 off to
|
||||
# the side. Ranking must be angle first, then distance.
|
||||
var heads := [Vector3(0, 0, -9), Vector3(0.9, 0, -6), Vector3(0, 0, -5)]
|
||||
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
|
||||
var a := TestUtils.check_eq(ranked, [2, 0, 1], "angle first, then distance")
|
||||
var b := TestUtils.check_eq(NpcFocus.pick_index(origin, forward, heads), 2,
|
||||
"pick_index returns the best-ranked head")
|
||||
return a and b
|
||||
|
||||
func test_rank_excludes_out_of_cone_and_range() -> bool:
|
||||
var origin := Vector3.ZERO
|
||||
var forward := Vector3(0, 0, -1)
|
||||
var heads := [
|
||||
Vector3(0, 0, 40), # too far
|
||||
Vector3(8, 0, -5), # too wide
|
||||
Vector3(0, 0, 6), # behind the camera
|
||||
Vector3(0.2, 0, -7), # valid
|
||||
]
|
||||
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
|
||||
return TestUtils.check_eq(ranked, [3], "only the in-cone head is ranked")
|
||||
|
||||
func test_rank_is_empty_when_nobody_is_in_the_cone() -> bool:
|
||||
var ranked: Array = NpcFocus.rank_indices(
|
||||
Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
|
||||
var picked := NpcFocus.pick_index(Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
|
||||
return TestUtils.check(ranked.is_empty() and picked == -1, "empty ranking and -1 pick")
|
||||
|
||||
func test_limb_swing_is_opposite_per_side() -> bool:
|
||||
var left := NpcAvatar.limb_swing(PI / 2.0, 0, 1.0)
|
||||
var right := NpcAvatar.limb_swing(PI / 2.0, 1, 1.0)
|
||||
var still := NpcAvatar.limb_swing(0.0, 0, 1.0)
|
||||
var a := TestUtils.check(absf(left - 1.0) < 0.001, "left limb swings to +amplitude")
|
||||
var b := TestUtils.check(absf(right + 1.0) < 0.001, "right limb swings the other way")
|
||||
var c := TestUtils.check(absf(still) < 0.001, "phase 0 is the neutral pose")
|
||||
return a and b and c
|
||||
|
||||
func test_outfit_is_deterministic_per_seed() -> bool:
|
||||
var first: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
|
||||
var second: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
|
||||
var a := TestUtils.check(first["shirt"] == second["shirt"], "same seed, same shirt")
|
||||
var b := TestUtils.check(first["pants"] == second["pants"], "same seed, same trousers")
|
||||
var c := TestUtils.check(first["accessory"] == second["accessory"], "same seed, same accessory")
|
||||
return a and b and c
|
||||
|
||||
func test_outfit_varies_across_seeds() -> bool:
|
||||
var shirts := {}
|
||||
var pants := {}
|
||||
for seed_value in [1, 2, 3, 4, 5, 6, 7, 8]:
|
||||
var outfit: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 977)
|
||||
shirts[outfit["shirt"]] = true
|
||||
pants[outfit["pants"]] = true
|
||||
var a := TestUtils.check(shirts.size() > 1, "shirt colour varies between people")
|
||||
var b := TestUtils.check(pants.size() > 1, "trouser colour varies between people")
|
||||
return a and b
|
||||
|
||||
func test_accessories_follow_the_role() -> bool:
|
||||
var a := TestUtils.check_eq(
|
||||
NpcAvatar.outfit_for(Rules.NpcType.RESPONDER, 5)["accessory"], "cap",
|
||||
"responders wear a cap")
|
||||
var b := TestUtils.check_eq(
|
||||
NpcAvatar.outfit_for(Rules.NpcType.TRESPASSER, 5)["accessory"], "hood",
|
||||
"trespassers wear a hood")
|
||||
var c := TestUtils.check_eq(
|
||||
NpcAvatar.outfit_for(Rules.NpcType.AUTHORIZED_GUEST, 5)["accessory"], "lanyard",
|
||||
"guests wear a lanyard")
|
||||
var d := TestUtils.check_eq(
|
||||
NpcAvatar.outfit_for(Rules.NpcType.INTOXICATED, 5)["accessory"], "none",
|
||||
"roles without an accessory say none")
|
||||
return a and b and c and d
|
||||
|
||||
func test_height_scale_stays_human() -> bool:
|
||||
var ok := true
|
||||
for seed_value in range(1, 40):
|
||||
var scale_y: float = float(NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 131)["height_scale"])
|
||||
if scale_y < 0.9 or scale_y > 1.1:
|
||||
ok = false
|
||||
return TestUtils.check(ok, "avatar height scale stays between 0.9 and 1.1")
|
||||
@@ -0,0 +1,65 @@
|
||||
extends RefCounted
|
||||
## Web export target: the preset exists, uses the non-threaded template (so it
|
||||
## runs on plain static hosting without COOP/COEP headers), and the release
|
||||
## scripts build and publish it.
|
||||
|
||||
func _preset() -> String:
|
||||
## Return the `[preset.N]` block whose name is Web plus its `.options` block.
|
||||
var lines := FileAccess.get_file_as_string("res://export_presets.cfg").split("\n")
|
||||
var sections := {}
|
||||
var current := ""
|
||||
var web_id := ""
|
||||
for line in lines:
|
||||
if line.begins_with("[") and line.ends_with("]"):
|
||||
current = line
|
||||
sections[current] = PackedStringArray()
|
||||
continue
|
||||
if sections.has(current):
|
||||
# PackedStringArray is a value type: mutate a local and write it back.
|
||||
var block: PackedStringArray = sections[current]
|
||||
block.append(line)
|
||||
sections[current] = block
|
||||
if line == 'name="Web"':
|
||||
web_id = current.trim_prefix("[").trim_suffix("]")
|
||||
if web_id == "":
|
||||
return ""
|
||||
var out: PackedStringArray = []
|
||||
out.append("[%s]" % web_id)
|
||||
out.append_array(sections.get("[%s]" % web_id, PackedStringArray()))
|
||||
var options := "[%s.options]" % web_id
|
||||
if sections.has(options):
|
||||
out.append(options)
|
||||
out.append_array(sections[options])
|
||||
return "\n".join(out)
|
||||
|
||||
func test_web_preset_exists() -> bool:
|
||||
var preset := _preset()
|
||||
var a := TestUtils.check(preset != "", "a Web export preset exists")
|
||||
var b := TestUtils.check(preset.contains('platform="Web"'), "preset targets the Web platform")
|
||||
var c := TestUtils.check(preset.contains('export_path="build/web/index.html"'), "preset writes build/web/index.html")
|
||||
return a and b and c
|
||||
|
||||
func test_web_preset_is_static_hosting_friendly() -> bool:
|
||||
var preset := _preset()
|
||||
return TestUtils.check(preset.contains("variant/thread_support=false"),
|
||||
"threads are disabled so no COOP/COEP headers are required")
|
||||
|
||||
func test_release_scripts_build_the_web_zip() -> bool:
|
||||
var build := FileAccess.get_file_as_string("res://tools/build_releases.sh")
|
||||
var a := TestUtils.check(build.contains('--export-release "Web"'), "build script exports Web")
|
||||
var b := TestUtils.check(build.contains("step-line-web-${VERSION}.zip"), "build script names the web zip")
|
||||
var publish := FileAccess.get_file_as_string("res://tools/publish_release.sh")
|
||||
var c := TestUtils.check(publish.contains("step-line-web-$VERSION.zip"), "publish script uploads the web zip")
|
||||
return a and b and c
|
||||
|
||||
func test_web_build_files_present_when_exported() -> bool:
|
||||
## Only asserts shape if a web build exists in build/web (created by the
|
||||
## release script); missing build output is not a test failure by itself.
|
||||
var index := "res://build/web/index.html"
|
||||
if not FileAccess.file_exists(index):
|
||||
return true
|
||||
var html := FileAccess.get_file_as_string(index)
|
||||
var a := TestUtils.check(html.contains("index.js"), "index.html loads index.js")
|
||||
var b := TestUtils.check(FileAccess.file_exists("res://build/web/index.wasm"), "wasm payload exists")
|
||||
var c := TestUtils.check(FileAccess.file_exists("res://build/web/index.pck"), "pck payload exists")
|
||||
return a and b and c
|
||||
Reference in new issue
Block a user