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.
181 lines
5.7 KiB
GDScript
181 lines
5.7 KiB
GDScript
extends SceneTree
|
||
## Windowed smoke helper for the ADR-0007–0010 control pass (focus circle + action
|
||
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
|
||
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
|
||
## radial state did not come up, so it can be used as a smoke check.
|
||
## Usage: godot --path . --script res://src/test/smoke_focus_radial.gd
|
||
|
||
const OUT_DIR := "user://screenshots"
|
||
const MAX_FRAMES := 1800
|
||
const STAGE_DISTANCE := 2.4
|
||
|
||
var frames := 0
|
||
var gm: Node = null
|
||
var state := 0
|
||
var state_frames := 0
|
||
var stage_npc: Node3D = null
|
||
var failures: Array[String] = []
|
||
var _shift_started := false
|
||
|
||
func _initialize() -> void:
|
||
root.size = Vector2i(1280, 720)
|
||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||
root.add_child(packed.instantiate())
|
||
|
||
## The game now boots into the venue menu, so the smoke starts a shift first.
|
||
func _start_shift_if_needed() -> void:
|
||
if _shift_started or gm == null:
|
||
return
|
||
var menu = gm.get("start_menu")
|
||
if menu != null and menu.visible:
|
||
gm.call("_on_venue_selected", "hollywood_building")
|
||
_shift_started = true
|
||
print("smoke: started hollywood_building shift from the menu")
|
||
|
||
func _process(_delta: float) -> bool:
|
||
frames += 1
|
||
if gm == null:
|
||
gm = root.find_child("GameManager", true, false)
|
||
if gm == null:
|
||
if frames > 600:
|
||
_fail("GameManager not found")
|
||
return false
|
||
if frames > MAX_FRAMES:
|
||
_fail("timeout before the radial state")
|
||
return true
|
||
_advance()
|
||
return false
|
||
|
||
func _advance() -> void:
|
||
state_frames += 1
|
||
match state:
|
||
0:
|
||
_start_shift_if_needed()
|
||
# Wait for the guard to settle on the ground, then grab one NPC.
|
||
if state_frames >= 40 and gm.npcs.size() > 0:
|
||
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
||
(gm.guard.global_position as Vector3).y,
|
||
(gm.guard.global_position as Vector3).z,
|
||
])
|
||
_shoot("smoke-00-spawn.png")
|
||
stage_npc = gm.npcs[0]
|
||
_hide_other_npcs(stage_npc)
|
||
state = 1
|
||
state_frames = 0
|
||
1:
|
||
# Keep the staged NPC in front of the guard so the ring is visible.
|
||
_stage_target()
|
||
if state_frames >= 20:
|
||
gm.call("_cycle_nearby_target")
|
||
if gm.targeted_npc == null:
|
||
_fail("no NPC focused")
|
||
elif gm.radial == null:
|
||
_fail("radial layer missing")
|
||
else:
|
||
print("smoke: guard_y=%.2f npc_y=%.2f" % [
|
||
(gm.guard.global_position as Vector3).y,
|
||
(gm.targeted_npc.global_position as Vector3).y,
|
||
])
|
||
_shoot("smoke-01-focus-circle.png")
|
||
gm.call("_open_radial")
|
||
state = 2
|
||
state_frames = 0
|
||
2:
|
||
if state_frames >= 20:
|
||
if not gm.radial.is_open:
|
||
_fail("action wheel did not open")
|
||
else:
|
||
_shoot("smoke-02-action-wheel.png")
|
||
gm.call("_cancel_radial")
|
||
state = 3
|
||
state_frames = 0
|
||
3:
|
||
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
|
||
if stage_npc == null:
|
||
return
|
||
_hide_other_npcs(stage_npc)
|
||
var guard_pos: Vector3 = gm.guard.global_position
|
||
var forward: Vector3 = -gm.guard.global_transform.basis.z
|
||
forward.y = 0.0
|
||
forward = forward.normalized()
|
||
stage_npc.global_position = guard_pos + forward * STAGE_DISTANCE + Vector3(0, 0.4, 0)
|
||
stage_npc.rotation.y = gm.guard.rotation.y + PI
|
||
|
||
func _hide_other_npcs(keep: Node3D) -> void:
|
||
for npc in gm.npcs:
|
||
if is_instance_valid(npc) and npc is Node3D and npc != keep:
|
||
(npc as Node3D).visible = false
|
||
|
||
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: int = img.save_png(OUT_DIR.path_join(file_name))
|
||
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
|
||
|
||
func _fail(message: String) -> void:
|
||
failures.append(message)
|
||
_finish()
|
||
|
||
func _finish() -> void:
|
||
if failures.is_empty():
|
||
print("SMOKE OK: focus circle + action wheel + aim-free cycling")
|
||
quit(0)
|
||
else:
|
||
for message in failures:
|
||
printerr("SMOKE FAIL: " + message)
|
||
quit(1)
|