61d92052ac
Adding an object to the game no longer requires any networking code. The
MultiplayerSpawner runs in its default mode — no spawn_function, no payload
dictionary — and NetReplication builds each object's SceneReplicationConfig
from a convention, so scenes carry no hand-authored replication at all.
Every replicated node gets two generated synchronizers: NetSync for script
state, always server-owned, and NetXform for position, handed to whoever is
holding the object. That split is what makes grab prediction work — a
synchronizer never applies inbound state on the peer that owns it, so a
player's own hand drives an object with no round trip while is_dirty and
friends keep flowing one way from the server.
Interaction is now two RPCs for the whole game (NetGrab), and client gating is
one rule applied to every object (NetWorld). Deleted: net_pickable.gd, the
replicated net_held_by field and its held-state juggling, the
grant/reject/force-release negotiation, the static-item despawn RPC, and the
per-scene replication configs. Authority is the single source of truth for who
simulates an object.
Two things the convention had to learn, both found by the test suite:
* Addon scripts are excluded. godot-xr-tools' snap zones and pickables expose
a public `enabled`, which is exactly the flag each peer must set for itself
— so replicating it meant the server sent `enabled = true` back over every
client's gate, and stations went on grabbing objects out of the local
player's hands.
* Arrays of nodes are excluded. Array[Node3D] and Array[FoodItem] would
otherwise try to serialise live node references.
table.gd's replicated state loses its underscore prefix, which now marks a
variable as private and unreplicated; two in-place array mutations there were
skipping their setters, and the progress bar could divide by zero on a client.
Suite: 146/146 passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1468 lines
59 KiB
GDScript
1468 lines
59 KiB
GDScript
extends Node
|
|
|
|
## Multiplayer debug harness for the kitchen flow, living in
|
|
## test/multiPlayerTest.tscn.
|
|
##
|
|
## Two ways to use it:
|
|
##
|
|
## AUTOMATIC — pass `--mptest` and the server runs the whole scripted 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 on-screen
|
|
## overlay, a fixed camera over the kitchen, and keyboard controls:
|
|
##
|
|
## H / J host / join 127.0.0.1
|
|
## 1 grab the plate 2 drop it
|
|
## 3 plate -> dirt station 4 plate -> sink
|
|
## 5 raw burger -> hob 6 cooked burger -> counter
|
|
## 7 buns -> cooked burger (combine)
|
|
## 8 plate -> counter 2 9 hamburger -> plate
|
|
## R run the whole automatic sequence (server only)
|
|
## 0 dump state C toggle camera
|
|
##
|
|
## 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 -> gets washed clean on both
|
|
## 6. a cook-and-plate round, run once by the CLIENT and once by the SERVER:
|
|
## raw burger -> hob (cooks into a cooked burger, 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 plate to replicate.
|
|
const SETUP_TIMEOUT_SEC := 30.0
|
|
## Distance under which an item counts as "at" a snap zone.
|
|
const SNAP_TOLERANCE := 0.12
|
|
## Cooking and washing both take ~3s of station time; allow generously for it.
|
|
const STATION_WORK_TIMEOUT := 20.0
|
|
## Lines kept in the on-screen overlay. Enough to show a whole step, without the
|
|
## panel eating the view of the kitchen underneath it.
|
|
const OVERLAY_LINES := 11
|
|
|
|
## Where the debug camera sits and what it aims at: a fixed vantage point that
|
|
## frames 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 _log_file: FileAccess
|
|
var _log_path := ""
|
|
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
|
|
|
|
# Server-side ledger of every check: {step, side, ok, detail}
|
|
var _results: Array[Dictionary] = []
|
|
# Latest reply from the client, consumed by _remote().
|
|
var _reply: Dictionary = {}
|
|
|
|
var _world: Node3D
|
|
var _controller: Node3D
|
|
var _hand: XRToolsFunctionPickup
|
|
var _hand_detached := false
|
|
|
|
var _overlay: Label
|
|
var _overlay_lines: Array[String] = []
|
|
|
|
# Per-step screenshots, later stitched into a side-by-side GIF of both peers.
|
|
var _frames_enabled := false
|
|
var _frames_dir := ""
|
|
var _frame_index := 0
|
|
|
|
|
|
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
|
|
_frames_enabled = "--mptest-frames" in args
|
|
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
|
|
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
|
|
_world = get_parent()
|
|
_open_log()
|
|
_build_overlay()
|
|
await get_tree().process_frame
|
|
_refresh_role()
|
|
_log("=== mp test driver ready (role=%s, peer=%d, mode=%s) ==="
|
|
% [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"])
|
|
# await: _resolve_nodes now waits for the server's spawns to arrive.
|
|
if not await _resolve_nodes():
|
|
if _auto_mode:
|
|
_finish(false)
|
|
return
|
|
_build_debug_camera()
|
|
_setup_frames()
|
|
if not _auto_mode:
|
|
_log("MANUAL MODE - keys act on this window's peer:")
|
|
_log(" " + HELP)
|
|
return
|
|
if _role == "server":
|
|
await _run_server()
|
|
|
|
|
|
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, because items get consumed and respawned as the test runs (the raw
|
|
# burger becomes a cooked burger, which becomes a hamburger, which is absorbed
|
|
# into a plate) — caching node references would leave us holding freed objects.
|
|
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:
|
|
_log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup")
|
|
return false
|
|
# The kitchen is no longer 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 _wait_until(func(): return _find(required) != null,
|
|
"'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC):
|
|
_log("FATAL: '%s' never appeared in the world" % required)
|
|
return false
|
|
_log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path())
|
|
_disable_despawn_timers()
|
|
return true
|
|
|
|
|
|
# The test deliberately leaves items sitting still for minutes at a time, which
|
|
# DespawningItem would treat as litter and remove (it took the raw burger out
|
|
# before the client had even connected). Hold them indefinitely instead, so the
|
|
# run tests the kitchen rather than the despawn timer.
|
|
func _disable_despawn_timers() -> void:
|
|
var stopped := 0
|
|
for node in _world.find_children("*", "DespawningItem", true, false):
|
|
if _despawn_timers_seen.has(node.get_instance_id()):
|
|
continue
|
|
_despawn_timers_seen[node.get_instance_id()] = true
|
|
node.set_process(false)
|
|
stopped += 1
|
|
if stopped > 0:
|
|
_log("disabled %d DespawningItem timer(s) so test items don't vanish mid-run" % stopped)
|
|
|
|
|
|
# Items appear as the run goes on (cooking and combining spawn new ones), so
|
|
# this is re-checked before every step rather than only at startup.
|
|
var _despawn_timers_seen := {}
|
|
|
|
|
|
# Items live either baked in the scene root or, once spawned at runtime, under
|
|
# WorldContent. Look in both.
|
|
func _find(name: String) -> Node3D:
|
|
var n := _world.get_node_or_null(name)
|
|
if not n:
|
|
n = _world.get_node_or_null("WorldContent/" + name)
|
|
return n as Node3D
|
|
|
|
|
|
func _zone_of(station_name: String) -> XRToolsSnapZone:
|
|
var s := _find(station_name)
|
|
return s.get_node_or_null("XRToolsSnapZone") as XRToolsSnapZone if s else null
|
|
|
|
|
|
# Cooking and combining spawn their results with engine-assigned names, so the
|
|
# only stable way to find them is by the food id they carry.
|
|
func _find_by_food_id(id: String) -> Node3D:
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for child in root.get_children():
|
|
var f := child.get_node_or_null("FoodItem") as FoodItem
|
|
if f and f.id == id and is_instance_valid(child):
|
|
return child as Node3D
|
|
return null
|
|
|
|
|
|
# --- Server orchestration --------------------------------------------------
|
|
|
|
func _run_server() -> void:
|
|
_running = true
|
|
_results.clear()
|
|
_step_no = 0
|
|
if not await _wait_for_client():
|
|
_running = false
|
|
if _auto_mode:
|
|
_finish(false)
|
|
return
|
|
await _wait_frames(60)
|
|
_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 items
|
|
# 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 can't 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 _report()
|
|
|
|
|
|
# One full food round performed by `actor`: cook a burger, combine it with buns
|
|
# on the counter, and put the result on a plate that's 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:
|
|
_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"])
|
|
|
|
# Get it off the hob before anything else: the hob keeps cooking whatever is
|
|
# in it, and a cooked burger left there burns to charcoal in another 2s —
|
|
# which would blow up the run as soon as there's any pause between steps.
|
|
await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"])
|
|
await _both("%s_raw_burger_removed" % actor, "verify_gone", [burger])
|
|
await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_burger"])
|
|
await _both("%s_hob_bar_hidden_when_empty" % actor, "verify_station_bar", ["Hob", "false"])
|
|
|
|
# Now bring the buns to it to combine.
|
|
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"])
|
|
|
|
# Take the finished plate away again, the way a player would carry it off to
|
|
# be served. Without this the counter stays occupied and the next round has
|
|
# nowhere to put its plate.
|
|
await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [plate])
|
|
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 it has been carried off the
|
|
# counter and set down again.
|
|
await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate])
|
|
|
|
|
|
# Spawn a second set of ingredients through NetworkManager for the server's
|
|
# round (the scene only bakes one of each).
|
|
func _spawn_round_items() -> void:
|
|
_banner("state between rounds (server's view)")
|
|
_dump_state()
|
|
_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])
|
|
_log(" spawned %s" % s[1])
|
|
await _wait_frames(30)
|
|
|
|
|
|
func _wait_for_client() -> bool:
|
|
var ok := await _wait_until(func(): return _find_client_id() != 0, "client to connect", SETUP_TIMEOUT_SEC)
|
|
_client_id = _find_client_id()
|
|
if ok:
|
|
_log("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's us, over RPC if it's the client)
|
|
# and record the verdict.
|
|
func _step(actor: String, label: String, step: String, args: Array) -> void:
|
|
_current_step = label
|
|
_disable_despawn_timers()
|
|
_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)
|
|
_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:
|
|
_current_step = label
|
|
_disable_despawn_timers()
|
|
_banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label])
|
|
_record(label, "server", await _run_local_step(step, args))
|
|
_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 _record(step: String, side: String, res: Dictionary) -> void:
|
|
var ok: bool = res.get("ok", false)
|
|
var detail: String = res.get("detail", "")
|
|
_results.append({"step": step, "side": side, "ok": ok, "detail": detail})
|
|
_log("%s [%s] %s" % ["PASS" if ok else "FAIL", side, step])
|
|
_log(" %s" % detail)
|
|
|
|
|
|
func _report() -> void:
|
|
var failed := 0
|
|
_banner("RESULTS")
|
|
for r in _results:
|
|
if not r["ok"]:
|
|
failed += 1
|
|
_log("%-4s %-8s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
|
_log("%d/%d checks passed" % [_results.size() - failed, _results.size()])
|
|
for r in _results:
|
|
if not r["ok"]:
|
|
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
|
await _capture_step_frame("final")
|
|
_write_frame_index()
|
|
_write_report(failed)
|
|
if not _auto_mode:
|
|
return
|
|
_quit_client.rpc_id(_client_id)
|
|
if _end_hold > 0.0:
|
|
_log("holding for %.0fs so you can look at the final state..." % _end_hold)
|
|
await get_tree().create_timer(_end_hold).timeout
|
|
else:
|
|
await _wait_frames(30)
|
|
_finish(failed == 0)
|
|
|
|
|
|
# A standalone report of the run, written next to the logs so it can be read
|
|
# without scrolling the console — and so the in-editor runner can print it back.
|
|
func _write_report(failed: int) -> void:
|
|
var path := "res://logs/mptest_report.txt"
|
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
|
if not f:
|
|
return
|
|
var passed := _results.size() - failed
|
|
f.store_line("VRyHungry multiplayer test report")
|
|
f.store_line("run at %s" % Time.get_datetime_string_from_system())
|
|
f.store_line("")
|
|
f.store_line("RESULT: %s (%d passed, %d failed, %d total)"
|
|
% ["ALL CHECKS PASSED" if failed == 0 else "FAILED", passed, failed, _results.size()])
|
|
f.store_line("")
|
|
if failed > 0:
|
|
f.store_line("--- failures ---")
|
|
for r in _results:
|
|
if not r["ok"]:
|
|
f.store_line("FAIL [%s] %s" % [r["side"], r["step"]])
|
|
f.store_line(" %s" % r["detail"])
|
|
f.store_line("")
|
|
f.store_line("--- every check, in order ---")
|
|
for r in _results:
|
|
f.store_line("%-4s %-6s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
|
f.close()
|
|
_log("report written to %s" % ProjectSettings.globalize_path(path))
|
|
|
|
|
|
# --- Client command handling ----------------------------------------------
|
|
|
|
@rpc("authority", "reliable")
|
|
func _cmd(step: String, args: Array) -> void:
|
|
_current_step = step
|
|
_disable_despawn_timers()
|
|
_log("<- server: %s%s" % [step, args])
|
|
_running = true
|
|
var res := await _run_local_step(step, args)
|
|
_running = false
|
|
_log("%s %s" % ["PASS" if res.get("ok") else "FAIL", step])
|
|
_log(" %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:
|
|
_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 _do_grab(_find(args[0]), args[0])
|
|
"drop":
|
|
return await _do_drop(_find(args[0]), args[0])
|
|
"place_in_zone":
|
|
return await _do_place_in_zone(_find(args[0]), args[0], args[1])
|
|
"place_food_in_zone":
|
|
return await _do_place_in_zone(_find_by_food_id(args[0]), args[0], args[1])
|
|
"carry_food_to_food":
|
|
return await _do_carry_item_to_item(_find(args[0]), args[0], _find_by_food_id(args[1]), args[1])
|
|
"carry_food_to_item":
|
|
return await _do_carry_item_to_item(_find_by_food_id(args[0]), args[0], _find(args[1]), args[1])
|
|
"await_food":
|
|
return await _do_await_food(args[0])
|
|
"await_clean":
|
|
return await _do_await_clean(args[0])
|
|
"park":
|
|
return await _do_park(_find(args[0]), args[0], args[1])
|
|
"verify_snapped":
|
|
return _check_snapped(args[0], args[1])
|
|
"verify_dirty":
|
|
return _check_dirty(args[0], args[1] == "true")
|
|
"verify_gone":
|
|
return _check_gone(args[0])
|
|
"verify_food_exists":
|
|
return _check_food_exists(args[0])
|
|
"verify_plate_contains":
|
|
return _check_plate_contains(args[0], args[1])
|
|
"verify_plate_visuals":
|
|
return _check_plate_visuals(args[0])
|
|
"verify_station_bar":
|
|
return _check_station_bar(args[0], args[1] == "true")
|
|
"verify_zone_empty":
|
|
return _check_zone_empty(args[0])
|
|
"verify_world_replicated":
|
|
return await _check_world_replicated()
|
|
return {"ok": false, "detail": "unknown step %s" % step}
|
|
|
|
|
|
# --- The simulated player actions -----------------------------------------
|
|
|
|
# Reach out to an item and squeeze the grip, the same way the real controller
|
|
# path does: position the hand, let the grab Area3D register what's in range,
|
|
# refresh the closest-object pick (normally done from _process, which bails out
|
|
# headlessly because the controller reports as untracked), then press grip.
|
|
func _do_grab(item: Node3D, label: String) -> Dictionary:
|
|
if not is_instance_valid(item):
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
|
|
_log(" reaching for %s at %s" % [label, item.global_position])
|
|
_move_hand_to(item.global_position)
|
|
await _wait_frames(4)
|
|
_hand._update_closest_object()
|
|
var closest := _hand.closest_object
|
|
_log(" hand at %s, closest grabbable = %s" % [_hand.global_position, closest])
|
|
if not is_instance_valid(closest):
|
|
return {"ok": false, "detail": "hand found nothing to grab. %s" % _diag(item)}
|
|
_log(" pressing grip")
|
|
_hand._on_grip_pressed()
|
|
await _wait_frames(4)
|
|
if not item.is_picked_up():
|
|
return {"ok": false, "detail": "grip pressed on %s but %s not picked up. %s"
|
|
% [closest.name, label, _diag(item)]}
|
|
if item.get_picked_up_by() != _hand:
|
|
return {"ok": false, "detail": "%s is held by %s, not our hand. %s"
|
|
% [label, item.get_picked_up_by(), _diag(item)]}
|
|
_log(" holding %s; waiting for authority..." % label)
|
|
if not await _wait_until(func(): return _xform_authority(item) == multiplayer.get_unique_id(),
|
|
"authority handoff", 5.0):
|
|
return {"ok": false, "detail": "grabbed %s, but NetXform authority stayed with peer %d. %s"
|
|
% [label, _xform_authority(item), _diag(item)]}
|
|
_log(" authority is ours (peer %d)" % multiplayer.get_unique_id())
|
|
return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, _diag(item)]}
|
|
|
|
|
|
func _do_drop(item: Node3D, label: String) -> Dictionary:
|
|
if not is_instance_valid(item):
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
|
|
if not item.is_picked_up():
|
|
return {"ok": false, "detail": "nothing to drop: %s is not held. %s" % [label, _diag(item)]}
|
|
_log(" releasing grip (held by %s)" % item.get_picked_up_by())
|
|
_hand._on_grip_release()
|
|
await _wait_frames(8)
|
|
if not is_instance_valid(item):
|
|
return {"ok": true, "detail": "%s was consumed on release" % label}
|
|
# Explicitly typed: `item` is a Node3D here, so the return type of
|
|
# get_picked_up_by() isn't known statically and := can't infer it.
|
|
var by: Node = item.get_picked_up_by()
|
|
_log(" after release %s is held by %s" % [label, by])
|
|
if by == _hand:
|
|
return {"ok": false, "detail": "grip released but our hand still holds %s" % label}
|
|
# A station catching the item on release is intended - but only on the peer
|
|
# that owns world logic. A client's own snap zone doing it means that peer is
|
|
# running station logic it has no authority for.
|
|
if by is XRToolsSnapZone and not NetworkManager.owns_world():
|
|
return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner (zone enabled=%s snap_mode=%d processing=%s in_grab_area=%d)"
|
|
% [by.get_parent().name, label, by.enabled, by.snap_mode,
|
|
by.is_processing(), by._object_in_grab_area.size()]}
|
|
if not await _wait_until(func(): return not is_instance_valid(item) or _xform_authority(item) == 1,
|
|
"authority return to server", 5.0):
|
|
return {"ok": false, "detail": "dropped %s, but NetXform authority stayed with peer %d"
|
|
% [label, _xform_authority(item)]}
|
|
return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]}
|
|
|
|
|
|
# Grab an item, carry it onto a station's snap zone, and let go there.
|
|
func _do_place_in_zone(item: Node3D, label: String, station: String) -> Dictionary:
|
|
var zone := _zone_of(station)
|
|
if not zone:
|
|
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
|
|
var res := await _do_grab(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_carry_to(item, label, zone.global_position)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_drop(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
_log(" waiting for %s to settle onto %s..." % [label, station])
|
|
await _wait_until(func(): return not is_instance_valid(item) \
|
|
or item.global_position.distance_to(zone.global_position) <= SNAP_TOLERANCE,
|
|
"%s to settle onto %s" % [label, station], 5.0)
|
|
return {"ok": true, "detail": "placed %s into %s; %s" % [label, station, _diag(item)]}
|
|
|
|
|
|
# Grab an item and carry it into another item, holding it there. Used for the
|
|
# combine (buns into the cooked burger) and for putting food on a plate, both of
|
|
# which are triggered by an Area3D overlap rather than by dropping.
|
|
func _do_carry_item_to_item(item: Node3D, label: String, target: Node3D, target_label: String) -> Dictionary:
|
|
if not is_instance_valid(target):
|
|
return {"ok": false, "detail": "target '%s' does not exist on this peer" % target_label}
|
|
var target_pos := target.global_position
|
|
var res := await _do_grab(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_carry_to(item, label, target_pos)
|
|
if not res["ok"] and is_instance_valid(item):
|
|
return res
|
|
# Hold it there a moment: the reaction happens on the server, and the item
|
|
# we're holding may be consumed by it.
|
|
for i in 60:
|
|
if not is_instance_valid(item):
|
|
_log(" %s was consumed (the reaction fired)" % label)
|
|
return {"ok": true, "detail": "%s consumed on contact with %s" % [label, target_label]}
|
|
await get_tree().physics_frame
|
|
# Both uses of this (combining, and putting food on a plate) consume the
|
|
# carried item, so still holding it means the reaction never fired. Let go
|
|
# first so the next step isn't left fighting our hand.
|
|
if is_instance_valid(item) and item.is_picked_up():
|
|
_hand._on_grip_release()
|
|
await _wait_frames(8)
|
|
return {"ok": false, "detail": "%s reached %s but was never consumed - the reaction did not fire; %s"
|
|
% [label, target_label, _diag(item)]}
|
|
|
|
|
|
# Carry an item somewhere clear and put it down, freeing the station it was in.
|
|
func _do_park(item: Node3D, label: String, pos: Vector3) -> Dictionary:
|
|
var res := await _do_grab(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_carry_to(item, label, pos)
|
|
if not res["ok"]:
|
|
return res
|
|
res = await _do_drop(item, label)
|
|
if not res["ok"]:
|
|
return res
|
|
return {"ok": true, "detail": "parked %s at %s" % [label, pos]}
|
|
|
|
|
|
# Move the held item onto a world position. Closed-loop: move the hand by
|
|
# whatever the item's remaining error is, since the grab point offsets the item
|
|
# from the hand by an amount we don't want to hard-code.
|
|
func _do_carry_to(item: Node3D, label: String, target: Vector3) -> Dictionary:
|
|
if not is_instance_valid(item) or not item.is_picked_up():
|
|
return {"ok": false, "detail": "cannot carry: %s is not held" % label}
|
|
_log(" carrying %s from %s to %s" % [label, item.global_position, target])
|
|
for i in 120:
|
|
if not is_instance_valid(item):
|
|
return {"ok": true, "detail": "%s was consumed while being carried" % label}
|
|
var err := item.global_position - target
|
|
if err.length() <= 0.01:
|
|
break
|
|
_controller.global_position -= err
|
|
await get_tree().physics_frame
|
|
await get_tree().physics_frame
|
|
if not is_instance_valid(item):
|
|
return {"ok": true, "detail": "%s was consumed while being carried" % label}
|
|
var dist := item.global_position.distance_to(target)
|
|
_log(" %s is now %.3fm from the target" % [label, dist])
|
|
if dist > 0.05:
|
|
return {"ok": false, "detail": "could not carry %s to the target: still %.3fm away" % [label, dist]}
|
|
return {"ok": true, "detail": "carried %s to within %.3fm" % [label, dist]}
|
|
|
|
|
|
# Wait for a station to produce an item with the given food id (cooking the
|
|
# burger, or combining into a hamburger). Server-side: stations only run there.
|
|
func _do_await_food(food_id: String) -> Dictionary:
|
|
_log(" waiting for a '%s' to appear..." % food_id)
|
|
var ok := await _wait_until(func(): return _find_by_food_id(food_id) != null,
|
|
"a '%s' to be produced" % food_id, STATION_WORK_TIMEOUT)
|
|
if not ok:
|
|
return {"ok": false, "detail": "no '%s' was produced within %.0fs" % [food_id, STATION_WORK_TIMEOUT]}
|
|
# Let the spawn replicate before the checks that follow look for it.
|
|
await _wait_frames(30)
|
|
var made := _find_by_food_id(food_id)
|
|
return {"ok": true, "detail": "'%s' produced: %s at %s" % [food_id, made.name, made.global_position]}
|
|
|
|
|
|
func _do_await_clean(plate_name: String) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var pc := plate.get_node_or_null("PlateController")
|
|
_log(" waiting for the sink to wash %s (dirty=%s)..." % [plate_name, pc.is_dirty if pc else "?"])
|
|
var ok := await _wait_until(func(): return pc and not pc.is_dirty,
|
|
"the sink to wash the plate", STATION_WORK_TIMEOUT)
|
|
await _wait_frames(30)
|
|
if not ok:
|
|
return {"ok": false, "detail": "%s was still dirty after %.0fs in the sink; %s"
|
|
% [plate_name, STATION_WORK_TIMEOUT, _diag(plate)]}
|
|
return {"ok": true, "detail": "%s was washed clean" % plate_name}
|
|
|
|
|
|
# --- Checks ----------------------------------------------------------------
|
|
|
|
# On the server "snapped" means the zone owns the item. On a client the snap is
|
|
# server-authoritative and never happens locally, so what must be true there is
|
|
# that the replicated item actually sits in the zone.
|
|
func _check_snapped(item_name: String, station: String) -> Dictionary:
|
|
var item := _find(item_name)
|
|
var zone := _zone_of(station)
|
|
if not item:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % item_name}
|
|
if not zone:
|
|
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
|
|
var dist := item.global_position.distance_to(zone.global_position)
|
|
var problems: Array[String] = []
|
|
if dist > SNAP_TOLERANCE:
|
|
problems.append("%s is %.3fm from %s's zone (tolerance %.2f)" % [item_name, dist, station, SNAP_TOLERANCE])
|
|
if NetworkManager.owns_world():
|
|
if zone.picked_up_object != item:
|
|
problems.append("%s's zone holds %s, not %s" % [station, zone.picked_up_object, item_name])
|
|
elif not item.is_picked_up():
|
|
problems.append("%s's zone claims %s but it has no grab driver (half-snapped)" % [station, item_name])
|
|
if problems.is_empty():
|
|
return {"ok": true, "detail": "%s is snapped into %s; %s" % [item_name, station, _diag(item)]}
|
|
return {"ok": false, "detail": "%s | %s" % [", ".join(problems), _diag(item)]}
|
|
|
|
|
|
func _check_dirty(plate_name: String, want_dirty: bool) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var pc := plate.get_node_or_null("PlateController")
|
|
if not pc:
|
|
return {"ok": false, "detail": "'%s' has no PlateController" % plate_name}
|
|
if pc.is_dirty != want_dirty:
|
|
return {"ok": false, "detail": "%s.is_dirty is %s, expected %s; %s"
|
|
% [plate_name, pc.is_dirty, want_dirty, _diag(plate)]}
|
|
return {"ok": true, "detail": "%s.is_dirty == %s as expected" % [plate_name, want_dirty]}
|
|
|
|
|
|
# A consumed item must be gone on EVERY peer, not just the one that consumed it.
|
|
func _check_gone(item_name: String) -> Dictionary:
|
|
var item := _find(item_name)
|
|
if item and is_instance_valid(item):
|
|
return {"ok": false, "detail": "'%s' still exists on this peer at %s (it should have been consumed)"
|
|
% [item_name, item.global_position]}
|
|
return {"ok": true, "detail": "'%s' is gone, as expected" % item_name}
|
|
|
|
|
|
func _check_food_exists(food_id: String) -> Dictionary:
|
|
var item := _find_by_food_id(food_id)
|
|
if not item:
|
|
return {"ok": false, "detail": "no item with food id '%s' exists on this peer" % food_id}
|
|
return {"ok": true, "detail": "'%s' exists: %s at %s" % [food_id, item.name, item.global_position]}
|
|
|
|
|
|
func _check_plate_contains(plate_name: String, food_id: String) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var pc := plate.get_node_or_null("PlateController")
|
|
if not pc:
|
|
return {"ok": false, "detail": "'%s' has no PlateController" % plate_name}
|
|
if not (food_id in pc.contained_ids):
|
|
return {"ok": false, "detail": "%s holds %s, expected it to contain '%s'"
|
|
% [plate_name, str(pc.contained_ids), food_id]}
|
|
return {"ok": true, "detail": "%s contains %s" % [plate_name, str(pc.contained_ids)]}
|
|
|
|
|
|
# --- Per-step screenshots --------------------------------------------------
|
|
#
|
|
# One frame per peer per step, saved as PNGs and stitched into a single
|
|
# side-by-side GIF afterwards (see test/make_gif.ps1). Seeing both peers'
|
|
# viewports next to each other for the same step is the fastest way to spot a
|
|
# visual desync — the numbers in the log tell you something diverged, the GIF
|
|
# shows you what it looked like.
|
|
|
|
## Frames are captured at half the viewport's resolution: 60-odd full-size
|
|
## frames per peer is a lot of pixels to write and then re-encode.
|
|
const FRAME_SCALE := 0.5
|
|
|
|
|
|
func _setup_frames() -> void:
|
|
if not _frames_enabled:
|
|
return
|
|
if DisplayServer.get_name() == "headless":
|
|
_frames_enabled = false
|
|
_log("frame capture disabled: a headless run has no rendered output to grab")
|
|
return
|
|
var role := "server" if "--server" in OS.get_cmdline_user_args() else "client"
|
|
_frames_dir = "res://logs/mptest_frames_%s" % role
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_frames_dir))
|
|
# Clear out a previous run's frames, or the GIF would splice the two together.
|
|
var dir := DirAccess.open(_frames_dir)
|
|
if dir:
|
|
for f in dir.get_files():
|
|
if f.ends_with(".png"):
|
|
dir.remove(f)
|
|
_log("capturing a frame per step into %s" % ProjectSettings.globalize_path(_frames_dir))
|
|
|
|
|
|
# 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 _frames_enabled:
|
|
return
|
|
_frame_index += 1
|
|
if NetworkManager.is_server() and _client_id != 0:
|
|
_capture_frame_rpc.rpc_id(_client_id, _frame_index, label)
|
|
await _save_frame(_frame_index, label)
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _capture_frame_rpc(index: int, label: String) -> void:
|
|
if not _frames_enabled:
|
|
return
|
|
_frame_index = index
|
|
await _save_frame(index, label)
|
|
|
|
|
|
func _save_frame(index: int, label: String) -> void:
|
|
# Wait for the frame to actually be drawn, or we capture whatever was in the
|
|
# buffer before this step's changes landed.
|
|
await RenderingServer.frame_post_draw
|
|
var tex := get_viewport().get_texture()
|
|
if not tex:
|
|
return
|
|
var img := tex.get_image()
|
|
if not img:
|
|
return
|
|
if FRAME_SCALE != 1.0:
|
|
img.resize(int(img.get_width() * FRAME_SCALE), int(img.get_height() * FRAME_SCALE),
|
|
Image.INTERPOLATE_BILINEAR)
|
|
# Index-only filenames so ffmpeg's image sequence reader can pick them up as
|
|
# frame_%04d.png; the step name is already legible in the on-screen overlay.
|
|
var err := img.save_png("%s/frame_%04d.png" % [_frames_dir, index])
|
|
if err != OK:
|
|
_log(" could not save frame %d (%s)" % [index, error_string(err)])
|
|
else:
|
|
_frame_labels.append("%04d %s" % [index, label])
|
|
|
|
|
|
# Written alongside the frames so a given frame number can be traced back to the
|
|
# step that produced it.
|
|
var _frame_labels: Array[String] = []
|
|
|
|
|
|
func _write_frame_index() -> void:
|
|
if not _frames_enabled or _frame_labels.is_empty():
|
|
return
|
|
var f := FileAccess.open("%s/frames.txt" % _frames_dir, FileAccess.WRITE)
|
|
if f:
|
|
for line in _frame_labels:
|
|
f.store_line(line)
|
|
f.close()
|
|
|
|
|
|
# --- Cross-peer sync audit -------------------------------------------------
|
|
#
|
|
# Runs after every step. Targeted per-step assertions only look at the one thing
|
|
# the step touched, which misses the whole class of bug where some *other*
|
|
# object quietly drifts out of sync — or where the data replicates fine but the
|
|
# thing you actually see on screen doesn't. So after each step both peers
|
|
# describe every item they can see, and the two descriptions must match.
|
|
|
|
## How far apart the same item may be on the two peers before it counts as a desync.
|
|
const SYNC_POS_TOLERANCE := 0.08
|
|
## Physics keeps moving after a step (dropped items fall, snaps settle), so give
|
|
## the peers this long to converge before calling a difference a failure.
|
|
const SYNC_SETTLE_SEC := 3.0
|
|
|
|
var _client_snapshot: Dictionary = {}
|
|
var _snapshot_pending := false
|
|
|
|
|
|
# Everything about an item that should look identical on every peer. Includes
|
|
# what is actually rendered, not just the replicated data behind it: a plate
|
|
# whose contained_ids arrived but whose visuals were never rebuilt looks empty
|
|
# to the player, and that must count as a desync.
|
|
func _describe(item: Node3D) -> Dictionary:
|
|
var d := {"pos": item.global_position, "visible": item.visible}
|
|
var pc := item.get_node_or_null("PlateController")
|
|
if pc:
|
|
d["dirty"] = pc.is_dirty
|
|
d["contents"] = ",".join(pc.contained_ids)
|
|
var dirty_node := item.get_node_or_null("Dirty")
|
|
d["dirty_shown"] = dirty_node.visible if dirty_node else false
|
|
# The cosmetic copies the container builds from contained_ids - this is
|
|
# literally what the player sees sitting on the plate.
|
|
d["meals_shown"] = _visual_count(item, "Container/MealContainer")
|
|
d["sides_shown"] = _visual_count(item, "Container/SidesContainer")
|
|
# Food sitting on a plate is a cosmetic child of the plate, so it must
|
|
# travel with it. Keys starting with "_" are per-peer diagnostics that
|
|
# _compare() skips (floats won't match exactly across peers); the
|
|
# attached flag is asserted absolutely instead, because this can — and
|
|
# did — go wrong on both peers at once, which a diff would miss.
|
|
var off := _max_visual_offset(item)
|
|
d["_visual_offset"] = off
|
|
d["visuals_attached"] = off <= MAX_VISUAL_OFFSET
|
|
return d
|
|
|
|
|
|
## How far a cosmetic item on a plate may sit from the plate's origin. The plate
|
|
## is ~0.4m across and the furthest slot is ~0.12m out, so anything past this has
|
|
## come off the plate.
|
|
const MAX_VISUAL_OFFSET := 0.3
|
|
|
|
|
|
# Largest distance from the plate's origin to any of the cosmetic items it is
|
|
# displaying. -1 when the plate is showing nothing.
|
|
func _max_visual_offset(item: Node3D) -> float:
|
|
var worst := -1.0
|
|
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
|
var root := item.get_node_or_null(path)
|
|
if not root:
|
|
continue
|
|
for c in root.get_children():
|
|
if c is Node3D and not c.is_queued_for_deletion():
|
|
worst = maxf(worst, item.global_position.distance_to((c as Node3D).global_position))
|
|
return snappedf(worst, 0.001)
|
|
|
|
|
|
# What the cosmetic children look like, for diagnosing why they moved.
|
|
func _visual_diag(item: Node3D) -> String:
|
|
var parts: Array[String] = []
|
|
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
|
var root := item.get_node_or_null(path)
|
|
if not root:
|
|
continue
|
|
for c in root.get_children():
|
|
var s := "%s global=%s local=%s parent=%s (%.3fm from plate at %s)" % [
|
|
c.name, (c as Node3D).global_position, (c as Node3D).position,
|
|
c.get_parent().name,
|
|
item.global_position.distance_to((c as Node3D).global_position),
|
|
item.global_position]
|
|
if c is RigidBody3D:
|
|
s += " [RigidBody3D freeze=%s freeze_mode=%d layer=%d top_level=%s queued=%s]" % [
|
|
c.freeze, c.freeze_mode, c.collision_layer, c.top_level,
|
|
c.is_queued_for_deletion()]
|
|
parts.append(s)
|
|
return "; ".join(parts) if parts else "(nothing on the plate)"
|
|
|
|
|
|
func _visual_count(item: Node3D, path: String) -> int:
|
|
var n := item.get_node_or_null(path)
|
|
if not n:
|
|
return -1
|
|
var count := 0
|
|
for c in n.get_children():
|
|
if not c.is_queued_for_deletion():
|
|
count += 1
|
|
return count
|
|
|
|
|
|
## Stations whose on-screen state has to match on every peer.
|
|
const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]
|
|
|
|
|
|
func _snapshot() -> Dictionary:
|
|
var out := {}
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for child in root.get_children():
|
|
if child is XRToolsPickable and not child.is_queued_for_deletion():
|
|
out[str(child.name)] = _describe(child)
|
|
for name in WATCHED_STATIONS:
|
|
var station := _find(name)
|
|
if station:
|
|
out["station:" + name] = _describe_station(station)
|
|
return out
|
|
|
|
|
|
# What a station shows the player. Only the world owner runs a station's logic
|
|
# and snap zone, so its display has to be driven from replicated state — a
|
|
# client that never updates it shows a hob that never lights up, or a sink bar
|
|
# that stays on screen after the plate came out clean.
|
|
#
|
|
# Bar *visibility* is compared across peers; the progress value is diagnostic
|
|
# only ("_" prefix), because it changes every tick and the two peers are
|
|
# legitimately a frame apart.
|
|
func _describe_station(station: Node3D) -> Dictionary:
|
|
var d := {}
|
|
var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D
|
|
d["bar_visible"] = bar.is_bar_visible() if bar else false
|
|
d["_bar_progress"] = snappedf(bar.get_progress(), 1.0) if bar else -1.0
|
|
if "cooking_result" in station:
|
|
d["cooking"] = str(station.cooking_result)
|
|
if "is_washing" in station:
|
|
d["washing"] = bool(station.is_washing)
|
|
return d
|
|
|
|
|
|
@rpc("authority", "reliable")
|
|
func _request_snapshot() -> void:
|
|
_snapshot_reply.rpc_id(1, _snapshot())
|
|
|
|
|
|
@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
|
|
|
|
|
|
# Compare the server's view with the client's, returning a list of differences.
|
|
func _compare(server: Dictionary, client: Dictionary) -> Array[String]:
|
|
var problems: Array[String] = []
|
|
for name in server:
|
|
if not client.has(name):
|
|
problems.append("'%s' exists on the server but NOT on the client" % name)
|
|
for name in client:
|
|
if not server.has(name):
|
|
problems.append("'%s' exists on the client but NOT on the server (ghost copy)" % name)
|
|
for name in server:
|
|
if not client.has(name):
|
|
continue
|
|
var s: Dictionary = server[name]
|
|
var c: Dictionary = client[name]
|
|
# Stations are compared on their displayed state, not a position.
|
|
if s.has("pos") and c.has("pos"):
|
|
var dist: float = (s["pos"] as Vector3).distance_to(c["pos"])
|
|
if dist > SYNC_POS_TOLERANCE:
|
|
problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["pos"]])
|
|
for key in s:
|
|
# "_" keys are per-peer diagnostics, not things that must match.
|
|
if key == "pos" or key.begins_with("_"):
|
|
continue
|
|
if s[key] != c[key]:
|
|
problems.append("%s.%s: server=%s client=%s" % [name, key, s[key], c[key]])
|
|
# Absolute invariants, checked per peer. A cross-peer diff can't catch a
|
|
# fault that happens identically on both sides.
|
|
for peer_name in ["server", "client"]:
|
|
var snap: Dictionary = server if peer_name == "server" else client
|
|
for name in snap:
|
|
var d: Dictionary = snap[name]
|
|
if d.has("visuals_attached") and not d["visuals_attached"]:
|
|
problems.append("on the %s, %s's food has come off the plate (%.3fm from it, limit %.2f)"
|
|
% [peer_name, name, d.get("_visual_offset", -1.0), MAX_VISUAL_OFFSET])
|
|
return problems
|
|
|
|
|
|
# Poll until the two peers agree, so ordinary physics settling isn't 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(SYNC_SETTLE_SEC * 1000.0)
|
|
while true:
|
|
var mine := _snapshot()
|
|
var theirs := await _fetch_client_snapshot()
|
|
problems = _compare(mine, theirs)
|
|
if problems.is_empty() or Time.get_ticks_msec() > deadline:
|
|
break
|
|
await _wait_frames(10)
|
|
var res := {"ok": problems.is_empty(), "detail": ""}
|
|
if problems.is_empty():
|
|
res["detail"] = "server and client agree on all %d items" % _snapshot().size()
|
|
else:
|
|
res["detail"] = "after %.0fs the peers still disagree: %s" % [SYNC_SETTLE_SEC, "; ".join(problems)]
|
|
_record("sync_after_" + label, "both", res)
|
|
|
|
|
|
# --- Live watch on plate visuals ------------------------------------------
|
|
#
|
|
# The per-step checks tell us the food ended up off the plate, but not when or
|
|
# why. This watches every frame and reports the first frame a cosmetic item
|
|
# departs from its slot, along with what was happening to the plate at the time.
|
|
|
|
## Local offset from its slot at which a cosmetic item counts as having moved.
|
|
const VISUAL_DRIFT_EPSILON := 0.05
|
|
|
|
var _current_step := "(before any step)"
|
|
var _drift_reported := {}
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if not _world:
|
|
return
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for item in root.get_children():
|
|
if not (item is Node3D) or not item.get_node_or_null("PlateController"):
|
|
continue
|
|
_watch_plate(item as Node3D)
|
|
|
|
|
|
func _watch_plate(plate: Node3D) -> void:
|
|
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
|
var holder := plate.get_node_or_null(path)
|
|
if not holder:
|
|
continue
|
|
for c in holder.get_children():
|
|
if not (c is Node3D) or c.is_queued_for_deletion():
|
|
continue
|
|
var drift: float = (c as Node3D).position.length()
|
|
var key := c.get_instance_id()
|
|
if drift <= VISUAL_DRIFT_EPSILON:
|
|
_drift_reported.erase(key)
|
|
continue
|
|
if _drift_reported.has(key):
|
|
continue
|
|
_drift_reported[key] = true
|
|
_log("VISUAL DRIFT: %s on %s moved to local %s (%.3f from its slot) during '%s'" % [
|
|
c.name, plate.name, (c as Node3D).position, drift, _current_step])
|
|
_log(" plate: pos=%s freeze=%s held_by=%s authority=%d" % [
|
|
plate.global_position, plate.freeze if plate is RigidBody3D else "-",
|
|
plate.get_picked_up_by() if plate.has_method("get_picked_up_by") else "-",
|
|
plate.get_multiplayer_authority()])
|
|
if c is RigidBody3D:
|
|
_log(" visual: freeze=%s mode=%d layer=%d top_level=%s sleeping=%s lin_vel=%s" % [
|
|
c.freeze, c.freeze_mode, c.collision_layer, c.top_level,
|
|
c.sleeping, c.linear_velocity])
|
|
|
|
|
|
# Food shown on a plate is a cosmetic child of that plate, so it has to stay put
|
|
# when the plate is picked up and carried around. If it drifts away, the player
|
|
# sees the burger fly off the plate.
|
|
func _check_plate_visuals(plate_name: String) -> Dictionary:
|
|
var plate := _find(plate_name)
|
|
if not plate:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
|
var off := _max_visual_offset(plate)
|
|
if off < 0.0:
|
|
return {"ok": false, "detail": "%s is not showing any food to check" % plate_name}
|
|
if off > MAX_VISUAL_OFFSET:
|
|
return {"ok": false, "detail": "%s's food has come off the plate: %.3fm away (limit %.2f). %s"
|
|
% [plate_name, off, MAX_VISUAL_OFFSET, _visual_diag(plate)]}
|
|
return {"ok": true, "detail": "%s's food is still on it (%.3fm from centre). %s"
|
|
% [plate_name, off, _visual_diag(plate)]}
|
|
|
|
|
|
# A station's progress bar must show the same thing to everyone: visible while
|
|
# the station is working, gone once it has finished. Only the world owner runs
|
|
# station logic, so a client can only get this right if the display is driven
|
|
# from replicated state.
|
|
func _check_station_bar(station_name: String, want_visible: bool) -> Dictionary:
|
|
var station := _find(station_name)
|
|
if not station:
|
|
return {"ok": false, "detail": "'%s' does not exist on this peer" % station_name}
|
|
var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D
|
|
if not bar:
|
|
return {"ok": false, "detail": "%s has no ProgressBar3D" % station_name}
|
|
var shown := bar.is_bar_visible()
|
|
var detail := "%s bar visible=%s progress=%.0f%%" % [station_name, shown, bar.get_progress()]
|
|
if "cooking_result" in station:
|
|
detail += " cooking='%s'" % station.cooking_result
|
|
if "is_washing" in station:
|
|
detail += " washing=%s" % station.is_washing
|
|
if shown != want_visible:
|
|
return {"ok": false, "detail": "expected %s's bar to be %s, but %s"
|
|
% [station_name, "visible" if want_visible else "hidden", detail]}
|
|
return {"ok": true, "detail": detail}
|
|
|
|
|
|
# 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.
|
|
# Server-side check: it asks the client for its inventory and compares.
|
|
func _check_world_replicated() -> Dictionary:
|
|
var mine := _snapshot()
|
|
var theirs := await _fetch_client_snapshot()
|
|
if theirs.is_empty():
|
|
return {"ok": false, "detail": "the client reported nothing at all"}
|
|
var problems := _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}
|
|
|
|
|
|
# A station that has had its item taken away must not still be holding it.
|
|
func _check_zone_empty(station_name: String) -> Dictionary:
|
|
var zone := _zone_of(station_name)
|
|
if not zone:
|
|
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station_name}
|
|
if not NetworkManager.owns_world():
|
|
# Client zones are gated off entirely; they never hold anything.
|
|
return {"ok": true, "detail": "%s: client zones are gated, nothing to check" % station_name}
|
|
if is_instance_valid(zone.picked_up_object):
|
|
return {"ok": false, "detail": "%s's zone still holds %s after it was taken away"
|
|
% [station_name, zone.picked_up_object]}
|
|
return {"ok": true, "detail": "%s's zone is empty" % station_name}
|
|
|
|
|
|
# --- Diagnostics -----------------------------------------------------------
|
|
|
|
## Who is currently driving this object's transform. Under the high-level model
|
|
## the object's own authority never changes — only its NetXform's does, and that
|
|
## is what "who is holding this" means now.
|
|
func _xform_authority(item: Node) -> int:
|
|
if not is_instance_valid(item):
|
|
return 0
|
|
var xform := item.get_node_or_null(NetReplication.XFORM_NAME)
|
|
return xform.get_multiplayer_authority() if xform else 0
|
|
|
|
|
|
func _diag(item: Node3D) -> String:
|
|
if not is_instance_valid(item):
|
|
return "item[freed]"
|
|
var pc := item.get_node_or_null("PlateController")
|
|
var s := "%s[pos=%s xform_authority=%d" % [
|
|
item.name, item.global_position, _xform_authority(item),
|
|
]
|
|
if item is XRToolsPickable:
|
|
s += " enabled=%s can_pick_up=%s layer=%d held_by=%s" % [
|
|
item.enabled, item.can_pick_up(_hand), item.collision_layer, item.get_picked_up_by()]
|
|
if pc:
|
|
s += " dirty=%s contains=%s" % [pc.is_dirty, str(pc.contained_ids)]
|
|
return s + "]"
|
|
|
|
|
|
func _dump_state() -> void:
|
|
_log(" stations:")
|
|
for station in ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]:
|
|
var z := _zone_of(station)
|
|
if z:
|
|
_log(" %-12s zone holds %s (enabled=%s)" % [station, z.picked_up_object, z.enabled])
|
|
_log(" items:")
|
|
for root in [_world, _world.get_node_or_null("WorldContent")]:
|
|
if not root:
|
|
continue
|
|
for child in root.get_children():
|
|
if child is XRToolsPickable:
|
|
_log(" %s" % _diag(child))
|
|
|
|
|
|
# --- 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:
|
|
_log("busy running a step, ignoring that key")
|
|
return
|
|
match event.keycode:
|
|
KEY_H:
|
|
_log("KEY: host"); NetworkManager.host(); _refresh_role()
|
|
KEY_J:
|
|
_log("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":
|
|
_log("KEY: run-all is server-only (this peer is %s)" % _role)
|
|
return
|
|
_log("KEY: running the full automatic sequence")
|
|
await _run_server()
|
|
KEY_0:
|
|
_log("KEY: state dump")
|
|
_dump_state()
|
|
KEY_A:
|
|
_refresh_role()
|
|
if _role != "server":
|
|
_log("KEY: the sync audit runs from the server window")
|
|
return
|
|
_log("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()
|
|
_banner("MANUAL: %s" % what)
|
|
_running = true
|
|
var res := await _run_local_step(step, args)
|
|
_running = false
|
|
_log("%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)
|
|
_write_frame_index()
|
|
|
|
|
|
# --- Helpers ---------------------------------------------------------------
|
|
|
|
func _move_hand_to(pos: Vector3) -> void:
|
|
# The controller is a child of the XROrigin, whose PlayerBody keeps moving
|
|
# (gravity) — that drags the hand off any position we place it at. top_level
|
|
# detaches it into world space. Done lazily on first use so that simply
|
|
# running the scene by hand leaves a real tracked controller alone.
|
|
if not _hand_detached:
|
|
_hand_detached = true
|
|
_controller.top_level = true
|
|
_log(" (detached the test hand from the XR rig so it can be driven directly)")
|
|
_controller.global_position = pos
|
|
_controller.force_update_transform()
|
|
|
|
|
|
func _wait_frames(n: int) -> void:
|
|
for i in n:
|
|
await get_tree().physics_frame
|
|
|
|
|
|
# Poll a condition instead of sleeping a guessed duration, so a slow machine
|
|
# doesn't produce a spurious failure and a fast one doesn't waste seconds.
|
|
func _wait_until(cond: Callable, what: String, timeout: float) -> bool:
|
|
var deadline := Time.get_ticks_msec() + int(timeout * 1000.0)
|
|
while Time.get_ticks_msec() < deadline:
|
|
if cond.call():
|
|
return true
|
|
await get_tree().process_frame
|
|
_log(" timed out after %.0fs waiting for %s" % [timeout, what])
|
|
return false
|
|
|
|
|
|
func _finish(ok: bool) -> void:
|
|
_log("=== %s exiting (%s) ===" % [_role, "ok" if ok else "FAILED"])
|
|
if _log_file:
|
|
_log_file.flush()
|
|
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 isn't driven by anything when XR is off, and the
|
|
# rig's PlayerBody keeps falling under gravity — it 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)
|
|
# Explicitly stand the rig's camera down 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()
|
|
_log("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()
|
|
_log("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 it's 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 := _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
|
|
_log(" 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():
|
|
_log(" framing: all test objects are in view")
|
|
else:
|
|
_log(" framing: WARNING - not visible: %s (adjust DEBUG_CAM_POS)" % ", ".join(offscreen))
|
|
|
|
|
|
func _toggle_debug_camera() -> void:
|
|
if not _debug_cam:
|
|
_log("KEY: no debug camera in a headless run")
|
|
return
|
|
_debug_cam.current = not _debug_cam.current
|
|
if not _debug_cam.current:
|
|
var xr_cam := _world.get_node_or_null("XROrigin3D/XRCamera3D") as Camera3D
|
|
if xr_cam:
|
|
xr_cam.current = true
|
|
_log("KEY: camera -> %s" % ("fixed debug camera" if _debug_cam.current else "XR rig camera"))
|
|
|
|
|
|
# --- Output ----------------------------------------------------------------
|
|
|
|
# A heading that's easy to find when scrolling a window full of engine spam.
|
|
func _banner(s: String) -> void:
|
|
_log("")
|
|
_log("======== %s ========" % s)
|
|
|
|
|
|
# Write the step log next to the engine's own log, under <project>/logs/, so
|
|
# everything from a run is in one place. Named per role because both instances
|
|
# would otherwise fight over one file (the engine's own godot.log has exactly
|
|
# that problem when two peers run at once — it rotates per process).
|
|
func _open_log() -> void:
|
|
var role := "server" if "--server" in OS.get_cmdline_user_args() else "client"
|
|
var path := "res://logs/mptest_%s.log" % role
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://logs"))
|
|
_log_file = FileAccess.open(path, FileAccess.WRITE)
|
|
if not _log_file:
|
|
# res:// is read-only in an exported build; fall back to somewhere writable.
|
|
path = OS.get_environment("TEMP").path_join("vryhungry_mptest_%s.log" % role)
|
|
_log_file = FileAccess.open(path, FileAccess.WRITE)
|
|
_log_path = ProjectSettings.globalize_path(path)
|
|
print("[MPTEST] step log -> %s" % _log_path)
|
|
|
|
|
|
func _log(s: String) -> void:
|
|
print("[MPTEST %s] %s" % [_role, s])
|
|
if _log_file:
|
|
_log_file.store_line(s)
|
|
_log_file.flush()
|
|
_push_overlay(s)
|
|
|
|
|
|
# On-screen mirror of the log, so a windowed run shows what's happening without
|
|
# needing the console — including which peer this window is.
|
|
func _build_overlay() -> void:
|
|
var layer := CanvasLayer.new()
|
|
layer.name = "MPTestOverlay"
|
|
var panel := PanelContainer.new()
|
|
panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
|
panel.modulate = Color(1, 1, 1, 0.85)
|
|
_overlay = Label.new()
|
|
# Sized for a 1800x1200 canvas, and to stay legible once a captured frame has
|
|
# been scaled down into the GIF.
|
|
_overlay.add_theme_font_size_override("font_size", 22)
|
|
_overlay.autowrap_mode = TextServer.AUTOWRAP_OFF
|
|
_overlay.clip_text = true
|
|
panel.add_child(_overlay)
|
|
layer.add_child(panel)
|
|
add_child(layer)
|
|
|
|
|
|
func _push_overlay(s: String) -> void:
|
|
if not _overlay:
|
|
return
|
|
_overlay_lines.append(s)
|
|
while _overlay_lines.size() > OVERLAY_LINES:
|
|
_overlay_lines.pop_front()
|
|
_overlay.text = "[%s] %s\n%s" % [_role.to_upper(), HELP, "\n".join(_overlay_lines)]
|