Files
VRyHungry1/test/mp_test_driver.gd
T
algodoogle ee835ea295 Split the multiplayer test harness into modules
mp_test_driver.gd was 1458 lines doing six unrelated jobs. It is now
orchestration only — the scripted sequence, the RPC plumbing between peers, and
the manual keyboard controls — with the work in modules that each have one:

  MpWorldView  finding things in the world and describing what they are doing
  MpSteps      the simulated player actions (reach, grab, carry, drop)
  MpAsserts    the per-step checks
  MpSnapshot   the cross-peer sync audit
  MpReport     the ledger, the log, the overlay, the screenshots

The scenario list and the audit logic carry over unchanged. That audit compares
what is actually RENDERED on both peers, not just the replicated values behind
it, which is the only thing that catches a plate whose contents arrived but
whose visuals were never rebuilt — so it was worth moving verbatim.

Assertions that tested the old model now test the new one: "who is holding
this" is the authority of the object's NetXform, not of the object itself.

Suite: 146/146 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:22:58 +01:00

644 lines
25 KiB
GDScript

extends Node
## Multiplayer harness for the kitchen flow, living in test/multiPlayerTest.tscn
## (and, inert unless asked for, in Scenes/multiPlayer.tscn).
##
## This file is the orchestration only — the scripted sequence, the RPC plumbing
## between the two peers, and the manual keyboard controls. The work itself lives
## in four modules, each with one job:
##
## MpWorldView finding things in the world and describing what they are doing
## MpSteps the simulated player actions (reach, grab, carry, drop)
## MpAsserts the per-step checks
## MpSnapshot the cross-peer sync audit
## MpReport the ledger, the log, the overlay, the screenshots
##
## Two ways to use it:
##
## AUTOMATIC — pass `--mptest` and the server runs the whole sequence and exits
## non-zero if any check fails. test/run_mp_test.ps1 does this headlessly;
## test/run_mp_test_windowed.ps1 does it in two visible windows with a pause
## between steps so you can watch it happen.
##
## MANUAL — just run the scene (no `--mptest`). Every window gets an overlay, a
## fixed camera over the kitchen, and keyboard controls. Keys act on the peer
## whose window has focus, so you can do a step on the client and watch the
## server window follow (or fail to).
##
## The scripted sequence:
## 1. client grabs the plate, then drops it
## 2. server grabs the plate, then drops it
## 3. client carries it to the dirt station -> snaps + turns dirty on both
## 4. both peers confirm they can still pick it up
## 5. client carries the dirty plate to the sink -> washed clean on both
## 6. a cook-and-plate round, run once by the CLIENT and once by the SERVER:
## raw burger -> hob (cooks, raw one removed), cooked burger -> counter,
## buns -> cooked burger (combines into a hamburger), plate -> counter 2,
## hamburger -> plate.
## How long the server waits for a client step reply before calling it failed.
const STEP_TIMEOUT_SEC := 30.0
## How long to wait for the client to connect and for the world to replicate.
const SETUP_TIMEOUT_SEC := 30.0
## Where the debug camera sits and what it aims at: a fixed vantage point framing
## the whole kitchen (Counter2 at x=-2 through DirtStation at x=2).
const DEBUG_CAM_POS := Vector3(0.0, 2.1, 3.4)
const DEBUG_CAM_LOOK_AT := Vector3(0.0, 0.9, 0.0)
const HELP := "[H]ost [J]oin 1 grab 2 drop 3 dirt 4 sink 5 hob 6 counter 7 combine 8 plate>counter2 9 >plate R run-all A sync-audit 0 dump C cam"
var _view: MpWorldView
var _steps: MpSteps
var _asserts: MpAsserts
var _snapshot: MpSnapshot
var _report: MpReport
var _world: Node3D
var _controller: Node3D
var _hand: XRToolsFunctionPickup
var _debug_cam: Camera3D
var _role := "?"
var _client_id := 0
var _auto_mode := false
var _running := false
var _step_pause := 0.0
var _end_hold := 0.0
var _step_no := 0
## Latest reply from the client, consumed by _remote().
var _reply: Dictionary = {}
var _client_snapshot: Dictionary = {}
var _snapshot_pending := false
func _ready() -> void:
var args := OS.get_cmdline_user_args()
_auto_mode = "--mptest" in args
# Opt-in only. This node also sits in the real multiplayer scene (so a client
# joining a test session has a driver), and must be completely inert during an
# ordinary game — no overlay, no debug camera, no keyboard hooks.
if not _auto_mode and not ("--mptest-manual" in args):
queue_free()
return
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
_world = get_parent()
await get_tree().process_frame
_refresh_role()
_build_modules(_role == "server")
_report.setup_frames("--mptest-frames" in args, _role == "server")
_report.log_line("=== mp test driver ready (role=%s, peer=%d, mode=%s) ==="
% [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"])
if not await _resolve_nodes():
if _auto_mode:
_finish(false)
return
_build_debug_camera()
if not _auto_mode:
_report.log_line("MANUAL MODE - keys act on this window's peer:")
_report.log_line(" " + HELP)
return
if _role == "server":
await _run_server()
func _build_modules(is_server: bool) -> void:
_report = MpReport.new()
add_child(_report)
_report.setup(is_server, _world)
_view = MpWorldView.new()
add_child(_view)
_view.setup(_world, _report)
_snapshot = MpSnapshot.new()
add_child(_snapshot)
_snapshot.setup(_view, _report)
func _arg_value(args: PackedStringArray, key: String, fallback: float) -> float:
var i := args.find(key)
if i >= 0 and i + 1 < args.size():
return args[i + 1].to_float()
return fallback
func _refresh_role() -> void:
_role = "server" if NetworkManager.is_server() else ("client" if NetworkManager.is_online() else "offline")
## Only the hand is resolved up front; everything else is looked up by name at
## use time (see MpWorldView).
func _resolve_nodes() -> bool:
_controller = _world.get_node_or_null("XROrigin3D/XRControllerRightHand") as Node3D
_hand = _controller.get_node_or_null("FunctionPickup") as XRToolsFunctionPickup if _controller else null
if not _hand:
_report.log_line("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup")
return false
_steps = MpSteps.new()
add_child(_steps)
_steps.setup(_view, _report, _hand, _controller)
_asserts = MpAsserts.new()
add_child(_asserts)
_asserts.setup(_view, _snapshot, _hand)
# The kitchen is not baked into the live scene: the server harvests the
# authored nodes and respawns them replicated, so on a client nothing exists
# until those spawns arrive. Wait for them rather than failing immediately.
for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]:
if not await _steps.wait_until(func(): return _view.find(required) != null,
"'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC):
_report.log_line("FATAL: '%s' never appeared in the world" % required)
return false
_report.log_line("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path())
_view.disable_despawn_timers()
return true
# --- server orchestration --------------------------------------------------
func _run_server() -> void:
_running = true
_report.results.clear()
_step_no = 0
if not await _wait_for_client():
_running = false
if _auto_mode:
_finish(false)
return
await _steps.wait_frames(60)
_report.banner("starting sequence")
# A frame of the untouched kitchen, so the GIF opens on the starting state.
await _capture_step_frame("start")
# 0. The client opened a different, empty scene, so everything it has must
# have arrived over the network. Check that before touching anything — this is
# the same path a player joining mid-session takes.
await _step("server", "world_replicated_to_client", "verify_world_replicated", [])
# 1-2. Both peers can pick the plate up and put it down.
await _step("client", "client_grab_plate", "grab", ["Plate"])
await _step("client", "client_drop_plate", "drop", ["Plate"])
await _step("server", "server_grab_plate", "grab", ["Plate"])
await _step("server", "server_drop_plate", "drop", ["Plate"])
# 3. Client carries it to the dirt station; it should snap in and go dirty.
await _step("client", "client_plate_to_dirt", "place_in_zone", ["Plate", "DirtStation"])
await _both("plate_snapped_and_dirty", "verify_snapped", ["Plate", "DirtStation"])
await _both("plate_is_dirty", "verify_dirty", ["Plate", "true"])
# 4. Both peers can still pick it back up out of the station.
await _step("client", "client_regrab_plate", "grab", ["Plate"])
await _step("client", "client_redrop_plate", "drop", ["Plate"])
await _step("server", "server_regrab_plate", "grab", ["Plate"])
await _step("server", "server_redrop_plate", "drop", ["Plate"])
# 5. Client takes the dirty plate to the sink, which should wash it clean.
await _step("client", "client_plate_to_sink", "place_in_zone", ["Plate", "Sink"])
await _both("plate_snapped_in_sink", "verify_snapped", ["Plate", "Sink"])
await _both("sink_bar_shown_while_washing", "verify_station_bar", ["Sink", "true"])
await _step("server", "wait_for_wash", "await_clean", ["Plate"])
await _both("plate_washed_clean", "verify_dirty", ["Plate", "false"])
await _both("sink_bar_hidden_when_done", "verify_station_bar", ["Sink", "false"])
# 6. The cook-and-plate round, once per peer. The first round uses the objects
# baked into the scene; the second uses freshly spawned ones, so both paths
# through the spawner get covered. Finished plates are parked well clear of
# every snap zone (all the stations sit on z=0), so they cannot get re-snapped
# on the way down.
await _cook_and_plate_round("client", "raw_burger", "BurgerBuns", "Plate", Vector3(-2.0, 1.1, 1.5))
await _spawn_round_items()
await _cook_and_plate_round("server", "RawBurger2", "BurgerBuns2", "Plate2", Vector3(-1.0, 1.1, 1.5))
_running = false
await _finish_run()
## One full food round performed by `actor`: cook a burger, combine it with buns
## on the counter, and put the result on a plate sitting on another counter.
## Every result is checked on BOTH peers, since the whole point is that the
## server's authoritative outcome reaches the client.
func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: String, park_at: Vector3) -> void:
_report.banner("%s's cook-and-plate round (%s + %s -> %s)" % [actor, burger, buns, plate])
# Burger onto the hob; it should cook and the raw one should disappear.
await _step(actor, "%s_burger_to_hob" % actor, "place_in_zone", [burger, "Hob"])
await _both("%s_burger_snapped_in_hob" % actor, "verify_snapped", [burger, "Hob"])
await _both("%s_hob_bar_shown_while_cooking" % actor, "verify_station_bar", ["Hob", "true"])
await _step("server", "%s_wait_for_cook" % actor, "await_food", ["cooked_burger"])
await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger])
await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"])
# Cooked burger onto the counter, then buns into it: they combine.
await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"])
await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"])
await _step(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"])
await _step("server", "%s_wait_for_combine" % actor, "await_food", ["hamburger"])
await _both("%s_ingredients_consumed" % actor, "verify_gone", [buns])
await _both("%s_hamburger_exists" % actor, "verify_food_exists", ["hamburger"])
# Plate onto the second counter, then the hamburger onto the plate.
await _step(actor, "%s_plate_to_counter2" % actor, "place_in_zone", [plate, "Counter2"])
await _both("%s_plate_snapped_on_counter2" % actor, "verify_snapped", [plate, "Counter2"])
await _step(actor, "%s_hamburger_to_plate" % actor, "carry_food_to_item", ["hamburger", plate])
await _both("%s_plate_holds_hamburger" % actor, "verify_plate_contains", [plate, "hamburger"])
await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate])
# Lift the plate off the counter and set it down clear of every station.
await _step(actor, "%s_plate_off_counter2" % actor, "park", [plate, park_at])
await _both("%s_counter2_freed" % actor, "verify_zone_empty", ["Counter2"])
# The food must still be on the plate after being carried off and set down.
await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate])
## A second set of ingredients for the server's round (the scene bakes only one
## of each), spawned through the normal path so the runtime-spawn case is covered
## as well as the authored one.
func _spawn_round_items() -> void:
_report.banner("state between rounds (server's view)")
_view.dump_state(MpSnapshot.WATCHED_STATIONS, _hand)
_report.banner("spawning a second set of ingredients for the server's round")
var spawns := [
["res://Items/burger.tscn", "RawBurger2", Vector3(0.3, 1.05, 0.7)],
["res://Items/BurgerBuns.tscn", "BurgerBuns2", Vector3(-0.4, 1.05, 0.7)],
["res://Containers/plate.tscn", "Plate2", Vector3(1.4, 1.05, 0.7)],
]
for s in spawns:
NetworkManager.spawn_item(s[0], Transform3D(Basis(), s[2]), s[1])
_report.log_line(" spawned %s" % s[1])
await _steps.wait_frames(30)
func _wait_for_client() -> bool:
var ok := await _steps.wait_until(func(): return _find_client_id() != 0,
"client to connect", SETUP_TIMEOUT_SEC)
_client_id = _find_client_id()
if ok:
_report.log_line("client connected: peer %d" % _client_id)
return ok
func _find_client_id() -> int:
for id in multiplayer.get_peers():
if id != 1:
return id
return 0
## Run one step as `actor` (locally if that is us, over RPC if it is the client)
## and record the verdict.
func _step(actor: String, label: String, step: String, args: Array) -> void:
_snapshot.current_step = label
_view.disable_despawn_timers()
_report.banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()])
var res: Dictionary
if actor == "server":
res = await _run_local_step(step, args)
else:
res = await _remote(step, args)
_report.record(label, actor, res)
await _audit_sync(label)
await _capture_step_frame(label)
await _pause()
## Run the same check on both peers — the server's state and the client's must
## agree, which is the whole point of the exercise.
func _both(label: String, step: String, args: Array) -> void:
_snapshot.current_step = label
_view.disable_despawn_timers()
_report.banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label])
_report.record(label, "server", await _run_local_step(step, args))
_report.record(label, "client", await _remote(step, args))
await _audit_sync(label)
await _capture_step_frame(label)
await _pause()
func _remote(step: String, args: Array) -> Dictionary:
_reply = {}
_cmd.rpc_id(_client_id, step, args)
var deadline := Time.get_ticks_msec() + int(STEP_TIMEOUT_SEC * 1000.0)
while _reply.is_empty() and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
if _reply.is_empty():
return {"ok": false, "detail": "no reply from client within %.0fs" % STEP_TIMEOUT_SEC}
return _reply.duplicate()
func _next_step_no() -> int:
_step_no += 1
return _step_no
func _pause() -> void:
if _step_pause > 0.0:
await get_tree().create_timer(_step_pause).timeout
func _finish_run() -> void:
_report.print_summary()
await _capture_step_frame("final")
_report.write_frame_index()
_report.write_report()
if not _auto_mode:
return
_quit_client.rpc_id(_client_id)
if _end_hold > 0.0:
_report.log_line("holding for %.0fs so you can look at the final state..." % _end_hold)
await get_tree().create_timer(_end_hold).timeout
else:
await _steps.wait_frames(30)
_finish(_report.failed_count() == 0)
# --- client command handling ----------------------------------------------
@rpc("authority", "reliable")
func _cmd(step: String, args: Array) -> void:
_snapshot.current_step = step
_view.disable_despawn_timers()
_report.log_line("<- server: %s%s" % [step, args])
_running = true
var res := await _run_local_step(step, args)
_running = false
_report.log_line("%s %s" % ["PASS" if res.get("ok") else "FAIL", step])
_report.log_line(" %s" % res.get("detail"))
_result.rpc_id(1, step, res.get("ok", false), str(res.get("detail", "")))
@rpc("any_peer", "reliable")
func _result(step: String, ok: bool, detail: String) -> void:
_reply = {"ok": ok, "detail": detail, "step": step}
@rpc("authority", "reliable")
func _quit_client() -> void:
_report.write_frame_index()
if _end_hold > 0.0:
await get_tree().create_timer(_end_hold).timeout
_finish(true)
func _run_local_step(step: String, args: Array) -> Dictionary:
match step:
"grab":
return await _steps.grab(_view.find(args[0]), args[0])
"drop":
return await _steps.drop(_view.find(args[0]), args[0])
"place_in_zone":
return await _steps.place_in_zone(_view.find(args[0]), args[0], args[1])
"place_food_in_zone":
return await _steps.place_in_zone(_view.find_by_food_id(args[0]), args[0], args[1])
"carry_food_to_food":
return await _steps.carry_item_to_item(
_view.find(args[0]), args[0], _view.find_by_food_id(args[1]), args[1])
"carry_food_to_item":
return await _steps.carry_item_to_item(
_view.find_by_food_id(args[0]), args[0], _view.find(args[1]), args[1])
"await_food":
return await _steps.await_food(args[0])
"await_clean":
return await _steps.await_clean(args[0])
"park":
return await _steps.park(_view.find(args[0]), args[0], args[1])
"verify_snapped":
return _asserts.snapped(args[0], args[1])
"verify_dirty":
return _asserts.dirty(args[0], args[1] == "true")
"verify_gone":
return _asserts.gone(args[0])
"verify_food_exists":
return _asserts.food_exists(args[0])
"verify_plate_contains":
return _asserts.plate_contains(args[0], args[1])
"verify_plate_visuals":
return _asserts.plate_visuals(args[0])
"verify_station_bar":
return _asserts.station_bar(args[0], args[1] == "true")
"verify_zone_empty":
return _asserts.zone_empty(args[0])
"verify_world_replicated":
return await _check_world_replicated()
return {"ok": false, "detail": "unknown step %s" % step}
# --- cross-peer sync audit -------------------------------------------------
@rpc("authority", "reliable")
func _request_snapshot() -> void:
_snapshot_reply.rpc_id(1, _snapshot.take())
@rpc("any_peer", "reliable")
func _snapshot_reply(snap: Dictionary) -> void:
_client_snapshot = snap
_snapshot_pending = false
func _fetch_client_snapshot() -> Dictionary:
_client_snapshot = {}
_snapshot_pending = true
_request_snapshot.rpc_id(_client_id)
var deadline := Time.get_ticks_msec() + 10000
while _snapshot_pending and Time.get_ticks_msec() < deadline:
await get_tree().process_frame
return _client_snapshot
## Poll until the two peers agree, so ordinary physics settling is not reported
## as a desync — a real one never converges and gets reported with its details.
func _audit_sync(label: String) -> void:
if not NetworkManager.is_server() or _client_id == 0:
return
var problems: Array[String] = []
var deadline := Time.get_ticks_msec() + int(MpSnapshot.SETTLE_SEC * 1000.0)
while true:
var mine := _snapshot.take()
var theirs := await _fetch_client_snapshot()
problems = _snapshot.compare(mine, theirs)
if problems.is_empty() or Time.get_ticks_msec() > deadline:
break
await _steps.wait_frames(10)
var res := {"ok": problems.is_empty(), "detail": ""}
if problems.is_empty():
res["detail"] = "server and client agree on all %d objects" % _snapshot.take().size()
else:
res["detail"] = "after %.0fs the peers still disagree: %s" % [MpSnapshot.SETTLE_SEC, "; ".join(problems)]
_report.record("sync_after_" + label, "both", res)
## The client loaded a bare multiplayer scene with no kitchen in it, so every
## object it can see arrived from the server. This confirms it got the whole
## layout — the same thing that has to work for a player joining mid-session.
func _check_world_replicated() -> Dictionary:
var mine := _snapshot.take()
var theirs := await _fetch_client_snapshot()
if theirs.is_empty():
return {"ok": false, "detail": "the client reported nothing at all"}
var problems := _snapshot.compare(mine, theirs)
var detail := "server has %d objects, client has %d" % [mine.size(), theirs.size()]
if not problems.is_empty():
return {"ok": false, "detail": "%s; %s" % [detail, "; ".join(problems)]}
return {"ok": true, "detail": "%s, all matching (client received the world over the network)" % detail}
# --- per-step screenshots --------------------------------------------------
## The server numbers the frames and tells the client to grab the matching one,
## so frame N is the same step on both sides and they can be stitched in pairs.
func _capture_step_frame(label: String) -> void:
if not _report.frames_enabled():
return
var index := _report.next_frame_index()
if NetworkManager.is_server() and _client_id != 0:
_capture_frame_rpc.rpc_id(_client_id, index, label)
await _report.save_frame(index, label)
@rpc("authority", "reliable")
func _capture_frame_rpc(index: int, label: String) -> void:
await _report.save_frame(index, label)
# --- manual controls -------------------------------------------------------
func _unhandled_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.pressed or event.echo:
return
if _running and event.keycode != KEY_0:
_report.log_line("busy running a step, ignoring that key")
return
match event.keycode:
KEY_H:
_report.log_line("KEY: host"); NetworkManager.host(); _refresh_role()
KEY_J:
_report.log_line("KEY: join 127.0.0.1"); NetworkManager.join("127.0.0.1")
await get_tree().create_timer(1.0).timeout
_refresh_role()
KEY_1: await _manual("grab plate", "grab", ["Plate"])
KEY_2: await _manual("drop plate", "drop", ["Plate"])
KEY_3: await _manual("plate -> dirt station", "place_in_zone", ["Plate", "DirtStation"])
KEY_4: await _manual("plate -> sink", "place_in_zone", ["Plate", "Sink"])
KEY_5: await _manual("raw burger -> hob", "place_in_zone", ["raw_burger", "Hob"])
KEY_6: await _manual("cooked burger -> counter", "place_food_in_zone", ["cooked_burger", "Counter"])
KEY_7: await _manual("buns -> cooked burger", "carry_food_to_food", ["BurgerBuns", "cooked_burger"])
KEY_8: await _manual("plate -> counter 2", "place_in_zone", ["Plate", "Counter2"])
KEY_9: await _manual("hamburger -> plate", "carry_food_to_item", ["hamburger", "Plate"])
KEY_R:
_refresh_role()
if _role != "server":
_report.log_line("KEY: run-all is server-only (this peer is %s)" % _role)
return
_report.log_line("KEY: running the full automatic sequence")
await _run_server()
KEY_0:
_report.log_line("KEY: state dump")
_view.dump_state(MpSnapshot.WATCHED_STATIONS, _hand)
KEY_A:
_refresh_role()
if _role != "server":
_report.log_line("KEY: the sync audit runs from the server window")
return
if _client_id == 0:
_client_id = _find_client_id()
_report.log_line("KEY: comparing every object against the client")
_running = true
await _audit_sync("manual_check")
_running = false
KEY_C:
_toggle_debug_camera()
func _manual(what: String, step: String, args: Array) -> void:
_refresh_role()
_report.banner("MANUAL: %s" % what)
_running = true
var res := await _run_local_step(step, args)
_running = false
_report.log_line("%s %s :: %s" % ["PASS" if res.get("ok") else "FAIL", what, res.get("detail", "")])
# Capture manual steps too, so a hand-driven repro can be turned into a GIF.
await _capture_step_frame(step)
_report.write_frame_index()
func _finish(ok: bool) -> void:
_report.log_line("=== %s exiting (%s) ===" % [_role, "ok" if ok else "FAILED"])
get_tree().quit(0 if ok else 1)
# --- camera ----------------------------------------------------------------
## A fixed camera overlooking the kitchen.
##
## Without this a windowed run shows nothing useful: the only camera is the
## XROrigin's XRCamera3D, which nothing drives when XR is off, and the rig's
## PlayerBody keeps falling under gravity — the view ends up metres below the
## floor pointing at the void. Ours is a plain Camera3D parented to the world,
## not the rig, so it stays put.
func _build_debug_camera() -> void:
if DisplayServer.get_name() == "headless":
return
_debug_cam = Camera3D.new()
_debug_cam.name = "MPTestDebugCamera"
_world.add_child(_debug_cam)
_debug_cam.global_position = DEBUG_CAM_POS
_debug_cam.look_at(DEBUG_CAM_LOOK_AT)
# Stand the rig's camera down explicitly first: setting `current` alone lost
# the race against XRCamera3D, leaving the view stuck under the floor.
var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D
if xr_cam:
xr_cam.current = false
_debug_cam.make_current()
_report.log_line("debug camera at %s looking at %s ([C] toggles back to the XR rig camera)"
% [DEBUG_CAM_POS, DEBUG_CAM_LOOK_AT])
_assert_debug_camera.call_deferred()
func _assert_debug_camera() -> void:
if _debug_cam and _debug_cam.current and get_viewport().get_camera_3d() != _debug_cam:
_debug_cam.make_current()
_report.log_line("rendering through camera: %s" % get_viewport().get_camera_3d())
_check_framing()
## Confirm the things the test acts on are actually on screen. Checked by
## projection rather than by eye, because that is the only way to be sure across
## window sizes and display scaling.
func _check_framing() -> void:
var size := get_viewport().get_visible_rect().size
var offscreen: Array[String] = []
for name in ["Counter2", "Counter", "Hob", "Sink", "DirtStation", "Plate"]:
var n := _view.find(name)
if not n:
continue
var p := _debug_cam.unproject_position(n.global_position)
var frac := Vector2(p.x / size.x, p.y / size.y)
var on := not _debug_cam.is_position_behind(n.global_position) \
and frac.x > 0.02 and frac.x < 0.98 and frac.y > 0.02 and frac.y < 0.98
_report.log_line(" framing: %-12s at %.2f,%.2f of frame%s"
% [name, frac.x, frac.y, "" if on else " <-- OFF SCREEN"])
if not on:
offscreen.append(name)
if offscreen.is_empty():
_report.log_line(" framing: all test objects are in view")
else:
_report.log_line(" framing: WARNING - not visible: %s (adjust DEBUG_CAM_POS)" % ", ".join(offscreen))
func _toggle_debug_camera() -> void:
if not _debug_cam:
return
var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D
if _debug_cam.current and xr_cam:
xr_cam.make_current()
_report.log_line("switched to the XR rig camera")
else:
_debug_cam.make_current()
_report.log_line("switched to the debug camera")