From 4f9e22f0b9650174ede0d18af19412313853f138 Mon Sep 17 00:00:00 2001 From: JonShard Date: Mon, 17 Aug 2026 14:40:59 +0200 Subject: [PATCH] Refactor Table to use new SharedInventoryStation --- Net/multiplayer_world.gd | 4 + Prefabs/build_mode_controller.gd | 8 +- Scenes/multiPlayer.tscn | 5 +- Stations/table.gd | 95 +++++++++++----- Stations/table.tscn | 138 +++++++++++++---------- abstract/shared_inventory_station.gd | 124 ++++++++++++++++++++ abstract/shared_inventory_station.gd.uid | 1 + abstract/station.gd | 19 ++-- abstract/work_station.gd | 19 ++-- content/station_layouts/small_line.tscn | 2 + 10 files changed, 301 insertions(+), 114 deletions(-) create mode 100644 abstract/shared_inventory_station.gd create mode 100644 abstract/shared_inventory_station.gd.uid diff --git a/Net/multiplayer_world.gd b/Net/multiplayer_world.gd index c0a5068..1aaf872 100644 --- a/Net/multiplayer_world.gd +++ b/Net/multiplayer_world.gd @@ -103,10 +103,14 @@ func _populate_world_if_owner() -> void: layout.queue_free() _remove_authored(authored) NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()]) + # One frame between spawns - MultiplayerSpawner corrupts replication when + # several nodes register for sync in the same frame (godotengine/godot#96914). for d in stations: NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) + await get_tree().process_frame for d in items: NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) + await get_tree().process_frame NetworkManager.log_line("World populated") diff --git a/Prefabs/build_mode_controller.gd b/Prefabs/build_mode_controller.gd index 5c02eca..781707b 100644 --- a/Prefabs/build_mode_controller.gd +++ b/Prefabs/build_mode_controller.gd @@ -18,10 +18,14 @@ func _on_game_state_changed(new_state: GameManager.GameState) -> void: _set_stations_enabled(true) -# Only Station-derived stations expose `enabled` (Table runs its own FSM). +# Only single-snap_zone Station-derived stations use the generic `enabled` +# toggle here. Table is Station-derived too now (SharedInventoryStation), but +# it has multiple snap_zones and manages their enabling itself via its own +# FSM (_set_snap_zones_enabled) - toggling only the base `snap_zone` here +# would desync it from the rest of Table's zones, so it's excluded. func _set_stations_enabled(value: bool) -> void: for station in get_tree().get_nodes_in_group("station"): - if station is Station: + if station is Station and not station is Table: station.enabled = value diff --git a/Scenes/multiPlayer.tscn b/Scenes/multiPlayer.tscn index d7f0b93..dda5761 100644 --- a/Scenes/multiPlayer.tscn +++ b/Scenes/multiPlayer.tscn @@ -8,7 +8,7 @@ [ext_resource type="PackedScene" uid="uid://damrxtlt7uswf" path="res://content/station_layouts/small_line.tscn" id="7_0sjqq"] [ext_resource type="PackedScene" uid="uid://dm70ynyuw1a5u" path="res://prefabs/day_controller.tscn" id="9_5wx0o"] [ext_resource type="PackedScene" uid="uid://bnwb7imcotkod" path="res://prefabs/build_mode_controller.tscn" id="10_464r1"] -[ext_resource type="Script" uid="uid://c4y0yigqv7355" path="res://test/vr_spectator_camera.gd" id="11_5wx0o"] +[ext_resource type="PackedScene" uid="uid://3i1xb74cfsh5" path="res://test/vr_spectator_camera.tscn" id="11_5wx0o"] [ext_resource type="PackedScene" uid="uid://dxe05wp60jg3l" path="res://scenes/queue_controller.tscn" id="11_de1dy"] [ext_resource type="Script" uid="uid://biu4qr3nr1eny" path="res://test/mp_test_driver.gd" id="99_mptst"] @@ -103,5 +103,4 @@ hob_scene = SubResource("Resource_464r1") [node name="QueueController" parent="." unique_id=326512876 instance=ExtResource("11_de1dy")] transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0, 0, 7.2000003) -[node name="Node3D" type="Node3D" parent="." unique_id=1675081068] -script = ExtResource("11_5wx0o") +[node name="VRSpectatorCamera" parent="." unique_id=310823561 instance=ExtResource("11_5wx0o")] diff --git a/Stations/table.gd b/Stations/table.gd index 31e003b..0e6ca9f 100644 --- a/Stations/table.gd +++ b/Stations/table.gd @@ -1,5 +1,5 @@ class_name Table -extends StaticBody3D +extends SharedInventoryStation @export var thinking_duration: float = 3.0 @export var ordering_duration: float = 50.0 @@ -32,22 +32,22 @@ enum TableState { EATING } -## State is server-authoritative and synced (see table.tscn's Sync node); -## state transitions only ever run where NetworkManager.owns_world() is true -## (the whole station's _process is gated off elsewhere for non-owners, see -## NetworkManager._gate_station). The setters below just refresh the display, -## so both the server (via _set_state) and clients (via incoming sync) show -## the same text. Use _set_state(), never assign _state directly. -@export var _state: TableState = TableState.EMPTY -@export var _state_time: float = 0.0 -@export var _state_duration: float = 0.0 # set in _set_state_time + +var _state: TableState = TableState.EMPTY +var _state_time: float = 0.0 +var _state_duration: float = 0.0 var _original_orders: Array[String] = [] -@export var _unsatisfied_orders: Array[String] = [] +var _unsatisfied_orders: Array[String] = [] var _players_count: int = 0 +var _is_leader: bool = true # Only the group leader orchestrates the state and shows its own visuals; see _on_group_changed(). func place_order() -> void: SweetLogger.debug("->[]") + if not NetworkManager.owns_world(): + server_place_order.rpc_id(1) + SweetLogger.debug(" []-> rpc") + return var new_orders: Array[String] = _unsatisfied_orders.duplicate() # for _i in range(0, randi_range(1, 2)): # new_orders.append(GameManager.get_random_meal()) @@ -56,9 +56,12 @@ func place_order() -> void: new_orders.append(GameManager.get_random_meal()) _unsatisfied_orders = new_orders _original_orders = _unsatisfied_orders.duplicate() + _set_state(TableState.WAITING_PRIMARY) + SweetLogger.info("Placed order, orders: {0}", [_unsatisfied_orders]) @rpc("any_peer", "call_remote", "reliable") func server_place_order() -> void: + SweetLogger.debug("->[]") if NetworkManager.owns_world(): place_order() @@ -71,8 +74,36 @@ func absorb_items(): continue _absorb_item_if_correct(held_object) - - + + +# Flattens items held across all of this table's snap zones +func get_exposed_food_items() -> Array[FoodItem]: + var items: Array[FoodItem] = [] + for zone in snap_zones: + var held_object = zone.picked_up_object + if not held_object: + continue + var plate: PlateController = _get_plate_controller_from_item(held_object) + if plate: + items.append_array(plate.container.contained_items) + continue + var food_item: FoodItem = Helper.find_food_item(held_object) + if food_item: + items.append(food_item) + return items + + +# Only the group leader orchestrates the FSM and shows its visuals +func _on_group_changed() -> void: + _is_leader = is_group_leader() + if label_3d: + label_3d.visible = _is_leader + if label_3d_time: + label_3d_time.visible = _is_leader + if progress_bar: + progress_bar.set_bar_visible(_is_leader and progress_bar.is_bar_visible()) + + func satisfyAllOrders() -> void: SweetLogger.debug("->[]") _unsatisfied_orders.clear() @@ -109,7 +140,18 @@ func try_consume_customer() -> bool: return false +func _enter_tree() -> void: + super._enter_tree() + var properties: Array[NodePath] = [".:_state_duration", ".:_state", ".:_state_time", ".:_unsatisfied_orders", "Customers:visible"] + for property_path in properties: + sync_config.add_property(property_path) + sync_config.property_set_spawn(property_path, false) + sync_config.property_set_replication_mode(property_path, SceneReplicationConfig.REPLICATION_MODE_ON_CHANGE) + SweetLogger.info("DEBUG sync_config properties: {0} | root_path: {1} | authority: {2} | peer: {3}", [sync_config.get_properties(), synchronizer.root_path, get_multiplayer_authority(), multiplayer.get_unique_id()]) # temp diagnostic + + func _ready() -> void: + super.ready() if not label_3d: SweetLogger.warning("{0} missing label_3d reference", [name]) if not label_3d_time: @@ -137,13 +179,6 @@ func _ready() -> void: for snap_zone_node in snap_zones: snap_zone_node.has_picked_up.connect(_on_object_picked_up) snap_zone_node.has_dropped.connect(_on_object_dropped) - # Never on a client: the state machine is server-authoritative and _state is - # synced down. NetworkManager._gate_station() already turned processing off, - # but a spawned station is gated BEFORE its _ready() runs, so an unconditional - # set_process(true) here would quietly re-arm the FSM on every client — and - # _state_end(EATING) re-enables the snap zones, which then fight the server - # for items sitting on the table. - set_process(NetworkManager.owns_world()) _set_state(TableState.EMPTY) @@ -185,11 +220,6 @@ func _place_order_if_player(): return if _players_count > 0: place_order() - _set_state(TableState.WAITING_PRIMARY) - - -# func _get_plate_controller_from_item(_item: Node) -> PlateController: -# return _item.get_children().filter(func(c): return c is PlateController).front() as PlateController func _get_plate_controller_from_item(_item: Node) -> PlateController: @@ -271,9 +301,9 @@ func _collect_money_from_food(): func _set_snap_zones_enabled(value: bool) -> void: # Enabling is the world owner's call only — a client's zones stay gated no # matter what state its copy of the FSM thinks it is in. - var enabled := value and NetworkManager.owns_world() + var is_enabled := value and NetworkManager.owns_world() for zone in snap_zones: - zone.enabled = enabled + zone.enabled = is_enabled ## Server-only state transition: sets the new state's timer and assigns @@ -302,8 +332,8 @@ func _set_state(newState: TableState) -> void: _state_time = primary_duration _state_duration = primary_duration _state = newState - # Absorm meals that were already in the table when the state starts - for zone in snap_zones: + # Absorm meals that were already in the table when the state starts (We missed the pickup event) + for zone in snap_zones: _absorb_item_if_correct(zone.picked_up_object) TableState.WAITING_FRIEND: _state_time = friend_duration @@ -383,12 +413,15 @@ func _refresh_display() -> void: func _process(delta: float) -> void: + super.process(delta) + #SweetLogger.debug("State {0} time: {1} duration: {2} is_leader: {3}", [TableState.keys()[_state], _state_time, _state_duration, _is_leader]) _refresh_display() + if not _is_leader: + return # Follower: the group leader orchestrates the FSM, we just mirror its synced state. _place_order_if_player() - #SweetLogger.error("State {0} time: {1} duration: {2}", [TableState.keys()[_state], _state_time, _state_duration]) if not NetworkManager.owns_world(): - SweetLogger.debug("Not server, skipping state update") + # SweetLogger.debug("Not server, skipping state update") return if GameManager.game_state == GameManager.GameState.GAME_OVER: diff --git a/Stations/table.tscn b/Stations/table.tscn index eacd333..cb49806 100644 --- a/Stations/table.tscn +++ b/Stations/table.tscn @@ -1,37 +1,14 @@ [gd_scene format=3 uid="uid://caf0xanmxbshy"] [ext_resource type="Script" uid="uid://caeikc7e3igkd" path="res://stations/table.gd" id="1_2vcpj"] -[ext_resource type="PackedScene" uid="uid://cvucwxibtol5f" path="res://stations/StationMovement.tscn" id="1_5mi7y"] [ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="1_8j1nt"] +[ext_resource type="PackedScene" uid="uid://cbs8jiqe8rcmn" path="res://abstract/station.tscn" id="1_station"] [ext_resource type="AudioStream" uid="uid://ck72h06t8hyyk" path="res://sounds/money.mp3" id="2_jslhm"] [ext_resource type="AudioStream" uid="uid://dllgyc8jh83an" path="res://sounds/220195__gameaudio__click-wooden-1.wav" id="3_0s6ir"] [ext_resource type="PackedScene" uid="uid://bxbocxdaayvwx" path="res://UI/progress_bar.tscn" id="3_kjf1i"] -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_table"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:rotation") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath(".:visible") -properties/2/spawn = false -properties/2/replication_mode = 1 -properties/3/path = NodePath(".:_state_duration") -properties/3/spawn = false -properties/3/replication_mode = 1 -properties/4/path = NodePath(".:_state") -properties/4/spawn = false -properties/4/replication_mode = 1 -properties/5/path = NodePath(".:_state_time") -properties/5/spawn = false -properties/5/replication_mode = 1 -properties/6/path = NodePath(".:_unsatisfied_orders") -properties/6/spawn = false -properties/6/replication_mode = 1 -properties/7/path = NodePath("Customers:visible") -properties/7/spawn = false -properties/7/replication_mode = 1 +[sub_resource type="BoxShape3D" id="BoxShape3D_probe"] +size = Vector3(0.2, 0.3, 0.2) [sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"] size = Vector3(0.7983472, 0.060000002, 0.59873295) @@ -45,28 +22,65 @@ albedo_color = Color(0.31, 0.21576, 0.1333, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"] size = Vector3(2.1, 0.5, 2.1) -[node name="Table" type="Node3D" unique_id=987495548] +[node name="Table" unique_id=987495548 instance=ExtResource("1_station")] -[node name="StationMovement" parent="." unique_id=946873975 node_paths=PackedStringArray("station") instance=ExtResource("1_5mi7y")] +[node name="StationMovement" parent="." index="0" unique_id=946873975 node_paths=PackedStringArray("station")] station = NodePath("../Table") -[node name="MultiplayerSyncronizer" type="MultiplayerSynchronizer" parent="." unique_id=2000411505] -root_path = NodePath("../Table") -replication_config = SubResource("SceneReplicationConfig_np_table") +[node name="ProgressBar3D" parent="." index="4" unique_id=654673176] +visible = false -[node name="Table" type="StaticBody3D" parent="." unique_id=1863572470 groups=["station", "table"]] +[node name="SnapZone" parent="." index="5" unique_id=1315859105] +enabled = false + +[node name="Table" type="StaticBody3D" parent="." index="6" unique_id=1863572470 node_paths=PackedStringArray("notification_audio", "ambient_audio", "snap_zone", "synchronizer") groups=["station", "table"]] script = ExtResource("1_2vcpj") money_sound = ExtResource("2_jslhm") +probe_paths = Array[NodePath]([NodePath("ProbeForward"), NodePath("ProbeRight"), NodePath("ProbeBack"), NodePath("ProbeLeft")]) +pickup_sound = ExtResource("3_0s6ir") +drop_sound = ExtResource("3_0s6ir") +notification_audio = NodePath("../AudioStreamPlayer3DNotification") +ambient_audio = NodePath("../AudioStreamPlayer3DAmbient") +snap_zone = NodePath("XRToolsSnapZone") +synchronizer = NodePath("../MultiplayerSynchronizer") -[node name="CollisionShape3D" type="CollisionShape3D" parent="Table" unique_id=228053941] +[node name="ProbeForward" type="Area3D" parent="Table" index="0" unique_id=-1294555795] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, -0.45) +collision_layer = 512 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Table/ProbeForward" index="0" unique_id=-1294555794] +shape = SubResource("BoxShape3D_probe") + +[node name="ProbeRight" type="Area3D" parent="Table" index="1" unique_id=-1294555793] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.45, 0.5, 0) +collision_layer = 512 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Table/ProbeRight" index="0" unique_id=-1294555792] +shape = SubResource("BoxShape3D_probe") + +[node name="ProbeBack" type="Area3D" parent="Table" index="2" unique_id=-1294555791] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0.45) +collision_layer = 512 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Table/ProbeBack" index="0" unique_id=-1294555790] +shape = SubResource("BoxShape3D_probe") + +[node name="ProbeLeft" type="Area3D" parent="Table" index="3" unique_id=-1294555789] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.45, 0.5, 0) +collision_layer = 512 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Table/ProbeLeft" index="0" unique_id=-1294555788] +shape = SubResource("BoxShape3D_probe") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Table" index="4" unique_id=228053941] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.50155246, 0) shape = SubResource("BoxShape3D_24d3s") -[node name="CollisionShape3D2" type="CollisionShape3D" parent="Table" unique_id=276384063] +[node name="CollisionShape3D2" type="CollisionShape3D" parent="Table" index="5" unique_id=276384063] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.094791204, 0.9715525, 0.0012244582) shape = SubResource("BoxShape3D_24d3s") -[node name="XRToolsSnapZone" type="Area3D" parent="Table" unique_id=1947858647 groups=["station_zone"]] +[node name="XRToolsSnapZone" type="Area3D" parent="Table" index="6" unique_id=1947858647 groups=["station_zone"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0285525, 0) collision_layer = 65536 collision_mask = 65540 @@ -75,90 +89,90 @@ stash_sound = ExtResource("3_0s6ir") snap_mode = 1 metadata/_custom_type_script = "uid://cquqe4f1m1sw6" -[node name="CollisionShape3D2" type="CollisionShape3D" parent="Table/XRToolsSnapZone" unique_id=1176887393] +[node name="CollisionShape3D2" type="CollisionShape3D" parent="Table/XRToolsSnapZone" index="0" unique_id=1176887393] transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0) shape = SubResource("SphereShape3D_dlkho") -[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="Table/XRToolsSnapZone" unique_id=2135860710] +[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="Table/XRToolsSnapZone" index="1" unique_id=2135860710] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.013531357, -0.015141487, 0.012176305) -[node name="CSGCombiner3D" type="CSGCombiner3D" parent="Table" unique_id=2085635675] +[node name="CSGCombiner3D" type="CSGCombiner3D" parent="Table" index="7" unique_id=2085635675] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.50155246, 0) -[node name="CSGBox3D" type="CSGBox3D" parent="Table/CSGCombiner3D" unique_id=411048765] +[node name="CSGBox3D" type="CSGBox3D" parent="Table/CSGCombiner3D" index="0" unique_id=411048765] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.46137518, 0) size = Vector3(0.6, 0.077, 0.6) material = SubResource("StandardMaterial3D_24d3s") -[node name="CSGBox3D2" type="CSGBox3D" parent="Table/CSGCombiner3D" unique_id=2093582402] +[node name="CSGBox3D2" type="CSGBox3D" parent="Table/CSGCombiner3D" index="1" unique_id=2093582402] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.02685462, 0) size = Vector3(0.1, 0.9258911, 0.1) material = SubResource("StandardMaterial3D_24d3s") -[node name="CSGBox3D4" type="CSGBox3D" parent="Table/CSGCombiner3D" unique_id=345709075] +[node name="CSGBox3D4" type="CSGBox3D" parent="Table/CSGCombiner3D" index="2" unique_id=345709075] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.41408914, 0) size = Vector3(0.1, 0.15142211, 0.5341797) material = SubResource("StandardMaterial3D_24d3s") -[node name="CSGBox3D5" type="CSGBox3D" parent="Table/CSGCombiner3D" unique_id=550923256] +[node name="CSGBox3D5" type="CSGBox3D" parent="Table/CSGCombiner3D" index="3" unique_id=550923256] transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 0, -0.41408914, 0) size = Vector3(0.1, 0.15142211, 0.5341797) material = SubResource("StandardMaterial3D_24d3s") -[node name="Customers" type="Node3D" parent="Table" unique_id=2147179512] +[node name="Customers" type="Node3D" parent="Table" index="8" unique_id=2147179512] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.50155246, 0) -[node name="Customer" type="Node3D" parent="Table/Customers" unique_id=1265943831] +[node name="Customer" type="Node3D" parent="Table/Customers" index="0" unique_id=1265943831] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.016780734, -0.08218986, 0) -[node name="CSGCombiner3D" type="CSGCombiner3D" parent="Table/Customers/Customer" unique_id=1211819222] +[node name="CSGCombiner3D" type="CSGCombiner3D" parent="Table/Customers/Customer" index="0" unique_id=1211819222] transform = Transform3D(1.095, 0, 0, 0, 1.095, 0, 0, 0, 1.095, 0.08442788, -0.19989291, 0) -[node name="CSGSphere3D" type="CSGSphere3D" parent="Table/Customers/Customer/CSGCombiner3D" unique_id=2104132980] +[node name="CSGSphere3D" type="CSGSphere3D" parent="Table/Customers/Customer/CSGCombiner3D" index="0" unique_id=2104132980] transform = Transform3D(0.9999998, 0, 0, 0, 0.9999998, 0, 0, 0, 0.9999998, -0.7149929, 1.2762557, 0) radius = 0.17520222 -[node name="CSGSphere3D2" type="CSGSphere3D" parent="Table/Customers/Customer/CSGCombiner3D" unique_id=808477724] +[node name="CSGSphere3D2" type="CSGSphere3D" parent="Table/Customers/Customer/CSGCombiner3D" index="1" unique_id=808477724] transform = Transform3D(0.9999998, 0, 0, 0, 1.7476468, 0, 0, 0, 0.9999998, -0.7366721, 0.8531885, 0) radius = 0.17520222 -[node name="Seats" type="Node3D" parent="Table" unique_id=1101423789] +[node name="Seats" type="Node3D" parent="Table" index="9" unique_id=1101423789] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.50155246, 0) -[node name="Seat" type="Node3D" parent="Table/Seats" unique_id=1951252898] +[node name="Seat" type="Node3D" parent="Table/Seats" index="0" unique_id=1951252898] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.9079602, -0.37259835, 0) -[node name="CSGCombiner3D" type="CSGCombiner3D" parent="Table/Seats/Seat" unique_id=384912485] +[node name="CSGCombiner3D" type="CSGCombiner3D" parent="Table/Seats/Seat" index="0" unique_id=384912485] -[node name="CSGCylinder3D" type="CSGCylinder3D" parent="Table/Seats/Seat/CSGCombiner3D" unique_id=170426769] +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="Table/Seats/Seat/CSGCombiner3D" index="0" unique_id=170426769] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.18637085, 0.51674986, 0) radius = 0.25634766 height = 0.051940918 sides = 16 -[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="Table/Seats/Seat/CSGCombiner3D" unique_id=1294605073] +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="Table/Seats/Seat/CSGCombiner3D" index="1" unique_id=1294605073] transform = Transform3D(0.24355066, 0.98373884, 0, -1.3339849, 0.1796049, 0, 0, 0, 0.8365013, -0.05247748, 0.8547088, 0) radius = 0.25634766 height = 0.051940918 sides = 16 -[node name="CSGBox3D" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" unique_id=1761839440] +[node name="CSGBox3D" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" index="2" unique_id=1761839440] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.030369163, 0.18746406, 0.1341562) size = Vector3(0.05, 0.6713196, 0.05) -[node name="CSGBox3D2" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" unique_id=1395371703] +[node name="CSGBox3D2" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" index="3" unique_id=1395371703] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.030369163, 0.18746406, -0.15065354) size = Vector3(0.05, 0.6713196, 0.05) -[node name="CSGBox3D3" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" unique_id=96608061] +[node name="CSGBox3D3" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" index="4" unique_id=96608061] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.33084005, 0.18746406, 0.1341562) size = Vector3(0.05, 0.6713196, 0.05) -[node name="CSGBox3D4" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" unique_id=1684738537] +[node name="CSGBox3D4" type="CSGBox3D" parent="Table/Seats/Seat/CSGCombiner3D" index="5" unique_id=1684738537] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.33084005, 0.18746406, -0.15065354) size = Vector3(0.05, 0.6713196, 0.05) -[node name="Label3D" type="Label3D" parent="Table" unique_id=662960977] +[node name="Label3D" type="Label3D" parent="Table" index="10" unique_id=662960977] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.8263302, 0) pixel_size = 0.003 billboard = 2 @@ -166,26 +180,26 @@ text = "table state" vertical_alignment = 0 line_spacing = -15.0 -[node name="Label3DTime" type="Label3D" parent="Table" unique_id=1534849507] +[node name="Label3DTime" type="Label3D" parent="Table" index="11" unique_id=1534849507] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0147986, 0) visible = false pixel_size = 0.003 billboard = 2 text = "20.1s" -[node name="ProgressBar3D" parent="Table" unique_id=654673176 instance=ExtResource("3_kjf1i")] +[node name="ProgressBar3D" parent="Table" index="12" unique_id=600000001 instance=ExtResource("3_kjf1i")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.8692299, 0) y_billboard = true -[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="Table" unique_id=724610642] +[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="Table" index="13" unique_id=724610642] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7950532, 0) -[node name="PlayerDetectArea3D" type="Area3D" parent="Table" unique_id=913445150] +[node name="PlayerDetectArea3D" type="Area3D" parent="Table" index="14" unique_id=913445150] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.50155246, 0) collision_layer = 0 collision_mask = 524288 -[node name="CollisionShape3D" type="CollisionShape3D" parent="Table/PlayerDetectArea3D" unique_id=1538221021] +[node name="CollisionShape3D" type="CollisionShape3D" parent="Table/PlayerDetectArea3D" index="0" unique_id=1538221021] shape = SubResource("BoxShape3D_vlqg6") [connection signal="body_entered" from="Table/PlayerDetectArea3D" to="Table" method="_on_player_enter"] diff --git a/abstract/shared_inventory_station.gd b/abstract/shared_inventory_station.gd new file mode 100644 index 0000000..594218d --- /dev/null +++ b/abstract/shared_inventory_station.gd @@ -0,0 +1,124 @@ +class_name SharedInventoryStation +extends Station + +### Abstract 'class', should never be instantiated ### +# Backbone for stations that need to know about neighboring stations of the +# same kind (tables, belts). + +## Path to one Area3D probe per direction this station should watch for a +## same-kind neighbor. Wired explicitly per concrete scene (e.g. a belt +## wires one path, a table wires four). +@export var probe_paths: Array[NodePath] = [] + +## Resolved from probe_paths in ready(). +var probes: Array[Area3D] = [] + +## Area3D probe -> the SharedInventoryStation neighbor detected through it +## (or null if that direction is currently unoccupied). +var neighbors: Dictionary = {} + + +# The child station has to call super._enter_tree() for this to be called +func _enter_tree() -> void: + super._enter_tree() + var properties: Array[NodePath] = [".:position", ".:rotation", ".:visible"] + for property_path in properties: + sync_config.add_property(property_path) + sync_config.property_set_spawn(property_path, false) + sync_config.property_set_replication_mode(property_path, SceneReplicationConfig.REPLICATION_MODE_ON_CHANGE) + + +# The child station has to call super.ready() for this to be called +func ready() -> void: + super.ready() + # Find probes + for path in probe_paths: + var probe := get_node_or_null(path) as Area3D + if probe: + probes.append(probe) + else: + SweetLogger.warning("{0} could not resolve neighbor probe at path {1}", [name, path]) + if probes.is_empty(): + SweetLogger.warning("{0} has no neighbor probes wired", [name]) + for probe in probes: + neighbors[probe] = null + probe.body_entered.connect(_on_probe_body_entered.bind(probe)) + probe.body_exited.connect(_on_probe_body_exited.bind(probe)) + # Init leader state before any probe signal has a chance to fire + _broadcast_group_changed.call_deferred() + + +## Cycle-safe search of the neighbor graph (handles circular belt/table loops) +func get_connected_group() -> Array[SharedInventoryStation]: + var visited: Dictionary = {get_instance_id(): true} + var queue: Array[SharedInventoryStation] = [self] + var group: Array[SharedInventoryStation] = [] + while not queue.is_empty(): + var current: SharedInventoryStation = queue.pop_front() + group.append(current) + for neighbor in current.neighbors.values(): + if neighbor and not visited.has(neighbor.get_instance_id()): + visited[neighbor.get_instance_id()] = true + queue.append(neighbor) + return group + + +## Deterministic leader: smallest grid position, computed the same on every peer +func is_group_leader() -> bool: + var leader: SharedInventoryStation = self + for member in get_connected_group(): + if _grid_sort_key(member) < _grid_sort_key(leader): + leader = member + return leader == self + + +static func _grid_sort_key(station: SharedInventoryStation) -> Vector2i: + var pos := station.global_position + return Vector2i(roundi(pos.x / Helper.SNAP_GRID_SIZE), roundi(pos.z / Helper.SNAP_GRID_SIZE)) + + +## Food items this station holds, exposed for neighbors to read; override for multi-zone stations +func get_exposed_food_items() -> Array[FoodItem]: + var items: Array[FoodItem] = [] + if snap_zone and snap_zone.picked_up_object: + var food_item := Helper.find_food_item(snap_zone.picked_up_object) + if food_item: + items.append(food_item) + return items + + +## Every food item held anywhere in this station's connected group +func get_group_food_items() -> Array[FoodItem]: + var items: Array[FoodItem] = [] + for member in get_connected_group(): + items.append_array(member.get_exposed_food_items()) + return items + + +func _on_probe_body_entered(body: Node3D, probe: Area3D) -> void: + var station := body as SharedInventoryStation + if not station or station == self: + return + neighbors[probe] = station + _broadcast_group_changed() + + +func _on_probe_body_exited(body: Node3D, probe: Area3D) -> void: + var former: SharedInventoryStation = neighbors.get(probe) + if not former or body != former: + return + neighbors[probe] = null + # former is no longer reachable from self, so it needs telling separately + former._broadcast_group_changed() + _broadcast_group_changed() + + +## Notifies every member of this station's current connected group (not just the two that changed) +func _broadcast_group_changed() -> void: + for member in get_connected_group(): + member._on_group_changed() + + +## Virtual hook: override to react to this station's group membership/leadership changing +func _on_group_changed() -> void: + pass diff --git a/abstract/shared_inventory_station.gd.uid b/abstract/shared_inventory_station.gd.uid new file mode 100644 index 0000000..a1b17b7 --- /dev/null +++ b/abstract/shared_inventory_station.gd.uid @@ -0,0 +1 @@ +uid://bciln4f4tjwgy diff --git a/abstract/station.gd b/abstract/station.gd index c90dc59..6935604 100644 --- a/abstract/station.gd +++ b/abstract/station.gd @@ -25,6 +25,17 @@ var enabled: bool: # When disabled the station only updated display and sounds. snap_zone.set_process(p_enabled) +# Godot requires replication_config to be fully built before _ready. So in _enter_tree +# The child station has to call super._enter_tree() for this to be called. +func _enter_tree() -> void: + # Always a fresh config - abstract/station.tscn's config sub-resource is shared + # in memory across every station scene that instances it, so reusing it here + # would pile every station type's properties onto the same shared object. + synchronizer.root_path = get_path() + synchronizer.replication_config = SceneReplicationConfig.new() + sync_config = synchronizer.replication_config + + # The child station has to call super.ready() for this to be called func ready() -> void: SweetLogger.debug("Station {0} ready", [name]) @@ -36,18 +47,12 @@ func ready() -> void: SweetLogger.warning("{0} missing snap_zone reference", [name]) if not synchronizer: SweetLogger.warning("{0} missing synchronizer reference", [name]) - # Disable if we're not the server. + # Disable if we're not the server. if not NetworkManager.owns_world(): enabled = false snap_zone.has_picked_up.connect(_on_object_picked_up_handler) snap_zone.has_dropped.connect(_on_object_dropped_handler) - - # Configure Multiplayer Syncronizer - # This overwrites any changes made in the inspector. - synchronizer.root_path = get_path() - synchronizer.replication_config = SceneReplicationConfig.new() - sync_config = synchronizer.replication_config # The child station has to call super.process(delta) for this to be called func process(_delta: float) -> void: diff --git a/abstract/work_station.gd b/abstract/work_station.gd index bd6537b..d34a66f 100644 --- a/abstract/work_station.gd +++ b/abstract/work_station.gd @@ -19,21 +19,22 @@ var current_work: float # How far a FoodItem conversion is toward completion var max_work: float # How much work has to be acheived to trigger conversion var result_id: String # Station active if not empty. What FoodItem id the conversion turns the current FoodItem into. +# The child station has to call super._enter_tree() for this to be called +func _enter_tree() -> void: + super._enter_tree() + var properties: Array[NodePath] = [".:current_work", ".:max_work", ".:result_id"] + for property_path in properties: + sync_config.add_property(property_path) + sync_config.property_set_spawn(property_path, false) + sync_config.property_set_replication_mode(property_path, SceneReplicationConfig.REPLICATION_MODE_ON_CHANGE) + + # The child station has to call super.ready() for this to be called func ready() -> void: super.ready() if not progress_bar: SweetLogger.warning("{0} missing progress_bar reference", [name]) - # Configure Multiplayer Syncronizer - # Base Station creates the config, here we append to it: - var properties: Array[NodePath] = [ ".:current_work", ".:max_work", ".:result_id"] - for property_path in properties: - sync_config.add_property(property_path) - sync_config.property_set_spawn(property_path, false) - sync_config.property_set_replication_mode(property_path, SceneReplicationConfig.REPLICATION_MODE_ON_CHANGE) - - # The child station has to call super.process(delta) for this to be called func process(delta: float) -> void: diff --git a/content/station_layouts/small_line.tscn b/content/station_layouts/small_line.tscn index b086d4c..91a2f80 100644 --- a/content/station_layouts/small_line.tscn +++ b/content/station_layouts/small_line.tscn @@ -37,3 +37,5 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.8000001, 0, 0) [node name="PotatoDispenser" parent="." unique_id=522124817 instance=ExtResource("9_ok0xr")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8000001, 0, 0.6) + +[editable path="Table"]