Files
security-game/src/test/capture_screenshots.gd
T
zwitschi a27e8cb74c
Full build / Full desktop build + optional release (push) Successful in 3m37s
Full build / Import + headless tests (push) Successful in 33s
Character and movement pass
NPC characters
- Articulated rig (hips/knees, shoulders/elbows/hands) replaces rigid capsules;
  the walk cycle bends knees and elbows and flees with a longer stride.
- Layered outfits (jacket/vest/coat/dress), extended accessories and hand items
  (bag, umbrella, phone, camera, clipboard, cup), face details and five hair
  silhouettes, all deterministic per profile seed.
- Idle behaviour layers (phone, clipboard, crossed arms, look-around), breathing
  and weight shift; escalation raises hands or points, and noticed NPCs turn
  toward the guard.
- Reaction symbols above the head (!!, ?!, !, ?), door-queue offsets assigned by
  GameManager, three LOD tiers with hysteresis and a billboard impostor.
- smoke_npc_budget.gd: 16 avatars = 534 meshes, ~6.9 ms average (50 ms budget).

Player movement
- Separate accel/decel, step-up assist for kerbs and the entrance steps, and a
  forward-axis fix: W was inverted (get_axis(forward, back) is negative).
- Head bob, landing dip and precision-walk FOV, gated by persisted head-bob and
  invert-Y settings; precision chip and tick; surface-aware footsteps and a
  landing cue forwarded to AudioFeedback.
- Gamepad support (sticks, deadzone, face buttons, bumpers), a control legend
  that follows the active device, and named actions for the remaining keys.
- Key/button remapping in Settings, persisted as input_bindings and applied by
  InputBindings (mouse and gamepad axes are never removed).
- smoke_movement.gd (windowed): climbs the steps, checks precision speed, chip,
  footsteps and landing; new 11-precision-walk capture and catalogue entry.

Verification: headless 208/208, Python tooling 56/56, all 11 smokes pass,
compare 11 good / 0 bad, movement judge match. Version 0.5.0. Only optional
Future ideas remain in TODO.
2026-11-01 10:41:17 +01:00

240 lines
7.9 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/.
## Usage: godot --path . --script res://src/test/capture_screenshots.gd
const 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:
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 to the LATE phase.
if gm != null:
gm.radio_panel.hide()
gm.hud.set_action_hint("")
gm.coordinator.elapsed_s = 620.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")
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("user://")
if dir == null or not dir.dir_exists("screenshots"):
dir.make_dir_recursive("screenshots")
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()])