Compare commits

...
4 Commits
Author SHA1 Message Date
zwitschi ac3bb7dcd8 Stop tracking build/ artifacts (binaries live in Gitea releases; CI/local rebuild via tools/build_releases.sh)
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped
2026-10-31 13:24:44 +01:00
zwitschi 513f8a0f60 Visual polish pass (v0.2.0): phase lighting, street lamps, marquee, dressing, night screenshots
- Phase-based lighting: sun direction/energy/color, ProceduralSkyMaterial
  gradient, and ambient per shift phase (GameManager.PHASE_LIGHTING),
  applied on phase transitions and shift start
- WorldBuilder: street lamp poles with emissive bulbs + OmniLights,
  set_night_mode(), glowing STEP LINE marquee (Label3D + emissive bars),
  crosswalk stripes, planters, low-poly street trees
- NPC avatars: per-seed height/brightness variation (deterministic LCG)
- Denser routine traffic probabilities
- capture_screenshots.gd: fast-forward to LATE phase and capture night set;
  README gallery is now 2x3 day+night; docs/screenshots regenerated
- Version bumped to 0.2.0; build/ artifacts rebuilt; Linux binary smoke-tested
- New headless tests for phased lighting config and street dressing (56 total)
2026-10-31 13:24:40 +01:00
zwitschi 93883334d0 Add rare hidden radio easter egg transmission 2026-10-31 13:20:46 +01:00
zwitschi 94b7646dce Record CI cache mitigation results and ignore runner logs
- Mark cache-timeout fix verified (run 1090: zero CacheService lines,
  54/54 tests, job succeeded)
- Ignore .gitea/logs/ (runner writes logs into the checkout)
- Leave collaborator's radio-easter-egg changes in the working tree
  (strings.en.json, game_manager.gd) uncommitted for review
2026-10-31 13:16:34 +01:00
24 changed files with 302 additions and 26 deletions

No files matched your search

+3
View File
@@ -15,3 +15,6 @@ Thumbs.db
# Logs and temp
*.log
user://
# CI runner workflow logs (written into the checkout by the Gitea runner)
.gitea/logs/
build/
+26
View File
@@ -156,6 +156,32 @@ 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
service; ~2 min of retry timeouts per run; Godot import is fast without it).
- [x] Add `GODOT_SILENCE_ROOT_WARNING=1` to both jobs.
- [x] Confirmed in run 1090: zero `CacheService` lines, 54/54 tests pass, job succeeds.
- [x] Document the cache situation and re-enable path in `docs/RELEASING.md`.
- [x] Ignore runner logs in `.gitignore` (`.gitea/logs/`).
## Gameplay — Venue door-staff dialogue
- [x] Add `VenueScenario.door_staff_line(context, rng)` picking a seeded-random line from
+6
View File
@@ -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
+6 -4
View File
@@ -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
+2 -1
View File
@@ -10,7 +10,8 @@ checks job still passes; this is noise + risk of flaky timeouts.
- [x] Remove the `actions/cache@v4` step from `checks` in `.gitea/workflows/build.yml`
(Godot import takes ~4s; caching .godot is not worth the timeout risk).
- [x] Add `GODOT_SILENCE_ROOT_WARNING=1` to godot steps to quiet container-as-root noise.
- [ ] Push and confirm the next `.gitea/logs/` run has no `CacheService` timeouts.
- [x] Push and confirm the next `.gitea/logs/` run has no `CacheService` timeouts (run 1090:
0 cache lines — previously ~12; 54/54 tests, job succeeded).
- [x] Optional (host admin): document in `docs/RELEASING.md` that `actions/cache` needs a
Gitea `[cache.actions]` + cache server before it can be reintroduced.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -21,6 +21,7 @@
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
"radio.received": "Dispatch received your report. Assistance en route.",
"radio.emergency_dispatched": "Emergency responder dispatched.",
"radio.easter_egg": "¿Dónde está la mota?",
"responder.arrived": "Responder arrives and %ss the situation.",
"react.comply": "%s complies and steps back.",
"react.leave": "%s turns and leaves.",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+1 -1
View File
@@ -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")
+5 -5
View File
@@ -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,
+75 -9
View File
@@ -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
@@ -458,6 +514,13 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
coordinator.crowd.apply_event("calm_professionalism")
if audio:
audio.play_radio_response()
_maybe_radio_easter_egg()
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
## over the radio. Fires on a small random chance after a successful call.
func _maybe_radio_easter_egg() -> void:
if _door_rng.randf() < 0.025:
hud.log(Strings.lookup("radio.easter_egg"))
# ----------------------------------------------------------------- access --
@@ -645,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()
+10
View File
@@ -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()
+116
View File
@@ -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"
+22 -5
View File
@@ -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:
+29 -1
View File
@@ -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)