This commit is contained in:
algodoogle
2026-07-28 19:41:31 +01:00
parent 7479cbd656
commit 96ea2dd1ea
18 changed files with 620 additions and 112 deletions
+11
View File
@@ -297,6 +297,17 @@ func _release_from_snap_zones(item: Node) -> void:
if zone.picked_up_object == item: if zone.picked_up_object == item:
log_line("releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name]) log_line("releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name])
zone.drop_object() zone.drop_object()
# Make the zone forget the item as well. These zones are snap_mode=RANGE,
# so every frame they re-grab anything still listed in their grab area
# that can be picked up — and Jolt does not emit body_exited when let_go()
# switches the item's collision layer back out of the zone's mask, so the
# entry goes stale and never clears. The station then snatches the item
# straight back off the player who just took it, teleporting it home.
# Bringing it near again re-adds it properly (a held item is on the layer
# the zone watches), and releasing next to a station is handled
# explicitly by _try_snap_into_station.
if zone._object_in_grab_area.has(item):
zone._object_in_grab_area.erase(item)
# Snap the item into the nearest empty station snap zone within grab range. # Snap the item into the nearest empty station snap zone within grab range.
+49 -33
View File
@@ -18,51 +18,67 @@ func get_node_data(node):
var data = {} var data = {}
data["scene"] = node.scene_file_path data["scene"] = node.scene_file_path
data["name"] = node.name data["name"] = node.name
data["xform"] = node.transform # global, not local: the copies are respawned under WorldContent, so an
# authored node that was nested inside another (JonScene parents Counter5
# under Counter3) would otherwise land in the wrong place.
data["xform"] = node.global_transform
data["props"] = {} data["props"] = {}
var scrip = node.get_script() var scrip = node.get_script()
if scrip: if scrip:
for i in scrip.get_script_property_list(): for i in scrip.get_script_property_list():
if i.usage & PROPERTY_USAGE_STORAGE: # Only authored configuration — i.e. @export vars, which are the ones
data["props"][i["name"]] = node.get(i["name"]) # the editor exposes. Plain script variables are live runtime state:
# copying those and replaying them into a fresh instance re-runs their
# setters before the node is in the tree, so any setter touching an
# @onready reference blows up (Table's _state calls into its progress
# bar, which is still null at that point).
if not (i.usage & PROPERTY_USAGE_EDITOR):
continue
if str(i["name"]).begins_with("_"):
continue
data["props"][i["name"]] = node.get(i["name"])
return data return data
## The authored station nodes sitting in the current scene: anything instanced
## from res://Stations/. Exposed as nodes (not just data) because every peer has
## to remove these originals — the server replaces them with replicated copies,
## and a client that kept its own would end up showing two of everything.
func get_station_nodes() -> Array[Node]:
return _authored_nodes("res://Stations/", "StaticBody3D")
## Likewise for authored items. Containers/ counts as items too — plates and
## trays are things the player carries, and a client that never received one
## would have an incomplete world.
func get_item_nodes() -> Array[Node]:
var found := _authored_nodes("res://Items/", "XRToolsPickable")
found.append_array(_authored_nodes("res://Containers/", "XRToolsPickable"))
return found
func _authored_nodes(dir: String, type: String) -> Array[Node]:
var scenes := []
for file in ResourceLoader.list_directory(dir):
scenes.append(dir + file)
var found: Array[Node] = []
for node in get_tree().root.find_children("*", type, true, false):
if node.scene_file_path in scenes and not found.has(node):
found.append(node)
return found
func get_stations() -> Array[Dictionary]: func get_stations() -> Array[Dictionary]:
var Stations = ResourceLoader.list_directory("res://Stations/")
var Stations2 = []
for i in Stations:
Stations2.append("res://Stations/" + i)
Stations = Stations2
var Stations_nodes = []
for i in get_tree().root.find_children("*", "StaticBody3D", true, false):
if i.scene_file_path in Stations:
Stations_nodes.append(i)
var data: Array[Dictionary] = [] var data: Array[Dictionary] = []
for i in Stations_nodes: for node in get_station_nodes():
data.append(get_node_data(i)) data.append(get_node_data(node))
return data return data
func get_items() -> Array[Dictionary]:
var Items = ResourceLoader.list_directory("res://Items/")
var Items2 = []
for i in Items: func get_items() -> Array[Dictionary]:
Items2.append("res://Items/" + i)
Items = Items2
var Items_nodes = []
for i in get_tree().root.find_children("*", "XRToolsPickable", true, false):
if i.scene_file_path in Items:
Items_nodes.append(i)
var data: Array[Dictionary] = [] var data: Array[Dictionary] = []
for i in Items_nodes: for node in get_item_nodes():
data.append(get_node_data(i)) data.append(get_node_data(node))
return data return data
+25 -3
View File
@@ -23,19 +23,41 @@ func _ready() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
# Despawning is a world decision, so only the owner of world logic runs the
# clock. Every peer used to run its own copy: the countdown drifted between
# them and the blink below toggled `visible` locally, so the same item was
# shown on one peer and hidden on the other. Non-owners just follow the
# server, which removes the item for everyone when its time is up.
if not NetworkManager.owns_world():
return
# if we're held or moving, reset timer and return # if we're held or moving, reset timer and return
if _pickable.get_picked_up_by() or _rigid.linear_velocity.length_squared() > pow(minimum_speed_square,2): if _pickable.get_picked_up_by() or _rigid.linear_velocity.length_squared() > pow(minimum_speed_square,2):
_time_left = time_to_despawn _time_left = time_to_despawn
get_parent().visible = true _set_visible(true)
return return
# Count down to zero and despawn # Count down to zero and despawn
_time_left -= delta _time_left -= delta
if _time_left <= 0: if _time_left <= 0:
print("DespawningItem despawning!" , get_parent()) print("DespawningItem despawning!" , get_parent())
NetworkManager.despawn_item(get_parent()) NetworkManager.despawn_item(get_parent())
# Stop counting: despawn_item() only queues the free, so without this we
# keep re-reporting the same item every frame until it actually goes.
set_process(false)
return
# Make item blink in and out before despawning # Make item blink in and out before despawning
if _time_left < time_to_despawn / 2: if _time_left < time_to_despawn / 2:
get_parent().visible = int(_time_left * 5.0) %2 == 0 _set_visible(int(_time_left * 5.0) % 2 == 0)
# `visible` is not a replicated property, so a blink driven only here would make
# the item flicker on the host and stay solid on clients. Until it is synced,
# only warn when there is nobody else to disagree with.
func _set_visible(value: bool) -> void:
if NetworkManager.is_online():
get_parent().visible = true
return
get_parent().visible = value
+13 -2
View File
@@ -1,6 +1,6 @@
[gd_scene format=3 uid="uid://do6mrslyqe5qe"] [gd_scene format=3 uid="uid://do6mrslyqe5qe"]
[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_57ppd"] [ext_resource type="Script" uid="uid://d1cefhe3yyyds" path="res://Scenes/multiplayer_world.gd" id="1_57ppd"]
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_o1b3t"] [ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_o1b3t"]
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_irf4n"] [ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_irf4n"]
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_ctden"] [ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_ctden"]
@@ -45,9 +45,20 @@ script = ExtResource("1_57ppd")
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250] [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
environment = SubResource("Environment_bvwq1") environment = SubResource("Environment_bvwq1")
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_o1b3t")] [node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_o1b3t")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9667189, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9667189, 0)
[node name="WorldContent" type="Node3D" parent="."]
[node name="Players" type="Node3D" parent="."]
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="."]
spawn_path = NodePath("../WorldContent")
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="."]
_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn")
spawn_path = NodePath("../Players")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384]
transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0) transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0)
+4
View File
@@ -4,6 +4,7 @@
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_g30gi"] [ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_g30gi"]
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_75ecy"] [ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_75ecy"]
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_c3xgf"] [ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_c3xgf"]
[ext_resource type="Script" uid="uid://biu4qr3nr1eny" path="res://test/mp_test_driver.gd" id="99_mptst"]
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"] [sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
size = Vector3(15, 0.1, 15) size = Vector3(15, 0.1, 15)
@@ -74,3 +75,6 @@ shape = SubResource("BoxShape3D_arao0")
[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1431367494] [node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1431367494]
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023) transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023)
shape = SubResource("BoxShape3D_arao0") shape = SubResource("BoxShape3D_arao0")
[node name="TestDriver" type="Node" parent="."]
script = ExtResource("99_mptst")
+35 -3
View File
@@ -72,12 +72,34 @@ func _on_session_started(_is_server: bool) -> void:
## Spawns the world's stations/items exactly once, on the machine that owns ## Spawns the world's stations/items exactly once, on the machine that owns
## world logic (server or offline). Safe to call multiple times/entry points. ## world logic (server or offline). Safe to call multiple times/entry points.
func _populate_world_if_owner() -> void: func _populate_world_if_owner() -> void:
if not NetworkManager.owns_world() or _populated or not populate_from_layout: if _populated or not populate_from_layout:
return return
# WorldLayout reads the live scene tree to find what to replicate, so it
# needs to be an instance sitting in that tree — its methods can't be called
# on the class itself.
var layout := WorldLayout.new()
add_child(layout)
var authored := layout.get_station_nodes()
authored.append_array(layout.get_item_nodes())
# The stations and items authored into the scene file are a *template*, not
# the live world. Only the server turns them into real objects, spawned
# through NetworkManager so they replicate. Every peer therefore drops its
# own authored copies: the client would otherwise show its local originals
# on top of the server's replicated ones, and the two sets would drift apart
# because only the server's are synced.
if not NetworkManager.owns_world():
NetworkManager.log_line("Clearing %d authored nodes; the server's copies replace them" % authored.size())
_remove_authored(authored)
layout.queue_free()
return
_populated = true _populated = true
GameManager.meals_in_play = ["hamburger"] GameManager.meals_in_play = ["hamburger"]
var stations := WorldLayout.get_stations() var stations := layout.get_stations()
var items := WorldLayout.get_items() var items := layout.get_items()
layout.queue_free()
_remove_authored(authored)
NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()]) NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()])
for d in stations: for d in stations:
NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"])
@@ -86,6 +108,16 @@ func _populate_world_if_owner() -> void:
NetworkManager.log_line("World populated") NetworkManager.log_line("World populated")
# Free the authored template nodes. Done immediately rather than with
# queue_free() so the names are released before the replicated copies are
# spawned under the same ones.
func _remove_authored(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node):
node.get_parent().remove_child(node)
node.free()
## Only the server (or the single offline machine) materialises player ## Only the server (or the single offline machine) materialises player
## avatars; MultiplayerSpawner replicates the result to everyone else, ## avatars; MultiplayerSpawner replicates the result to everyone else,
## including late joiners. ## including late joiners.
+3
View File
@@ -22,6 +22,9 @@ properties/0/replication_mode = 1
properties/1/path = NodePath(".:cooking_result_time") properties/1/path = NodePath(".:cooking_result_time")
properties/1/spawn = true properties/1/spawn = true
properties/1/replication_mode = 1 properties/1/replication_mode = 1
properties/2/path = NodePath(".:cooking_result")
properties/2/spawn = true
properties/2/replication_mode = 1
[sub_resource type="Animation" id="Animation_7uuqv"] [sub_resource type="Animation" id="Animation_7uuqv"]
length = 0.001 length = 0.001
+47 -13
View File
@@ -6,24 +6,63 @@ const RECIPE_MANAGER = preload("res://RecipeManager.gd")
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone @onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
@onready var progress_bar: ProgressBar3D = $ProgressBar3D @onready var progress_bar: ProgressBar3D = $ProgressBar3D
var time_cooked = 0 ## Cooking state. All three are replicated (see Hob.tscn's Sync node) and each
var cooking_result: String ## refreshes the display when it changes, which is what makes the hob look alive
var cooking_result_time = 0 ## on a client: only the world owner runs the station's _process and its snap
## zone, so a client never reaches the code below by itself and would otherwise
## show a hob that never lights up.
var time_cooked: float = 0.0: set = _set_time_cooked
var cooking_result: String = "": set = _set_cooking_result
var cooking_result_time: float = 0.0: set = _set_cooking_result_time
func _set_time_cooked(value: float) -> void:
if is_equal_approx(time_cooked, value):
return
time_cooked = value
_refresh_display()
func _set_cooking_result(value: String) -> void:
if cooking_result == value:
return
cooking_result = value
_refresh_display()
# The flames follow whether we're cooking, on every peer.
_play_animation("hob" if not cooking_result.is_empty() else "RESET")
func _set_cooking_result_time(value: float) -> void:
if is_equal_approx(cooking_result_time, value):
return
cooking_result_time = value
_refresh_display()
func _ready() -> void: func _ready() -> void:
if not progress_bar or progress_bar is not ProgressBar3D: if not progress_bar or progress_bar is not ProgressBar3D:
push_error("Hob missing reference to progressbar, or wrong type:", progress_bar) push_error("Hob missing reference to progressbar, or wrong type:", progress_bar)
snap_zone.has_picked_up.connect(_on_object_picked_up) snap_zone.has_picked_up.connect(_on_object_picked_up)
snap_zone.has_dropped.connect(_on_object_dropped) snap_zone.has_dropped.connect(_on_object_dropped)
_refresh_progress_bar() _refresh_display()
func _refresh_progress_bar() -> void: func _play_animation(name: String) -> void:
# Replicated setters can fire before the node is in the tree (spawn payload),
# when the @onready children don't exist yet.
var player := get_node_or_null("AnimationPlayer") as AnimationPlayer
if player:
player.play(name)
func _refresh_display() -> void:
# Guarded for the same reason as _play_animation.
if not progress_bar:
return
var progress := 0.0 var progress := 0.0
if cooking_result and cooking_result_time > 0: if cooking_result and cooking_result_time > 0:
progress = clampf(float(time_cooked) / cooking_result_time, 0.0, 1.0) progress = clampf(float(time_cooked) / cooking_result_time, 0.0, 1.0)
#print("time_cooked: %s / cooking_result_time: %s = progress: %s" % [time_cooked, cooking_result_time, progress])
progress_bar.set_progress(progress) progress_bar.set_progress(progress)
progress_bar.set_bar_visible(not cooking_result.is_empty()) progress_bar.set_bar_visible(not cooking_result.is_empty())
progress_bar.override_fill_color(Color.RED if cooking_result == "charcoal" else Color.GREEN) progress_bar.override_fill_color(Color.RED if cooking_result == "charcoal" else Color.GREEN)
@@ -44,25 +83,20 @@ func _on_object_picked_up(_item) -> void:
cooking_result = result cooking_result = result
cooking_result_time = RECIPE_MANAGER.get_cooking_time(_food_item.id) cooking_result_time = RECIPE_MANAGER.get_cooking_time(_food_item.id)
print("Hob: set cooking_result ", cooking_result) print("Hob: set cooking_result ", cooking_result)
_refresh_progress_bar() # The setters above already refresh the display and start the flames.
$AnimationPlayer.play("hob")
func _on_object_dropped() -> void: func _on_object_dropped() -> void:
print("Hob: object drop ") print("Hob: object drop ")
$AnimationPlayer.play("RESET")
time_cooked = 0 time_cooked = 0
cooking_result = "" cooking_result = ""
cooking_result_time = 0 cooking_result_time = 0
_refresh_progress_bar()
func convert_held_to_item(_item: String) -> void: func convert_held_to_item(_item: String) -> void:
if not NetworkManager.owns_world(): if not NetworkManager.owns_world():
return return
print("Hob converting ", _item) print("Hob converting ", _item)
$AnimationPlayer.play("RESET")
var old_pickable = snap_zone.picked_up_object var old_pickable = snap_zone.picked_up_object
if not old_pickable: if not old_pickable:
@@ -86,7 +120,7 @@ func _process(delta: float) -> void:
if cooking_result: if cooking_result:
#print("Hob cooking_result: ", cooking_result) #print("Hob cooking_result: ", cooking_result)
time_cooked += cook_speed * delta time_cooked += cook_speed * delta
_refresh_progress_bar() _refresh_display()
if time_cooked >= cooking_result_time: if time_cooked >= cooking_result_time:
time_cooked = 0 time_cooked = 0
convert_held_to_item(cooking_result) convert_held_to_item(cooking_result)
+38 -12
View File
@@ -6,10 +6,41 @@ const plate_wash_time: float = 3.0
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone @onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
@onready var progress_bar: ProgressBar3D = $ProgressBar3D @onready var progress_bar: ProgressBar3D = $ProgressBar3D
var time_washed: float = 0.0 ## Washing state. Both are replicated (see sink.tscn's Sync node) and refresh the
var is_washing: bool = false ## display when they change. Only the world owner runs this station's _process
## and snap zone, so without this a client never updates its bar — it stayed
## frozen on screen long after the plate came out clean.
var time_washed: float = 0.0: set = _set_time_washed
var is_washing: bool = false: set = _set_is_washing
var plate: PlateController = null var plate: PlateController = null
func _set_time_washed(value: float) -> void:
if is_equal_approx(time_washed, value):
return
time_washed = value
_refresh_progress_bar()
func _set_is_washing(value: bool) -> void:
if is_washing == value:
return
is_washing = value
_refresh_progress_bar()
_set_effects_playing(is_washing)
# Water, bubbles and the animation follow the washing state on every peer, not
# just the one running the logic.
func _set_effects_playing(playing: bool) -> void:
var player := get_node_or_null("AnimationPlayer") as AnimationPlayer
if player:
player.play("working" if playing else "RESET")
for effect in ["water", "bubbles", "GPUParticles3D"]:
var node := get_node_or_null(effect) as Node3D
if node:
node.visible = playing
func _ready() -> void: func _ready() -> void:
if not progress_bar or progress_bar is not ProgressBar3D: if not progress_bar or progress_bar is not ProgressBar3D:
push_error("Sink missing reference to progressbar, or wrong type:", progress_bar) push_error("Sink missing reference to progressbar, or wrong type:", progress_bar)
@@ -21,6 +52,8 @@ func _ready() -> void:
func _refresh_progress_bar() -> void: func _refresh_progress_bar() -> void:
if not progress_bar:
return
progress_bar.set_progress(clampf(float(time_washed) / plate_wash_time, 0.0, 1.0)) progress_bar.set_progress(clampf(float(time_washed) / plate_wash_time, 0.0, 1.0))
progress_bar.set_bar_visible(is_washing) progress_bar.set_bar_visible(is_washing)
@@ -29,12 +62,8 @@ func _on_object_picked_up(_item) -> void:
print("Sink: object picked up: ", _item) print("Sink: object picked up: ", _item)
plate = _item.get_node_or_null("PlateController") as PlateController plate = _item.get_node_or_null("PlateController") as PlateController
if plate: if plate:
# The setter starts the effects and shows the bar, here and on clients.
is_washing = plate.is_dirty is_washing = plate.is_dirty
if is_washing:
$AnimationPlayer.play("working")
$bubbles.show()
$water.show()
$GPUParticles3D.show()
func _on_object_dropped() -> void: func _on_object_dropped() -> void:
@@ -46,13 +75,10 @@ func _reset_sink():
time_washed = 0 time_washed = 0
is_washing = false is_washing = false
plate = null plate = null
$AnimationPlayer.play("RESET")
$water.hide()
$bubbles.hide()
$GPUParticles3D.hide()
_refresh_progress_bar()
func complete_washing(): func complete_washing():
if not plate:
return
plate.is_dirty = false plate.is_dirty = false
_reset_sink() _reset_sink()
print("Sink washing complete!") print("Sink washing complete!")
+4 -1
View File
@@ -16,9 +16,12 @@ stereo = true
size = Vector3(0.5, 0.128, 0.5) size = Vector3(0.5, 0.128, 0.5)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_sink"] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_sink"]
properties/0/path = NodePath(".:progress") properties/0/path = NodePath(".:time_washed")
properties/0/spawn = true properties/0/spawn = true
properties/0/replication_mode = 1 properties/0/replication_mode = 1
properties/1/path = NodePath(".:is_washing")
properties/1/spawn = true
properties/1/replication_mode = 1
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"]
+4
View File
@@ -25,6 +25,10 @@ func set_bar_visible(value: bool) -> void:
progress_bar.visible = value progress_bar.visible = value
func is_bar_visible() -> bool:
return progress_bar != null and progress_bar.visible
func override_fill_color(color: Color) -> void: func override_fill_color(color: Color) -> void:
var style_box := StyleBoxFlat.new() var style_box := StyleBoxFlat.new()
style_box.bg_color = color style_box.bg_color = color
+170 -6
View File
@@ -88,6 +88,12 @@ var _frame_index := 0
func _ready() -> void: func _ready() -> void:
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
_auto_mode = "--mptest" in 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 _frames_enabled = "--mptest-frames" in args
_step_pause = _arg_value(args, "--mptest-pause", 0.0) _step_pause = _arg_value(args, "--mptest-pause", 0.0)
_end_hold = _arg_value(args, "--mptest-hold", 0.0) _end_hold = _arg_value(args, "--mptest-hold", 0.0)
@@ -98,7 +104,8 @@ func _ready() -> void:
_refresh_role() _refresh_role()
_log("=== mp test driver ready (role=%s, peer=%d, mode=%s) ===" _log("=== mp test driver ready (role=%s, peer=%d, mode=%s) ==="
% [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"]) % [_role, multiplayer.get_unique_id(), "automatic" if _auto_mode else "manual"])
if not _resolve_nodes(): # await: _resolve_nodes now waits for the server's spawns to arrive.
if not await _resolve_nodes():
if _auto_mode: if _auto_mode:
_finish(false) _finish(false)
return return
@@ -133,14 +140,40 @@ func _resolve_nodes() -> bool:
if not _hand: if not _hand:
_log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup") _log("FATAL: could not resolve XROrigin3D/XRControllerRightHand/FunctionPickup")
return false 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"]: for required in ["Hob", "Sink", "DirtStation", "Counter", "Counter2", "Plate"]:
if not _find(required): if not await _wait_until(func(): return _find(required) != null,
_log("FATAL: test scene is missing '%s'" % required) "'%s' to arrive from the server" % required, SETUP_TIMEOUT_SEC):
_log("FATAL: '%s' never appeared in the world" % required)
return false return false
_log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path()) _log("resolved hand=%s; kitchen has Hob, Sink, DirtStation, Counter, Counter2" % _hand.get_path())
_disable_despawn_timers()
return true 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 # Items live either baked in the scene root or, once spawned at runtime, under
# WorldContent. Look in both. # WorldContent. Look in both.
func _find(name: String) -> Node3D: func _find(name: String) -> Node3D:
@@ -184,6 +217,11 @@ func _run_server() -> void:
# A frame of the untouched kitchen, so the GIF opens on the starting state. # A frame of the untouched kitchen, so the GIF opens on the starting state.
await _capture_step_frame("start") 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. # 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_grab_plate", "grab", ["Plate"])
await _step("client", "client_drop_plate", "drop", ["Plate"]) await _step("client", "client_drop_plate", "drop", ["Plate"])
@@ -204,8 +242,10 @@ func _run_server() -> void:
# 5. Client takes the dirty plate to the sink, which should wash it clean. # 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 _step("client", "client_plate_to_sink", "place_in_zone", ["Plate", "Sink"])
await _both("plate_snapped_in_sink", "verify_snapped", ["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 _step("server", "wait_for_wash", "await_clean", ["Plate"])
await _both("plate_washed_clean", "verify_dirty", ["Plate", "false"]) 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 # 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 # baked into the scene; the second uses freshly spawned ones, so both paths
@@ -230,6 +270,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S
# Burger onto the hob; it should cook and the raw one should disappear. # 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 _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_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 _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 # Get it off the hob before anything else: the hob keeps cooking whatever is
@@ -238,6 +279,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S
await _step(actor, "%s_cooked_to_counter" % actor, "place_food_in_zone", ["cooked_burger", "Counter"]) 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_raw_burger_removed" % actor, "verify_gone", [burger])
await _both("%s_cooked_burger_exists" % actor, "verify_food_exists", ["cooked_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. # 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(actor, "%s_buns_to_cooked" % actor, "carry_food_to_food", [buns, "cooked_burger"])
@@ -256,6 +298,7 @@ func _cook_and_plate_round(actor: String, burger: String, buns: String, plate: S
# nowhere to put its plate. # nowhere to put its plate.
await _both("%s_food_on_plate_before_lift" % actor, "verify_plate_visuals", [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 _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 # The food must still be on the plate after it has been carried off the
# counter and set down again. # counter and set down again.
await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate]) await _both("%s_food_stayed_on_plate" % actor, "verify_plate_visuals", [plate])
@@ -297,6 +340,7 @@ func _find_client_id() -> int:
# and record the verdict. # and record the verdict.
func _step(actor: String, label: String, step: String, args: Array) -> void: func _step(actor: String, label: String, step: String, args: Array) -> void:
_current_step = label _current_step = label
_disable_despawn_timers()
_banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()]) _banner("STEP %d: %s (on the %s)" % [_next_step_no(), label, actor.to_upper()])
var res: Dictionary var res: Dictionary
if actor == "server": if actor == "server":
@@ -313,6 +357,7 @@ func _step(actor: String, label: String, step: String, args: Array) -> void:
# agree, which is the whole point of the exercise. # agree, which is the whole point of the exercise.
func _both(label: String, step: String, args: Array) -> void: func _both(label: String, step: String, args: Array) -> void:
_current_step = label _current_step = label
_disable_despawn_timers()
_banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label]) _banner("STEP %d: %s (checked on BOTH peers)" % [_next_step_no(), label])
_record(label, "server", await _run_local_step(step, args)) _record(label, "server", await _run_local_step(step, args))
_record(label, "client", await _remote(step, args)) _record(label, "client", await _remote(step, args))
@@ -363,6 +408,7 @@ func _report() -> void:
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]]) _log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
await _capture_step_frame("final") await _capture_step_frame("final")
_write_frame_index() _write_frame_index()
_write_report(failed)
if not _auto_mode: if not _auto_mode:
return return
_quit_client.rpc_id(_client_id) _quit_client.rpc_id(_client_id)
@@ -374,11 +420,40 @@ func _report() -> void:
_finish(failed == 0) _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 ---------------------------------------------- # --- Client command handling ----------------------------------------------
@rpc("authority", "reliable") @rpc("authority", "reliable")
func _cmd(step: String, args: Array) -> void: func _cmd(step: String, args: Array) -> void:
_current_step = step _current_step = step
_disable_despawn_timers()
_log("<- server: %s%s" % [step, args]) _log("<- server: %s%s" % [step, args])
_running = true _running = true
var res := await _run_local_step(step, args) var res := await _run_local_step(step, args)
@@ -433,6 +508,12 @@ func _run_local_step(step: String, args: Array) -> Dictionary:
return _check_plate_contains(args[0], args[1]) return _check_plate_contains(args[0], args[1])
"verify_plate_visuals": "verify_plate_visuals":
return _check_plate_visuals(args[0]) 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} return {"ok": false, "detail": "unknown step %s" % step}
@@ -872,6 +953,10 @@ func _visual_count(item: Node3D, path: String) -> int:
return count return count
## Stations whose on-screen state has to match on every peer.
const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]
func _snapshot() -> Dictionary: func _snapshot() -> Dictionary:
var out := {} var out := {}
for root in [_world, _world.get_node_or_null("WorldContent")]: for root in [_world, _world.get_node_or_null("WorldContent")]:
@@ -880,9 +965,33 @@ func _snapshot() -> Dictionary:
for child in root.get_children(): for child in root.get_children():
if child is XRToolsPickable and not child.is_queued_for_deletion(): if child is XRToolsPickable and not child.is_queued_for_deletion():
out[str(child.name)] = _describe(child) 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 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") @rpc("authority", "reliable")
func _request_snapshot() -> void: func _request_snapshot() -> void:
_snapshot_reply.rpc_id(1, _snapshot()) _snapshot_reply.rpc_id(1, _snapshot())
@@ -918,9 +1027,11 @@ func _compare(server: Dictionary, client: Dictionary) -> Array[String]:
continue continue
var s: Dictionary = server[name] var s: Dictionary = server[name]
var c: Dictionary = client[name] var c: Dictionary = client[name]
var dist: float = (s["pos"] as Vector3).distance_to(c["pos"]) # Stations are compared on their displayed state, not a position.
if dist > SYNC_POS_TOLERANCE: if s.has("pos") and c.has("pos"):
problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["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: for key in s:
# "_" keys are per-peer diagnostics, not things that must match. # "_" keys are per-peer diagnostics, not things that must match.
if key == "pos" or key.begins_with("_"): if key == "pos" or key.begins_with("_"):
@@ -1031,6 +1142,59 @@ func _check_plate_visuals(plate_name: String) -> Dictionary:
% [plate_name, off, _visual_diag(plate)]} % [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 ----------------------------------------------------------- # --- Diagnostics -----------------------------------------------------------
func _diag(item: Node3D) -> String: func _diag(item: Node3D) -> String:
+3 -3
View File
@@ -34,7 +34,7 @@ size = Vector3(15, 20, 0.1)
[node name="Main" type="Node3D" unique_id=1312265607] [node name="Main" type="Node3D" unique_id=1312265607]
script = ExtResource("1_6uucx") script = ExtResource("1_6uucx")
populate_from_layout = false populate_from_layout = true
[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_j7vd1")] [node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_j7vd1")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
@@ -100,10 +100,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0)
script = ExtResource("9_mptst") script = ExtResource("9_mptst")
[node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("10_51k0c")] [node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("10_51k0c")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 0.3505687) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 1.1)
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("11_psgbv")] [node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("11_psgbv")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 0.34120744) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 1.1)
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("12_j5uvh")] [node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("12_j5uvh")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0.5, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0.5, 0)
+8 -7
View File
@@ -29,27 +29,28 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$proj = Split-Path -Parent $PSScriptRoot $proj = Split-Path -Parent $PSScriptRoot
$scene = "res://test/multiPlayerTest.tscn" $serverScene = "res://test/multiPlayerTest.tscn"
$clientScene = "res://Scenes/multiPlayer.tscn"
. (Join-Path $PSScriptRoot "mp_window_layout.ps1") . (Join-Path $PSScriptRoot "mp_window_layout.ps1")
function Start-Instance($extraArgs) { function Start-Instance($extraArgs, $sceneFor) {
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene" $a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- --mptest-manual"
if ($extraArgs) { $a += " -- $($extraArgs -join ' ')" } if ($extraArgs) { $a += " $($extraArgs -join ' ')" }
return Start-Process -FilePath $Godot -ArgumentList $a -PassThru return Start-Process -FilePath $Godot -ArgumentList $a -PassThru
} }
if ($Solo) { if ($Solo) {
Write-Host "Opening a single offline window (no networking)." Write-Host "Opening a single offline window (no networking)."
$null = Move-GameWindow (Start-Instance $null) 300 100 $null = Move-GameWindow (Start-Instance $null $serverScene) 300 100
return return
} }
Write-Host "Opening SERVER window (left)..." Write-Host "Opening SERVER window (left)..."
$w = Move-GameWindow (Start-Instance @("--server")) 20 60 $w = Move-GameWindow (Start-Instance @("--server") $serverScene) 20 60
Start-Sleep -Seconds 5 Start-Sleep -Seconds 5
Write-Host "Opening CLIENT window (right)..." Write-Host "Opening CLIENT window (right)..."
$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1")) (20 + $w + 12) 60 $null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1") $clientScene) (20 + $w + 12) 60
Write-Host "" Write-Host ""
Write-Host "Both windows are up. Click one to focus it, then press:" Write-Host "Both windows are up. Click one to focus it, then press:"
+62 -24
View File
@@ -1,41 +1,69 @@
# Runs the headless two-instance multiplayer test (test/multiPlayerTest.tscn). # Runs the two-instance multiplayer test and ALWAYS produces a GIF of the run.
# #
# powershell -File test\run_mp_test.ps1 # powershell -File test\run_mp_test.ps1
# #
# Starts a server instance and a client instance of the game with --xr-mode off # Starts a server instance and a client instance on test/multiPlayerTest.tscn,
# (SteamVR's OpenXR runtime crashes a headless process), lets test/mp_test_driver.gd # lets test/mp_test_driver.gd drive the scripted kitchen sequence, then writes:
# drive the scripted plate/dirt-station sequence, then prints both logs. # logs\mptest_report.txt - every check, pass/fail, plus failure details
# logs\mptest_run.gif - both peers side by side, one frame per step
# logs\mptest_{server,client}.log - full step logs
# Exits non-zero if any check failed. # Exits non-zero if any check failed.
#
# The windows are visible because frame capture needs a real framebuffer: a
# headless Godot renders nothing, so there would be no GIF. Use -Headless when
# you only want the pass/fail result (faster, but no GIF).
param( param(
[string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe", [string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe",
[int]$TimeoutSec = 120 [int]$TimeoutSec = 400,
# Seconds to pause between steps, and to hold the final state on screen.
[double]$Pause = 0.4,
[double]$Hold = 3,
# Passed through to make_gif.ps1.
[int]$HalfWidth = 900,
[double]$SecondsPerStep = 1.2,
# Skip the windows entirely. No frames are captured, so no GIF is produced.
[switch]$Headless
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$proj = Split-Path -Parent $PSScriptRoot $proj = Split-Path -Parent $PSScriptRoot
$scene = "res://test/multiPlayerTest.tscn" # The two peers deliberately load DIFFERENT scenes, mirroring the real game:
# the host opens whatever world it is running, the client opens the bare
# multiplayer scene and must receive the entire layout over the network. That is
# also exactly what a client joining mid-session goes through.
$serverScene = "res://test/multiPlayerTest.tscn"
$clientScene = "res://Scenes/multiPlayer.tscn"
$serverLog = Join-Path $proj "logs/mptest_server.log" $serverLog = Join-Path $proj "logs/mptest_server.log"
$clientLog = Join-Path $proj "logs/mptest_client.log" $clientLog = Join-Path $proj "logs/mptest_client.log"
$report = Join-Path $proj "logs/mptest_report.txt"
$gif = Join-Path $proj "logs/mptest_run.gif"
foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } } foreach ($f in @($serverLog, $clientLog, $report, $gif)) { if (Test-Path $f) { Remove-Item $f -Force } }
function Start-Instance($extraArgs) { . (Join-Path $PSScriptRoot "mp_window_layout.ps1")
# Single argument string with the project path quoted: Start-Process does
# not quote array elements, so the space in the path would split it. function Start-Instance($extraArgs, $sceneFor) {
$a = "--headless --xr-mode off --path `"$proj`" $scene -- $($extraArgs -join ' ')" # --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow # takes over, and two instances can't share a headset anyway).
# Touching .Handle caches it, which is what makes .ExitCode readable later; $a = "--xr-mode off"
# without this it comes back empty even after the process has exited. if ($Headless) { $a = "--headless " + $a }
$a += " --resolution 900x600 --path `"$proj`" $sceneFor -- $($extraArgs -join ' ') --mptest"
if (-not $Headless) { $a += " --mptest-frames" }
$a += " --mptest-pause $Pause --mptest-hold $Hold"
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow:$Headless
# Touching .Handle caches it, which is what makes .ExitCode readable later.
$null = $p.Handle $null = $p.Handle
return $p return $p
} }
Write-Host "Starting server..." Write-Host "Starting SERVER..."
$server = Start-Instance @("--server", "--mptest") $server = Start-Instance @("--server") $serverScene
Start-Sleep -Seconds 4 if (-not $Headless) { $w = Move-GameWindow $server 20 60 }
Write-Host "Starting client..." Start-Sleep -Seconds 5
$client = Start-Instance @("--join", "127.0.0.1", "--mptest") Write-Host "Starting CLIENT..."
$client = Start-Instance @("--join", "127.0.0.1") $clientScene
if (-not $Headless) { $null = Move-GameWindow $client (20 + $w + 12) 60 }
$deadline = (Get-Date).AddSeconds($TimeoutSec) $deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 } while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 }
@@ -45,16 +73,26 @@ foreach ($p in @($server, $client)) {
} }
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) { if (-not $Headless) {
Write-Host "" Write-Host ""
Write-Host "======================== $($pair[0]) ========================" Write-Host "======================== GIF ========================"
if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" } & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "make_gif.ps1") `
-HalfWidth $HalfWidth -SecondsPerStep $SecondsPerStep
} else {
Write-Host ""
Write-Host "(headless run: no frames captured, so no GIF - drop -Headless to get one)"
} }
# ExitCode is only populated on the process object after a WaitForExit() call, Write-Host ""
# even when HasExited is already true - without this it reads back empty. Write-Host "======================== REPORT ========================"
if (Test-Path $report) { Get-Content $report -Encoding UTF8 } else { Write-Host "(no report written - the run did not finish)" }
$server.WaitForExit(2000) | Out-Null $server.WaitForExit(2000) | Out-Null
$code = if ($server.HasExited) { $server.ExitCode } else { 1 } $code = if ($server.HasExited) { $server.ExitCode } else { 1 }
Write-Host "" Write-Host ""
Write-Host "report: $report"
if (-not $Headless -and (Test-Path $gif)) { Write-Host "gif: $gif" }
Write-Host "logs: $serverLog"
Write-Host " $clientLog"
Write-Host "server exit code: $code" Write-Host "server exit code: $code"
exit $code exit $code
+6 -5
View File
@@ -24,7 +24,8 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$proj = Split-Path -Parent $PSScriptRoot $proj = Split-Path -Parent $PSScriptRoot
$scene = "res://test/multiPlayerTest.tscn" $serverScene = "res://test/multiPlayerTest.tscn"
$clientScene = "res://Scenes/multiPlayer.tscn"
$serverLog = Join-Path $proj "logs/mptest_server.log" $serverLog = Join-Path $proj "logs/mptest_server.log"
$clientLog = Join-Path $proj "logs/mptest_client.log" $clientLog = Join-Path $proj "logs/mptest_client.log"
@@ -32,10 +33,10 @@ foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -
. (Join-Path $PSScriptRoot "mp_window_layout.ps1") . (Join-Path $PSScriptRoot "mp_window_layout.ps1")
function Start-Instance($extraArgs) { function Start-Instance($extraArgs, $sceneFor) {
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise # --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
# takes over, and two instances can't share a headset anyway). # takes over, and two instances can't share a headset anyway).
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " + $a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- " +
"$($extraArgs -join ' ') --mptest --mptest-frames --mptest-pause $Pause --mptest-hold $Hold" "$($extraArgs -join ' ') --mptest --mptest-frames --mptest-pause $Pause --mptest-hold $Hold"
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru $p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru
# Touching .Handle caches it, which is what makes .ExitCode readable later. # Touching .Handle caches it, which is what makes .ExitCode readable later.
@@ -44,11 +45,11 @@ function Start-Instance($extraArgs) {
} }
Write-Host "Starting SERVER window (left)..." Write-Host "Starting SERVER window (left)..."
$server = Start-Instance @("--server") $server = Start-Instance @("--server") $serverScene
$w = Move-GameWindow $server 20 60 $w = Move-GameWindow $server 20 60
Start-Sleep -Seconds 5 Start-Sleep -Seconds 5
Write-Host "Starting CLIENT window (right)..." Write-Host "Starting CLIENT window (right)..."
$client = Start-Instance @("--join", "127.0.0.1") $client = Start-Instance @("--join", "127.0.0.1") $clientScene
$null = Move-GameWindow $client (20 + $w + 12) 60 $null = Move-GameWindow $client (20 + $w + 12) 60
Write-Host "Watch the two windows (each has a fixed camera on the test area)." Write-Host "Watch the two windows (each has a fixed camera on the test area)."
+137
View File
@@ -0,0 +1,137 @@
@tool
extends EditorScript
## Runs the whole multiplayer test suite from inside the Godot editor.
##
## HOW TO USE
## 1. Open this file in the editor's script panel.
## 2. File > Run (Ctrl+Shift+X).
##
## It launches two copies of the game (server + client) as separate processes,
## waits for them to finish, then prints the full pass/fail report into the
## Output panel and builds the side-by-side GIF.
##
## Everything it produces lands in logs/:
## mptest_report.txt every check, pass/fail, plus details for failures
## mptest_run.gif both peers side by side, one frame per step
## mptest_{server,client}.log full step logs
##
## NOTE: the editor is blocked while the run is in progress (a couple of
## minutes). Watch the two game windows to follow along.
## Give up if the server hasn't finished in this long.
const TIMEOUT_SEC := 400
## Seconds each step is held, so the run is watchable and the GIF is readable.
const STEP_PAUSE := 0.4
## Seconds the final state stays on screen before the instances close.
const END_HOLD := 3.0
## The peers load different scenes on purpose, mirroring the real game: the host
## opens whatever world it is running, the client opens the bare multiplayer
## scene and has to receive the whole layout over the network — the same path a
## client joining mid-session takes.
const SERVER_SCENE := "res://test/multiPlayerTest.tscn"
const CLIENT_SCENE := "res://Scenes/multiPlayer.tscn"
func _run() -> void:
var exe := OS.get_executable_path()
var project_dir := ProjectSettings.globalize_path("res://").rstrip("/")
var logs_dir := ProjectSettings.globalize_path("res://logs")
DirAccess.make_dir_recursive_absolute(logs_dir)
_clear_previous_results(logs_dir)
print_rich("[b]Running the multiplayer test suite...[/b]")
print(" the editor will be unresponsive until it finishes (~2 minutes)")
var server_pid := _launch(exe, project_dir, ["--server"], SERVER_SCENE)
if server_pid <= 0:
push_error("Could not start the server instance")
return
# Give the host time to come up and open its port before the client dials in.
OS.delay_msec(5000)
var client_pid := _launch(exe, project_dir, ["--join", "127.0.0.1"], CLIENT_SCENE)
if client_pid <= 0:
push_error("Could not start the client instance")
OS.kill(server_pid)
return
var waited := 0.0
while OS.is_process_running(server_pid) and waited < TIMEOUT_SEC:
OS.delay_msec(500)
waited += 0.5
if OS.is_process_running(server_pid):
print(" timed out after %ds, stopping the instances" % TIMEOUT_SEC)
OS.kill(server_pid)
if OS.is_process_running(client_pid):
OS.kill(client_pid)
OS.delay_msec(500)
_print_report(logs_dir)
_build_gif(project_dir, logs_dir)
func _launch(exe: String, project_dir: String, extra: Array, scene: String) -> int:
var args := PackedStringArray([
"--xr-mode", "off", "--resolution", "900x600",
"--path", project_dir, scene, "--",
])
for a in extra:
args.append(a)
# --mptest runs the scripted sequence; --mptest-frames captures the frame per
# step that the GIF is stitched from (needs a real window, hence no
# --headless here).
args.append_array(PackedStringArray([
"--mptest", "--mptest-frames",
"--mptest-pause", str(STEP_PAUSE), "--mptest-hold", str(END_HOLD),
]))
return OS.create_process(exe, args)
# Remove the previous run's output so a failed launch can't leave stale results
# looking like this run's.
func _clear_previous_results(logs_dir: String) -> void:
for name in ["mptest_report.txt", "mptest_run.gif", "mptest_server.log", "mptest_client.log"]:
DirAccess.remove_absolute(logs_dir.path_join(name))
for role in ["server", "client"]:
var frames := logs_dir.path_join("mptest_frames_%s" % role)
var dir := DirAccess.open(frames)
if dir:
for f in dir.get_files():
if f.ends_with(".png"):
dir.remove(f)
func _print_report(logs_dir: String) -> void:
var path := logs_dir.path_join("mptest_report.txt")
if not FileAccess.file_exists(path):
push_error("No report at %s — the run did not finish. Check logs/mptest_server.log" % path)
return
var text := FileAccess.get_file_as_string(path)
print("")
# Colour the summary so a failure is obvious in the Output panel.
for line in text.split("\n"):
if line.begins_with("FAIL") or line.contains("RESULT: FAILED"):
print_rich("[color=red]%s[/color]" % line)
elif line.contains("ALL CHECKS PASSED"):
print_rich("[color=green][b]%s[/b][/color]" % line)
elif line.begins_with("PASS"):
print_rich("[color=gray]%s[/color]" % line)
else:
print(line)
print("report: %s" % path)
func _build_gif(project_dir: String, logs_dir: String) -> void:
var script := project_dir.path_join("test/make_gif.ps1")
var out := []
var code := OS.execute("powershell", [
"-ExecutionPolicy", "Bypass", "-File", script,
], out, true)
for line in out:
print(line)
var gif := logs_dir.path_join("mptest_run.gif")
if code == 0 and FileAccess.file_exists(gif):
print_rich("[b]gif:[/b] %s" % gif)
else:
push_warning("GIF was not produced (is ffmpeg on PATH?). See the output above.")
+1
View File
@@ -0,0 +1 @@
uid://crcuhy3n8goep