Files
security-game/src/test/capture_screenshots.gd
T
zwitschi 8b0845bcce
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m57s
Build distinct Joke Shop and TV Studio venues
Add the alley walls, warm lantern, shutters, dumpster, AC clutter and door
step for the Joke Shop. Build the studio stone facade, bulb canopy, glossy
black car, stanchions and barriers. Rebuild only the world container when the
venue changes, so the guard and UI survive; add per-venue navigation checks.
Capture gallery shots 12/13, update the concept mapping and docs. Headless
231/231, Python 87/87, screenshot gate 13 good/0 bad. The vision-judge retry is
tracked separately because the API key is invalid.
2026-11-01 19:09:47 +01:00

276 lines
9.1 KiB
GDScript

extends SceneTree
## Dev helper: run the game windowed and capture staged screenshots for the
## README (day set + LATE-night set with street lamps and flashlight).
## Saves 1280x720 PNGs to user://screenshots/ by default.
## Usage: godot --path . --script res://src/test/capture_screenshots.gd
## godot --path . --script res://src/test/capture_screenshots.gd -- --out=res://docs/screenshots
var out_dir := "user://screenshots"
const MAX_FRAMES := 3600 # hard cap (~60s)
var frames := 0
var gm: Node = null
var state := 0
var state_frames := 0
var targeted := false
var camera_staged := false
var wand_attempts := 0
func _initialize() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--out="):
out_dir = arg.trim_prefix("--out=")
root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate()
root.add_child(inst)
func _process(_delta: float) -> bool:
frames += 1
# The main scene's _ready may not have run when _initialize added it;
# look for GameManager lazily each frame until it exists.
if gm == null:
gm = root.find_child("GameManager", true, false)
if gm == null and frames > 120:
push_error("GameManager not found")
quit(1)
return true
if gm != null and not camera_staged:
# The game boots into the venue menu now; start the Hollywood shift first.
var menu = gm.get("start_menu")
if menu != null and menu.visible:
gm.call("_on_venue_selected", "hollywood_building")
gm.guard.global_position = Vector3(0, 0.1, 10.5)
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
camera_staged = true
if frames > MAX_FRAMES:
print("timeout reached")
quit(0)
return true
_advance()
return false
func _advance() -> void:
state_frames += 1
match state:
0:
# 2s overview from the guard post (early morning light).
if state_frames >= 120:
_shoot("01-venue-overview.png")
state = 1
state_frames = 0
1:
# Wait for a nearby NPC so the interaction HUD has a target. NPCs now
# walk in from the street, so this can take a few simulated seconds.
if gm != null and gm.npcs.size() > 0:
gm.call("_cycle_nearby_target")
_face_target()
targeted = true
state = 2
state_frames = 0
2:
if state_frames >= 30:
_shoot("02-targeting-npc.png")
if targeted and gm != null:
_face_target()
gm.call("_toggle_inspection")
state = 3
state_frames = 0
3:
if state_frames >= 30:
_shoot("03-access-inspection.png")
if gm != null:
gm.hud.hide_inspection()
if gm.npcs.size() > 0:
gm.call("_cycle_nearby_target")
gm.call("_open_radio", false)
state = 4
state_frames = 0
4:
if state_frames >= 30:
_shoot("04-radio-report.png")
# Close the radio form, then jump the shift into LATE night: the
# avenue and flashlight shots are night scenes (the old 620 s put
# them in dusk, where the flashlight had nothing to cut through).
if gm != null:
gm.radio_panel.hide()
gm.hud.set_action_hint("")
gm.coordinator.elapsed_s = 760.0
state = 5
state_frames = 0
5:
# Stage the avenue view down the boulevard (not into the wall) and let
# the night lighting and street lamps settle.
if state_frames == 1 and gm != null:
gm.guard.rotation.y = deg_to_rad(-70)
gm.guard.head.rotation.x = deg_to_rad(3.0)
if state_frames >= 90:
_shoot("05-late-night-avenue.png")
state = 6
state_frames = 0
6:
# Face the entrance again and switch the flashlight on for the night set.
if state_frames == 1 and gm != null:
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(4.0)
gm.call("_toggle_flashlight")
if state_frames >= 30:
_shoot("06-night-flashlight.png")
# Move to The Joke Shop for the wand/shift-review set, and keep
# the late-night phase so the UI sits on the night scene.
if gm != null:
gm.call("_toggle_flashlight")
gm.switch_scenario("joke_shop")
gm.coordinator.elapsed_s = 620.0
state = 7
state_frames = 0
7:
# Wand screening: wait for guests and keep re-targeting until one
# accepts the screen (some authored Joke Shop guests refuse it).
if state_frames > 40 and state_frames % 20 == 0 and gm != null and gm.npcs.size() > 0:
# Prefer a guest whose items make the wand alert, so the panel
# shows the interesting case instead of "Alert: none".
if gm.wand_open:
if _wand_alerts():
state = 8
state_frames = 0
elif wand_attempts >= 6:
# Nobody nearby carried an alerting item: stage the documented
# false-alarm case (keys) on this guest and re-open the panel.
var guest: Object = gm.call("_targeted_controller")
if guest != null and guest.profile != null:
var false_alarm: Array[String] = ["keys"]
guest.profile.carried_items = false_alarm
gm.call("_toggle_wand_screening")
gm.call("_toggle_wand_screening")
state = 8
state_frames = 0
else:
gm.call("_toggle_wand_screening")
wand_attempts += 1
else:
gm.call("_cycle_nearby_target")
_face_target()
gm.call("_toggle_wand_screening")
wand_attempts += 1
elif state_frames > 600 and gm != null:
# Fallback: real controller, panel shown directly.
var controller: Object = gm.call("_targeted_controller")
if controller != null and controller.profile != null:
gm.hud.show_wand(controller, gm.wand_screener.screen(controller.profile.carried_items))
state = 8
state_frames = 0
8:
if state_frames == 1 and gm != null:
gm.hud.clear_log()
_stage_closeup()
if state_frames >= 45:
_shoot("07-wand-screening.png")
if gm != null:
if gm.wand_open:
gm.call("_toggle_wand_screening")
# Real escalation path: formal warning arms the body camera
# (a timeline row), then blocking raises the meter to L3.
gm.call("_apply_interaction", 2, "formal warning")
gm.call("_apply_interaction", 3, "blocking")
state = 9
state_frames = 0
9:
if state_frames == 1 and gm != null:
# Jump into the late phase now that guests exist: LATE has no
# routine spawn pool, so this happens after staging the target.
gm.coordinator.elapsed_s = 760.0
_stage_closeup()
if state_frames >= 45:
_shoot("08-escalation-hud.png")
if gm != null and gm.coordinator != null:
# Close the open incident so the recorder's review includes it
# (to_dict only lists closed incidents).
gm.coordinator.recorder.close_all_open("unresolved")
gm.call("_open_timeline", {"incidents": gm.coordinator.recorder.to_dict()})
state = 10
state_frames = 0
10:
if state_frames >= 30:
_shoot("09-body-camera-timeline.png")
if gm != null:
gm.event_timeline.close()
gm.guard.global_position = Vector3(0, 0.1, 6.2)
gm.guard.rotation.y = 0.0
Input.action_press("move_forward")
Input.action_press("walk_slow")
state = 11
state_frames = 0
11:
if state_frames >= 50:
_shoot("11-precision-walk.png")
Input.action_release("move_forward")
Input.action_release("walk_slow")
if gm != null:
gm.switch_scenario("joke_shop")
gm.coordinator.elapsed_s = 620.0
state = 12
state_frames = 0
12:
if state_frames == 1 and gm != null:
gm.guard.global_position = Vector3(-1.5, 0.1, 8.5)
gm.guard.rotation.y = deg_to_rad(-5.0)
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.hud.clear_log()
gm.hud.set_escalation(0)
gm.hud.set_body_camera(false)
if state_frames >= 90:
_shoot("12-joke-shop.png")
if gm != null:
gm.switch_scenario("tv_studio")
gm.coordinator.elapsed_s = 620.0
state = 13
state_frames = 0
13:
if state_frames == 1 and gm != null:
gm.guard.global_position = Vector3(0, 0.1, 14.0)
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.hud.clear_log()
gm.hud.set_escalation(0)
gm.hud.set_body_camera(false)
if state_frames >= 120:
_shoot("13-studio-arrival.png")
quit(0)
## Put the guard a couple of steps in front of the target so a staged UI capture
## frames the person the panel is about.
func _wand_alerts() -> bool:
if gm == null or gm.hud == null or gm.hud.wand_text == null:
return false
return gm.hud.wand_text.text.contains("Alert: YES")
func _stage_closeup() -> void:
var target: Node3D = gm.targeted_npc
if target == null or not is_instance_valid(target):
return
gm.guard.global_position = target.global_position + Vector3(0, -0.4, 2.2)
gm.guard.global_position.y = 0.1
_face_target()
gm.guard.head.rotation.x = deg_to_rad(1.0)
func _face_target() -> void:
var target: Node3D = gm.targeted_npc
if target == null:
return
var to: Vector3 = target.global_position - gm.guard.global_position
to.y = 0.0
if to.length() < 0.01:
return
gm.guard.rotation.y = atan2(-to.x, -to.z)
gm.guard.head.rotation.x = deg_to_rad(-4.0)
func _shoot(file_name: String) -> void:
var dir := DirAccess.open(out_dir)
if dir == null:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out_dir))
var img: Image = root.get_texture().get_image()
var err := img.save_png(out_dir.path_join(file_name))
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])