diff --git a/DONE.md b/DONE.md index 50bab01..9445dfc 100644 --- a/DONE.md +++ b/DONE.md @@ -156,6 +156,23 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every - [x] Add a `V` control that cycles Hollywood Blvd β†’ The Joke Shop β†’ TV Studio and restarts the shift (`GameManager._cycle_venue`), with a `hud.venue_switched` string entry. +## 🎨 Visual polish pass (v0.2.0, pre-release graphics) + +- [x] Phase-based lighting: per-phase sun direction/energy/color, sky gradient + (ProceduralSkyMaterial), and ambient in `GameManager.PHASE_LIGHTING`; + applied on phase change and shift start. +- [x] Street lamps: poles with emissive bulbs + OmniLights on the sidewalk; + `WorldBuilder.set_night_mode` switches bulbs/lights on at night. +- [x] Marquee signage: emissive β€œSTEP LINE” strips + Label3D above the entrance. +- [x] Street dressing: crosswalk stripes, planters, and low-poly trees. +- [x] NPC visual variety: per-seed height/brightness variation (deterministic LCG). +- [x] Denser routine traffic (tourists/performers/influencers/trespassers). +- [x] `capture_screenshots.gd` now fast-forwards to the LATE phase and captures a + night set (lamps + marquee + flashlight cone); README gallery is now 2Γ—3 + (day + night); six shots regenerated in `docs/screenshots/`. +- [x] `config/version` β†’ `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested. +- [x] New world/lighting headless tests (56 total). + ## CI β€” cache timeout mitigation - [x] Remove `actions/cache@v4` from the checks job (runner cannot reach GitHub's cache diff --git a/MEMORY.md b/MEMORY.md index 9e5303a..36f8e75 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -47,6 +47,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - CI cache: `.gitea/workflows/build.yml` no longer uses `actions/cache` (the runner can't reach the GitHub cache service; import is fast). `GODOT_SILENCE_ROOT_WARNING=1` set on jobs. Logs live in `.gitea/logs/` after each run. +- Visual polish (v0.2.0): phase lighting (GameManager.PHASE_LIGHTING + ProceduralSkyMaterial), + street lamps + night mode in WorldBuilder.set_night_mode, marquee Label3D, crosswalk/planters/ + trees, NPC per-seed variation. capture_screenshots.gd shoots day + fast-forwarded LATE night + (6 shots in docs/screenshots/). Version bumped to 0.2.0; binaries rebuilt. +- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio + easter egg); check `git status` before committing and leave foreign changes untouched unless asked. ## Known Issues / Notes diff --git a/README.md b/README.md index 1997e7e..da9f2c4 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ The central design goal is simple: ## Screenshots | | | -|---|---| -| ![Venue overview: the Hollywood Boulevard entrance with steps, sidewalk, and restricted door](docs/screenshots/01-venue-overview.png) | ![Targeting an approaching NPC with the interaction HUD](docs/screenshots/02-targeting-npc.png) | +| --- | --- | +| ![Venue overview: the Hollywood Boulevard entrance with steps, sidewalks, street lamps, and signage](docs/screenshots/01-venue-overview.png) | ![Targeting an approaching NPC with the interaction HUD](docs/screenshots/02-targeting-npc.png) | | ![Access inspection: wristband/credential check with allow/deny prompt](docs/screenshots/03-access-inspection.png) | ![Radio report form: emergency dispatch panel](docs/screenshots/04-radio-report.png) | +| ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) | -*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build.* +*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build β€” day and late-night shifts.* ## ⬇️ Downloads @@ -203,8 +204,9 @@ Implemented so far: - Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings) - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review +- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic β€” day and late-night screenshot sets -Not yet implemented: body-camera replay timeline, long (10-15 minute) shift lengths with phase-based lighting, venue selection screen, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`). +Not yet implemented: body-camera replay timeline, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`). ### Smoke Test Checklist diff --git a/build/step-line-linux-0.2.0.pck b/build/step-line-linux-0.2.0.pck new file mode 100644 index 0000000..a81db87 Binary files /dev/null and b/build/step-line-linux-0.2.0.pck differ diff --git a/build/step-line-linux-0.2.0.x86_64 b/build/step-line-linux-0.2.0.x86_64 new file mode 100755 index 0000000..9689667 Binary files /dev/null and b/build/step-line-linux-0.2.0.x86_64 differ diff --git a/build/step-line-macos-0.2.0.zip b/build/step-line-macos-0.2.0.zip new file mode 100644 index 0000000..e4d2268 Binary files /dev/null and b/build/step-line-macos-0.2.0.zip differ diff --git a/build/step-line-windows-0.2.0.exe b/build/step-line-windows-0.2.0.exe new file mode 100644 index 0000000..81c5697 Binary files /dev/null and b/build/step-line-windows-0.2.0.exe differ diff --git a/build/step-line-windows-0.2.0.pck b/build/step-line-windows-0.2.0.pck new file mode 100644 index 0000000..0d28327 Binary files /dev/null and b/build/step-line-windows-0.2.0.pck differ diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index 8699bac..e242f2d 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index a63888b..f6f9c72 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index 42bf2b3..68677e9 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index f7c6d0d..fbd23ef 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png new file mode 100644 index 0000000..9fbc757 Binary files /dev/null and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png new file mode 100644 index 0000000..60a93bd Binary files /dev/null and b/docs/screenshots/06-night-flashlight.png differ diff --git a/project.godot b/project.godot index 2114d61..c320f2e 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="Step Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.1.0" +config/version="0.2.0" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index 2882a3b..ab75203 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -335,17 +335,17 @@ static func hollywood_building() -> VenueScenario: ]) scenario.event_pool = [ - _building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), + _building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.42), _building_routine("protest_gather", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18), - _building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20), + _building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.26), _building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), _building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30), _building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35), _building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25), - _building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.18), - _building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20), + _building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24), + _building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26), _building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15), - _building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.12), + _building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16), { "id": "crisis_event", "phase": Rules.Phase.EVENING, diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 65da857..a77ba97 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -27,6 +27,35 @@ var door := DoorState.new() var flashlight_on := false var coordinator: ShiftCoordinator = null var guard: GuardController = null +var _sun: DirectionalLight3D = null +var _env_node: WorldEnvironment = null +var _sky: ProceduralSkyMaterial = null +var _last_phase: int = -1 + +## Per-phase lighting looks for the sun, sky, and ambient (PLAN.md: shift +## 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), + "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, + }, + Rules.Phase.MIDDAY: { + "sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98), + "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, + }, + 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, + }, + 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, + }, +} var hud: Hud = null var radio_panel: RadioPanel = null var shift_review_panel: ShiftReview = null @@ -82,6 +111,8 @@ func _ready() -> void: _build_ui() coordinator.start() + _last_phase = -1 + _apply_phase_lighting() var selected_scenario: VenueScenario = null @@ -139,6 +170,8 @@ func restart_shift() -> void: current_incident_npc_id = "" coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.start() + _last_phase = -1 + _apply_phase_lighting() var inspector_current_events: Array = [] @@ -160,17 +193,40 @@ func _build_ui() -> void: shift_review_panel.restart_requested.connect(restart_shift) func _ensure_lighting() -> void: - var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-50, -30, 0) - sun.light_energy = 1.2 - scene_root.add_child(sun) - var env_node := WorldEnvironment.new() + _sun = DirectionalLight3D.new() + _sun.name = "Sun" + _sun.rotation_degrees = Vector3(-50, -30, 0) + _sun.light_energy = 1.2 + scene_root.add_child(_sun) + _env_node = WorldEnvironment.new() var env := Environment.new() - env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - env.ambient_light_color = Color(0.7, 0.7, 0.75) + env.background_mode = Environment.BG_SKY + _sky = ProceduralSkyMaterial.new() + var sky := Sky.new() + sky.sky_material = _sky + env.sky = sky + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY env.ambient_light_energy = 0.5 - env_node.environment = env - scene_root.add_child(env_node) + env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY + _env_node.environment = env + scene_root.add_child(_env_node) + +## Apply the lighting look for the current shift phase (sun, sky, ambient, +## street lamps). Runs on phase change and once at shift start. +func _apply_phase_lighting() -> void: + var phase: int = coordinator.current_phase if coordinator else Rules.Phase.EARLY + var cfg: Dictionary = PHASE_LIGHTING.get(phase, PHASE_LIGHTING[Rules.Phase.EARLY]) + if _sun: + _sun.light_energy = float(cfg.get("sun_energy", 1.0)) + _sun.light_color = cfg.get("sun_color", Color.WHITE) + _sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)), -30, 0) + if _sky: + _sky.sky_top_color = cfg.get("sky_top", Color.BLUE) + _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 world_builder: + world_builder.set_night_mode(cfg.get("night", false)) func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void: npc_counter += 1 @@ -652,6 +708,9 @@ func _physics_process(delta: float) -> void: if coordinator == null or not coordinator.running: return coordinator.tick(delta) + if coordinator.current_phase != _last_phase: + _last_phase = coordinator.current_phase + _apply_phase_lighting() hud.set_shift_state(coordinator, selected_scenario) _perception_pass() diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index b05c700..9f70187 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -41,6 +41,7 @@ func get_npc_controller() -> NPCController: func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void: npc.setup("npc_%d" % seed_value, profile, intent, seed_value) + _visual_seed = seed_value spawn_position = p_spawn target_position = p_target door_position = p_door @@ -66,6 +67,8 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector collider.position = Vector3(0, 0.8, 0) add_child(collider) +var _visual_seed := 0 + func _build_body(npc_type: int) -> void: var body := MeshInstance3D.new() var capsule := CapsuleMesh.new() @@ -74,6 +77,13 @@ func _build_body(npc_type: int) -> void: body.mesh = capsule var mat := StandardMaterial3D.new() mat.albedo_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.9 + 0.2 * frac + body.scale = Vector3(1.0, height_scale, 1.0) + body.position.y = (height_scale - 1.0) * 0.8 + mat.albedo_color = mat.albedo_color.lightened(0.15 * frac) if frac < 0.5 else mat.albedo_color.darkened(0.15 * (1.0 - frac)) body.material_override = mat add_child(body) var id_tag := MeshInstance3D.new() diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index bb229ef..11df3fd 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -7,6 +7,12 @@ 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] = [] func _init(root_node: Node3D) -> void: root = root_node @@ -14,11 +20,15 @@ func _init(root_node: Node3D) -> void: func build() -> void: _build_ground() _build_building() + _build_marquee() _build_steps() _build_sidewalk() _build_street() + _build_crosswalk() + _build_street_furniture() _build_guard_spawn() _build_navmesh() + set_night_mode(false) func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D: var mesh := BoxMesh.new() @@ -127,6 +137,112 @@ func _build_navmesh() -> void: region.navigation_mesh = mesh root.add_child(region) +func _build_marquee() -> void: + ## 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 = 72 + marquee_label.pixel_size = 0.004 + marquee_label.outline_size = 12 + 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)) + _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)) + 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 = 14.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) + # 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) + for i in 6: + var x := -11.0 + i * 4.4 + 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) + 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) + crown.position = Vector3(x, 2.1, z) + tree.add_child(crown) + root.add_child(tree) + +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: + omni.light_energy = (3.0 if night else 0.0) + for mat in _marquee_emissive: + mat.emission_energy_multiplier = (2.0 if night else 0.2) + 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 + +func _plain_material(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.albedo_color = color + return mat + func _build_guard_spawn() -> void: var marker := Marker3D.new() marker.name = "GuardSpawn" diff --git a/src/test/capture_screenshots.gd b/src/test/capture_screenshots.gd index 309b5a7..9a45482 100644 --- a/src/test/capture_screenshots.gd +++ b/src/test/capture_screenshots.gd @@ -1,10 +1,11 @@ extends SceneTree ## Dev helper: run the game windowed and capture staged screenshots for the -## README. Saves 1600x900 PNGs to user://screenshots/. +## README (day set + LATE-night set with street lamps and flashlight). +## Saves 1280x720 PNGs to user://screenshots/. ## Usage: godot --path . --script res://src/test/capture_screenshots.gd const OUT_DIR := "user://screenshots" -const MAX_FRAMES := 2400 # hard cap (~40s) +const MAX_FRAMES := 3600 # hard cap (~60s) var frames := 0 var gm: Node = null @@ -39,7 +40,7 @@ func _advance() -> void: state_frames += 1 match state: 0: - # 2s overview from the guard post. + # 2s overview from the guard post (early morning light). if state_frames >= 120: _shoot("01-venue-overview.png") state = 1 @@ -52,7 +53,6 @@ func _advance() -> void: state = 2 state_frames = 0 2: - # Give the HUD a moment, then shoot the interaction shot. if state_frames >= 30: _shoot("02-targeting-npc.png") if targeted and gm != null: @@ -64,7 +64,6 @@ func _advance() -> void: _shoot("03-access-inspection.png") if gm != null: gm.hud.hide_inspection() - # Re-target (target may have moved) before opening the radio form. if gm.npcs.size() > 0: gm.call("_cycle_nearby_target") gm.call("_open_radio", false) @@ -73,7 +72,25 @@ func _advance() -> void: 4: if state_frames >= 30: _shoot("04-radio-report.png") + # Close the radio form, then jump the shift to the LATE phase. + if gm != null: + gm.radio_panel.hide() + gm.coordinator.elapsed_s = 750.0 state = 5 + state_frames = 0 + 5: + # Let the phase transition (and street lamps / marquee) settle, then + # take the night avenue shot. + if state_frames >= 90: + _shoot("05-late-night-avenue.png") + if gm != null: + gm.call("_toggle_flashlight") + state = 6 + state_frames = 0 + 6: + if state_frames >= 40: + _shoot("06-night-flashlight.png") + state = 7 quit(0) func _shoot(file_name: String) -> void: diff --git a/src/test/tests/test_world.gd b/src/test/tests/test_world.gd index 1d27bed..42f6839 100644 --- a/src/test/tests/test_world.gd +++ b/src/test/tests/test_world.gd @@ -1,7 +1,9 @@ extends RefCounted -## Headless tests for the procedural world and navigation mesh. +## Headless tests for the procedural world, navigation mesh, and phase lighting. const WorldBuilder := preload("res://src/game/world_builder.gd") +const Rules := preload("res://src/core/rules.gd") +const GameManager := preload("res://src/game/game_manager.gd") func test_navmesh_built() -> bool: var root := Node3D.new() @@ -17,6 +19,32 @@ func test_navmesh_built() -> bool: root.free() return a and b +func test_phase_lighting_config_covered() -> bool: + var ok := true + for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]: + var cfg: Variant = GameManager.PHASE_LIGHTING.get(phase, null) + if not (cfg is Dictionary): + ok = false + continue + for key in ["sun_energy", "sun_angle", "sun_color", "sky_top", "sky_horizon", "ambient", "night"]: + if not (cfg as Dictionary).has(key): + ok = false + return TestUtils.check(ok, "all four phases have a full lighting config") + +func test_street_lamps_and_marquee_built() -> bool: + var root := Node3D.new() + var builder := WorldBuilder.new(root) + builder.build() + var a := TestUtils.check(builder._street_lamps.size() >= 2, "street lamps built") + var b := TestUtils.check(builder.marquee_label != null, "marquee label built") + # Night mode raises bulb emission; day mode drops it. + builder.set_night_mode(true) + var night_on := builder._lamp_bulb_mats[0].emission_energy_multiplier > 1.0 + builder.set_night_mode(false) + var day_off := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.5 + root.free() + return a and b and TestUtils.check(night_on, "lamps brighten at night") and TestUtils.check(day_off, "lamps dim in daylight") + func test_door_indicator_built() -> bool: var root := Node3D.new() var builder := WorldBuilder.new(root)