36 Commits

Author SHA1 Message Date
JonShard 34a649f507 Kanban 2026-07-26 16:14:29 +02:00
JonShard e04c5519d0 Move stations to floor 2026-07-26 16:00:30 +02:00
algodoogle 7ac87984bc Merge branch 'multi2' of https://git.offcoursegames.com/JonShard/VRyHungry1 into multi2 2026-07-26 14:33:20 +01:00
algodoogle 776aaa3020 bug fix 2026-07-26 14:33:15 +01:00
JonShard 5ca64b8dff Fix error about custom_solver_bias not supported 2026-07-26 15:05:05 +02:00
algodoogle ae3e7ec674 jiff 2026-07-26 13:49:46 +01:00
algodoogle 23ec41c1d6 Fix five multiplayer sync bugs, add automated two-instance test
Adds test/multiPlayerTest.tscn plus a driver that runs the kitchen flow
across two game instances: grab/drop, dirt station, sink washing, hob
cooking, counter combining and plating. Runs headless (run_mp_test.ps1),
in two visible windows (run_mp_test_windowed.ps1), or by hand with
keyboard controls (play_mp_test.ps1). 108 checks, exits non-zero on
failure.

After every step both peers snapshot every item's position and rendered
state and the server diffs them. Targeted assertions only look at the
thing a step touched, which misses desyncs elsewhere - that audit is
what caught the last bug below.

Bugs found and fixed:

- net_pickable: apply_held_state() only wrote `enabled` in its
  non-authority branch, so once a client grabbed an item every other
  peer set enabled=false and regaining authority never restored it. The
  server could then never pick that item up again, and a station would
  "snap" it (emitting has_picked_up, so a plate still got marked dirty)
  while pick_up() bailed out on the disabled item - leaving the zone
  holding an item with no grab driver.

- network_manager: station gating only happened in the spawn path, so
  stations baked into a scene file kept running their snap zones on
  clients and grabbed items straight out of the local hand. Added
  gate_existing_stations().

- network_manager: despawn_item() only freed the server's copy. Items
  baked into a scene aren't tracked by the MultiplayerSpawner, so
  consuming one left a ghost on every client, which then blocked the
  station it sat in and got grabbed instead of its replacement.

- network_manager: the snap-into-station decision read the server's own
  copy of the item position, but the reliable release RPC routinely
  overtakes the synchronizer's unordered position updates - so it acted
  on a stale position and teleported items back into the station they
  had just been carried away from. The releasing peer now sends its
  final transform and the server adopts it first.

- container: contained_ids.append()/erase() mutate the array in place,
  which never fires the setter that rebuilds the plate's visuals. The
  peer that put food on a plate was the only peer that never redrew it;
  remote peers looked right because the synchronizer assigns there.

Also null-guards XRServer.get_tracker() in the vendored xr-tools hand
grab point, which threw on every successful grab without an XR runtime,
and adds multiplayer_world.populate_from_layout so debug scenes can bake
their own content instead of spawning the whole kitchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 02:00:50 +01:00
algodoogle 33ef81306d asd 2026-07-25 21:58:14 +01:00
JonShard 50efeeb353 Merge branch 'multi2' of https://git.offcoursegames.com/JonShard/VRyHungry1 into multi2 2026-07-25 22:11:03 +02:00
JonShard 71d01e5be1 Add game export preset 2026-07-25 22:09:46 +02:00
algodoogle 87641dd55c Enable file logging to logs/, gitignore its output
Pairs with the debug-log-heavy investigation workflow: Godot writes its
own engine log to logs/godot.log alongside the existing NetworkManager
temp-file log, and the directory is excluded from version control since
it's runtime output, not source.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:07:29 +01:00
algodoogle d65b2ca863 Add diagnostic logging for item grab/authority state transitions
The snap-zone crash fixed just before this was invisible in the logs
until traced through addon source by hand — the log showed grabs,
drops, and authority handoffs, but nothing about which peer/hand/zone
actually held an item at each step, or when a guard clause silently
changed behavior.

Adds a _holder_desc() helper (reports "loose", "hand(<path>)",
"zone(<station>)", or "other(...)") and logs every net_held_by
transition, every reclaim that has to restore freeze_mode/collision_mask
from a stuck state, every time the grab-race guard protects an actively-
held item from a stale sync, every force-drop triggered by a losing
authority race, and every pickup/drop event including the ones that
don't get forwarded (picked up by something other than a hand, or
dropped while not authority — exactly the silent case behind the
snap-zone crash). NetworkManager's snap/release-from-zone calls get the
same treatment: which station an item is pulled from, and whether a
snap attempt succeeded, was skipped by the race guard, or found nothing
in range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:07:21 +01:00
algodoogle 0af0c1bfc1 Merge branch 'multi2' of https://git.offcoursegames.com/JonShard/VRyHungry1 into multi2 2026-07-25 20:49:38 +01:00
JonShard 428f00a812 Ignore .log 2026-07-25 21:49:19 +02:00
JonShard 773cf647d8 Add invisible walls 2026-07-25 21:48:58 +02:00
algodoogle 61ea80b933 Fix self-targeted RPC breaking host-side item grab/release
request_item_authority and release_item_authority were always sent as
an RPC to peer 1, even when the caller already WAS peer 1 (the host's
own player). Godot rejects rpc_id() targeting your own peer id ("RPC on
yourself is not allowed by selected mode"), so every host-side grab or
release was silently failing to run its server-side half: no denial
check on grab, and — the more damaging part — no
_try_snap_into_station()/_release_from_snap_zones() call on release,
leaving a station's snap zone holding a stale reference once the host
picked something back up. That's what made the host "no longer able to
interact" with an item after a client had handled it.

Split each entry point into a public function that runs the logic
directly when we're already the server, and a private @rpc handler used
only when an actual remote client calls it. force_release_item gets the
same treatment for the symmetric case (server rejecting its own grab
attempt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:47:03 +01:00
algodoogle f6ac103233 Fix join-floor fall-through, grab race, drop physics, and remote hand sync
Four bugs from real cross-network play:

- Player/net_player.gd: the per-peer spawn-spread offset (added to avoid
  stacking joiners on top of each other) could push a joining client up
  to 12 units from center, but the floor is only 15x15 (~7.5 unit
  half-extent) — landing a client off the edge. Bounded to ~2.7 units.

- Player/net_player.gd: the glove scenes used for remote-hand visuals
  carry hand.gd (XRToolsHand), whose root node sets top_level = true and
  repositions itself to its parent's transform every physics frame,
  expecting to be parented under a live XRController3D. Parented under
  the plain avatar node instead, it fought both the local transform copy
  and the replicated sync every tick — whichever wrote last that frame
  won, which read as hands stuck near origin except momentarily. Disabled
  physics processing on those nodes; nothing else in the script matters
  without a real controller ancestor.

- Net/net_pickable.gd: grabbing an item sends an RPC to the server to
  confirm authority, but the item's regular state sync travels on a
  different channel with no ordering guarantee against that RPC. A
  stale "still loose" sync packet could arrive after an optimistic local
  grab but before the confirmation, and was being treated as a real
  authority loss, force-dropping the item — explaining "first attempt
  fails, second succeeds". Now a sync update can't yank an item out of
  our own hand mid-grab; only an explicit rejection or an actual loss of
  authority can.

- Net/net_pickable.gd: the non-authority path zeroes collision_mask and
  forces freeze_mode to KINEMATIC, but nothing ever restored either when
  a peer regained authority (e.g. the server after a client's release) —
  so a released item stayed collision-less forever, looking physics-less.
  Now both are restored from the item's own baked values whenever a peer
  owns a loose (not actively held) item again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:16:29 +01:00
algodoogle 17d20440da Fix catastrophic player spawn-offset overflow for real peer ids
The per-peer spread offset used the raw ENet peer id directly
((peer_id - 1) * 1.5), assuming ids are small sequential numbers. Real
peer ids are large effectively-random 32-bit values, so a joining
client's XR rig was being shifted by hundreds of millions of units off
the origin — the world had actually replicated correctly, the player
was just teleported far away from all of it, with float precision bad
enough at that range to jitter the view and destabilize physics.

Bound the offset to a small deterministic slot instead; the host still
keeps its original baked spot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:59:36 +01:00
JonShard a9ca11fd17 Merge branch 'multi2' of https://git.offcoursegames.com/JonShard/VRyHungry1 into multi2 2026-07-25 20:48:39 +02:00
JonShard 13567e5dd3 Add global key events to close game 2026-07-25 20:48:36 +02:00
algodoogle 4ca0a05d1b Fix world population race and add multiplayer diagnostics
The world was populated before NetworkManager.world_ready() actually
called host()/join(), so owns_world() read true for every peer
(including a joining client) and each one built its own local,
unreplicated copy instead of the client receiving the server's spawn
through the MultiplayerSpawner. Population now happens after the
session is actually established.

Also adds net-log coverage for spawn/despawn, station gating, item
authority handoff, station snapping, avatar spawn/despawn, and scene
transitions, so multiplayer behavior is visible in
%TEMP%\vryhungry_net_<pid>.log instead of failing silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:42:49 +01:00
algodoogle 596d777fae asd 2026-07-25 19:26:43 +01:00
algodoogle f7024db98d added multi 2026-07-25 18:20:12 +01:00
JonShard 265dd12b37 Add Table 2026-07-25 18:18:17 +02:00
JonShard a1cb484ca5 Add Table picks an order 2026-07-22 15:15:30 +02:00
JonShard ab6cce3d9a Add table with finite state machine 2026-07-20 13:43:18 +02:00
JonShard b6372c532a Improve look of stations 2026-07-20 09:37:06 +02:00
JonShard fc2c854305 Kanban 2026-07-19 11:15:35 +02:00
JonShard bd2ee722df Support multiple cooking recipes for same item 2026-07-19 10:48:41 +02:00
JonShard a48611c0ff Refactor hob to use Recipemanager 2026-07-19 10:48:22 +02:00
JonShard f0e3ba705d Load chopping, rolling and augmenting recipes from YAML 2026-07-19 10:48:21 +02:00
JonShard 714d813344 Reafactoring Recipemanager 2026-07-19 10:48:21 +02:00
JonShard 847af48394 Clean up what the AI did 2026-07-19 10:48:21 +02:00
JonShard 4d06e72bc1 Add Recipemanager that loads recipes from YAML 2026-07-19 10:47:28 +02:00
JonShard 840908e9ff Add Dirty plates and sink. Dirty plates don't accept food 2026-07-17 11:52:21 +02:00
JonShard b0fd26f3da Kanban 2026-07-16 18:22:11 +02:00
198 changed files with 13693 additions and 1161 deletions
+4
View File
@@ -1,3 +1,7 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
.build/
/android/ /android/
/logs
*.log
+3
View File
@@ -0,0 +1,3 @@
{
"godotTools.editorPath.godot4": "c:\\Program Files (x86)\\Godot\\Godot_v4.7-stable_win64.exe"
}
+142 -35
View File
@@ -1,5 +1,7 @@
class_name ItemContainer
extends Node3D extends Node3D
@export var enabled: bool = true
@export var target_group : String # Items with this tag can be added to the container @export var target_group : String # Items with this tag can be added to the container
@export var meal_positions: Array[Node3D] = [] @export var meal_positions: Array[Node3D] = []
@export var side_positions: Array[Node3D] = [] @export var side_positions: Array[Node3D] = []
@@ -22,64 +24,169 @@ func _ready() -> void:
push_error("XRPickable node not found in container.gd") push_error("XRPickable node not found in container.gd")
# Absorbing items is a server decision (the container's holder is server-
# snapped into a station, matching table.gd/hob.gd's convention).
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
# Container fill mutates world state (reparent/freeze); run only on the
# world owner. Item transforms replicate to clients via each item's
# MultiplayerSynchronizer (global_transform), so contents follow the plate.
if not NetworkManager.owns_world(): if not NetworkManager.owns_world():
return return
print("Container enabled: ", enabled)
if not enabled:
print("Container disabled in _on_body_entered body")
return
if not body.is_in_group(target_group): if not body.is_in_group(target_group):
return return
print("Container _on_body_entered body: ", body)
var picked_by = xr_pickable.get_picked_up_by() var picked_by = xr_pickable.get_picked_up_by()
if picked_by and picked_by.is_in_group("station"): if picked_by and picked_by.is_in_group("station"):
# If compatible and enough space, add item # If compatible and enough space, add item
var food_item = body.get_node("FoodItem") var food_item = body.get_node("FoodItem")
print("Container in station found %s of type %s: %s" % [target_group, FoodItem.Type.keys()[food_item.type], body.name]) print("Container in station found %s of type %s: %s" % [target_group, FoodItem.Type.keys()[food_item.type], body.name])
if food_item.type == FoodItem.Type.MEAL and _meal_container.get_child_count() < meal_positions.size(): var meal_count := contained_items.filter(func(f): return f.type == FoodItem.Type.MEAL).size()
var side_count := contained_items.filter(func(f): return f.type == FoodItem.Type.SIDE).size()
if food_item.type == FoodItem.Type.MEAL and meal_count < meal_positions.size():
print("Container adding meal") print("Container adding meal")
_add_item(body, meal_positions, _meal_container) _add_item(body)
if food_item.type == FoodItem.Type.SIDE and _side_container.get_child_count() < side_positions.size(): if food_item.type == FoodItem.Type.SIDE and side_count < side_positions.size():
print("Container adding side") print("Container adding side")
_add_item(body, side_positions, _side_container) _add_item(body)
func _add_item(item: Node3D, positions: Array[Node3D], container_root: Node3D) -> void: # Contents are synced as data (ids on the plate's PlateController), not
# reparented nodes: reparenting a MultiplayerSpawner-tracked item out of
# WorldContent would despawn it on every client the instant it happened.
func _add_item(item: Node3D) -> void:
var pickable = item as XRToolsPickable var pickable = item as XRToolsPickable
var rigidbody = item as RigidBody3D if pickable and pickable.is_picked_up():
# Drop item
if pickable:
if pickable.is_picked_up():
pickable.drop() pickable.drop()
pickable.enabled = false
# Freeze the rigid body and disable its collisions so it doesn't fight the container var food_node := item.get_node_or_null("FoodItem") as FoodItem
if rigidbody: if not food_node:
rigidbody.freeze = true return
rigidbody.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
rigidbody.process_mode = PROCESS_MODE_DISABLED
# Optional: Disable collision layer/mask so it doesn't bump into other food
rigidbody.collision_layer = 0
rigidbody.collision_mask = 0
item.reparent(container_root, false) # false = discard global transform # Copy the data out before despawning the real item.
var data := FoodItem.new()
data.id = food_node.id
data.type = food_node.type
data.sell_value = food_node.sell_value
contained_items.append(data)
# Get target position index var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
var target_slot_index = container_root.get_child_count() - 1 if plate_controller:
if target_slot_index < positions.size(): # Reassign rather than append in place. contained_ids has a setter that
item.position = positions[target_slot_index].position # rebuilds the plate's visuals, and mutating the array never triggers it —
item.rotation = positions[target_slot_index].rotation # so the peer that actually added the food was the one peer that never
# redrew the plate. Remote peers looked right (the synchronizer assigns
# the value there, which does fire the setter), and the stale peer only
# caught up if someone else took the plate and sent the value back.
# duplicate() keeps the Array[String] typing that the property requires.
var updated := plate_controller.contained_ids.duplicate()
updated.append(food_node.id)
plate_controller.contained_ids = updated
NetworkManager.despawn_item(item)
func erase_item(item: FoodItem) -> void:
contained_items.erase(item)
var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
if plate_controller:
# Same reason as _add_item: assign so the visuals actually refresh.
var remaining := plate_controller.contained_ids.duplicate()
remaining.erase(item.id)
plate_controller.contained_ids = remaining
# Add to list
var food_node = item.get_node_or_null("FoodItem")
if food_node:
contained_items.append(food_node as FoodItem)
func clear() -> void: func clear() -> void:
# delete all nodes in containers and contained_items contained_items.clear()
pass var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
if plate_controller:
plate_controller.contained_ids = []
## Rebuilds the purely-cosmetic visual representation of the plate's contents
## from a synced id list. Runs on every peer (called from PlateController
## whenever contained_ids changes, whether set locally or by the network).
func refresh_visuals(ids: Array[String]) -> void:
for child in _meal_container.get_children():
child.queue_free()
for child in _side_container.get_children():
child.queue_free()
var meal_idx := 0
var side_idx := 0
for id in ids:
var scene := RecipeManager.get_item_scene(id)
if not scene:
continue
var visual := scene.instantiate()
var food_node := visual.get_node_or_null("FoodItem") as FoodItem
var container_root: Node3D
var positions: Array[Node3D]
var idx: int
if food_node and food_node.type == FoodItem.Type.MEAL and meal_idx < meal_positions.size():
container_root = _meal_container
positions = meal_positions
idx = meal_idx
meal_idx += 1
elif food_node and food_node.type == FoodItem.Type.SIDE and side_idx < side_positions.size():
container_root = _side_container
positions = side_positions
idx = side_idx
side_idx += 1
else:
visual.queue_free()
continue
_make_cosmetic(visual)
container_root.add_child(visual)
# Must happen after add_child: entering the world is what puts the body
# into the physics space, so it can only be taken out again afterwards.
_remove_from_physics(visual)
visual.position = positions[idx].position
visual.rotation = positions[idx].rotation
# Strip interactivity/networking from a display-only copy: it's not spawned
# through NetworkManager, so it must never try to sync (its NetPickable child,
# if any, would have no corresponding replicated identity on other peers) or
# be grabbable/collidable.
func _make_cosmetic(visual: Node3D) -> void:
var net_pickable := visual.get_node_or_null("NetPickable")
if net_pickable:
# Detach and free it outright rather than queue_free(): this runs before
# `visual` is added to the tree, and a merely-queued node still enters the
# tree with its parent and runs _ready() (which starts syncing and logging)
# before the queued deletion lands at the end of the frame.
visual.remove_child(net_pickable)
net_pickable.free()
if visual is RigidBody3D:
visual.freeze = true
# STATIC, not KINEMATIC: a kinematic body is still driven by the physics
# engine (see _remove_from_physics), and "static decoration" is what this
# actually is.
visual.freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
visual.collision_layer = 0
visual.collision_mask = 0
if visual is XRToolsPickable:
visual.enabled = false
visual.set_process(false)
visual.set_physics_process(false)
# Take a display-only copy out of the physics simulation completely.
#
# Freezing is not enough. Under Jolt (this project's physics engine) a frozen
# KINEMATIC RigidBody3D is still simulated: it is driven toward its target
# transform by velocity rather than being teleported. Parented to a plate that
# gets picked up and carried, it therefore lags behind, keeps its velocity, and
# overshoots — so the food visibly slid off the plate and ended up metres away,
# differently on each peer since each simulates its own copy. A body with no
# space is never touched by the engine, so it simply follows its parent.
func _remove_from_physics(visual: Node3D) -> void:
if visual is RigidBody3D:
PhysicsServer3D.body_set_space((visual as RigidBody3D).get_rid(), RID())
#plate (Pickalbe) #plate (Pickalbe)
+74 -12
View File
@@ -2,17 +2,13 @@
[ext_resource type="Script" uid="uid://bfrlpbsqg5lqr" path="res://addons/godot-xr-tools/objects/pickable.gd" id="1_nq5sa"] [ext_resource type="Script" uid="uid://bfrlpbsqg5lqr" path="res://addons/godot-xr-tools/objects/pickable.gd" id="1_nq5sa"]
[ext_resource type="PackedScene" uid="uid://du31pqeytu8as" path="res://Containers/container.tscn" id="1_s5yhh"] [ext_resource type="PackedScene" uid="uid://du31pqeytu8as" path="res://Containers/container.tscn" id="1_s5yhh"]
[ext_resource type="Script" uid="uid://iqjqsk4v6qfh" path="res://Containers/plate_controller.gd" id="1_vjsmi"]
[ext_resource type="PackedScene" uid="uid://c25yxb0vt53vc" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_left.tscn" id="2_3bkoa"] [ext_resource type="PackedScene" uid="uid://c25yxb0vt53vc" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_left.tscn" id="2_3bkoa"]
[ext_resource type="Animation" uid="uid://db62hs5s4n2b3" path="res://addons/godot-xr-tools/hands/animations/left/Grip 4.res" id="3_6v47p"] [ext_resource type="Animation" uid="uid://db62hs5s4n2b3" path="res://addons/godot-xr-tools/hands/animations/left/Grip 4.res" id="3_6v47p"]
[ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_dhtvl"] [ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_dhtvl"]
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vlcpl"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vlcpl"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_el51w"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_el51w"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="7_net"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_kek77"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_kek77"]
height = 0.0635376 height = 0.0635376
@@ -28,12 +24,35 @@ script = ExtResource("4_dhtvl")
closed_pose = ExtResource("6_el51w") closed_pose = ExtResource("6_el51w")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8" metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_vlqg6"]
albedo_color = Color(0.36656043, 0.16690676, 0.12531222, 1)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_plate"]
properties/0/path = NodePath(".:position")
properties/0/spawn = false
properties/0/replication_mode = 1
properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
properties/3/path = NodePath("PlateController:contained_ids")
properties/3/spawn = true
properties/3/replication_mode = 1
properties/4/path = NodePath("PlateController:is_dirty")
properties/4/spawn = true
properties/4/replication_mode = 1
[node name="Plate" type="RigidBody3D" unique_id=190487773] [node name="Plate" type="RigidBody3D" unique_id=190487773]
collision_layer = 4 collision_layer = 4
collision_mask = 196615 collision_mask = 196615
freeze_mode = 1 freeze_mode = 1
script = ExtResource("1_nq5sa") script = ExtResource("1_nq5sa")
[node name="PlateController" type="Node" parent="." unique_id=1503264193]
script = ExtResource("1_vjsmi")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1365637864] [node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1365637864]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.007171631, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.007171631, 0)
shape = SubResource("CylinderShape3D_kek77") shape = SubResource("CylinderShape3D_kek77")
@@ -67,12 +86,6 @@ hand_pose = SubResource("Resource_2hcq0")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.16517048, 0.042291984, -0.08604182) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.16517048, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_dlngj") hand_pose = SubResource("Resource_dlngj")
[node name="NetPickable" type="Node" parent="."]
script = ExtResource("7_net")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("Repl_item")
[node name="Container" parent="." unique_id=377793422 node_paths=PackedStringArray("meal_positions", "side_positions") instance=ExtResource("1_s5yhh")] [node name="Container" parent="." unique_id=377793422 node_paths=PackedStringArray("meal_positions", "side_positions") instance=ExtResource("1_s5yhh")]
target_group = "platalbe_item" target_group = "platalbe_item"
meal_positions = [NodePath("MealPositions/Slot1")] meal_positions = [NodePath("MealPositions/Slot1")]
@@ -95,3 +108,52 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.08457637, 0, 0.08950478)
[node name="Slot4" type="Node3D" parent="Container/SidePositions" unique_id=379534402] [node name="Slot4" type="Node3D" parent="Container/SidePositions" unique_id=379534402]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06938871, 0, 0.072598234) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06938871, 0, 0.072598234)
[node name="Dirty" type="Node3D" parent="." unique_id=319270152]
visible = false
[node name="CSGCylinder3D" type="CSGCylinder3D" parent="Dirty" unique_id=1075901752]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.074060306, -1.4901161e-08, -0.021821946)
radius = 0.02
height = 0.005
sides = 16
material = SubResource("StandardMaterial3D_vlqg6")
[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="Dirty" unique_id=1566886557]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.035480514, 0, 0.09590546)
radius = 0.04
height = 0.005
sides = 16
material = SubResource("StandardMaterial3D_vlqg6")
[node name="CSGCylinder3D3" type="CSGCylinder3D" parent="Dirty" unique_id=1761966340]
transform = Transform3D(0.9644101, 0.26441094, 0, -0.26441094, 0.9644101, 0, 0, 0, 1, -0.11377221, 0.005669184, 0.010442272)
radius = 0.05
height = 0.005
sides = 16
material = SubResource("StandardMaterial3D_vlqg6")
[node name="CSGCylinder3D4" type="CSGCylinder3D" parent="Dirty" unique_id=1869071428]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.023516195, 0, -0.088724144)
radius = 0.024
height = 0.005
sides = 16
material = SubResource("StandardMaterial3D_vlqg6")
[node name="CSGTorus3D" type="CSGTorus3D" parent="Dirty" unique_id=753275027]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0009794924, -0.005010009, -0.0004204195)
inner_radius = 0.10664626
outer_radius = 0.14543402
sides = 16
material = SubResource("StandardMaterial3D_vlqg6")
[node name="CSGPolygon3D" type="CSGPolygon3D" parent="Dirty" unique_id=280929999]
transform = Transform3D(0.21189868, 0, 0, 0, -9.2623855e-09, 0.21189868, 0, -0.21189868, -9.2623855e-09, -0.04347625, 0, 0.017095774)
polygon = PackedVector2Array(-0.053057775, 0.2586278, 0.09469998, 0.40791017, 0.31746316, 0.26044407, 0.4514293, -0.05809097, 0.105977096, -0.11683442, -4.3913722e-05, 0.02302903)
depth = 0.02
material = SubResource("StandardMaterial3D_vlqg6")
[node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_plate")
script = ExtResource("20_netpk")
+48
View File
@@ -0,0 +1,48 @@
class_name PlateController
extends Node
@onready var dirty_node: Node3D = $"../Dirty"
@onready var container: ItemContainer = $"../Container"
@export var is_dirty: bool = false
## Synced plate contents (item ids), replacing reparented child nodes so
## contents survive replication (a MultiplayerSpawner-tracked item would
## despawn on every client the instant it was reparented out of WorldContent).
## Server writes it via ItemContainer; every peer (server included) renders
## the cosmetic result via the setter below.
@export var contained_ids: Array[String] = []: set = _set_contained_ids
func _ready() -> void:
if not dirty_node:
push_error("Plate is missing its dirty_node")
dirty_node.visible = is_dirty
func _process(_delta: float) -> void:
if is_dirty:
container.enabled = false
dirty_node.visible = true
else:
container.enabled = true
dirty_node.visible = false
func _set_contained_ids(value: Array[String]) -> void:
# Only rebuild when the contents actually changed. This property is
# replicated in ALWAYS mode, so the synchronizer assigns it every network
# tick on every peer that doesn't own the plate — and refresh_visuals()
# frees and re-instantiates a scene per item each time. That was thousands
# of throwaway nodes per run (and a log line from each one's NetPickable).
if contained_ids == value:
return
contained_ids = value
# Deferred: this can be written by the replicated spawn payload before
# this node's own @onready vars (container) have resolved.
_refresh_visuals.call_deferred()
func _refresh_visuals() -> void:
if container:
container.refresh_visuals(contained_ids)
+1
View File
@@ -0,0 +1 @@
uid://iqjqsk4v6qfh
+25
View File
@@ -0,0 +1,25 @@
class_name GameManager
extends Node
static var money: int
static var meals_in_play: Array[String]
static var sides_in_play: Array[String]
static func get_random_meal() -> String:
if meals_in_play.size() == 0:
push_error("GameManager: get_random_meal() called but meals_in_play is empty")
return ""
var rand_index = randi() % meals_in_play.size()
print("GameManager: get_random_meal() returning ", meals_in_play[rand_index])
return meals_in_play[rand_index]
static func get_random_side() -> String:
if sides_in_play.size() == 0:
push_error("GameManager: get_random_side() called but sides_in_play is empty")
return ""
var rand_index = randi() % sides_in_play.size()
print("GameManager: get_random_side() returning ", sides_in_play[rand_index])
return sides_in_play[rand_index]
+1
View File
@@ -0,0 +1 @@
uid://y4sol8l8vih1
+22 -10
View File
@@ -7,8 +7,8 @@
[ext_resource type="PackedScene" uid="uid://c25yxb0vt53vc" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_left.tscn" id="5_wqyr2"] [ext_resource type="PackedScene" uid="uid://c25yxb0vt53vc" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_left.tscn" id="5_wqyr2"]
[ext_resource type="Animation" uid="uid://bediglpx0rj7i" path="res://addons/godot-xr-tools/hands/animations/left/Grip 5.res" id="6_ftab3"] [ext_resource type="Animation" uid="uid://bediglpx0rj7i" path="res://addons/godot-xr-tools/hands/animations/left/Grip 5.res" id="6_ftab3"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_wb51u"] [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_wb51u"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="8_wqyr2"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="8_t9y3x"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="9_wb51u"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_vlqg6"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_vlqg6"]
height = 0.10708985 height = 0.10708985
@@ -27,11 +27,16 @@ metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hoqox"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hoqox"]
albedo_color = Color(0.6784191, 0.54546416, 0.016760282, 1) albedo_color = Color(0.6784191, 0.54546416, 0.016760282, 1)
[sub_resource type="Resource" id="Resource_hwyyd"] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_buns"]
script = ExtResource("8_wqyr2") properties/0/path = NodePath(".:position")
ingredient_id = &"cooked_burger" properties/0/spawn = false
result = ExtResource("9_wb51u") properties/0/replication_mode = 1
metadata/_custom_type_script = "uid://coidnv8b2yvxr" properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="BurgerBuns" unique_id=1088240294 instance=ExtResource("1_g1t48")] [node name="BurgerBuns" unique_id=1088240294 instance=ExtResource("1_g1t48")]
@@ -74,6 +79,13 @@ sides = 16
cone = true cone = true
material = SubResource("StandardMaterial3D_hoqox") material = SubResource("StandardMaterial3D_hoqox")
[node name="CombinableItem" parent="." index="4" unique_id=996936271 instance=ExtResource("7_wb51u")] [node name="FoodItem" parent="." index="4" unique_id=123456789 instance=ExtResource("8_t9y3x")]
id = &"burger_buns" id = "burger_buns"
recipes = Array[ExtResource("8_wqyr2")]([SubResource("Resource_hwyyd")]) type = 2
[node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("7_wb51u")]
[node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_buns")
script = ExtResource("20_netpk")
+23 -11
View File
@@ -6,15 +6,11 @@
[ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_mgacb"] [ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_mgacb"]
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vw7i6"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vw7i6"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_cp3eg"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_cp3eg"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="7_net"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="7_mde49"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="8_wmrff"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"]
custom_solver_bias = 0.1
height = 0.1 height = 0.1
radius = 0.1 radius = 0.1
@@ -37,6 +33,17 @@ script = ExtResource("4_mgacb")
closed_pose = ExtResource("6_cp3eg") closed_pose = ExtResource("6_cp3eg")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8" metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_charcoal"]
properties/0/path = NodePath(".:position")
properties/0/spawn = false
properties/0/replication_mode = 1
properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="PickableObject" unique_id=1675596942 instance=ExtResource("1_r73y2")] [node name="PickableObject" unique_id=1675596942 instance=ExtResource("1_r73y2")]
[node name="CollisionShape3D" parent="." index="0"] [node name="CollisionShape3D" parent="." index="0"]
@@ -53,8 +60,13 @@ hand_pose = SubResource("Resource_lc22d")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot") hand_pose = SubResource("Resource_qyiot")
[node name="NetPickable" type="Node" parent="." index="4"] [node name="FoodItem" parent="." index="4" unique_id=345678912 instance=ExtResource("7_mde49")]
script = ExtResource("7_net") id = "charcoal"
type = 2
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="5"] [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("8_wmrff")]
replication_config = SubResource("Repl_item")
[node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_charcoal")
script = ExtResource("20_netpk")
+17 -25
View File
@@ -7,16 +7,8 @@
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_a0a5b"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_a0a5b"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_bdp75"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_bdp75"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_bdp75"] [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_bdp75"]
[ext_resource type="PackedScene" uid="uid://cucc3gaqu2nab" path="res://Items/Charcoal.tscn" id="8_a0a5b"]
[ext_resource type="PackedScene" uid="uid://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="10_65ccv"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="11_recipe"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="11_vjw41"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="11_vjw41"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="12_net"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="BoxShape3D" id="BoxShape3D_m1xbq"] [sub_resource type="BoxShape3D" id="BoxShape3D_m1xbq"]
size = Vector3(0.1, 0.1, 0.1) size = Vector3(0.1, 0.1, 0.1)
@@ -38,11 +30,16 @@ script = ExtResource("4_hc3f7")
closed_pose = ExtResource("6_bdp75") closed_pose = ExtResource("6_bdp75")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8" metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="Resource" id="Resource_i8ixk"] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_cube"]
script = ExtResource("11_recipe") properties/0/path = NodePath(".:position")
ingredient_id = &"cube" properties/0/spawn = false
result = ExtResource("8_a0a5b") properties/0/replication_mode = 1
metadata/_custom_type_script = "uid://coidnv8b2yvxr" properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="PickableObject" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_dbtw8")] [node name="PickableObject" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_dbtw8")]
@@ -60,18 +57,13 @@ hand_pose = SubResource("Resource_lc22d")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot") hand_pose = SubResource("Resource_qyiot")
[node name="CombinableItem" parent="." index="4" unique_id=996936271 instance=ExtResource("7_bdp75")] [node name="FoodItem" parent="." index="4" unique_id=63948206 instance=ExtResource("11_vjw41")]
id = &"cube"
recipes = Array[ExtResource("11_recipe")]([SubResource("Resource_i8ixk")])
[node name="CookableItem" parent="." index="5" unique_id=280820828 instance=ExtResource("10_65ccv")]
[node name="FoodItem" parent="." index="6" unique_id=63948206 instance=ExtResource("11_vjw41")]
id = "cube" id = "cube"
type = 1 type = 1
[node name="NetPickable" type="Node" parent="." index="7"] [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("7_bdp75")]
script = ExtResource("12_net")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="8"] [node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("Repl_item") root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_cube")
script = ExtResource("20_netpk")
+21 -16
View File
@@ -7,14 +7,8 @@
[ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="5_w8sii"] [ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="5_w8sii"]
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="6_mvnl5"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="6_mvnl5"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="7_wqxjj"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="7_wqxjj"]
[ext_resource type="PackedScene" uid="uid://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="9_hwyyd"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="10_yxtxs"]
[ext_resource type="PackedScene" uid="uid://cfc4ho67u4r5e" path="res://Items/cooked_burger.tscn" id="9_r8jvx"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="12_net"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"]
height = 0.0338974 height = 0.0338974
@@ -33,7 +27,18 @@ script = ExtResource("5_w8sii")
closed_pose = ExtResource("7_wqxjj") closed_pose = ExtResource("7_wqxjj")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8" metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[node name="burger" unique_id=1675596942 instance=ExtResource("1_fco8w")] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_burger"]
properties/0/path = NodePath(".:position")
properties/0/spawn = false
properties/0/replication_mode = 1
properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="raw_burger" unique_id=1675596942 instance=ExtResource("1_fco8w")]
[node name="CollisionShape3D" parent="." index="0"] [node name="CollisionShape3D" parent="." index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.00077831745, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.00077831745, 0)
@@ -53,11 +58,11 @@ hand_pose = SubResource("Resource_lc22d")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot") hand_pose = SubResource("Resource_qyiot")
[node name="CookableItem" parent="." index="4" unique_id=280820828 instance=ExtResource("9_hwyyd")] [node name="FoodItem" parent="." index="4" unique_id=63948206 instance=ExtResource("10_yxtxs")]
turns_into = ExtResource("9_r8jvx") id = "raw_burger"
type = 2
[node name="NetPickable" type="Node" parent="." index="5"] [node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
script = ExtResource("12_net") root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_burger")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="6"] script = ExtResource("20_netpk")
replication_config = SubResource("Repl_item")
+21 -11
View File
@@ -7,9 +7,8 @@
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="6_etdv2"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="6_etdv2"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="7_rl64h"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="7_rl64h"]
[ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="10_ut7mg"] [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="10_ut7mg"]
[ext_resource type="PackedScene" uid="uid://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="11_5ch2b"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="10_zz42p"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="11_rjayc"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="11_ut7mg"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"]
height = 0.04777527 height = 0.04777527
@@ -28,11 +27,16 @@ script = ExtResource("5_ychvb")
closed_pose = ExtResource("7_rl64h") closed_pose = ExtResource("7_rl64h")
metadata/_custom_type_script = "uid://dvobm6vcfnqe8" metadata/_custom_type_script = "uid://dvobm6vcfnqe8"
[sub_resource type="Resource" id="Resource_wi1yl"] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_cookedburger"]
script = ExtResource("11_rjayc") properties/0/path = NodePath(".:position")
ingredient_id = &"burger_buns" properties/0/spawn = false
result = ExtResource("11_ut7mg") properties/0/replication_mode = 1
metadata/_custom_type_script = "uid://coidnv8b2yvxr" properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="CookedBurger" unique_id=1675596942 instance=ExtResource("1_ut7mg")] [node name="CookedBurger" unique_id=1675596942 instance=ExtResource("1_ut7mg")]
@@ -54,7 +58,13 @@ hand_pose = SubResource("Resource_lc22d")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot") hand_pose = SubResource("Resource_qyiot")
[node name="CombinableItem" parent="." index="4" unique_id=434304805 instance=ExtResource("10_ut7mg")] [node name="FoodItem" parent="." index="4" unique_id=234567891 instance=ExtResource("10_zz42p")]
recipes = Array[ExtResource("11_rjayc")]([SubResource("Resource_wi1yl")]) id = "cooked_burger"
type = 2
[node name="CookableItem" parent="." index="5" unique_id=280820828 instance=ExtResource("11_5ch2b")] [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("10_ut7mg")]
[node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_cookedburger")
script = ExtResource("20_netpk")
+20 -1
View File
@@ -7,6 +7,7 @@
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_dunns"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_dunns"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_fh0f6"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_fh0f6"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="7_yy3y8"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="7_yy3y8"]
[ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"]
height = 0.10392761 height = 0.10392761
@@ -28,8 +29,19 @@ albedo_color = Color(0.6784191, 0.54546416, 0.016760282, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_6l01i"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_6l01i"]
albedo_color = Color(0.29, 0.101500005, 0, 1) albedo_color = Color(0.29, 0.101500005, 0, 1)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_hamburger"]
properties/0/path = NodePath(".:position")
properties/0/spawn = false
properties/0/replication_mode = 1
properties/1/path = NodePath(".:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("NetPickable:net_held_by")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="Hamburger" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_3gf3l")] [node name="Hamburger" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_3gf3l")]
gravity_scale = 0.4 gravity_scale = 0.04
[node name="CollisionShape3D" parent="." index="0"] [node name="CollisionShape3D" parent="." index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.02, 0.02, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.02, 0.02, 0)
@@ -78,4 +90,11 @@ sides = 16
material = SubResource("StandardMaterial3D_6l01i") material = SubResource("StandardMaterial3D_6l01i")
[node name="FoodItem" parent="." index="5" unique_id=63948206 instance=ExtResource("7_yy3y8")] [node name="FoodItem" parent="." index="5" unique_id=63948206 instance=ExtResource("7_yy3y8")]
id = "hamburger"
type = 0 type = 0
sell_value = 4
[node name="NetPickable" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_hamburger")
script = ExtResource("20_netpk")
-49
View File
@@ -1,49 +0,0 @@
extends Control
## The 2D UI rendered inside the world-space menu (XRToolsViewport2DIn3D).
## Lets the player type an IP on a numeric keypad and pick Host / Join / Solo.
## Pure UI: it emits intents; main.gd performs the networking.
signal host_pressed()
signal join_pressed(ip: String)
signal solo_pressed()
#var _ip := "127.0.0.1"
var _ip := "51.175.10.161"
@onready var _ip_label: Label = %IPLabel
@onready var _status: Label = %Status
@onready var _keypad: GridContainer = %Keypad
@onready var _host_btn: Button = %HostButton
@onready var _join_btn: Button = %JoinButton
@onready var _solo_btn: Button = %SoloButton
func _ready() -> void:
for child in _keypad.get_children():
if child is Button:
child.pressed.connect(_on_key.bind(child.text))
_host_btn.pressed.connect(func(): host_pressed.emit())
_join_btn.pressed.connect(func(): join_pressed.emit(_ip))
_solo_btn.pressed.connect(func(): solo_pressed.emit())
_refresh()
func _on_key(key: String) -> void:
match key:
"DEL":
_ip = _ip.substr(0, max(0, _ip.length() - 1))
_:
if _ip.length() < 21:
_ip += key
_refresh()
func _refresh() -> void:
_ip_label.text = _ip if not _ip.is_empty() else "_"
## Called by main.gd (via network_menu) to show connection feedback.
func set_status(text: String) -> void:
if _status:
_status.text = text
-1
View File
@@ -1 +0,0 @@
uid://dvivyrspjcjgm
+209
View File
@@ -0,0 +1,209 @@
extends MultiplayerSynchronizer
## Networked sync component for a pickable item. Added as a child literally
## named "NetPickable" (network_manager.gd's authority RPCs already expect
## this) of every net-synced pickable, replicating its transform and held
## state. Only the current multiplayer authority (the server while loose, or
## whichever peer is holding it) actually simulates physics for the item;
## every other peer freezes their local copy and just follows the synced
## transform.
## 0 = loose/server-simulated; otherwise the peer id currently holding it.
## Replicated at spawn and on change so a late joiner sees the current holder.
var net_held_by: int = 0: set = _set_net_held_by
var _pickable: XRToolsPickable
# This item's own baked freeze_mode (e.g. plate.tscn bakes KINEMATIC, not the
# RigidBody3D default of STATIC) — captured once so it can be restored when
# this peer regains ownership, instead of getting stuck on whatever
# apply_held_state() last forced it to while non-authority.
var _original_freeze_mode: int
# Same idea for the pickable's authored `enabled` flag, which the non-authority
# branch of apply_held_state() clears while someone else is holding the item.
var _original_enabled: bool
# Whether the "our own hand still holds this" guard has already been logged for
# the current grab. apply_held_state() runs every network tick, so without this
# the guard message repeats for as long as you hold the item.
var _grab_race_logged := false
func _ready() -> void:
_pickable = get_parent() as XRToolsPickable
if not _pickable:
push_error("NetPickable must be a child of an XRToolsPickable")
return
_original_freeze_mode = _pickable.freeze_mode
_original_enabled = _pickable.enabled
_pickable.picked_up.connect(_on_picked_up)
_pickable.dropped.connect(_on_dropped)
# Deferred: the pickable root captures its own original_collision_mask/
# original_collision_layer via @onready, which runs AFTER this child's
# _ready() but BEFORE the root's _ready() body. Calling apply_held_state
# synchronously here would freeze/mask the item before that capture runs,
# permanently corrupting the "restore on drop" values.
apply_held_state.call_deferred()
func _set_net_held_by(value: int) -> void:
var old := net_held_by
net_held_by = value
if old != value and NetworkManager.is_online():
print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [
_pickable.name, old, value, _holder_desc(), get_multiplayer_authority()
])
apply_held_state()
## Puts the item in the right physics state for whether this peer currently
## owns it. Called locally after net_held_by changes, and directly by
## NetworkManager._set_item_authority right after an authority handoff.
##
## IMPORTANT: this runs on every network tick, not just on a real change.
## net_held_by is replicated in ALWAYS mode, so the synchronizer assigns it every
## tick on non-authority peers — unchanged value included — and that assignment
## lands in _set_net_held_by(), which calls this. So every branch here has to be
## idempotent and silent when there is nothing to do: otherwise each item logs a
## line and rewrites four physics properties every tick on every peer that
## doesn't own it.
func apply_held_state() -> void:
if not _pickable:
return
if not NetworkManager.is_online() or is_multiplayer_authority():
_grab_race_logged = false
# We own this item's simulation (offline, loose+server, or currently
# holding it). If it's not actively in our own hand right now, make
# sure it isn't still left frozen/collision-less from a previous
# non-authority period (e.g. right after regaining authority when a
# client released it) — while actually held, XRToolsPickable's own
# pick_up()/let_go() already manage these fields, so leave those be.
if not _pickable.is_picked_up():
var changed := _pickable.freeze_mode != _original_freeze_mode \
or _pickable.collision_mask != _pickable.original_collision_mask
if changed:
if NetworkManager.is_online():
print(
"%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [
_pickable.name, _pickable.freeze_mode, _original_freeze_mode,
_pickable.collision_mask, _pickable.original_collision_mask
]
)
_pickable.freeze_mode = _original_freeze_mode
_pickable.collision_mask = _pickable.original_collision_mask
# Unlike freeze/collision (which XRToolsPickable manages itself while
# held), `enabled` is only ever written by the non-authority branch
# below, so it must be restored here or it stays false forever: once a
# client grabbed this item, every other peer set enabled=false, and
# regaining authority left it that way. On the server that silently
# broke everything downstream — hands couldn't pick the item up again,
# and a station snap zone would "snap" it (emitting has_picked_up, so
# e.g. a plate still got marked dirty) while pick_up() bailed out on
# the disabled item, leaving the zone holding an item with no grab
# driver that then fell out of the station.
if _pickable.enabled != _original_enabled:
if NetworkManager.is_online():
print("%s: reclaiming ownership, restoring enabled %s->%s" % [
_pickable.name, _pickable.enabled, _original_enabled
])
_pickable.enabled = _original_enabled
return
# A net_held_by/position sync update can race ahead of the
# authority-handoff RPC that's about to confirm a grab we just made
# optimistically (they travel on different channels with no ordering
# guarantee). Don't let a stale sync value yank an item out of our own
# hand mid-grab — only an explicit force_release_item rejection, or
# actually losing authority for real, should end a grab we initiated.
if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup:
# Log once per grab, not once per tick.
if NetworkManager.is_online() and not _grab_race_logged:
_grab_race_logged = true
print(
"%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [
_pickable.name, net_held_by
]
)
return
_grab_race_logged = false
# Someone else owns it: stop simulating locally, just follow the sync.
if _pickable.is_picked_up():
if NetworkManager.is_online():
print(
"%s: was held by %s on this peer, but authority now says peer %d owns it — force-dropping" % [
_pickable.name, _holder_desc(), net_held_by
]
)
_pickable.drop()
# Bail out when we're already in the follow-the-sync state. Without this the
# writes below (and the line logged with them) repeated every tick for every
# item on every non-authority peer — 90% of the log, plus four redundant
# physics-property writes per item per tick. The comparison also means we
# still re-apply if something else perturbs the state (e.g. let_go()
# restoring the collision mask after a force-drop).
var want_enabled := (net_held_by == 0)
if _pickable.freeze \
and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \
and _pickable.collision_mask == 0 \
and _pickable.enabled == want_enabled:
return
if NetworkManager.is_online():
print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by])
_pickable.freeze = true
_pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
_pickable.collision_mask = 0
_pickable.enabled = want_enabled
## Local hand grab (not a station snap zone, which is server-only): request
## authority immediately so the throw/drop can be reconciled, but let the grab
## happen instantly here rather than waiting on the round trip.
func _on_picked_up(_p) -> void:
var by := _pickable.get_picked_up_by()
if not (by is XRToolsFunctionPickup):
# e.g. a station snap zone grabbed it (server-side auto-snap, or the
# addon's own "grab out of a snap zone" shortcut mid-cascade) — not a
# player-initiated hand grab, so no authority request from here.
if NetworkManager.is_online():
print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()])
return
if NetworkManager.is_online():
print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by])
NetworkManager.request_item_authority_from(_pickable.get_path())
func _on_dropped(_p) -> void:
# Only forward if we're actually still the authority — a drop caused by
# apply_held_state() losing authority (see above) must not re-report.
if not is_multiplayer_authority():
if NetworkManager.is_online():
print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by])
return
if NetworkManager.is_online():
print("%s dropped, reporting release to server (lin=%s ang=%s)" % [
_pickable.name, _pickable.linear_velocity, _pickable.angular_velocity
])
# Send our own final transform too: we were the authority until now, and the
# server's copy may not have received our last position sync yet.
NetworkManager.release_item_authority_from(
_pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity,
_pickable.global_transform
)
## Human-readable description of what's currently holding this item on THIS
## peer, for diagnosing desyncs between a hand's own "what am I holding"
## bookkeeping and the item's actual grab state (see the snap-zone-grab race
## in NetworkManager._try_snap_into_station for a real example).
func _holder_desc() -> String:
if not _pickable or not _pickable.is_picked_up():
return "loose"
var by := _pickable.get_picked_up_by()
if not by:
return "held(no grabber?)"
if by is XRToolsFunctionPickup:
return "hand(%s)" % by.get_path()
if by is XRToolsSnapZone:
var station := by.get_parent()
return "zone(%s)" % (station.name if station else str(by.get_path()))
return "other(%s: %s)" % [by.get_class(), by.get_path()]
+1
View File
@@ -0,0 +1 @@
uid://bwd0pe2udb5xo
+254 -25
View File
@@ -24,9 +24,15 @@ signal connection_failed()
var _world: Node = null var _world: Node = null
var _players_spawner: MultiplayerSpawner = null var _players_spawner: MultiplayerSpawner = null
var _items_spawner: MultiplayerSpawner = null var _items_spawner: MultiplayerSpawner = null
var _content_root: Node = null
var _log_file: FileAccess var _log_file: FileAccess
## Reason the session ended, shown by the menu on the next _ready() (see
## take_status). Avoids depending on a live signal connection to a panel that
## doesn't exist yet at the moment the session actually ends.
var last_status := ""
func _ready() -> void: func _ready() -> void:
_open_log() _open_log()
@@ -80,26 +86,69 @@ func _go_offline() -> void:
if multiplayer.multiplayer_peer: if multiplayer.multiplayer_peer:
multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
unregister_world()
# --- Item spawning --------------------------------------------------------- # --- Item spawning ---------------------------------------------------------
## Spawn a networked item. Server-only when online (replicates to all peers via ## Spawn a networked item. Server-only when online (replicates to all peers via
## the ItemsSpawner); works directly when offline. Returns the new node on the ## the ItemsSpawner, including late joiners); works directly when offline.
## machine that owns spawning, else null. ## node_name gives the spawned node a deterministic, identical name on every
func spawn_item(scene_path: String, xform: Transform3D) -> Node: ## peer (needed for NodePath-based RPCs to resolve it); props are applied to
## the instance before it enters the tree, so exported vars land correctly.
## Returns the new node on the machine that owns spawning, else null.
func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node:
if is_online() and not is_server(): if is_online() and not is_server():
return null return null
var data := {"scene": scene_path, "xform": xform} var data := {"scene": scene_path, "xform": xform, "name": node_name, "props": props}
var via := "spawner" if (is_online() and _items_spawner) else "offline"
log_line("spawn_item: %s (name=%s, via=%s)" % [scene_path.get_file(), node_name, via])
if is_online() and _items_spawner: if is_online() and _items_spawner:
return _items_spawner.spawn(data) return _items_spawner.spawn(data)
# Offline: instantiate directly under the world. # Offline: instantiate directly under the registered content root, or (for
# scenes that never call register_world, e.g. the offline menu/dev scenes)
# the current scene, so this keeps working without every offline scene
# needing to opt in.
var inst := _spawn_item_from_data(data) var inst := _spawn_item_from_data(data)
if _world and inst: if inst:
_world.add_child(inst) var parent: Node = _content_root if _content_root else get_tree().current_scene
if parent:
parent.add_child(inst)
return inst return inst
## Despawn a server-spawned item. MultiplayerSpawner broadcasts a despawn to
## every peer when a tracked node exits the tree on the authority, so this is
## the single seam for destroying spawned items (works offline too).
func despawn_item(node: Node) -> void:
if not owns_world() or not is_instance_valid(node):
return
log_line("despawn_item: %s" % node.name)
# Items that came from the ItemsSpawner are despawned on every peer
# automatically when they leave the tree here. Items baked into a scene file
# are unknown to the spawner, so their removal has to be broadcast
# explicitly — otherwise every client keeps a ghost copy of an item the
# server has consumed, which then blocks the station it was sitting in and
# gets grabbed instead of the real item that replaced it.
if is_online() and not _is_spawner_tracked(node):
_despawn_static_item.rpc(node.get_path())
node.queue_free()
# Items the ItemsSpawner replicates live under its spawn path; anything else was
# baked into the scene file and the spawner knows nothing about it.
func _is_spawner_tracked(node: Node) -> bool:
return _content_root != null and _content_root.is_ancestor_of(node)
@rpc("authority", "call_remote", "reliable")
func _despawn_static_item(path: NodePath) -> void:
var node := get_node_or_null(path)
if node:
log_line("despawn_static_item: freeing %s (the server consumed it)" % node.name)
node.queue_free()
# MultiplayerSpawner custom spawn function: runs on every peer to build the node # MultiplayerSpawner custom spawn function: runs on every peer to build the node
# from the replicated payload. # from the replicated payload.
func _spawn_item_from_data(data: Variant) -> Node: func _spawn_item_from_data(data: Variant) -> Node:
@@ -110,50 +159,135 @@ func _spawn_item_from_data(data: Variant) -> Node:
var inst := scene.instantiate() var inst := scene.instantiate()
if inst is Node3D: if inst is Node3D:
inst.transform = data["xform"] inst.transform = data["xform"]
if data.get("name", "") != "":
inst.name = data["name"]
for key in data.get("props", {}):
inst.set(key, data["props"][key])
if not owns_world():
_gate_station(inst)
return inst return inst
# Stations run their own logic and auto-grab (XRToolsSnapZone with
# snap_mode=RANGE) identically on every peer by default, which would let each
# peer independently grab/simulate the same shared object. Disable both on
# every peer except the one that owns world logic; the server-authoritative
# item-authority RPCs are what let clients still grab a server-held item by
# hand. Runs before the node enters the tree, so its own _ready() sees the
# final (disabled) state.
func _gate_station(node: Node) -> void:
if not (node is StaticBody3D):
return
for child in node.get_children():
if child is XRToolsSnapZone:
child.enabled = false
child.set_process(false)
node.set_process(false)
log_line("gated station (non-owner peer): %s" % node.name)
## Gate every station already sitting in the scene tree, for peers that don't
## own world logic. Stations that arrive through spawn_item() are gated as they
## are built (see _spawn_item_from_data), but ones baked into a scene file never
## pass through there — leaving a client running its own snap zones, which then
## grab items straight out of the local hand and fight the server's
## authoritative placement. Idempotent, so it's safe on every session start.
func gate_existing_stations() -> void:
if owns_world():
return
for station in get_tree().get_nodes_in_group("station"):
_gate_station(station)
# --- Item grab-authority transfer ----------------------------------------- # --- Item grab-authority transfer -----------------------------------------
## A client (or host) requests authority over an item it just grabbed. Runs on ## Called by NetPickable when this peer grabs an item by hand. Godot rejects
## the server. If the item was snapped into a station, the station releases it ## rpc_id() targeting your own peer id ("RPC on yourself is not allowed"), so
## so the grabber cleanly takes ownership. ## when we ARE the server this runs the logic directly instead of round-
## tripping an RPC to ourselves — otherwise every host-side grab/drop was
## silently failing to run its server-side half (no denial checks, and
## crucially no auto-snap-into-station on release).
func request_item_authority_from(item_path: NodePath) -> void:
if is_server():
_grant_or_reject_item_authority(item_path, multiplayer.get_unique_id())
else:
_request_item_authority_rpc.rpc_id(1, item_path)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func request_item_authority(item_path: NodePath) -> void: func _request_item_authority_rpc(item_path: NodePath) -> void:
if not is_server(): if not is_server():
return return
var sender := multiplayer.get_remote_sender_id() _grant_or_reject_item_authority(item_path, multiplayer.get_remote_sender_id())
## Runs on the server (called directly if the requester IS the server, or via
## the RPC above otherwise). If the item was snapped into a station, the
## station releases it so the grabber cleanly takes ownership.
func _grant_or_reject_item_authority(item_path: NodePath, sender: int) -> void:
var item := get_node_or_null(item_path)
if item:
var np := item.get_node_or_null("NetPickable")
if np and np.net_held_by != 0 and np.net_held_by != sender:
# Already legitimately held by a different live peer: reject the
# requester's optimistic client-side grab instead of stealing it.
log_line("request_item_authority: DENIED %s to peer %d (already held by %d)" % [item.name, sender, np.net_held_by])
_force_release_item_to(sender, item_path)
return
log_line("request_item_authority: granting %s to peer %d" % [str(item.name) if item else str(item_path), sender])
# Assign authority + held state first (disables the item on the server so its # Assign authority + held state first (disables the item on the server so its
# snap zone won't re-grab it), then release it from any station. # snap zone won't re-grab it), then release it from any station.
_set_item_authority.rpc(item_path, sender) _set_item_authority.rpc(item_path, sender)
var item := get_node_or_null(item_path)
if item: if item:
_release_from_snap_zones(item) _release_from_snap_zones(item)
## A player releases an item, forwarding its throw velocity so the server can ## Called by NetPickable when this peer releases an item, forwarding its throw
## resume simulating it. Runs on the server. If released next to a station, the ## velocity so the server can resume simulating it. Same self-RPC issue as
## server snaps it in (server-authoritative placement). ## above: runs directly if we're the server.
func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
if is_server():
_do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_unique_id())
else:
_release_item_authority_rpc.rpc_id(1, item_path, lin, ang, xform)
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3) -> void: func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
if not is_server(): if not is_server():
return return
_do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_remote_sender_id())
## Runs on the server. If released next to a station, the server snaps it in
## (server-authoritative placement).
func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D, sender: int) -> void:
log_line("release_item_authority: %s released by peer %d" % [str(item_path), sender])
_set_item_authority.rpc(item_path, 1) _set_item_authority.rpc(item_path, 1)
var item := get_node_or_null(item_path) var item := get_node_or_null(item_path)
if item is RigidBody3D: if item is RigidBody3D:
# Adopt the releasing peer's own final transform rather than trusting our
# copy's. That peer was the item's authority right up to this moment, and
# its position updates travel on the synchronizer's separate, unordered
# channel — this reliable RPC routinely overtakes them, leaving our copy
# still sitting where the item was BEFORE the peer carried it away. The
# snap decision below then reads that stale position and teleports the
# item straight back into the station it was just picked up from.
item.global_transform = xform
item.freeze = false item.freeze = false
item.linear_velocity = lin item.linear_velocity = lin
item.angular_velocity = ang item.angular_velocity = ang
_try_snap_into_station(item) _try_snap_into_station.call_deferred(item)
# All station snap zones in the world (nodes in the "station" group). # All station snap zones in the world (every XRToolsSnapZone child of a node in
# the "station" group — some stations, e.g. Table, have more than one).
func _station_snap_zones() -> Array: func _station_snap_zones() -> Array:
var zones := [] var zones := []
for station in get_tree().get_nodes_in_group("station"): for station in get_tree().get_nodes_in_group("station"):
var zone = station.get_node_or_null("XRToolsSnapZone") for child in station.get_children():
if zone: if child is XRToolsSnapZone:
zones.append(zone) zones.append(child)
return zones return zones
@@ -161,19 +295,41 @@ func _station_snap_zones() -> Array:
func _release_from_snap_zones(item: Node) -> void: func _release_from_snap_zones(item: Node) -> void:
for zone in _station_snap_zones(): for zone in _station_snap_zones():
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])
zone.drop_object() zone.drop_object()
# 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.
#
# Called deferred from _do_release_item_authority: XRToolsFunctionPickup's own
# "grab an item out of a snap zone" path calls zone.drop_object() BEFORE it
# calls pick_up() on the hand's behalf. drop_object()'s let_go() synchronously
# fires the pickable's `dropped` signal, which (via NetPickable) lands here —
# if this ran synchronously it would immediately re-snap the item into the
# very same zone it's still physically inside, stealing it away before the
# hand's own pick_up() call (later in the same call stack) ever runs. That
# leaves XRToolsFunctionPickup.picked_up_object pointing at an item whose
# _grab_driver actually belongs to the zone — a stale reference that crashes
# (null _grab_driver) the next time a controller button is pressed. Deferring
# lets the hand's pick_up() go first; the is_picked_up() check below is a
# second guard in case the item gets grabbed for real before this runs.
func _try_snap_into_station(item: Node) -> void: func _try_snap_into_station(item: Node) -> void:
if not (item is Node3D): if not (item is Node3D):
return return
if item.has_method("is_picked_up") and item.is_picked_up():
var by: Node = null
if item.has_method("get_picked_up_by"):
by = item.get_picked_up_by()
log_line("skipped snapping %s: already held by %s (grab-race guard)" % [item.name, by.get_path() if by else "?"])
return
for zone in _station_snap_zones(): for zone in _station_snap_zones():
if is_instance_valid(zone.picked_up_object): if is_instance_valid(zone.picked_up_object):
continue continue
if zone.global_position.distance_to(item.global_position) <= zone.grab_distance: if zone.global_position.distance_to(item.global_position) <= zone.grab_distance:
log_line("snapped %s into %s" % [item.name, zone.get_parent().name])
zone.pick_up_object(item) zone.pick_up_object(item)
return return
log_line("no station in range to snap %s into (or none empty)" % item.name)
# Server broadcasts an authority assignment so every peer agrees on who owns the # Server broadcasts an authority assignment so every peer agrees on who owns the
@@ -183,6 +339,7 @@ func _set_item_authority(item_path: NodePath, peer: int) -> void:
var item := get_node_or_null(item_path) var item := get_node_or_null(item_path)
if not item: if not item:
return return
log_line("_set_item_authority: %s -> peer %d" % [item.name, peer])
item.set_multiplayer_authority(peer) # recursive: item + synchronizer + NetPickable item.set_multiplayer_authority(peer) # recursive: item + synchronizer + NetPickable
var np := item.get_node_or_null("NetPickable") var np := item.get_node_or_null("NetPickable")
if np: if np:
@@ -190,6 +347,28 @@ func _set_item_authority(item_path: NodePath, peer: int) -> void:
np.apply_held_state() np.apply_held_state()
## Rejects peer's optimistic grab (the item was already legitimately held by
## someone else). Same self-RPC concern: if the rejected peer is the server
## itself, apply it directly rather than rpc_id-ing ourselves.
func _force_release_item_to(peer: int, item_path: NodePath) -> void:
if peer == 1:
_do_force_release(item_path)
else:
force_release_item.rpc_id(peer, item_path)
@rpc("authority", "reliable")
func force_release_item(item_path: NodePath) -> void:
_do_force_release(item_path)
func _do_force_release(item_path: NodePath) -> void:
log_line("force_release_item: dropping %s (server rejected our grab)" % str(item_path))
var item := get_node_or_null(item_path)
if item and item.has_method("drop"):
item.drop()
func is_server() -> bool: func is_server() -> bool:
return is_online() and multiplayer.is_server() return is_online() and multiplayer.is_server()
@@ -220,23 +399,71 @@ func submit_work(station_path: NodePath, amount: float) -> void:
return return
var station := get_node_or_null(station_path) var station := get_node_or_null(station_path)
if station and station.has_method("add_work"): if station and station.has_method("add_work"):
log_line("submit_work: peer %d contributed %.2f to %s" % [multiplayer.get_remote_sender_id(), amount, station.name])
station.add_work(multiplayer.get_remote_sender_id(), amount) station.add_work(multiplayer.get_remote_sender_id(), amount)
## Called by main.gd once the world scene is ready, passing its spawners. ## Called by the world scene once it's ready, passing its spawners. Must run
## before world_ready()/host()/join() on every peer so the custom spawn
## function is installed before any spawn packet can arrive.
func register_world(world: Node, players_spawner: MultiplayerSpawner, items_spawner: MultiplayerSpawner) -> void: func register_world(world: Node, players_spawner: MultiplayerSpawner, items_spawner: MultiplayerSpawner) -> void:
_world = world _world = world
_players_spawner = players_spawner _players_spawner = players_spawner
_items_spawner = items_spawner _items_spawner = items_spawner
_content_root = items_spawner.get_node(items_spawner.spawn_path) if items_spawner else world
if _items_spawner: if _items_spawner:
_items_spawner.spawn_function = _spawn_item_from_data _items_spawner.spawn_function = _spawn_item_from_data
log_line("World registered (players_spawner=%s items_spawner=%s)" % [str(players_spawner != null), str(items_spawner != null)]) log_line("World registered (players_spawner=%s items_spawner=%s)" % [str(players_spawner != null), str(items_spawner != null)])
## Called when the world scene goes away (disconnect, leaving the session) so
## the autoload doesn't hold stale/freed references across a scene reload.
func unregister_world() -> void:
_world = null
_content_root = null
_players_spawner = null
_items_spawner = null
## Returns the reason the last session ended (if any) and clears it. The menu
## pulls this on its own _ready() rather than depending on a live signal
## connection to a panel that doesn't exist yet when the session ends.
func take_status() -> String:
var s := last_status
last_status = ""
return s
## Called by main.gd after it has registered the world and connected its ## Called by main.gd after it has registered the world and connected its
## player_joined/left listeners. Kicks off any command-line driven session so ## player_joined/left listeners. Kicks off any menu- or command-line-driven
## that session signals never fire before the world is listening. ## session so that session signals never fire before the world is listening.
func world_ready() -> void: func world_ready() -> void:
consume_pending_session()
# --- Menu-driven session request -------------------------------------------
# Set by the main menu's Host/Join buttons before switching to the multiplayer
# scene; consumed once that scene's world is ready to listen for session
# signals (avoids a race between change_scene_to_file and connection callbacks).
var pending_action := ""
var pending_ip := ""
func request_host() -> void:
pending_action = "host"
func request_join(ip: String) -> void:
pending_action = "join"
pending_ip = ip
func consume_pending_session() -> void:
if pending_action == "host":
pending_action = ""
host()
elif pending_action == "join":
pending_action = ""
join(pending_ip)
else:
_handle_cmdline() _handle_cmdline()
@@ -260,11 +487,13 @@ func _on_connected_to_server() -> void:
func _on_connection_failed() -> void: func _on_connection_failed() -> void:
log_line("connection_failed") log_line("connection_failed")
_go_offline() _go_offline()
last_status = "Could not connect"
connection_failed.emit() connection_failed.emit()
func _on_server_disconnected() -> void: func _on_server_disconnected() -> void:
log_line("server_disconnected") log_line("server_disconnected")
_go_offline() _go_offline()
last_status = "Host disconnected"
session_ended.emit() session_ended.emit()
-37
View File
@@ -1,37 +0,0 @@
extends Node3D
## World-space main menu: hosts the 2D join_panel on an XRToolsViewport2DIn3D so
## the player can point at it with a controller laser. Re-emits the panel's
## intents; main.gd drives the actual networking.
signal host_pressed()
signal join_pressed(ip: String)
signal solo_pressed()
@onready var _vp: XRToolsViewport2DIn3D = $Viewport2Din3D
func _ready() -> void:
# The viewport instantiates its 2D scene during its own _ready; connect once
# that has happened.
_connect_scene.call_deferred()
func _connect_scene() -> void:
_vp.connect_scene_signal("host_pressed", func(): host_pressed.emit())
_vp.connect_scene_signal("join_pressed", func(ip): join_pressed.emit(ip))
_vp.connect_scene_signal("solo_pressed", func(): solo_pressed.emit())
func show_menu() -> void:
visible = true
func hide_menu() -> void:
visible = false
func set_status(text: String) -> void:
var scene := _vp.get_scene_instance()
if scene and scene.has_method("set_status"):
scene.set_status(text)
-1
View File
@@ -1 +0,0 @@
uid://1o2nca75po1f
-13
View File
@@ -1,13 +0,0 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://Net/network_menu.gd" id="1_menu"]
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="2_vp"]
[ext_resource type="PackedScene" path="res://Net/join_panel.tscn" id="3_panel"]
[node name="NetworkMenu" type="Node3D"]
script = ExtResource("1_menu")
[node name="Viewport2Din3D" parent="." instance=ExtResource("2_vp")]
screen_size = Vector2(0.6, 0.4)
scene = ExtResource("3_panel")
viewport_size = Vector2(600, 400)
+81
View File
@@ -0,0 +1,81 @@
class_name WorldLayout
## Data-driven description of what's in the multiplayer world. The server (or
## the single machine, when offline) spawns every entry through
## NetworkManager.spawn_item() instead of baking these into the scene file, so
## a joining client receives them from the server rather than assuming its own
## copy of the scene matches. Swapping the contents of these two functions is
## the only change needed for a future varying/procedural layout.
##
## Positions below are transcribed verbatim from the previous baked layout in
## Scenes/multiPlayer.tscn so the starting world is unchanged.
static func get_stations() -> Array[Dictionary]:
return [
{"scene": "res://Stations/Hob.tscn", "name": "Hob",
"xform": Transform3D(Basis(), Vector3(0.29336345, 0.8981018, -1.484)), "props": {}},
{"scene": "res://Stations/BurgerBunsDispenser.tscn", "name": "BurgerBunsDispenser",
"xform": Transform3D(Basis(), Vector3(-1.832131, 0.40028095, -1.4813508)), "props": {}},
{"scene": "res://Stations/sink.tscn", "name": "Sink",
"xform": Transform3D(Basis(), Vector3(1.3140475, 0.9061539, -1.4941733)), "props": {}},
{"scene": "res://Stations/dirt_station.tscn", "name": "DirtStation",
"xform": Transform3D(Basis(), Vector3(2.0864775, 0.8981018, -1.244947)), "props": {}},
{"scene": "res://Stations/Counter.tscn", "name": "Counter",
"xform": Transform3D(Basis(), Vector3(-0.7124918, 0.90304357, -1.4886917)), "props": {}},
{"scene": "res://Stations/Counter.tscn", "name": "Counter2",
"xform": Transform3D(Vector3(-4.371139e-08, 0.0, 1.0), Vector3(0.0, 1.0, 0.0), Vector3(-1.0, 0.0, -4.371139e-08), Vector3(-1.7648025, 0.90304357, -0.4458799)), "props": {}},
{"scene": "res://Stations/Counter.tscn", "name": "Counter3",
"xform": Transform3D(Vector3(-4.371139e-08, 0.0, 1.0), Vector3(0.0, 1.0, 0.0), Vector3(-1.0, 0.0, -4.371139e-08), Vector3(-1.7648025, 0.90304357, 0.55367994)), "props": {}},
{"scene": "res://Stations/Counter.tscn", "name": "Counter4",
"xform": Transform3D(Vector3(-4.371139e-08, 0.0, 1.0), Vector3(0.0, 1.0, 0.0), Vector3(-1.0, 0.0, -4.371139e-08), Vector3(-1.7648025, 0.90304357, 1.5539298)), "props": {}},
{"scene": "res://Stations/table.tscn", "name": "Table",
"xform": Transform3D(Basis(), Vector3(1.633146, 0.9030438, 1.1343781)),
"props": {
"initial_thinking_time": 8.0,
"initial_primary_time": 40.0,
"initial_friend_time": 3.0,
"initial_eating_time": 3.0,
}},
]
static func get_items() -> Array[Dictionary]:
var items: Array[Dictionary] = []
items.append(_item("res://Items/BurgerBuns.tscn", "BurgerBuns", Vector3(-1.8162017, 1.6081157, -1.4714175)))
items.append(_item("res://Items/BurgerBuns.tscn", "BurgerBuns2", Vector3(-1.3596323, 1.4110342, -0.22482127)))
items.append(_item("res://Containers/plate.tscn", "Plate", Vector3(-1.5717233, 1.5454081, 1.5373346)))
items.append(_item("res://Containers/plate.tscn", "Plate2", Vector3(-1.5730225, 1.499024, 0.59790254)))
items.append(_item("res://Containers/plate.tscn", "Plate3", Vector3(-1.5818124, 1.499024, -0.4634577)))
items.append(_item("res://Items/burger.tscn", "burger", Vector3(0.6888188, 1.4195822, -1.7110313)))
items.append(_item("res://Items/burger.tscn", "burger2", Vector3(0.6931299, 1.5300478, -1.71225)))
items.append(_item("res://Items/burger.tscn", "burger3", Vector3(0.69027674, 1.4969791, -1.7210286)))
items.append(_item("res://Items/burger.tscn", "burger4", Vector3(0.69134104, 1.4543622, -1.7210286)))
items.append(_item("res://Items/hamburger.tscn", "Hamburger", Vector3(-1.9352558, 1.623975, 1.2447833)))
items.append(_item("res://Items/hamburger.tscn", "Hamburger2", Vector3(-1.9857153, 1.5329368, 0.9472374)))
items.append(_item("res://Items/hamburger.tscn", "Hamburger3", Vector3(-1.7201865, 1.5329367, 0.14773655)))
items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger", Vector3(-0.30671906, 1.4131018, -1.0928738)))
items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger2", Vector3(-0.30671906, 1.4496142, -1.0928738)))
items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger3", Vector3(-1.3344773, 1.4398065, -0.7096845)))
items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger4", Vector3(-0.30671906, 1.525444, -1.0928738)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject", Vector3(0.6225724, 1.4792972, -1.0473135)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject2", Vector3(0.6359743, 1.4639391, -1.1673055)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject3", Vector3(0.5142721, 1.4792972, -1.0473135)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject4", Vector3(0.5276739, 1.4639391, -1.1673055)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject5", Vector3(0.73287535, 1.4792972, -1.0473135)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject6", Vector3(0.7462772, 1.4639391, -1.1673055)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject7", Vector3(-1.3556751, 1.4792972, 0.28881657)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject8", Vector3(-1.3422732, 1.4639391, 0.16882455)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject9", Vector3(-1.4639754, 1.4792972, 0.28881657)))
items.append(_item("res://Items/PickupCube.tscn", "PickableObject10", Vector3(-1.4505737, 1.4639391, 0.16882455)))
return items
static func _item(scene: String, name: String, pos: Vector3) -> Dictionary:
return {"scene": scene, "name": name, "xform": Transform3D(Basis(), pos), "props": {}}
+1
View File
@@ -0,0 +1 @@
uid://donvkica3drtx
-48
View File
@@ -1,48 +0,0 @@
[gd_scene load_steps=7 format=3 uid="uid://cnetplayer0001"]
[ext_resource type="Script" path="res://Player/player.gd" id="1_player"]
[sub_resource type="SceneReplicationConfig" id="Repl_player"]
properties/0/path = NodePath(".:head_xform")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:left_xform")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:right_xform")
properties/2/spawn = true
properties/2/replication_mode = 1
properties/3/path = NodePath(".:net_heartbeat")
properties/3/spawn = true
properties/3/replication_mode = 1
[sub_resource type="StandardMaterial3D" id="Mat_head"]
albedo_color = Color(0.85, 0.7, 0.55, 1)
[sub_resource type="BoxMesh" id="Mesh_head"]
material = SubResource("Mat_head")
size = Vector3(0.18, 0.24, 0.16)
[sub_resource type="StandardMaterial3D" id="Mat_hand"]
albedo_color = Color(0.2, 0.5, 0.9, 1)
[sub_resource type="BoxMesh" id="Mesh_hand"]
material = SubResource("Mat_hand")
size = Vector3(0.09, 0.05, 0.12)
[node name="Player" type="Node3D"]
script = ExtResource("1_player")
[node name="Avatar" type="Node3D" parent="."]
[node name="Head" type="MeshInstance3D" parent="Avatar"]
mesh = SubResource("Mesh_head")
[node name="LeftHand" type="MeshInstance3D" parent="Avatar"]
mesh = SubResource("Mesh_hand")
[node name="RightHand" type="MeshInstance3D" parent="Avatar"]
mesh = SubResource("Mesh_hand")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("Repl_player")
+71
View File
@@ -0,0 +1,71 @@
extends Node3D
## Networked representation of one connected player. The owning peer's local
## XR rig (found via the "local_xr_origin" group) drives Head/LeftHand/
## RightHand each frame; the MultiplayerSynchronizer replicates those
## transforms so every other peer sees a matching avatar.
@onready var _head: Node3D = $Head
@onready var _left_hand: Node3D = $LeftHand
@onready var _right_hand: Node3D = $RightHand
var _local_camera: Node3D
var _local_left_hand: Node3D
var _local_right_hand: Node3D
func _enter_tree() -> void:
# MultiplayerSynchronizer resolves authority when this node enters the
# tree, so authority must be set here rather than in _ready(), or the
# first synced frames go the wrong direction.
set_multiplayer_authority(str(name).to_int())
func _ready() -> void:
# LeftHand/RightHand are the full godot-xr-tools glove scenes, whose root
# node carries hand.gd (XRToolsHand). That script sets top_level = true
# and every physics frame does global_transform = get_parent().
# global_transform * offset — i.e. it actively repositions itself to
# track a live XRController3D parent. Here the parent is just this
# NetPlayer node, not a controller, so left running it fights (and mostly
# wins, since it runs every physics tick regardless of our own _process)
# against both the local authority's transform copy below and the
# replicated values on other peers — the exact "hands stuck near origin,
# only occasionally correct" symptom. Disable it everywhere; nothing else
# in that script matters here since _controller is always null without a
# real controller ancestor (grip/trigger animation already no-ops).
_left_hand.set_physics_process(false)
_right_hand.set_physics_process(false)
if is_multiplayer_authority():
var origin := get_tree().get_first_node_in_group("local_xr_origin")
if origin:
_local_camera = origin.get_node_or_null("XRCamera3D")
_local_left_hand = origin.get_node_or_null("XRControllerLeftHand")
_local_right_hand = origin.get_node_or_null("XRControllerRightHand")
# Every peer's rig is baked at the same spot in the scene; spread
# joiners out along X so they don't start stacked on top of each
# other. Peer ids from ENet are large effectively-random 32-bit
# numbers, so this must be bounded, AND kept well within the
# floor's actual footprint (15x15, so ~7.5 units from center) —
# a previous version used up to 12 units and could place a
# joining player off the edge of the floor.
var peer_id := str(name).to_int()
if peer_id != 1:
var slot := absi(peer_id) % 4
origin.position += Vector3((slot - 1.5) * 1.2, 0, 0)
# Don't render your own floating head/hands from the inside.
_head.visible = false
_left_hand.visible = false
_right_hand.visible = false
else:
set_process(false)
func _process(_delta: float) -> void:
if _local_camera:
_head.global_transform = _local_camera.global_transform
if _local_left_hand:
_left_hand.global_transform = _local_left_hand.global_transform
if _local_right_hand:
_right_hand.global_transform = _local_right_hand.global_transform
+1
View File
@@ -0,0 +1 @@
uid://bncuxh7j7ix5q
+44
View File
@@ -0,0 +1,44 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://Player/net_player.gd" id="1_np001"]
[ext_resource type="PackedScene" uid="uid://bq86r4yll8po" path="res://addons/godot-xr-tools/hands/scenes/lowpoly/left_fullglove_low.tscn" id="2_np002"]
[ext_resource type="PackedScene" uid="uid://xqimcf20s2jp" path="res://addons/godot-xr-tools/hands/scenes/lowpoly/right_fullglove_low.tscn" id="3_np003"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh_np004"]
radius = 0.09
height = 0.22
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np005"]
properties/0/path = NodePath("Head:position")
properties/0/spawn = false
properties/0/replication_mode = 1
properties/1/path = NodePath("Head:quaternion")
properties/1/spawn = false
properties/1/replication_mode = 1
properties/2/path = NodePath("LeftHand:position")
properties/2/spawn = false
properties/2/replication_mode = 1
properties/3/path = NodePath("LeftHand:quaternion")
properties/3/spawn = false
properties/3/replication_mode = 1
properties/4/path = NodePath("RightHand:position")
properties/4/spawn = false
properties/4/replication_mode = 1
properties/5/path = NodePath("RightHand:quaternion")
properties/5/spawn = false
properties/5/replication_mode = 1
[node name="NetPlayer" type="Node3D"]
script = ExtResource("1_np001")
[node name="Head" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh_np004")
[node name="LeftHand" parent="." instance=ExtResource("2_np002")]
[node name="RightHand" parent="." instance=ExtResource("3_np003")]
[node name="Sync" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np005")
replication_interval = 0.033
-80
View File
@@ -1,80 +0,0 @@
extends Node3D
## One instance per connected peer. The owning peer is the multiplayer
## authority. The local (authority) player builds the full XR rig from
## XROrigin.tscn and feeds its head/hand poses into synced properties; remote
## players show a lightweight avatar driven by those synced poses.
const RIG_SCENE := preload("res://XROrigin.tscn")
# Synced avatar pose. Authority writes these from the XR trackers each frame;
# remotes read them onto the Avatar. net_heartbeat is a monotonic counter used
# to confirm replication is live (also handy for debugging).
@export var head_xform: Transform3D = Transform3D.IDENTITY
@export var left_xform: Transform3D = Transform3D.IDENTITY
@export var right_xform: Transform3D = Transform3D.IDENTITY
@export var net_heartbeat: int = 0
var _rig: Node3D
var _camera: Node3D
var _left: Node3D
var _right: Node3D
var _is_local := false
@onready var _avatar: Node3D = $Avatar
@onready var _avatar_head: Node3D = $Avatar/Head
@onready var _avatar_left: Node3D = $Avatar/LeftHand
@onready var _avatar_right: Node3D = $Avatar/RightHand
func _enter_tree() -> void:
# The spawner names each player by its owning peer id.
set_multiplayer_authority(str(name).to_int())
# (replication config is authored in Player.tscn's MultiplayerSynchronizer)
func _ready() -> void:
_is_local = is_multiplayer_authority()
if _is_local:
_setup_local()
else:
_setup_remote()
if Engine.has_singleton("NetworkManager") or get_node_or_null("/root/NetworkManager"):
NetworkManager.log_line("Player %s ready (authority=%d local=%s)" % [name, get_multiplayer_authority(), str(_is_local)])
func _setup_local() -> void:
# We see through the headset; hide our own avatar mesh.
_avatar.visible = false
_rig = RIG_SCENE.instantiate()
add_child(_rig)
_camera = _rig.get_node_or_null("XRCamera3D")
_left = _rig.get_node_or_null("XRControllerLeftHand")
_right = _rig.get_node_or_null("XRControllerRightHand")
_enable_xr()
func _enable_xr() -> void:
var xr := XRServer.find_interface("OpenXR")
if xr and xr.is_initialized():
get_viewport().use_xr = true
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
func _setup_remote() -> void:
_avatar.visible = true
func _process(_delta: float) -> void:
if _is_local:
if _camera:
head_xform = _camera.global_transform
left_xform = _left.global_transform
right_xform = _right.global_transform
net_heartbeat += 1
else:
_avatar_head.global_transform = head_xform
_avatar_left.global_transform = left_xform
_avatar_right.global_transform = right_xform
-1
View File
@@ -1 +0,0 @@
uid://ndrfa2xle033
+93 -14
View File
@@ -1,24 +1,103 @@
class_name CombinableItem class_name CombinableItem
extends Node extends Node
## Identity of this item, referenced by other items' recipes. const RECIPE_MANAGER = preload("res://RecipeManager.gd")
@export var id: StringName
## Recipes describing what the item holding this component turns into when @onready var combine_area_3d: Area3D = $Area3d
## another item is combined into it while snapped. Recipes are directional: @onready var _pickable: XRToolsPickable = get_parent() as XRToolsPickable
## only the snapped (base) item's recipes are consulted.
@export var recipes: Array[CombineRecipe] = [] # Guard so a combine only fires once.
var _combining: bool = false
var _food_item: FoodItem
func _ready() -> void: func _ready() -> void:
if id.is_empty(): print("CombinableItem _ready(): ", _pickable.name)
push_error("CombinableItem on ", get_parent().name, " has no 'id' set.") _food_item = get_parent().get_node_or_null("FoodItem") as FoodItem
if not _food_item:
push_error("CombinableItem is missing FoodItem reference. must be a sibling of a FoodItem on ", get_parent().name, ".")
if not _pickable:
push_error("CombineZone must be a grand child of an XRToolsPickable.")
return
if not _food_item:
push_error("CombineZone requires a FoodItem sibling on ", _pickable.name, ".")
return
# Detect items whether held (layer 17 "Held Objects") or loose
# (layer 3 "Pickable Objects"). Don't advertise a layer of our own.
combine_area_3d.collision_mask = 0b0000_0000_0000_0001_0000_0000_0000_0100
combine_area_3d.collision_layer = 0
# Deferred: _ready can run during a physics signal flush (when a combine
# spawns the result), where toggling monitoring directly is forbidden.
set_deferred("monitoring", false)
set_deferred("monitorable", false)
# Signals
_pickable.picked_up.connect(_on_item_picked_up)
_pickable.dropped.connect(_on_item_dropped)
combine_area_3d.body_entered.connect(_on_body_entered)
## Returns the scene this item becomes when combined with [param other_id], # Enable the trigger only while snapped into a snap zone (not hand-held).
## or null if there is no matching recipe. func _on_item_picked_up(_item: Node3D) -> void:
func get_result_for(other_id: StringName) -> PackedScene: var by := _pickable.get_picked_up_by()
for recipe in recipes: var snapped: bool = by != null and by.has_method("is_xr_class") and by.is_xr_class("XRToolsSnapZone")
if recipe and recipe.ingredient_id == other_id: set_deferred("monitoring", snapped)
return recipe.result
func _on_item_dropped(_item: Node3D) -> void:
set_deferred("monitoring", false)
# If the other body is a FoodItem, check for a recipe and combine if one exists.
# Combining is a server decision (the base item is server-snapped into a zone).
func _on_body_entered(body: Node3D) -> void:
if not NetworkManager.owns_world():
return
if _combining or body == _pickable:
print("CombinableItem _on_body_entered: other is our own pickable")
return
var other := _find_food_item(body)
if not other:
print("CombinableItem _on_body_entered: other is not a foodItem")
return
var result: PackedScene = RECIPE_MANAGER.get_combination_result(_food_item.id, other.id)
if not result:
print("CombinableItem _on_body_entered: There is no recipe for %s + %s" % [_food_item.id, other.id])
return
_combine(body, result)
# Instantiate the result of combination and free the two ingredient items
func _combine(other_body: Node3D, result: PackedScene) -> void:
print("CombinableItem _combine: combining %s + %s into %s" % [_food_item.id, _find_food_item(other_body).id, result.resource_path])
# Get Snapzone
var snap_zone := _pickable.get_picked_up_by()
if not snap_zone or not snap_zone.has_method("pick_up_object"):
return
# Spawn the result through the server (so it replicates to every peer,
# including late joiners) at the base item's location, in world space.
var base_transform := _pickable.global_transform
var result_instance: Node3D = NetworkManager.spawn_item(result.resource_path, base_transform)
# Free the pickable / root of this item and consume the incoming item.
snap_zone.drop_object()
NetworkManager.despawn_item(_pickable)
other_body.drop()
NetworkManager.despawn_item(other_body)
# Snap the result into the now-empty zone.
snap_zone.pick_up_object(result_instance)
# Find a FoodItem among the direct children of [param node].
func _find_food_item(node: Node) -> FoodItem:
if not node:
return null
for child in node.get_children():
if child is FoodItem:
return child
return null return null
+2 -7
View File
@@ -1,19 +1,14 @@
[gd_scene format=3 uid="uid://3lr2dhy62rhk"] [gd_scene format=3 uid="uid://3lr2dhy62rhk"]
[ext_resource type="Script" uid="uid://dounic663dn5q" path="res://Prefabs/combinable_item.gd" id="1_q21ti"] [ext_resource type="Script" uid="uid://dounic663dn5q" path="res://Prefabs/combinable_item.gd" id="1_q21ti"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="2_anbu7"]
[ext_resource type="Script" uid="uid://e3im02nq5cye" path="res://Prefabs/combine_zone.gd" id="4_0om48"]
[sub_resource type="BoxShape3D" id="BoxShape3D_combine"] [sub_resource type="BoxShape3D" id="BoxShape3D_combine"]
size = Vector3(0.16, 0.16, 0.16) size = Vector3(0.16, 0.16, 0.16)
[node name="CombinableItem" type="Node3D" unique_id=996936271] [node name="CombinableItem" type="Node3D" unique_id=996936271]
script = ExtResource("1_q21ti") script = ExtResource("1_q21ti")
id = &"some_food_item"
recipes = Array[ExtResource("2_anbu7")]([null])
[node name="CombineZone" type="Area3D" parent="." unique_id=1514178016] [node name="Area3d" type="Area3D" parent="." unique_id=1514178016]
script = ExtResource("4_0om48")
[node name="CollisionShape3D" type="CollisionShape3D" parent="CombineZone" unique_id=706892516] [node name="CollisionShape3D" type="CollisionShape3D" parent="Area3d" unique_id=706892516]
shape = SubResource("BoxShape3D_combine") shape = SubResource("BoxShape3D_combine")
-108
View File
@@ -1,108 +0,0 @@
class_name CombineZone
extends Area3D
## Trigger area that lets a snapped item combine with another item pushed into
## it. The zone only monitors while its owning item is held by an
## [XRToolsSnapZone]; when an item carrying a matching [CombinableItem.id]
## enters, the snapped (base) item transforms into the recipe result and the
## incoming item is consumed.
# The pickable this zone belongs to (its parent).
@onready var _item: XRToolsPickable = get_parent().get_parent() as XRToolsPickable
# The base item's recipe data.
@onready var _combinable: CombinableItem = _find_combinable(_item)
# Guard so a combine only fires once.
var _combining: bool = false
func _ready() -> void:
# Detect items whether held (layer 17 "Held Objects") or loose
# (layer 3 "Pickable Objects"). Don't advertise a layer of our own.
collision_mask = 0b0000_0000_0000_0001_0000_0000_0000_0100
collision_layer = 0
# Deferred: _ready can run during a physics signal flush (when a combine
# spawns the result), where toggling monitoring directly is forbidden.
set_deferred("monitoring", false)
set_deferred("monitorable", false)
if not _item:
push_error("CombineZone must be a grand child of an XRToolsPickable.")
return
if not _combinable:
push_error("CombineZone requires a CombinableItem sibling on ", _item.name, ".")
return
_item.picked_up.connect(_on_item_picked_up)
_item.dropped.connect(_on_item_dropped)
body_entered.connect(_on_body_entered)
# Enable the trigger only while snapped into a snap zone (not hand-held).
func _on_item_picked_up(_pickable: Node3D) -> void:
var by := _item.get_picked_up_by()
var snapped: bool = by != null and by.has_method("is_xr_class") and by.is_xr_class("XRToolsSnapZone")
set_deferred("monitoring", snapped)
func _on_item_dropped(_pickable: Node3D) -> void:
set_deferred("monitoring", false)
func _on_body_entered(body: Node3D) -> void:
if _combining or body == _item:
return
# Combining is a world-state change: only the server/offline host runs it.
# (Clients never have the base item snapped, so their zone won't monitor, but
# guard explicitly in case of overlap during authority handoff.)
if not NetworkManager.owns_world():
return
var other := _find_combinable(body)
if not other:
return
var result: PackedScene = _combinable.get_result_for(other.id)
if not result:
return
_combine(body, result)
# Transform the base item into [param result], consuming the incoming item.
func _combine(other_body: Node3D, result: PackedScene) -> void:
_combining = true
var snap_zone := _item.get_picked_up_by()
if not snap_zone or not snap_zone.has_method("pick_up_object"):
push_warning("CombineZone: base item is not held by a snap zone; cannot combine.")
_combining = false
return
# Spawn the result (networked) at the base item's location, in world space.
var base_transform := _item.global_transform
var result_instance := NetworkManager.spawn_item(result.resource_path, base_transform)
# Free the base item and consume the incoming item.
snap_zone.drop_object()
_item.queue_free()
if other_body.has_method("drop_and_free"):
other_body.drop_and_free()
else:
other_body.queue_free()
# Snap the result into the now-empty zone.
if result_instance:
snap_zone.pick_up_object(result_instance)
# Find a CombinableItem among the direct children of [param node].
func _find_combinable(node: Node) -> CombinableItem:
if not node:
return null
for child in node.get_children():
if child is CombinableItem:
return child
return null
-1
View File
@@ -1 +0,0 @@
uid://e3im02nq5cye
+1
View File
@@ -6,3 +6,4 @@ enum Type { MEAL, SIDE, INGREDIENT, NONE }
@export var id: String @export var id: String
@export var type: Type = Type.NONE @export var type: Type = Type.NONE
@export var sell_value = 1
+1 -2
View File
@@ -4,5 +4,4 @@
[node name="FoodItem" type="Node" unique_id=63948206] [node name="FoodItem" type="Node" unique_id=63948206]
script = ExtResource("1_0na5k") script = ExtResource("1_0na5k")
id = "hamburger" id = "some_food_id"
item_type = "Meal"
-75
View File
@@ -1,75 +0,0 @@
class_name NetPickable
extends Node
## Networks an XRToolsPickable (RigidBody3D). Default authority is the server,
## which simulates free items and syncs their transform. Grabbing an item with a
## hand transfers authority to the grabbing client (its local kinematic grab
## driver then drives the synced transform); releasing returns authority and the
## throw velocity to the server. Non-authority peers keep the body frozen and
## follow the synced transform, so only one machine ever simulates an item.
@onready var _item: XRToolsPickable = get_parent() as XRToolsPickable
var net_held_by := 0 # peer id currently holding (0 = free); kept consistent via NetworkManager
var _was_authority := true
func _ready() -> void:
if not _item:
push_error("NetPickable must be a child of an XRToolsPickable")
return
_item.grabbed.connect(_on_grabbed)
_item.released.connect(_on_released)
_was_authority = _item.is_multiplayer_authority()
_apply_authority_state()
## Enable the item only where it is free or where we are its current holder, so
## a peer's snap zone / hand never grabs an item another peer is holding. Called
## by NetworkManager whenever net_held_by changes. Note: net_held_by is set per
## peer by RPC (not by a synchronizer), so the holder keeps its item enabled
## while everyone else disables their copy.
func apply_held_state() -> void:
if not _item:
return
_item.enabled = net_held_by == 0 or net_held_by == multiplayer.get_unique_id()
func _physics_process(_delta: float) -> void:
if not NetworkManager.is_online():
return
var mine := _item.is_multiplayer_authority()
if mine != _was_authority:
_was_authority = mine
_apply_authority_state()
# Configure physics for whether we own this item or merely mirror it.
func _apply_authority_state() -> void:
if not _item:
return
if _item.is_multiplayer_authority():
# We own physics: simulate the item whenever it is not held.
if not _item.is_picked_up():
_item.freeze = false
else:
# Someone else owns it: follow the synced transform kinematically.
_item.freeze = true
_item.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
func _on_grabbed(_pickable: Node3D, by: Node3D) -> void:
if not NetworkManager.is_online():
return
# Only hand grabs transfer authority; snap-zone grabs are server-driven.
if not (by is XRToolsFunctionPickup):
return
NetworkManager.request_item_authority.rpc_id(1, _item.get_path())
func _on_released(_pickable: Node3D, by: Node3D) -> void:
if not NetworkManager.is_online():
return
if not (by is XRToolsFunctionPickup):
return
NetworkManager.release_item_authority.rpc_id(1, _item.get_path(), _item.linear_velocity, _item.angular_velocity)
-1
View File
@@ -1 +0,0 @@
uid://6n784li8n5ck
+341
View File
@@ -0,0 +1,341 @@
class_name RecipeManager
extends Node
static var _recipes: Dictionary
static var _combining_map: Dictionary
static var _cooking_map: Dictionary
static var _chopping_map: Dictionary
static var _rolling_map: Dictionary
static var _augmenting_map: Dictionary
static var _scene_paths: Dictionary
static var _loaded: bool = false
static func load_recipes() -> void:
if _loaded:
return
var recipe_path := "res://recipes.yaml"
if not FileAccess.file_exists(recipe_path):
push_error("RecipeManager: recipes.yaml was not found at %s" % recipe_path)
return
var yaml_available := ClassDB.class_exists("YAML")
if yaml_available:
var result = ClassDB.class_call_static("YAML", "load_file", recipe_path)
if result != null and not result.has_error():
var data = result.get_data()
if typeof(data) != TYPE_DICTIONARY:
push_warning("RecipeManager: YAML addon parsed recipes.yaml but returned an unsupported structure")
_recipes = data
_build_scene_paths()
_build_combining_map()
_build_cooking_map()
_build_chopping_map()
_build_rolling_map()
_build_augmenting_map()
_loaded = true
static func get_combination_result(first_id: StringName, second_id: StringName) -> PackedScene:
load_recipes()
var result_id = _combining_map.get(_make_pair_key(first_id, second_id))
if result_id == null:
return null
return get_item_scene(result_id)
static func get_cooking_result(ingredient_id: StringName) -> PackedScene:
load_recipes()
for cooked_id in _cooking_map.keys():
var cook_defs = _cooking_map[cooked_id]
# cook_defs may be an Array of recipe dicts (new) or a single dict (backwards compat)
if typeof(cook_defs) == TYPE_ARRAY:
for cook_def in cook_defs:
if cook_def["ingredient"] == ingredient_id:
return get_item_scene(cooked_id)
else:
var cook_def = cook_defs
if cook_def["ingredient"] == ingredient_id:
return get_item_scene(cooked_id)
return null
static func get_cooking_time(ingredient_id: StringName) -> float:
load_recipes()
for cooked_id in _cooking_map.keys():
var cook_defs = _cooking_map[cooked_id]
if typeof(cook_defs) == TYPE_ARRAY:
for cook_def in cook_defs:
if cook_def["ingredient"] == ingredient_id:
return cook_def["time"]
else:
var cook_def = cook_defs
if cook_def["ingredient"] == ingredient_id:
return cook_def["time"]
return 0.0
static func get_item_scene(item_id: StringName) -> PackedScene:
load_recipes()
var scene_path = _scene_paths.get(str(item_id), "")
if typeof(scene_path) != TYPE_STRING or scene_path.is_empty():
push_error("RecipeManager: no scene mapping found for item id '%s'" % item_id)
return null
var scene = ResourceLoader.load(scene_path)
if not scene:
push_error("RecipeManager: failed to load scene '%s' for item '%s'" % [scene_path, item_id])
return null
return scene as PackedScene
static func print_all_recipes() -> void:
load_recipes()
if not _loaded:
print("RecipeManager: could not load recipes.yaml; no recipes printed.")
return
if _combining_map.is_empty():
print("RecipeManager: no combining recipes found")
return
print("#### RecipeManager: Loaded recipes ####")
_print_combining_recipes()
print("")
_print_cooking_recipes()
print("")
_print_chopping_recipes()
print("")
_print_rolling_recipes()
print("")
_print_augmenting_recipes()
static func _print_combining_recipes() -> void:
for pair_key in _combining_map.keys():
var result_id = _combining_map[pair_key]
var key_str = str(pair_key)
var separator_index = key_str.find("|")
if separator_index == -1:
print("RecipeManager: invalid combining map key '%s'" % key_str)
continue
var first = key_str.substr(0, separator_index)
var second = key_str.substr(separator_index + 1, key_str.length() - separator_index - 1)
print(" %s <-combine-- %s + %s" % [result_id, first, second])
static func _print_cooking_recipes() -> void:
for cooked_id in _cooking_map.keys():
var cook_defs = _cooking_map[cooked_id]
if typeof(cook_defs) == TYPE_ARRAY:
for cook_def in cook_defs:
print(" %s <-cook-- %s - time: %.1f" % [cooked_id, cook_def["ingredient"], cook_def["time"]])
else:
var cook_def = cook_defs
print(" %s <-cook-- %s - time: %.1f" % [cooked_id, cook_def["ingredient"], cook_def["time"]])
static func _print_chopping_recipes() -> void:
for chopped_id in _chopping_map.keys():
var chop_def = _chopping_map[chopped_id]
print(" %s <-chop-- %s - work: %.1f" % [chopped_id, chop_def["ingredient"], chop_def["work"]])
static func _print_rolling_recipes() -> void:
for rolled_id in _rolling_map.keys():
var roll_def = _rolling_map[rolled_id]
print(" %s <-roll-- %s - work: %.1f" % [rolled_id, roll_def["ingredient"], roll_def["work"]])
static func _print_augmenting_recipes() -> void:
for target_id in _augmenting_map.keys():
var augment_def = _augmenting_map[target_id]
for ingredient_id in augment_def.keys():
var attr_key = augment_def[ingredient_id]
print(" %s <-augment-- %s adds attribute '%s'" % [target_id, ingredient_id, attr_key])
static func _build_scene_paths() -> void:
_scene_paths.clear()
var items = _recipes.get("items", {})
if typeof(items) != TYPE_DICTIONARY:
return
for item_id in items.keys():
var item_def = items[item_id]
if typeof(item_def) != TYPE_DICTIONARY:
continue
var scene_path = item_def.get("scene", "")
if typeof(scene_path) == TYPE_STRING and not scene_path.is_empty():
_scene_paths[str(item_id)] = scene_path
# Reads strucure into _combining_map:
# combining:
# hamburger:
# - [cooked_burger, burger_buns]
static func _build_combining_map() -> void:
_combining_map.clear()
var combining = _recipes.get("combining", {})
for result_id in combining.keys():
var recipe_entries = _get_recipe_entries(combining[result_id])
if recipe_entries.is_empty():
push_error("RecipeManager: combining recipe '%s' must contain at least one valid recipe" % result_id)
continue
for recipe in recipe_entries:
var first = StringName(recipe[0])
var second = StringName(recipe[1])
var key = _make_pair_key(first, second)
_combining_map[key] = result_id
static func _get_recipe_entries(recipe_value: Variant) -> Array:
var recipes: Array = []
if typeof(recipe_value) != TYPE_ARRAY:
return recipes
if recipe_value.is_empty():
return recipes
if typeof(recipe_value[0]) == TYPE_ARRAY:
for recipe in recipe_value:
if typeof(recipe) == TYPE_ARRAY and recipe.size() == 2:
recipes.append(recipe)
else:
if recipe_value.size() == 2:
recipes.append(recipe_value)
return recipes
static func _make_pair_key(first_id: StringName, second_id: StringName) -> StringName:
if str(first_id) <= str(second_id):
return StringName(str(first_id) + "|" + str(second_id))
return StringName(str(second_id) + "|" + str(first_id))
# Reads structure into _cooking_map:
# cooking:
# cooked_burger:
# ingredient: raw_burger
# time: 3
# or allowing multiple recipes for the same cooked item:
# charcoal:
# - ingredient: cube
# time: 1
# - ingredient: some_other
# time: 2
static func _build_cooking_map() -> void:
_cooking_map.clear()
var cooking = _recipes.get("cooking", {})
if typeof(cooking) != TYPE_DICTIONARY:
return
for cooked_id in cooking.keys():
var cook_val = cooking[cooked_id]
var entries: Array = []
if typeof(cook_val) == TYPE_ARRAY:
for entry in cook_val:
if typeof(entry) != TYPE_DICTIONARY:
push_error("RecipeManager: cooking recipe for '%s' contains a non-dictionary entry" % cooked_id)
continue
entries.append(entry)
elif typeof(cook_val) == TYPE_DICTIONARY:
entries.append(cook_val)
else:
push_error("RecipeManager: cooking recipe for '%s' must be a dictionary or array of dictionaries" % cooked_id)
continue
var parsed_entries: Array = []
for cook_def in entries:
var ingredient = cook_def.get("ingredient", "")
if typeof(ingredient) != TYPE_STRING or str(ingredient).is_empty():
push_error("RecipeManager: cooking recipe '%s' missing a valid 'ingredient' field" % cooked_id)
continue
var time_val = cook_def.get("time", null)
if typeof(time_val) != TYPE_INT and typeof(time_val) != TYPE_FLOAT:
push_error("RecipeManager: cooking recipe '%s' missing a valid 'time' field" % cooked_id)
continue
parsed_entries.append({"ingredient": StringName(str(ingredient)), "time": float(time_val)})
if not parsed_entries.is_empty():
_cooking_map[StringName(cooked_id)] = parsed_entries
# Reads structure into _chopping_map:
# chopping:
# chopped_onion:
# ingredient: onion
# work: 2
static func _build_chopping_map() -> void:
_chopping_map.clear()
var chopping = _recipes.get("chopping", {})
if typeof(chopping) != TYPE_DICTIONARY:
return
for chopped_id in chopping.keys():
var chop_def = chopping[chopped_id]
if typeof(chop_def) != TYPE_DICTIONARY:
push_error("RecipeManager: chopping recipe for '%s' must be a dictionary" % chopped_id)
continue
var ingredient = chop_def.get("ingredient", "")
if typeof(ingredient) != TYPE_STRING or str(ingredient).is_empty():
push_error("RecipeManager: chopping recipe '%s' missing a valid 'ingredient' field" % chopped_id)
continue
var work_val = chop_def.get("work", null)
if typeof(work_val) != TYPE_INT and typeof(work_val) != TYPE_FLOAT:
push_error("RecipeManager: chopping recipe '%s' missing a valid 'work' field" % chopped_id)
continue
# store chopped item -> { ingredient: StringName, work: float }
_chopping_map[StringName(chopped_id)] = {"ingredient": StringName(str(ingredient)), "work": float(work_val)}
# Reads structure into _rolling_map:
# rolling:
# pie_base:
# ingredient: dough
# work: 80
static func _build_rolling_map() -> void:
_rolling_map.clear()
var rolling = _recipes.get("rolling", {})
if typeof(rolling) != TYPE_DICTIONARY:
return
for rolled_id in rolling.keys():
var roll_def = rolling[rolled_id]
if typeof(roll_def) != TYPE_DICTIONARY:
push_error("RecipeManager: rolling recipe for '%s' must be a dictionary" % rolled_id)
continue
var ingredient = roll_def.get("ingredient", "")
if typeof(ingredient) != TYPE_STRING or str(ingredient).is_empty():
push_error("RecipeManager: rolling recipe '%s' missing a valid 'ingredient' field" % rolled_id)
continue
var work_val = roll_def.get("work", null)
if typeof(work_val) != TYPE_INT and typeof(work_val) != TYPE_FLOAT:
push_error("RecipeManager: rolling recipe '%s' missing a valid 'work' field" % rolled_id)
continue
# store rolled item -> { ingredient: StringName, work: float }
_rolling_map[StringName(rolled_id)] = {"ingredient": StringName(str(ingredient)), "work": float(work_val)}
# Reads structure into _augmenting_map:
# augmenting: # Process of adding an item onto another without changing it's type, but toggling attributes.
# cooked_burger:
# has_tomato: sliced_tomato
# has_cheese: cheese
static func _build_augmenting_map() -> void:
_augmenting_map.clear()
var augmenting = _recipes.get("augmenting", {})
if typeof(augmenting) != TYPE_DICTIONARY:
return
for target_id in augmenting.keys():
var augment_def = augmenting[target_id]
if typeof(augment_def) != TYPE_DICTIONARY:
push_error("RecipeManager: augmenting recipe for '%s' must be a dictionary" % target_id)
continue
var map: Dictionary = {}
for attr_key in augment_def.keys():
var ingredient = augment_def[attr_key]
if typeof(ingredient) != TYPE_STRING or str(ingredient).is_empty():
push_error("RecipeManager: augmenting recipe '%s' has invalid ingredient for '%s'" % [target_id, attr_key])
continue
# store ingredient -> attribute_key (e.g. sliced_tomato -> has_tomato)
map[StringName(str(ingredient))] = StringName(str(attr_key))
if not map.is_empty():
_augmenting_map[StringName(target_id)] = map
+1
View File
@@ -0,0 +1 @@
uid://cuo88u56uqwuy
+68
View File
@@ -0,0 +1,68 @@
# RecipeManager
## Overview
`RecipeManager` is a static Godot class that centralizes recipe data for food item combinations and allows runtime lookup of result scenes.
The manager loads a single YAML file at startup: `res://recipes.yaml`, using the installed `addons/yaml` addon.
## Responsibilities
- Load recipe definitions from `recipes.yaml`.
- Provide symmetric lookups for two-item combining recipes so `A + B` and `B + A` map to the same result.
- Resolve item IDs to their corresponding scene resources.
- Print all loaded combining recipes when the application starts.
## Data structure in `recipes.yaml`
The recipe YAML file contains two top-level sections:
- `items`: maps item IDs to their scene path and optional metadata.
- `combining`: maps result item IDs to one or more recipe pairs.
Example structure:
```yaml
items:
hamburger:
scene: res://Items/hamburger.tscn
type: meal
burger_buns:
scene: res://Items/BurgerBuns.tscn
type: ingredient
combining:
hamburger:
- [cooked_burger, burger_buns]
- [charcoal, charcoal]
charcoal:
- [cube, cube]
```
### `items`
Each entry under `items` uses the item ID as the key.
The manager reads the `scene` field for each item and uses it to resolve the packed scene for recipe results.
### `combining`
Each entry under `combining` represents a result item ID, and its value is a list of recipe pairs.
Each recipe pair is a two-item array of ingredient IDs.
This allows a single result item to have multiple valid recipes.
The manager normalizes each ingredient pair using a sorted key string internally, so lookups are symmetric.
## Runtime behavior
- `FoodItem.id` is used when combining items to look up the recipe result.
- `RecipeManager.get_combination(first_id, second_id)` computes a canonical key for the pair and returns the resulting item's scene.
- On startup, `main.gd` calls `RecipeManager.print_all_recipes()`, which logs each combining recipe in the form:
`ingredient_a + ingredient_b -> result_id`
## Notes
- `RecipeManager` prefers the installed YAML addon to parse `recipes.yaml` when it is available.
- If the addon is unavailable in the current runtime, the manager falls back to a lightweight built-in parser for the simple `items`/`combining` file format.
- Scene paths now live in the YAML data instead of being hardcoded in the manager.
- Because `RecipeManager` is static, it can be used from any script without creating an instance.
+101 -47
View File
@@ -4,7 +4,6 @@
[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"]
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="5_57ppd"]
[ext_resource type="PackedScene" uid="uid://e4i6o5oriecx" path="res://Items/PickupCube.tscn" id="6_eoxxy"] [ext_resource type="PackedScene" uid="uid://e4i6o5oriecx" path="res://Items/PickupCube.tscn" id="6_eoxxy"]
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="7_n8fyw"] [ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="7_n8fyw"]
[ext_resource type="PackedScene" uid="uid://c0lknik4noobs" path="res://Items/BurgerBuns.tscn" id="8_rraok"] [ext_resource type="PackedScene" uid="uid://c0lknik4noobs" path="res://Items/BurgerBuns.tscn" id="8_rraok"]
@@ -12,6 +11,11 @@
[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="10_ay2w6"] [ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="10_ay2w6"]
[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="11_qn1ku"] [ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="11_qn1ku"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="12_hfwnn"] [ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="12_hfwnn"]
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="13_o1b3t"]
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="14_irf4n"]
[ext_resource type="PackedScene" uid="uid://cfc4ho67u4r5e" path="res://Items/cooked_burger.tscn" id="15_ctden"]
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="15_di04w"]
[ext_resource type="PackedScene" uid="uid://caf0xanmxbshy" path="res://Stations/table.tscn" id="16_di04w"]
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"] [sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
size = Vector3(5, 0.1, 5) size = Vector3(5, 0.1, 5)
@@ -20,13 +24,6 @@ size = Vector3(5, 0.1, 5)
material = ExtResource("3_irf4n") material = ExtResource("3_irf4n")
size = Vector3(5, 0.1, 5) size = Vector3(5, 0.1, 5)
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
[sub_resource type="BoxMesh" id="BoxMesh_i5j2u"]
[sub_resource type="SphereShape3D" id="SphereShape3D_dlkho"]
radius = 0.3
[sub_resource type="Environment" id="Environment_bvwq1"] [sub_resource type="Environment" id="Environment_bvwq1"]
background_mode = 2 background_mode = 2
sky = ExtResource("7_n8fyw") sky = ExtResource("7_n8fyw")
@@ -36,10 +33,14 @@ ssao_enabled = true
ssil_enabled = true ssil_enabled = true
sdfgi_enabled = true sdfgi_enabled = true
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
size = Vector3(5.1, 10, 0.1)
[node name="Main" type="Node3D" unique_id=1312265607] [node name="Main" type="Node3D" unique_id=1312265607]
script = ExtResource("1_57ppd") script = ExtResource("1_57ppd")
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_o1b3t")] [node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_o1b3t")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
[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)
@@ -57,32 +58,6 @@ mesh = SubResource("BoxMesh_24d3s")
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_ctden")] [node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.8981018, -1.484) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.8981018, -1.484)
[node name="Counter" type="StaticBody3D" parent="." unique_id=1487893288 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.73186195, 0.8981018, -1.484)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Counter" unique_id=692568688]
shape = SubResource("BoxShape3D_24d3s")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Counter/CollisionShape3D" unique_id=127773521]
mesh = SubResource("BoxMesh_i5j2u")
[node name="Label3D" type="Label3D" parent="Counter/CollisionShape3D" unique_id=149978729]
transform = Transform3D(0.9999992, 0, 0, 0, 0.99999946, 0, 0, 0, 0.9999992, 0.002797456, 0.6309581, -0.2812457)
text = "Counter"
[node name="XRToolsSnapZone" type="Area3D" parent="Counter" unique_id=1647380272 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("5_57ppd")
snap_mode = 1
initial_object = NodePath("../../Plate")
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="Counter/XRToolsSnapZone" unique_id=969340130]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[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")
@@ -93,40 +68,119 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8162017, 1.6081157, -1.471
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.832131, 0.40028095, -1.4813508) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.832131, 0.40028095, -1.4813508)
[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("10_ay2w6")] [node name="Plate" parent="." unique_id=190487773 instance=ExtResource("10_ay2w6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.72025335, 1.4458435, -1.5107731) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5717233, 1.5454081, 1.5373346)
[node name="burger" parent="." unique_id=1417604760 instance=ExtResource("11_qn1ku")] [node name="burger" parent="." unique_id=1417604760 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1439226, 1.4195822, -1.2004558) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6888188, 1.4195822, -1.7110313)
[node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("11_qn1ku")] [node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1396115, 1.5300478, -1.2016745) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6931299, 1.5300478, -1.71225)
[node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("11_qn1ku")] [node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1424646, 1.4969791, -1.210453) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69027674, 1.4969791, -1.7210286)
[node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("11_qn1ku")] [node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("11_qn1ku")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.1414003, 1.4543622, -1.210453) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69134104, 1.4543622, -1.7210286)
[node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("12_hfwnn")] [node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("12_hfwnn")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.18456237, 1.5438088, -1.3716147) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9352558, 1.623975, 1.2447833)
[node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("6_eoxxy")] [node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -0.042440414, 1.4792972, -1.0473135) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.6225724, 1.4792972, -1.0473135)
[node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("6_eoxxy")] [node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -0.029038578, 1.4639391, -1.1673055) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.6359743, 1.4639391, -1.1673055)
[node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("6_eoxxy")] [node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -0.15074077, 1.4792972, -1.0473135) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.5142721, 1.4792972, -1.0473135)
[node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("6_eoxxy")] [node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -0.13733894, 1.4639391, -1.1673055) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.5276739, 1.4639391, -1.1673055)
[node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("12_hfwnn")] [node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("12_hfwnn")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.15603885, 1.5329368, -1.6386203) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9857153, 1.5329368, 0.9472374)
[node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("6_eoxxy")] [node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.06786254, 1.4792972, -1.0473135) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.73287535, 1.4792972, -1.0473135)
[node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("6_eoxxy")] [node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.08126438, 1.4639391, -1.1673055) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.7462772, 1.4639391, -1.1673055)
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("13_o1b3t")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3140475, 0.9061539, -1.4941733)
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("14_irf4n")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0864775, 0.8981018, -1.244947)
[node name="Plate2" parent="." unique_id=356790445 instance=ExtResource("10_ay2w6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5730225, 1.499024, 0.59790254)
[node name="CookedBurger" parent="." unique_id=1127807542 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4131018, -1.0928738)
[node name="CookedBurger2" parent="." unique_id=160088590 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4496142, -1.0928738)
[node name="CookedBurger3" parent="." unique_id=992183367 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344773, 1.4398065, -0.7096845)
[node name="CookedBurger4" parent="." unique_id=1837607086 instance=ExtResource("15_ctden")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.525444, -1.0928738)
[node name="BurgerBuns2" parent="." unique_id=1210358958 instance=ExtResource("8_rraok")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3596323, 1.4110342, -0.22482127)
[node name="PickableObject7" parent="." unique_id=641653545 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.3556751, 1.4792972, 0.28881657)
[node name="PickableObject8" parent="." unique_id=1733819363 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.3422732, 1.4639391, 0.16882455)
[node name="PickableObject9" parent="." unique_id=12419746 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.4639754, 1.4792972, 0.28881657)
[node name="PickableObject10" parent="." unique_id=313160217 instance=ExtResource("6_eoxxy")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.4505737, 1.4639391, 0.16882455)
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("15_di04w")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.7124918, 0.90304357, -1.4886917)
[node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_di04w")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.90304357, -0.4458799)
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_di04w")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.90304357, 0.55367994)
[node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_di04w")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.90304357, 1.5539298)
[node name="Table" parent="." unique_id=1863572470 instance=ExtResource("16_di04w")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.50244665, 0.9030438, -0.20709503)
initial_thinking_time = 8.0
initial_primary_time = 40.0
initial_friend_time = 3.0
initial_eating_time = 3.0
[node name="Hamburger3" parent="." unique_id=369573848 instance=ExtResource("12_hfwnn")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.7201865, 1.5329367, 0.14773655)
[node name="Plate3" parent="." unique_id=2004250522 instance=ExtResource("10_ay2w6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5818124, 1.499024, -0.4634577)
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=315740174]
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=33059670]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.688614, 2.5070028)
shape = SubResource("BoxShape3D_24d3s")
[node name="CollisionShape3D2" type="CollisionShape3D" parent="StaticBody3D" unique_id=2018792171]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.688614, -2.5446289)
shape = SubResource("BoxShape3D_24d3s")
[node name="CollisionShape3D3" type="CollisionShape3D" parent="StaticBody3D" unique_id=757662929]
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -2.525816, 4.688614, -0.018813243)
shape = SubResource("BoxShape3D_24d3s")
[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1468386997]
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 2.525816, 4.688614, -0.018813023)
shape = SubResource("BoxShape3D_24d3s")
+166
View File
@@ -0,0 +1,166 @@
[gd_scene format=3 uid="uid://c1nv4w33fedj6"]
[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_72gy5"]
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_8wkh3"]
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_m56cs"]
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_5kvh0"]
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_l1qm6"]
[ext_resource type="PackedScene" uid="uid://c0lknik4noobs" path="res://Items/BurgerBuns.tscn" id="6_bktvt"]
[ext_resource type="PackedScene" uid="uid://c6rift56ql3f8" path="res://Stations/BurgerBunsDispenser.tscn" id="7_1nkd0"]
[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="8_l1owj"]
[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="9_220hi"]
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="10_qacki"]
[ext_resource type="PackedScene" uid="uid://e4i6o5oriecx" path="res://Items/PickupCube.tscn" id="11_npf8s"]
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="12_8apyq"]
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="13_jnwcx"]
[ext_resource type="PackedScene" uid="uid://cfc4ho67u4r5e" path="res://Items/cooked_burger.tscn" id="14_1lg2m"]
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="15_yy81s"]
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="16_vp"]
[ext_resource type="PackedScene" path="res://UI/main_menu_panel.tscn" id="17_panel"]
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
size = Vector3(115, 0.1, 15)
[sub_resource type="BoxMesh" id="BoxMesh_24d3s"]
material = ExtResource("3_m56cs")
size = Vector3(15, 0.1, 15)
[sub_resource type="Environment" id="Environment_bvwq1"]
background_mode = 2
sky = ExtResource("5_l1qm6")
reflected_light_source = 2
ssr_enabled = true
ssao_enabled = true
ssil_enabled = true
sdfgi_enabled = true
[node name="Main" type="Node3D" unique_id=1312265607]
script = ExtResource("1_72gy5")
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_8wkh3")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
[node name="MainMenuPanel3D" parent="." unique_id=1448860532 instance=ExtResource("16_vp")]
transform = Transform3D(5, 0, 0, 0, 5, 0, 0, 0, 5, 0.36171648, 2.0238447, -1.9758987)
screen_size = Vector2(0.6, 0.4)
scene = ExtResource("17_panel")
viewport_size = Vector2(600, 400)
transparent = 1
scene_properties_keys = PackedStringArray("main_menu_panel.gd")
[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)
[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor" unique_id=958841648]
shape = SubResource("BoxShape3D_vlqg6")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056]
mesh = SubResource("BoxMesh_24d3s")
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_5kvh0")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.5, -1.484)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
environment = SubResource("Environment_bvwq1")
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("6_bktvt")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5530176, 1.0505146, -1.3074328)
[node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("7_1nkd0")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.568947, -0.018512607, -1.317366)
[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("8_l1owj")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5717233, 1.1548845, 1.5373346)
[node name="burger" parent="." unique_id=1417604760 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6888188, 1.0325116, -1.7110313)
[node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6931299, 1.1429771, -1.71225)
[node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69027674, 1.1099085, -1.7210286)
[node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69134104, 1.0672915, -1.7210286)
[node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("10_qacki")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9352558, 1.2334514, 1.2447833)
[node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.6225724, 1.0654817, -1.0473135)
[node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.6359743, 1.0501236, -1.1673055)
[node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.5142721, 1.0654817, -1.0473135)
[node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.5276739, 1.0501236, -1.1673055)
[node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("10_qacki")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9857153, 1.1424131, 0.9472374)
[node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.73287535, 1.0922265, -1.0473135)
[node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.7462772, 1.0768684, -1.1673055)
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("12_8apyq")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3147688, 0.5, -1.4940417)
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("13_jnwcx")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0864775, 0.5, -1.244947)
[node name="Plate2" parent="." unique_id=356790445 instance=ExtResource("8_l1owj")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5730225, 1.1085004, 0.59790254)
[node name="CookedBurger" parent="." unique_id=1127807542 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30782473, 1.1446649, -1.0928738)
[node name="CookedBurger2" parent="." unique_id=160088590 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.0522771, -1.0928738)
[node name="CookedBurger3" parent="." unique_id=992183367 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344773, 1.0492828, -0.7096845)
[node name="CookedBurger4" parent="." unique_id=1837607086 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.098119, -1.0928738)
[node name="BurgerBuns2" parent="." unique_id=1210358958 instance=ExtResource("6_bktvt")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3596323, 1.0205106, -0.22482127)
[node name="PickableObject7" parent="." unique_id=641653545 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.3556751, 1.0887735, 0.28881657)
[node name="PickableObject8" parent="." unique_id=1733819363 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.3422732, 1.0734154, 0.16882455)
[node name="PickableObject9" parent="." unique_id=12419746 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.4639754, 1.0887735, 0.28881657)
[node name="PickableObject10" parent="." unique_id=313160217 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.4505737, 1.0734154, 0.16882455)
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("15_yy81s")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.7124918, 0.5, -1.4886917)
[node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_yy81s")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, -0.4458799)
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_yy81s")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, 0.55367994)
[node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_yy81s")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, 1.5539298)
[node name="Hamburger3" parent="." unique_id=369573848 instance=ExtResource("10_qacki")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.7201865, 1.142413, 0.14773655)
[node name="Plate3" parent="." unique_id=2004250522 instance=ExtResource("8_l1owj")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5818124, 1.1085004, -0.4634577)
+76
View File
@@ -0,0 +1,76 @@
[gd_scene format=3 uid="uid://c30i6h32w8p47"]
[ext_resource type="Script" uid="uid://d1cefhe3yyyds" path="res://Scenes/multiplayer_world.gd" id="1_kdan8"]
[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="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_c3xgf"]
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
size = Vector3(15, 0.1, 15)
[sub_resource type="BoxMesh" id="BoxMesh_24d3s"]
material = ExtResource("3_75ecy")
size = Vector3(15, 0.1, 15)
[sub_resource type="Environment" id="Environment_bvwq1"]
background_mode = 2
sky = ExtResource("5_c3xgf")
reflected_light_source = 2
ssr_enabled = true
ssao_enabled = true
ssil_enabled = true
sdfgi_enabled = true
[sub_resource type="BoxShape3D" id="BoxShape3D_arao0"]
size = Vector3(15, 20, 0.1)
[node name="Main" type="Node3D" unique_id=1312265607]
script = ExtResource("1_kdan8")
[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_g30gi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
[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)
[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor" unique_id=958841648]
shape = SubResource("BoxShape3D_vlqg6")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00018164515, 0.0006352663, -0.002532661)
mesh = SubResource("BoxMesh_24d3s")
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
environment = SubResource("Environment_bvwq1")
[node name="WorldContent" type="Node3D" parent="." unique_id=291550153]
[node name="Players" type="Node3D" parent="." unique_id=1595463693]
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248]
spawn_path = NodePath("../WorldContent")
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565]
_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn")
spawn_path = NodePath("../Players")
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=4404969]
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=998476672]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.197384, 7.589958)
shape = SubResource("BoxShape3D_arao0")
[node name="CollisionShape3D2" type="CollisionShape3D" parent="StaticBody3D" unique_id=340303902]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.197384, -7.509142)
shape = SubResource("BoxShape3D_arao0")
[node name="CollisionShape3D3" type="CollisionShape3D" parent="StaticBody3D" unique_id=1193703037]
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -7.438612, 9.197384, -0.018813243)
shape = SubResource("BoxShape3D_arao0")
[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)
shape = SubResource("BoxShape3D_arao0")
+117
View File
@@ -0,0 +1,117 @@
extends Node3D
## World script for the multiplayer scene. Consumes the host/join request set
## by the main menu, spawns the world's stations/items on whichever machine
## owns the world (server, or the local player when offline), spawns/despawns
## a player avatar per connected peer, and returns to the menu if the session
## ends.
##
## The world's actual content (stations, items) is NOT baked into this scene —
## it's spawned at runtime from Net/world_layout.gd via NetworkManager, so a
## joining client receives it from the server (MultiplayerSpawner replays
## existing spawns to late joiners) instead of relying on its own local copy
## matching.
const PLAYER_SCENE := preload("res://Player/net_player.tscn")
## Whether to spawn the full WorldLayout on the machine that owns the world.
## The real game scene wants this; focused debug scenes (test/) bake their own
## handful of stations and items instead and turn it off, so the thing under
## test isn't sharing the world with a second copy of the whole kitchen.
@export var populate_from_layout: bool = true
var xr_interface: XRInterface
var _populated := false
func _ready() -> void:
xr_interface = XRServer.find_interface("OpenXR")
if xr_interface and xr_interface.is_initialized():
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
get_viewport().use_xr = true
NetworkManager.register_world(self, $PlayersSpawner, $ItemsSpawner)
NetworkManager.player_joined.connect(_on_player_joined)
NetworkManager.player_left.connect(_on_player_left)
NetworkManager.session_started.connect(_on_session_started)
NetworkManager.session_ended.connect(_on_session_ended)
NetworkManager.connection_failed.connect(_on_connection_failed)
# world_ready() is what actually calls host()/join() (or the cmdline
# equivalent). Populating before this point is wrong for EVERY case, not
# just offline: is_online() is still false until host()/join() runs, so
# owns_world() would read true for a joining client too, and it would
# build its own local copy instead of receiving the server's via the
# spawner. host() emits session_started synchronously, which populates
# via _on_session_started below; the explicit call after world_ready()
# only matters for the case where neither host() nor join() ran (no
# pending session, no cmdline args) — running this scene directly offline.
NetworkManager.world_ready()
_populate_world_if_owner()
get_tree().create_timer(3.0).timeout.connect(_log_world_state)
# Temporary-ish sanity check: confirms WorldContent actually ended up
# populated on this peer (whether by spawning it or by receiving it via
# replication), so a silent replication failure shows up in the net log
# instead of just an empty-looking world.
func _log_world_state() -> void:
NetworkManager.log_line("World state: WorldContent=%d children, Players=%d children" % [$WorldContent.get_child_count(), $Players.get_child_count()])
func _exit_tree() -> void:
NetworkManager.unregister_world()
func _on_session_started(_is_server: bool) -> void:
NetworkManager.gate_existing_stations()
_populate_world_if_owner()
## 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.
func _populate_world_if_owner() -> void:
if not NetworkManager.owns_world() or _populated or not populate_from_layout:
return
_populated = true
GameManager.meals_in_play = ["hamburger"]
var stations := WorldLayout.get_stations()
var items := WorldLayout.get_items()
NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()])
for d in stations:
NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"])
for d in items:
NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"])
NetworkManager.log_line("World populated")
## Only the server (or the single offline machine) materialises player
## avatars; MultiplayerSpawner replicates the result to everyone else,
## including late joiners.
func _on_player_joined(peer_id: int) -> void:
if not NetworkManager.owns_world() or $Players.has_node(str(peer_id)):
return
var p := PLAYER_SCENE.instantiate()
p.name = str(peer_id)
$Players.add_child(p, true)
NetworkManager.log_line("Spawned avatar for peer %d" % peer_id)
func _on_player_left(peer_id: int) -> void:
if not NetworkManager.owns_world():
return
var p := $Players.get_node_or_null(str(peer_id))
if p:
p.queue_free()
NetworkManager.log_line("Despawned avatar for peer %d" % peer_id)
func _on_session_ended() -> void:
NetworkManager.log_line("Session ended, returning to main menu")
get_tree().change_scene_to_file("res://Scenes/mainMenu.tscn")
func _on_connection_failed() -> void:
NetworkManager.log_line("Connection failed, returning to main menu")
get_tree().change_scene_to_file("res://Scenes/mainMenu.tscn")
+1
View File
@@ -0,0 +1 @@
uid://d1cefhe3yyyds
-1
View File
@@ -22,7 +22,6 @@ collision_layer = 65536
collision_mask = 65536 collision_mask = 65536
script = ExtResource("1_p30r1") script = ExtResource("1_p30r1")
snap_mode = 1 snap_mode = 1
initial_object = NodePath("../../BurgerBuns")
metadata/_custom_type_script = "uid://cquqe4f1m1sw6" metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=1104626493] [node name="CollisionShape3D" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=1104626493]
+51 -11
View File
@@ -4,31 +4,71 @@
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"] [sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
[sub_resource type="BoxMesh" id="BoxMesh_i5j2u"]
[sub_resource type="SphereShape3D" id="SphereShape3D_dlkho"] [sub_resource type="SphereShape3D" id="SphereShape3D_dlkho"]
radius = 0.3 radius = 0.3
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_24d3s"]
albedo_color = Color(0.49, 0.3854667, 0.29400003, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_vlqg6"]
albedo_color = Color(1.1759251, 0.741115, 0.5692133, 1)
[node name="Counter" type="StaticBody3D" unique_id=1487893288 groups=["station"]] [node name="Counter" type="StaticBody3D" unique_id=1487893288 groups=["station"]]
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=692568688] [node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=692568688]
shape = SubResource("BoxShape3D_24d3s") shape = SubResource("BoxShape3D_24d3s")
[node name="MeshInstance3D" type="MeshInstance3D" parent="CollisionShape3D" unique_id=127773521]
mesh = SubResource("BoxMesh_i5j2u")
[node name="Label3D" type="Label3D" parent="CollisionShape3D" unique_id=149978729]
transform = Transform3D(0.9999992, 0, 0, 0, 0.99999946, 0, 0, 0, 0.9999992, 0.002797456, 0.6309581, -0.2812457)
text = "Counter"
[node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=1647380272 groups=["station"]] [node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=1647380272 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5608841, 0)
collision_layer = 65536 collision_layer = 65536
collision_mask = 65536 collision_mask = 65536
script = ExtResource("1_dlkho") script = ExtResource("1_dlkho")
snap_mode = 0 snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6" metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=969340130] [node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=969340130]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0) transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho") shape = SubResource("SphereShape3D_dlkho")
[node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=451757283]
[node name="CSGBox3D" type="CSGBox3D" parent="CSGCombiner3D" unique_id=305755330]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.045776367, 0)
size = Vector3(1, 0.90844727, 1)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGBox3D2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=43663195]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.45385742, 0.025390625)
size = Vector3(1, 0.092285156, 1.0507813)
[node name="CSGBox3D3" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1210567516]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5040283, -0.46044922)
size = Vector3(1, 0.19262695, 0.07910156)
[node name="CSGBox3D4" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1533996708]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.25531006, -0.054498255, 0.5233302)
size = Vector3(0.48937988, 0.90844727, 0.023986816)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGBox3D5" type="CSGBox3D" parent="CSGCombiner3D" unique_id=645225767]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.24944723, -0.054498255, 0.5233302)
size = Vector3(0.48937988, 0.90844727, 0.023986816)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGSphere3D" type="CSGSphere3D" parent="CSGCombiner3D" unique_id=1785057925]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.057649717, 0.12459713, 0.55428153)
radius = 0.025492515
[node name="CSGSphere3D2" type="CSGSphere3D" parent="CSGCombiner3D" unique_id=1224446005]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.060837455, 0.12459713, 0.55428153)
radius = 0.025492515
[node name="CSGBox3D6" type="CSGBox3D" parent="CSGCombiner3D" unique_id=187377667]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.060742255, 0.4885016, 0.0011152327)
size = Vector3(0.56365967, 0.092285156, 0.42160034)
material = SubResource("StandardMaterial3D_vlqg6")
[node name="CSGBox3D6" type="CSGBox3D" parent="CSGCombiner3D/CSGBox3D6" unique_id=1146824846]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.19631815, 0.01924485, -0.0039587077)
operation = 2
size = Vector3(0.06542969, 0.092285156, 0.26757813)
+87 -20
View File
@@ -1,25 +1,20 @@
[gd_scene format=3 uid="uid://j7caslh27nor"] [gd_scene format=3 uid="uid://j7caslh27nor"]
[ext_resource type="Script" uid="uid://bsn8vhv5adxdo" path="res://Stations/hob.gd" id="1_7jc4g"] [ext_resource type="Script" uid="uid://bsn8vhv5adxdo" path="res://Stations/hob.gd" id="1_7jc4g"]
[ext_resource type="Texture2D" uid="uid://b7n8d4krwbcvl" path="res://Textures/5.png" id="2_0sasq"]
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="2_er1dp"] [ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="2_er1dp"]
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"] [sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_m5xe2"]
albedo_texture = ExtResource("2_0sasq")
uv1_triplanar = true
[sub_resource type="BoxMesh" id="BoxMesh_i5j2u"]
material = SubResource("StandardMaterial3D_m5xe2")
[sub_resource type="SphereShape3D" id="SphereShape3D_vlqg6"] [sub_resource type="SphereShape3D" id="SphereShape3D_vlqg6"]
radius = 0.3 radius = 0.3
[sub_resource type="SceneReplicationConfig" id="Repl_hob"] [sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_hob"]
properties/0/path = NodePath(".:progress") properties/0/path = NodePath(".:time_cooked")
properties/0/spawn = true properties/0/spawn = true
properties/0/replication_mode = 1 properties/0/replication_mode = 1
properties/1/path = NodePath(".:cooking_result_time")
properties/1/spawn = true
properties/1/replication_mode = 1
[node name="Hob" type="StaticBody3D" unique_id=1687971542 groups=["station"]] [node name="Hob" type="StaticBody3D" unique_id=1687971542 groups=["station"]]
script = ExtResource("1_7jc4g") script = ExtResource("1_7jc4g")
@@ -27,24 +22,96 @@ script = ExtResource("1_7jc4g")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1283846023] [node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1283846023]
shape = SubResource("BoxShape3D_24d3s") shape = SubResource("BoxShape3D_24d3s")
[node name="MeshInstance3D" type="MeshInstance3D" parent="CollisionShape3D" unique_id=1454284058]
mesh = SubResource("BoxMesh_i5j2u")
[node name="Label3D" type="Label3D" parent="CollisionShape3D" unique_id=757148654]
transform = Transform3D(0.9999992, 0, 0, 0, 0.99999946, 0, 0, 0, 0.9999992, 0.002797456, 0.6309581, -0.2812457)
text = "Hob"
[node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=538157739] [node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=538157739]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0)
collision_layer = 65536 collision_layer = 65536
collision_mask = 65536 collision_mask = 65536
script = ExtResource("2_er1dp") script = ExtResource("2_er1dp")
snap_mode = 0 snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6" metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=370920392] [node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=370920392]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0) transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_vlqg6") shape = SubResource("SphereShape3D_vlqg6")
[node name="ProgressSync" type="MultiplayerSynchronizer" parent="."] [node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=70901698]
replication_config = SubResource("Repl_hob")
[node name="CSGBox3D" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1111505172]
[node name="CSGCylinder3D" type="CSGCylinder3D" parent="CSGCombiner3D/CSGBox3D" unique_id=1338463939]
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5103538, -7.827557e-05)
operation = 2
radius = 0.25976563
height = 0.08898926
sides = 32
[node name="CSGTorus3D3" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=666891341]
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5003869, -7.827557e-05)
inner_radius = 0.09153879
outer_radius = 0.120084204
sides = 32
[node name="CSGTorus3D" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=1901978664]
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5003869, -7.827557e-05)
inner_radius = 0.20426142
outer_radius = 0.2321898
sides = 32
[node name="CSGTorus3D2" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=24541894]
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5003869, -7.827557e-05)
inner_radius = 0.15036294
outer_radius = 0.17592031
sides = 32
[node name="CSGCylinder3D" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=1107239311]
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.4498226, -7.827557e-05)
radius = 0.071777344
height = 0.09637451
sides = 32
[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=435540815]
transform = Transform3D(0.8943019, 0, 0, 0, -3.9091177e-08, 0.8943019, 0, -0.8943019, -3.9091177e-08, 0, 0.48668858, -0.0020651226)
radius = 0.008
height = 0.44487303
[node name="CSGCylinder3D3" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=878171801]
transform = Transform3D(-3.9091177e-08, -0.8943019, -3.9091177e-08, 0, -3.9091177e-08, 0.8943019, -0.8943019, 3.9091177e-08, 1.7087296e-15, 0, 0.48668858, -0.0020651226)
radius = 0.008
height = 0.44487303
[node name="CSGCylinder3D4" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=1619646436]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, -0.26920843, 0.42728558, 0.4856163)
radius = 0.044433594
height = 0.10932617
[node name="CSGCylinder3D5" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=1748405277]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0.1318646, 0.42728558, 0.4856163)
radius = 0.044433594
height = 0.10932617
[node name="CSGCylinder3D6" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=624102144]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0.25760216, 0.42728558, 0.4856163)
radius = 0.044433594
height = 0.10932617
[node name="CSGBox3D2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=465340362]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.039733887, -0.07685089, 0.47770447)
operation = 2
size = Vector3(1.0999756, 0.8462982, 0.072387695)
[node name="CSGBox3D3" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1813079759]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.003479004, -0.08253479, 0.4977833)
size = Vector3(0.9842529, 0.8349304, 0.072387695)
[node name="CSGBox3D4" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1978186019]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.003479004, -0.45215607, 0.46387827)
size = Vector3(0.9842529, 0.095687866, 0.14019775)
[node name="CSGBox3D5" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1423530445]
transform = Transform3D(0.94465846, 0, 0, 0, 0.94465846, 0, 0, 0, 0.94465846, -0.003479004, -0.08253479, 0.5616675)
operation = 2
size = Vector3(0.9842529, 0.8349304, 0.072387695)
[node name="Sync" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_hob")
+19
View File
@@ -0,0 +1,19 @@
extends StaticBody3D
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
snap_zone.has_picked_up.connect(_makeDirty)
func _makeDirty(item) -> void:
if not NetworkManager.owns_world():
return
var plate: PlateController = item.get_node_or_null("PlateController") as PlateController
if not plate:
print("Dirt Station: held object is not a Plate")
return
if not plate.is_dirty:
plate.is_dirty = true
+1
View File
@@ -0,0 +1 @@
uid://q7tpwmrbmu1m
+43
View File
@@ -0,0 +1,43 @@
[gd_scene format=3 uid="uid://cnjwtnhwh0i8q"]
[ext_resource type="Script" uid="uid://q7tpwmrbmu1m" path="res://Stations/dirt_station.gd" id="1_hc1d4"]
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="2_v0ytd"]
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
size = Vector3(0.5, 1, 0.5)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_qn1ku"]
albedo_color = Color(0.45262197, 0.26770625, 0.2601587, 1)
[sub_resource type="BoxMesh" id="BoxMesh_ay2w6"]
material = SubResource("StandardMaterial3D_qn1ku")
size = Vector3(0.5, 1, 0.5)
[sub_resource type="SphereShape3D" id="SphereShape3D_dlkho"]
radius = 0.3
[node name="DirtStation" type="StaticBody3D" unique_id=160842153 groups=["station"]]
script = ExtResource("1_hc1d4")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1400366312]
shape = SubResource("BoxShape3D_24d3s")
[node name="MeshInstance3D" type="MeshInstance3D" parent="CollisionShape3D" unique_id=55726639]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.008318901, 0, -0.022509098)
mesh = SubResource("BoxMesh_ay2w6")
[node name="Label3D" type="Label3D" parent="CollisionShape3D" unique_id=1251766743]
transform = Transform3D(0.9999992, 0, 0, 0, 0.99999946, 0, 0, 0, 0.9999992, 0.002797456, 0.6309581, -0.2812457)
text = "Dirt"
[node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=333161027 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5270121, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("2_v0ytd")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=1398712192]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
+52 -45
View File
@@ -1,62 +1,69 @@
extends StaticBody3D extends StaticBody3D
## Cooking station. The cook timer and item conversion run only on the machine const RECIPE_MANAGER = preload("res://RecipeManager.gd")
## that owns world logic (server or offline host); `progress` is replicated to
## clients for UI via a MultiplayerSynchronizer. This is the reference
## implementation of the reusable "station work-progress" pattern: a server-owned
## progress value advanced by an input source (here, a timer), which converts the
## held item once it reaches the threshold and spawns the result over the network.
@export var cook_speed := 1.0 @export var cook_speed = 1
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone @onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
var item = null var time_cooked = 0
var progress: float = 0.0 var cooking_result = null
var cooking_result_time = 0
func _ready() -> void: func _ready() -> void:
if snap_zone.initial_object: snap_zone.has_picked_up.connect(_on_object_picked_up)
item = snap_zone.initial_object snap_zone.has_dropped.connect(_on_object_dropped)
func _on_object_picked_up(_item) -> void:
print("Hob: object picked up: ", _item)
func convert_item(cookable: CookableItem) -> void: # Find the CookableItem in held object
var old_pickable = snap_zone.picked_up_object var _food_item = _item.get_node_or_null("FoodItem") as FoodItem
if not old_pickable: if not _food_item:
push_warning("Hob finished cooking item, but snap zone is missing its reference") print("Hob: held object is not a FoodItem")
return
var result = RECIPE_MANAGER.get_cooking_result(_food_item.id)
if not result:
print("Hob: held a FoodItem that is not cookable, id: ", _food_item.id)
return return
var original_transform: Transform3D = old_pickable.global_transform cooking_result = result
cooking_result_time = RECIPE_MANAGER.get_cooking_time(_food_item.id)
# Spawn the result over the network (server) or locally (offline).
var result := NetworkManager.spawn_item(cookable.turns_into.resource_path, original_transform)
# Remove the cooked item (despawn replicates for spawner-managed items).
snap_zone.drop_object()
old_pickable.queue_free()
# Snap the result into the now-empty zone.
if result:
snap_zone.pick_up_object(result)
func _process(delta: float) -> void: func _on_object_dropped() -> void:
# Only the world owner runs cooking logic; clients receive `progress` + the print("Hob: object drop ")
# converted item via replication. time_cooked = 0
cooking_result = null
cooking_result_time = 0
func convert_held_to_item(_item: PackedScene) -> void:
if not NetworkManager.owns_world(): if not NetworkManager.owns_world():
return return
print("Hob converting ", _item)
# Find the CookableItem in the held object. var old_pickable = snap_zone.picked_up_object
var cookable = null if not old_pickable:
if snap_zone.picked_up_object: push_warning("Hob finished cooking _item, but snap zone is missing its reference")
var matches = snap_zone.picked_up_object.get_children().filter(func(c): return c is CookableItem) return
if matches.size() > 0:
cookable = matches[0] # Spawn the new item through the server so it replicates to every peer
# (including late joiners), in the old item's position.
var original_transform = old_pickable.global_transform
var new_scene_instance = NetworkManager.spawn_item(_item.resource_path, original_transform)
# Drop and free the old item and pick up the new one
print("Hob freeing old_pickable ", old_pickable)
snap_zone.drop_object()
NetworkManager.despawn_item(old_pickable)
snap_zone.pick_up_object(new_scene_instance)
# If cooking something, progress cooking, when done, convert item
func _process(delta: float) -> void:
if cooking_result:
time_cooked += cook_speed * delta
if time_cooked >= cooking_result_time:
time_cooked = 0
convert_held_to_item(cooking_result)
# Advance progress and convert once cooked.
if cookable:
progress += cook_speed * delta
if progress >= cookable.cooking_time:
progress = 0.0
convert_item(cookable)
else:
progress = 0.0
+1 -3
View File
@@ -13,10 +13,8 @@ func _ready() -> void:
# Called every frame. 'delta' is the elapsed time since the previous frame. # Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void: func _process(delta: float) -> void:
# Only the world owner dispenses, so every client doesn't spawn its own copy.
if not NetworkManager.owns_world(): if not NetworkManager.owns_world():
return return
if not snap_zone.picked_up_object: if not snap_zone.picked_up_object:
var new_item := NetworkManager.spawn_item(item_scene.resource_path, snap_zone.global_transform) var new_item = NetworkManager.spawn_item(item_scene.resource_path, snap_zone.global_transform)
if new_item:
snap_zone.pick_up_object(new_item) snap_zone.pick_up_object(new_item)
+40
View File
@@ -0,0 +1,40 @@
extends StaticBody3D
const plate_wash_time: float = 3.0
@export var plate_scene : PackedScene
@export_range(0.0, 10.0, 0.1) var wash_speed: float = 1.0
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
var progress: float = 0.0
func _ready() -> void:
if not plate_scene:
push_error("Sink is missing reference to plate scene")
func _process(delta: float) -> void:
var item = snap_zone.picked_up_object
if not item:
progress = 0
return # Exit early since there's nothing to process
var plate: PlateController = item.get_node_or_null("PlateController") as PlateController
if plate:
# Not dirty? Nothing to wash
if not plate.is_dirty:
return
# Washing logic using the encapsulated property
if progress >= plate_wash_time:
plate.is_dirty = false # This automatically updates visibility and the container state!
print("Sink washing complete!")
else:
progress += wash_speed * delta
print("Sink washing plate: ", progress)
# Sink empty, reset progress
if not snap_zone.picked_up_object:
progress = 0
+1
View File
@@ -0,0 +1 @@
uid://byh5j25mwt3oc
+86
View File
@@ -0,0 +1,86 @@
[gd_scene format=3 uid="uid://dvrk268s7gkxh"]
[ext_resource type="Script" uid="uid://byh5j25mwt3oc" path="res://Stations/sink.gd" id="1_7hh4b"]
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="2_3ocod"]
[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="2_ai6d4"]
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
[sub_resource type="SphereShape3D" id="SphereShape3D_dlkho"]
radius = 0.3
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_sink"]
properties/0/path = NodePath(".:progress")
properties/0/spawn = true
properties/0/replication_mode = 1
[node name="Sink" type="StaticBody3D" unique_id=2055277359 groups=["station"]]
script = ExtResource("1_7hh4b")
plate_scene = ExtResource("2_ai6d4")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=964735164]
shape = SubResource("BoxShape3D_24d3s")
[node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=1762550988 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5163779, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("2_3ocod")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=860566355]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=311064646]
[node name="CSGBox3D" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1147823280]
[node name="CSGSphere3D" type="CSGSphere3D" parent="CSGCombiner3D" unique_id=2129615695]
transform = Transform3D(1.1506689, 0, 0, 0, 2.334253, 0, 0, 0, 1, 0, 1.2831618, 0.06031096)
operation = 2
radial_segments = 20
[node name="CSGTorus3D" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=2055166617]
transform = Transform3D(-4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0, 0.84928787, 0, 0.5107032, -0.20686007)
inner_radius = 0.2
outer_radius = 0.3
sides = 16
[node name="CSGBox3D" type="CSGBox3D" parent="CSGCombiner3D/CSGTorus3D" unique_id=1722820789]
transform = Transform3D(0.5128697, 0, 0, 0, 0.3125893, 0, 0, 0, 1, -0.29027426, 2.3841858e-07, 0)
operation = 2
[node name="CSGBox3D2" type="CSGBox3D" parent="CSGCombiner3D/CSGTorus3D" unique_id=1812160684]
transform = Transform3D(0.7700283, 1.4431469e-09, 0.15563977, 3.974847e-09, 0.3125893, -6.8032304e-09, -0.38511342, 6.1118572e-09, 0.3111993, -0.2084502, 8.34465e-07, 0.36219144)
operation = 2
[node name="CSGBox3D2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=45680409]
transform = Transform3D(1, 0, 0, 0, 0.9915717, -0.12955965, 0, 0.12955965, 0.9915717, 0, 0.019589934, 0.57785285)
operation = 2
size = Vector3(1, 1.2053223, 0.352417)
[node name="CSGBox3D3" type="CSGBox3D" parent="CSGCombiner3D" unique_id=641957703]
transform = Transform3D(1, 0, 0, 0, 0.9367828, -0.34991136, 0, 0.34991136, 0.9367828, 0, -0.16928616, 0.4441058)
operation = 2
size = Vector3(1, 1.2053223, 0.352417)
[node name="CSGBox3D4" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1059266043]
transform = Transform3D(1, 0, 0, 0, 0.7815701, -0.62381756, 0, 0.62381756, 0.7815701, 0, -0.2719775, 0.22231093)
operation = 2
size = Vector3(1, 1.2053223, 0.352417)
[node name="CSGBox3D5" type="CSGBox3D" parent="CSGCombiner3D" unique_id=880649240]
transform = Transform3D(-4.371139e-08, -0.12955964, -0.9915717, 0, 0.99157166, -0.12955962, 0.99999994, -5.6632317e-09, -4.3342972e-08, -0.6016656, 0.019589934, -0.20879287)
operation = 2
size = Vector3(1.4033203, 1.2053223, 0.352417)
[node name="CSGBox3D6" type="CSGBox3D" parent="CSGCombiner3D" unique_id=770532411]
transform = Transform3D(1.3113414e-07, 0.12955962, 0.99157166, 8.8817837e-16, 0.9915716, -0.1295596, -0.9999999, 1.6989695e-08, 1.3002891e-07, 0.6035124, 0.019589934, -0.20879287)
operation = 2
size = Vector3(1.4033203, 1.2053223, 0.352417)
[node name="Sync" type="MultiplayerSynchronizer" parent="."]
root_path = NodePath("..")
replication_config = SubResource("SceneReplicationConfig_np_sink")
+200
View File
@@ -0,0 +1,200 @@
class_name Table
extends StaticBody3D
const GAME_MANAGER = preload("res://GameManager.gd")
@export var initial_thinking_time: float = 6
@export var initial_primary_time: float = 60
@export var initial_friend_time: float = 10
@export var initial_eating_time: float = 4
@onready var label_3d: Label3D = $Label3D
@onready var label_3d_time: Label3D = $Label3DTime
@onready var snap_zones: Array[XRToolsSnapZone] = []
const lbl_thinking: String = "Thinking..."
const lbl_waiting_primary: String = "Waiting for food"
const lbl_gameover: String = "Game Over!"
const lbl_eating: String = "Eating..."
### Finite State Machine ####
enum TableState {
EMPTY, # Just finished eating or havent eaten yet
THINKING,
WAITING_PRIMARY, # Inital wait to be served
WAITING_FRIEND, # Waiting for friends food to arrive
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 _setState) and clients (via incoming sync) show
## the same text. Use _setState(), never assign _state directly.
var _state: TableState = TableState.EMPTY: set = _set_state
var _state_time: float = 0.0: set = _set_state_time
var _unsatisfied_orders: Array[String] = []: set = _set_unsatisfied_orders
func _ready() -> void:
if not label_3d:
push_error("Table is missing reference to Label3D")
if not label_3d_time:
push_error("Table is missing reference to Label3DTime")
# Get snap_zones for plates, store in array snap_zones
for child in get_children():
var snap_zone_node = child as XRToolsSnapZone
if snap_zone_node:
snap_zones.append(snap_zone_node)
if snap_zones.is_empty():
push_error("Table is missing XRToolsSnapZone children")
return
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)
_setState(TableState.EMPTY)
func _on_object_picked_up(_item) -> void:
print("Table: object picked up: ", _item)
_absorb_item_if_correct(_item)
func _on_object_dropped() -> void:
print("Table: object dropped ")
func _get_plate_from_item(_item: Node) -> PlateController:
return _item.get_children().filter(func(c): return c is PlateController).front() as PlateController
func _absorb_item_if_correct(_item: Node) -> void:
# Get plate controller in childer of _item (hopefully a plate XRpickable)
var plate = _get_plate_from_item(_item)
if not plate:
print("Table: held object is not a Plate")
return
print("Table: held a Plate!")
# Absorm items from the plate we want
for food_item in plate.container.contained_items:
print("Table: held a plate with FoodItem: ", food_item.id)
if food_item.id in _unsatisfied_orders:
print("Table: held FoodItem is in unsatisfied orders, removing it")
_unsatisfied_orders.erase(food_item.id)
GAME_MANAGER.money += food_item.sell_value
_setState(TableState.WAITING_FRIEND)
# Table has everything it wants. Start eating
if _unsatisfied_orders.is_empty():
_setState(TableState.EATING)
func place_order() -> void:
print("Table: place_order()")
_unsatisfied_orders.append(GAME_MANAGER.get_random_meal())
_unsatisfied_orders.append(GAME_MANAGER.get_random_meal())
func satisfyAllOrders() -> void:
print("Table: satisfyAllOrders()")
_unsatisfied_orders.clear()
clearAllPlates()
_setState(TableState.EMPTY)
func clearAllPlates() -> void:
print("Table: clearAllPlates()")
for snap_zone_node in snap_zones:
var held_object = snap_zone_node.picked_up_object
if not held_object:
continue
var plate = _get_plate_from_item(held_object)
if plate:
plate.container.clear()
plate.is_dirty = true
## Server-only state transition: sets the new state's timer and assigns
## _state (whose setter refreshes the display on every peer).
func _setState(newState: TableState) -> void:
print("Table set _state: ", TableState.keys()[newState])
match newState:
TableState.EMPTY:
_state_time = 0.0
TableState.THINKING:
_state_time = initial_eating_time
TableState.WAITING_PRIMARY:
_state_time = initial_primary_time
TableState.WAITING_FRIEND:
_state_time = initial_friend_time
TableState.EATING:
_state_time = initial_eating_time
_state = newState
## Pure presentation, driven off the current (locally authoritative or
## synced-from-server) state. Runs on every peer.
func _refresh_display() -> void:
if not label_3d:
return
match _state:
TableState.EMPTY:
label_3d.text = "empty"
TableState.THINKING:
label_3d.text = lbl_thinking
TableState.WAITING_PRIMARY, TableState.WAITING_FRIEND:
label_3d.text = "%s\n%s" % [TableState.keys()[_state], "\n".join(_unsatisfied_orders)]
TableState.EATING:
label_3d.text = lbl_eating
if label_3d_time:
label_3d_time.text = "%.1f" % _state_time
func _set_state(value: TableState) -> void:
_state = value
_refresh_display()
func _set_state_time(value: float) -> void:
_state_time = value
_refresh_display()
func _set_unsatisfied_orders(value: Array[String]) -> void:
_unsatisfied_orders = value
_refresh_display()
func _process(delta: float) -> void:
# Wait for state to finish
if _state_time > 0:
_state_time -= delta
return
# When state timer is finished, do this stuff before moving to next state
match _state:
TableState.EMPTY:
_setState(TableState.THINKING)
TableState.THINKING:
place_order()
_setState(TableState.WAITING_PRIMARY)
TableState.WAITING_PRIMARY:
label_3d.text = lbl_gameover
TableState.WAITING_FRIEND:
label_3d.text = lbl_gameover
TableState.EATING:
clearAllPlates()
_setState(TableState.EMPTY)
+1
View File
@@ -0,0 +1 @@
uid://caeikc7e3igkd
+171
View File
@@ -0,0 +1,171 @@
[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="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="1_8j1nt"]
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
[sub_resource type="SphereShape3D" id="SphereShape3D_dlkho"]
radius = 0.3
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_24d3s"]
albedo_color = Color(0.31, 0.21576, 0.1333, 1)
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_table"]
properties/0/path = NodePath(".:_state")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:_state_time")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:_unsatisfied_orders")
properties/2/spawn = true
properties/2/replication_mode = 1
[node name="Table" type="StaticBody3D" unique_id=1863572470 groups=["station"]]
script = ExtResource("1_2vcpj")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=228053941]
shape = SubResource("BoxShape3D_24d3s")
[node name="XRToolsSnapZone" type="Area3D" parent="." unique_id=1947858647 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.31874347, 0.5270121, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("1_8j1nt")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" 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="XRToolsSnapZone2" type="Area3D" parent="." unique_id=694448387 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.34755045, 0.5270121, 0)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("1_8j1nt")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone2" unique_id=650960099]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[node name="XRToolsSnapZone3" type="Area3D" parent="." unique_id=1734026785 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.012355864, 0.5270121, 0.33038777)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("1_8j1nt")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone3" unique_id=1271210642]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[node name="XRToolsSnapZone4" type="Area3D" parent="." unique_id=1358457948 groups=["station"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.022546828, 0.5270121, -0.36528283)
collision_layer = 65536
collision_mask = 65536
script = ExtResource("1_8j1nt")
snap_mode = 1
metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone4" unique_id=1880499470]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_dlkho")
[node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=2085635675]
[node name="CSGBox3D" type="CSGBox3D" parent="CSGCombiner3D" unique_id=411048765]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.46137518, 0)
size = Vector3(1, 0.07678223, 1)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGBox3D2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=2093582402]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.0023368, 0)
size = Vector3(0.1, 0.8768555, 0.1)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGBox3D3" type="CSGBox3D" parent="CSGCombiner3D" unique_id=839228683]
transform = Transform3D(0.86602545, 0.50000006, 0, -0.50000006, 0.86602545, 0, 0, 0, 1, -0.21657634, -0.5113707, 0)
size = Vector3(0.1, 0.8768555, 0.1)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGBox3D4" type="CSGBox3D" parent="CSGCombiner3D" unique_id=1028598922]
transform = Transform3D(-0.45971727, -0.2654179, 0.8474747, -0.50000006, 0.86602545, 0, -0.73393464, -0.4237374, -0.53083575, 0.10796261, -0.5113707, 0.16580808)
size = Vector3(0.1, 0.8768555, 0.1)
material = SubResource("StandardMaterial3D_24d3s")
[node name="CSGBox3D5" type="CSGBox3D" parent="CSGCombiner3D" unique_id=892392906]
transform = Transform3D(-0.38498235, -0.12489955, -0.9144338, -0.5286442, 0.8420017, 0.107556336, 0.756521, 0.5248176, -0.39018327, 0.059352398, -0.5113707, -0.2290039)
size = Vector3(0.1, 0.8768555, 0.1)
material = SubResource("StandardMaterial3D_24d3s")
[node name="Customers" type="Node3D" parent="." unique_id=2147179512]
[node name="Customer" type="Node3D" parent="Customers" 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="Customers/Customer" 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="Customers/Customer/CSGCombiner3D" unique_id=2104132980]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.88519466, 0.9358529, 0)
radius = 0.17520222
[node name="CSGSphere3D2" type="CSGSphere3D" parent="Customers/Customer/CSGCombiner3D" unique_id=808477724]
transform = Transform3D(1, 0, 0, 0, 1.747647, 0, 0, 0, 1, -0.9068739, 0.51278543, 0)
radius = 0.17520222
[node name="Seats" type="Node3D" parent="." unique_id=1101423789]
[node name="Seat" type="Node3D" parent="Seats" 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="Seats/Seat" unique_id=384912485]
[node name="CSGCylinder3D" type="CSGCylinder3D" parent="Seats/Seat/CSGCombiner3D" unique_id=170426769]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.14400849, 0)
radius = 0.25634766
height = 0.051940918
sides = 16
[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="Seats/Seat/CSGCombiner3D" unique_id=1294605073]
transform = Transform3D(0.24355066, 0.98373884, 0, -1.3339849, 0.1796049, 0, 0, 0, 0.8365013, -0.23884833, 0.48196742, 0)
radius = 0.25634766
height = 0.051940918
sides = 16
[node name="CSGBox3D" type="CSGBox3D" parent="Seats/Seat/CSGCombiner3D" unique_id=1761839440]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.15600169, -0.18527734, 0.1341562)
size = Vector3(0.05, 0.6713196, 0.05)
[node name="CSGBox3D2" type="CSGBox3D" parent="Seats/Seat/CSGCombiner3D" unique_id=1395371703]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.15600169, -0.18527734, -0.15065354)
size = Vector3(0.05, 0.6713196, 0.05)
[node name="CSGBox3D3" type="CSGBox3D" parent="Seats/Seat/CSGCombiner3D" unique_id=96608061]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.1444692, -0.18527734, 0.1341562)
size = Vector3(0.05, 0.6713196, 0.05)
[node name="CSGBox3D4" type="CSGBox3D" parent="Seats/Seat/CSGCombiner3D" unique_id=1684738537]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.1444692, -0.18527734, -0.15065354)
size = Vector3(0.05, 0.6713196, 0.05)
[node name="Label3D" type="Label3D" parent="." unique_id=662960977]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.3247777, 0)
billboard = 2
text = "table state"
vertical_alignment = 0
line_spacing = -15.0
[node name="Label3DTime" type="Label3D" parent="." unique_id=1534849507]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.3306234, 0)
pixel_size = 0.003
billboard = 2
text = "20.1s"
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=2000411505]
replication_config = SubResource("SceneReplicationConfig_np_table")
+3
View File
@@ -1,3 +1,6 @@
- Move recipes into a Yaml file that is read, and handles symetry through a Static manager class? - Move recipes into a Yaml file that is read, and handles symetry through a Static manager class?
A `FoodItem` has an ID. A `FoodItem` has an ID.
Example: Example:
+5 -2
View File
@@ -4,10 +4,13 @@
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"]
transparency = 1 transparency = 1
albedo_color = Color(0.62908286, 0.62908286, 0.62908286, 1) albedo_color = Color(0.11502249, 0.11502249, 0.11502249, 1)
albedo_texture = ExtResource("1_3jxsn") albedo_texture = ExtResource("1_3jxsn")
uv1_triplanar = true uv1_triplanar = true
[resource] [resource]
next_pass = SubResource("StandardMaterial3D_0gbf8") next_pass = SubResource("StandardMaterial3D_0gbf8")
albedo_color = Color(0.35156325, 0.3515627, 0.3515629, 1) albedo_color = Color(0, 0, 0, 1)
metallic = 1.0
metallic_specular = 0.0
roughness = 0.0
+130
View File
@@ -0,0 +1,130 @@
extends Control
## The 2D UI rendered inside the main menu's world-space viewport
## (XRToolsViewport2DIn3D). Lets the player pick John's scene, host a
## multiplayer game, or join one by IP, driving scene switches + NetworkManager
## directly.
const JON_SCENE := "res://Scenes/JonScene.tscn"
const MULTIPLAYER_SCENE := "res://Scenes/multiPlayer.tscn"
const SETTINGS_PATH := "user://vryhungry_settings.cfg"
var _ip := "127.0.0.1"
@onready var _root_view: Control = %RootView
@onready var _join_view: Control = %JoinView
@onready var _ip_label: Label = %IPLabel
@onready var _status: Label = %Status
@onready var _keypad: GridContainer = %Keypad
@onready var _john_btn: Button = %JohnButton
@onready var _host_btn: Button = %HostButton
@onready var _join_menu_btn: Button = %JoinMenuButton
@onready var _join_btn: Button = %JoinButton
@onready var _back_btn: Button = %BackButton
func _ready() -> void:
if _bypass_menu_for_cmdline():
return
for child in _keypad.get_children():
if child is Button:
child.pressed.connect(_on_key.bind(child.text))
_john_btn.pressed.connect(_on_john_pressed)
_host_btn.pressed.connect(_on_host_pressed)
_join_menu_btn.pressed.connect(_show_join_view)
_join_btn.pressed.connect(_on_join_pressed)
_back_btn.pressed.connect(_show_root_view)
_load_ip()
_show_root_view()
_refresh_ip()
# Show why we're back here, if the last session ended unexpectedly
# (dropped connection, failed join). Pulled rather than pushed, since
# NetworkManager may have set this before this panel even existed.
var status := NetworkManager.take_status()
if not status.is_empty():
set_status(status)
## --server / --join <ip> on the command line skip straight to the multiplayer
## scene, same as the previously headless-tested main.tscn flow.
func _bypass_menu_for_cmdline() -> bool:
var args := OS.get_cmdline_user_args()
if args.has("--server"):
NetworkManager.request_host()
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
return true
if args.has("--join"):
var idx := args.find("--join")
var addr := "127.0.0.1"
if idx + 1 < args.size():
addr = args[idx + 1]
NetworkManager.request_join(addr)
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
return true
return false
func _show_root_view() -> void:
_root_view.visible = true
_join_view.visible = false
func _show_join_view() -> void:
_root_view.visible = false
_join_view.visible = true
set_status("Enter host IP, then Join")
func _on_key(key: String) -> void:
match key:
"DEL":
_ip = _ip.substr(0, max(0, _ip.length() - 1))
_:
if _ip.length() < 21:
_ip += key
_refresh_ip()
func _refresh_ip() -> void:
_ip_label.text = _ip if not _ip.is_empty() else "_"
func _on_john_pressed() -> void:
NetworkManager.leave()
get_tree().change_scene_to_file.call_deferred(JON_SCENE)
func _on_host_pressed() -> void:
NetworkManager.request_host()
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
func _on_join_pressed() -> void:
if _ip.is_empty():
set_status("Enter an IP first")
return
_save_ip()
NetworkManager.request_join(_ip)
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
## Remembers the last IP the player tried to join, so the keypad starts
## pre-filled next time instead of always defaulting to 127.0.0.1.
func _load_ip() -> void:
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) == OK:
_ip = cfg.get_value("network", "last_ip", _ip)
func _save_ip() -> void:
var cfg := ConfigFile.new()
cfg.load(SETTINGS_PATH)
cfg.set_value("network", "last_ip", _ip)
cfg.save(SETTINGS_PATH)
## Called by network_manager (e.g. on connection_failed after a bounce back to
## this menu) to show feedback.
func set_status(text: String) -> void:
if _status:
_status.text = text
+1
View File
@@ -0,0 +1 @@
uid://crs47shds8bm4
+72 -40
View File
@@ -1,8 +1,8 @@
[gd_scene load_steps=2 format=3] [gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Net/join_panel.gd" id="1_panel"] [ext_resource type="Script" path="res://UI/main_menu_panel.gd" id="1_panel"]
[node name="JoinPanel" type="Control"] [node name="MainMenuPanel" type="Control"]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
@@ -26,32 +26,72 @@ theme_override_constants/margin_top = 16
theme_override_constants/margin_right = 24 theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 16 theme_override_constants/margin_bottom = 16
[node name="VBox" type="VBoxContainer" parent="Margin"] [node name="Title" type="Label" parent="Margin"]
layout_mode = 2
size_flags_vertical = 0
theme_override_font_sizes/font_size = 26
text = "VRyHungry"
horizontal_alignment = 1
[node name="Status" type="Label" parent="Margin"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 0
theme_override_colors/font_color = Color(0.8, 0.8, 0.5, 1)
theme_override_font_sizes/font_size = 16
text = ""
horizontal_alignment = 1
[node name="RootView" type="VBoxContainer" parent="Margin"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 14
[node name="Spacer" type="Control" parent="Margin/RootView"]
custom_minimum_size = Vector2(0, 36)
layout_mode = 2
[node name="JohnButton" type="Button" parent="Margin/RootView"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 64)
theme_override_font_sizes/font_size = 26
text = "Play John's Scene"
[node name="HostButton" type="Button" parent="Margin/RootView"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 64)
theme_override_font_sizes/font_size = 26
text = "Host Multiplayer"
[node name="JoinMenuButton" type="Button" parent="Margin/RootView"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 64)
theme_override_font_sizes/font_size = 26
text = "Join Multiplayer"
[node name="JoinView" type="VBoxContainer" parent="Margin"]
unique_name_in_owner = true
visible = false
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 10 theme_override_constants/separation = 10
[node name="Title" type="Label" parent="Margin/VBox"] [node name="Title" type="Label" parent="Margin/JoinView"]
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 22
text = "VRyHungry — Multiplayer" text = "Join Multiplayer"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="IPLabel" type="Label" parent="Margin/VBox"] [node name="IPLabel" type="Label" parent="Margin/JoinView"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 34 theme_override_font_sizes/font_size = 34
text = "127.0.0.1" text = "127.0.0.1"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Status" type="Label" parent="Margin/VBox"] [node name="Keypad" type="GridContainer" parent="Margin/JoinView"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.8, 0.8, 0.5, 1)
theme_override_font_sizes/font_size = 18
text = "Enter host IP, then Join"
horizontal_alignment = 1
[node name="Keypad" type="GridContainer" parent="Margin/VBox"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 size_flags_vertical = 3
@@ -59,103 +99,95 @@ theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 8 theme_override_constants/v_separation = 8
columns = 3 columns = 3
[node name="B1" type="Button" parent="Margin/VBox/Keypad"] [node name="B1" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "1" text = "1"
[node name="B2" type="Button" parent="Margin/VBox/Keypad"] [node name="B2" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "2" text = "2"
[node name="B3" type="Button" parent="Margin/VBox/Keypad"] [node name="B3" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "3" text = "3"
[node name="B4" type="Button" parent="Margin/VBox/Keypad"] [node name="B4" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "4" text = "4"
[node name="B5" type="Button" parent="Margin/VBox/Keypad"] [node name="B5" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "5" text = "5"
[node name="B6" type="Button" parent="Margin/VBox/Keypad"] [node name="B6" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "6" text = "6"
[node name="B7" type="Button" parent="Margin/VBox/Keypad"] [node name="B7" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "7" text = "7"
[node name="B8" type="Button" parent="Margin/VBox/Keypad"] [node name="B8" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "8" text = "8"
[node name="B9" type="Button" parent="Margin/VBox/Keypad"] [node name="B9" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "9" text = "9"
[node name="BDot" type="Button" parent="Margin/VBox/Keypad"] [node name="BDot" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "." text = "."
[node name="B0" type="Button" parent="Margin/VBox/Keypad"] [node name="B0" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 26 theme_override_font_sizes/font_size = 26
text = "0" text = "0"
[node name="BDel" type="Button" parent="Margin/VBox/Keypad"] [node name="BDel" type="Button" parent="Margin/JoinView/Keypad"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "DEL" text = "DEL"
[node name="Buttons" type="HBoxContainer" parent="Margin/VBox"] [node name="Buttons" type="HBoxContainer" parent="Margin/JoinView"]
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 10 theme_override_constants/separation = 10
[node name="HostButton" type="Button" parent="Margin/VBox/Buttons"] [node name="JoinButton" type="Button" parent="Margin/JoinView/Buttons"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(0, 56)
theme_override_font_sizes/font_size = 24
text = "HOST"
[node name="JoinButton" type="Button" parent="Margin/VBox/Buttons"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
@@ -163,10 +195,10 @@ custom_minimum_size = Vector2(0, 56)
theme_override_font_sizes/font_size = 24 theme_override_font_sizes/font_size = 24
text = "JOIN" text = "JOIN"
[node name="SoloButton" type="Button" parent="Margin/VBox/Buttons"] [node name="BackButton" type="Button" parent="Margin/JoinView/Buttons"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
custom_minimum_size = Vector2(0, 56) custom_minimum_size = Vector2(0, 56)
theme_override_font_sizes/font_size = 24 theme_override_font_sizes/font_size = 24
text = "SOLO" text = "BACK"
@@ -186,6 +186,12 @@ func _is_correct_hand(grabber : Node3D) -> bool:
# Get the positional tracker # Get the positional tracker
var tracker := XRServer.get_tracker(controller.tracker) as XRPositionalTracker var tracker := XRServer.get_tracker(controller.tracker) as XRPositionalTracker
# Without an XR runtime (desktop/headless testing) there is no tracker, so
# we can't tell which hand this is. Treat it as "not the correct hand" —
# the same result the null deref below used to produce after erroring.
if not tracker:
return false
# If left hand then verify left controller # If left hand then verify left controller
if hand == Hand.LEFT and tracker.hand != XRPositionalTracker.TRACKER_HAND_LEFT: if hand == Hand.LEFT and tracker.hand != XRPositionalTracker.TRACKER_HAND_LEFT:
return false return false
+323
View File
@@ -0,0 +1,323 @@
# Godot YAML Changelog
## Version 2.2.0
- Updated GDSchema to version 2.0.0, with JSON schema draft 2020-12 validation spec
- Enable CTRL+mouse wheel to zoom in the YAML editor ([Issue #17](https://github.com/fimbul-works/godot-yaml/issues/17))
- Renamed HistoryState class to YAMLEditorHistoryState, fixing [Issue #19](https://github.com/fimbul-works/godot-yaml/issues/19)
- Various small fixes
## Version 2.1.3
- Changed the behavior of the `x-yaml-tag` Schema validation rule to ignore tagless nodes
## Version 2.1.2
- Added support for Android (arm64, x86_64) platforms thanks to @Nemo1166
- Rewrote `String` variant strigification rules, resolving [Issue #15](https://github.com/fimbul-works/godot-yaml/issues/15)
- Fixed application hanging when closed due to an issue with the YAML class registry
## Version 2.1.1
Empty strings are stringified with quotes.
## Version 2.1.0
When passing the custom tag to `YAML.schema_register(class, serialize_method, deserialize_static, custom_tag)` use the custom tag when emitting, resolving [Issue #13](https://github.com/fimbul-works/godot-yaml/issues/13).
## Version 2.0.0
The second major version of Godot YAML brings powerful schema validation capabilities through the integration of [GDSchema](https://github.com/fimbul-works/gdschema), enabling [JSON Schema Draft-7](https://json-schema.org/) validation for your YAML data. This release also includes improved multi-document handling, custom tag support, and several important bug fixes. See the [migration guide](#migration-guide-from-version-1-to-200) for breaking changes.
### Major Features
#### Schema Validation (New!)
Version 2.0.0 introduces comprehensive schema validation powered by [GDSchema](https://github.com/fimbul-works/gdschema), bringing the full power of [JSON Schema Draft-7](https://json-schema.org/) to your YAML workflows. This flagship feature enables you to:
- **Validate YAML data** against industry-standard JSON Schema specifications
- **Define schemas in YAML** with native YAML syntax for better readability
- **Auto-apply defaults** when properties are missing using the `default` keyword
- **Validate YAML tags** with the custom `x-yaml-tag` keyword for type safety
- **Reference schemas** using `$ref` for modular, reusable validation rules
- **Register schemas globally** with `$id` for cross-file validation
- **Get detailed error reports** with JSON Pointer paths and constraint information
New classes:
- `Schema` - Validates data against JSON Schema Draft-7 specifications
- `SchemaValidationResult` - Contains detailed validation results and errors
New YAML methods:
- `YAML.load_schema_from_string()` - Parse YAML schema definitions
- `YAML.load_schema_from_file()` - Load schema files with auto-registration
- `YAML.parse_and_validate()` - Parse and validate YAML in one step
Schema features include:
- Full JSON Schema Draft-7 support (type checking, numeric constraints, string patterns, array/object validation)
- Logical composition (`allOf`, `anyOf`, `oneOf`, `not`)
- Conditional validation (`if`/`then`/`else`)
- Schema definitions and references (`$defs`, `$ref`)
- Custom format validators
- Thread-safe lazy compilation with caching
- YAML-specific extensions:
- `default` - Provides default values for missing properties
- `x-yaml-tag` - Validates YAML type tags (e.g., `!Resource`, `!CustomType`)
Example:
```gdscript
# Define a schema in YAML
var schema_yaml = """
$id: "http://example.com/user.yaml"
type: object
properties:
username:
type: string
minLength: 3
email:
type: string
format: email
role:
type: string
default: user
x-yaml-tag: UserRole
required:
- username
- email
"""
# Build the schema (auto-registered with $id)
var schema = YAML.load_schema_from_string(schema_yaml)
# Validate data
var data_yaml = """
username: Alice
email: alice@exampe.com
"""
var result = YAML.parse_and_validate(data_yaml, schema)
if result.is_valid():
print(result.get_data().role) # "user" (default applied)
else:
print(result.get_summary()) # Detailed error report
```
### Other Changes
- **Breaking:** Removed the `YAMLResult.get_data(index)` parameter. Use `YAMLResult.get_document(index)` instead for multi-document access.
- Added `YAMLResult.get_documents()` and `YAMLResult.has_multiple_documents()` methods for easier multi-document YAML handling
- Added support for custom tags when registering classes with `YAML.register_class()` - now you can specify custom YAML tags like `!Item` or `!ruby:object/Item`
- Added `YAMLCodeEdit` class to enable embedding the YAML editor directly into your projects
- Added missing example for handling multi-document YAML files
- Upgraded [RapidYAML](https://github.com/biojppm/rapidyaml) to version 0.10.0
### Bug Fixes
- Fixed `YAMLResult.get_data()` only returning the first element of a document that only contains an array
- Fixed invalid numeric value parsing that could cause crashes or incorrect values
- Fixed empty strings being parsed and emitted as null values instead of empty strings
- Fixed YAML files not opening correctly in Godot 4.3 editor
### Migration Guide From Version 1.* to 2.0.0
Replace `YAMLResult.get_data(index)` calls with `YAMLResult.get_document(index)`:
**Old version:**
```gdscript
var result = YAML.parse(multi_doc_yaml)
var first_doc = result.get_data(0)
var second_doc = result.get_data(1)
```
**New version:**
```gdscript
var result = YAML.parse(multi_doc_yaml)
var first_doc = result.get_document(0)
var second_doc = result.get_document(1)
# Or get all documents at once
var all_docs = result.get_documents()
```
## Version 1.1.0
- YAML files can now be loaded with the `!Resource` tag, allowing modular composition
- Fixes:
- Force YAML indentation to always use spaces instead of tabs
- The file system should now update properly when saving a new YAML file
- Fixed tab indentation not working correctly in the YAML editor
- Fixed Packed Array types not detecting array templates
- Prevent duplicates in YAML editor file list
## Version 1.0.0
The first major version of Godot YAML includes many improvements, and some breaking changes. See the [migration guide](#migration-guide-from-version-0121-to-100) for details.
### Changes
- Streamlined API in many places
- Added class documentation for the in-editor help
- Added custom YAML editor inside the Godot engine editor
- Added `YAMLSecurity` class for safer resource loading during parsing
- `YAMLStyle` API changes:
- Adopted Godot terminology for container form (Array and Dictionary instead of Sequence and Map)
- Merged scalar and quote styles into unified string style
- Separated number format into integer and float formats respectively
- Removed binary encoding (now always uses base64 encoding)
- Implemented serializing/deserializing style definitions
- Implemented stringifying non-local-to-scene resource references
- Performance optimizations
- Improved error reporting across the board with line/column tracking when parsing
- Fixes:
- Style definitions should now be parsed correctly
- Stringifying with styles should now work consistently
- Fixed custom tags not being used when stringifying with styles
- Fixed integer to string conversion with 64 bit values
### Migration Guide From Version 0.12.1 to 1.0.0
This migration guide should help you update your code to work with the latest version of the YAML plugin. The core functionality and architecture remain similar, but the style system has been refined for better consistency and usability.
#### Custom Class Serialization and Deserialization
Custom class serialization has been changed to allow serializing to/from just dictionaries to any of the supported Godot variants:
**Old version:**
```gdscript
class_name CustomClass extends Resource
@export var name: String
func _init(p_name: String = "") -> void:
name = p_name
func to_dict() -> Dictionary:
return {
"name": name
}
static func from_dict(data: Dictionary) -> CustomClass:
return CustomClass.new(data["name"])
```
**New version:**
```gdscript
class_name CustomClass extends Resource
@export var name: String
func _init(p_name: String = "") -> void:
name = p_name
func serialize() -> Variant:
return {
"name": name
}
static func deserialize(data: Variant) -> Variant:
if typeof(data) != TYPE_DICTIONARY:
return YAMLResult.error("CustomClass expects Dictionary")
return CustomClass.new(data["name"])
```
#### Container Form Changes
Previously the container forms used the YAML standard naming of *Sequences* and *Maps*, but to make the extension more Godot friendly the containers now follow Godot's naming conventions of *Arrays* and *Dictionaries*:
```gdscript
# Specify how containers are formatted
style.set_container_form(YAMLStyle.FORM_ARRAY) # Previously FORM_SEQ
style.set_container_form(YAMLStyle.FORM_DICTIONARY) # Previously FORM_MAP
```
#### Scalar Style and Quote Style Merged
**Old version:**
```gdscript
var style = YAML.create_style()
# Separate methods for different aspects of string formatting
style.set_scalar_style(YAMLStyle.SCALAR_LITERAL) # For block style (|)
style.set_quote_style(YAMLStyle.QUOTE_DOUBLE) # For quoting style
```
**New version:**
```gdscript
var style = YAML.create_style()
# Single method combining both formatting aspects
style.set_string_style(YAMLStyle.STRING_LITERAL) # For block style (|)
style.set_string_style(YAMLStyle.STRING_QUOTE_DOUBLE) # For quoting style
```
The constants have been unified:
- `YAMLStyle.SCALAR_PLAIN`, `YAMLStyle.SCALAR_LITERAL`, `YAMLStyle.SCALAR_FOLDED` are now accessible through the `STRING_*` constants
- `YAMLStyle.QUOTE_NONE`, `YAMLStyle.QUOTE_SINGLE`, `YAMLStyle.QUOTE_DOUBLE` have been replaced with `STRING_PLAIN`, `STRING_QUOTE_SINGLE`, `STRING_QUOTE_DOUBLE`
#### Number Format Changes
**Old version:**
```gdscript
style.set_number_format(YAMLStyle.NUM_HEX)
```
**New version:**
```gdscript
style.set_integer_format(YAMLStyle.INT_HEX)
style.set_float_format(YAMLStyle.FLOAT_SCIENTIFIC)
```
Integer and float formatting have been split into two methods with dedicated enum values:
- `YAMLStyle.NUM_DECIMAL`, `YAMLStyle.NUM_HEX`, etc. are now `YAMLStyle.INT_DECIMAL`, `YAMLStyle.INT_HEX`, etc.
- `YAMLStyle.NUM_SCIENTIFIC` is now `YAMLStyle.FLOAT_SCIENTIFIC`
#### Removed YAMLLoader and YAMLWriter Classes
If using the old loaders, they have been integrated to the main `YAML` class:
**Old version:**
```gdscript
# Load YAML from a file
var data = YAMLLoader.load_file("res://data.yaml")
if YAMLLoader.last_error != null:
print("Error loading file: ", YAMLLoader.last_error)
else:
print("Loaded data: ", data)
# Save data to a YAML file
var data = {"key": "value", "list": [1, 2, 3]}
var success = YAMLWriter.save_file(data, "user://output.yaml")
if !success:
print("Error saving file: ", YAMLWriter.last_error)
```
**New version:**
```gdscript
# Load YAML from a file
var load_result = YAML.load_file("res://example.yaml")
if load_result.has_error():
push_error(load_result.get_error())
return null
var data = load_result.get_data()
print("Loaded data: ", data)
# Save data to a YAML file
var save_result = YAML.save_file(data, "user://output.yaml")
if save_result.has_error():
push_error(save_result.get_error())
return null
var yaml_text = save_result.get_data()
print("Saved YAML:\n", yaml_text)
# The above can also be written as
var null_on_fail = YAML.try_load_file("res://example.yaml")
var success = YAML.try_save_file(data, "user://output.yaml")
```
## Older Versions
- **0.12.1** - Build support for Linux (x86 64-bit)
- **0.12.0** - Performance optimizations, bug fixes, and comprehensive tests for all variant types
- **0.11.0** - Added support for parsing multiple documents, and error handling for custom class deserialization
- **0.10.1** - Fixed issue with custom Resources not being serializable
- **0.10.0** - Added custom class serialization support, upgraded to Godot 4.3
- **0.9.0** - Initial public release
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 FimbulWorks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+767
View File
@@ -0,0 +1,767 @@
# Godot YAML
A high-performance YAML parsing and serialization plugin for Godot 4.3, powered by [RapidYAML](https://github.com/biojppm/rapidyaml). This plugin offers comprehensive YAML support with customizable styling options, full Godot variant type handling, custom class serialization, and industry-standard schema validation.
**New to YAML in Godot?** Check out the [`examples/`](./addons/yaml/examples/) directory for comprehensive usage examples covering all features.
## Version History
- **2.2.0** (Current) - Upgraded GDSchema to 2.0.0 for JSON validation draft 2020-12 spec, and various bug fixes
- **2.1.2** - Added support for Android (arm64, x86_64) platforms thanks to @Nemo1166, and fixed some bugs
- **2.1.1** - Empty strings are stringified with quotes
- **2.1.0** - When passing custom tag to `YAML.schema_register(class, serialize_method, deserialize_static, custom_tag)` use the custom tag when stringifying
- **2.0.0** - Major release with schema validation powered by [GDSchema](https://github.com/fimbul-works/gdschema), improved multi-document handling, and bug fixes.
See [the full changelog](./CHANGELOG.md) for more details.
## Features
-**High Performance**: Built on the lightweight and efficient [RapidYAML](https://github.com/biojppm/rapidyaml) library
- 🧩 **Comprehensive Variant Support**: Handles all Godot built-in Variant types (except Callable and RID)
-**Schema Validation**: Full JSON Schema Draft 2020-12 validation powered by [GDSchema](https://github.com/fimbul-works/gdschema) with YAML-specific extensions
- 🧪 **Custom Class Serialization**: Register your GDScript classes for seamless serialization and deserialization
- 📄 **Multi-Document Support**: Parse YAML files with multiple `---` separated documents
- 🎨 **Style Customization**: Control how YAML is formatted with customizable style options
- 📝 **Comprehensive Error Handling**: Detailed error reporting with line and column information
- 🔀 **Thread-Safe**: Fully supports multi-threaded parsing and emission
- 🗂️ **Resource References**: Use `!Resource` tags to reference and load external resources
- 🛡️ **Security Controls**: Manage resource loading security during YAML parsing
## Compatibility
- Requires **Godot 4.3** or higher
- Supported platforms:
- Windows (x86 64-bit)
- Linux (x86 64-bit)
- macOS: (Universal)
- **Note**: Some macOS configurations (particularly newer versions with stricter Gatekeeper policies) may prevent loading of GDExtensions generally, not just this plugin. If the extension fails to load, try building from source or test with other GDExtensions to determine if this is a system-wide issue.
- Android (x86 64-bit, ARM 64-bit)
## Basic Usage
### Parsing YAML
```gdscript
# Parse a YAML string
var yaml_text = """
player:
name: Knight
health: 100
inventory:
- Sword
- Shield
- Health Potion
"""
var result = YAML.parse(yaml_text)
if result.has_error():
push_error("Parse error: %s" % result.get_error())
return
var data = result.get_data()
print("Player name: %s" % data.player.name)
print("Health: %d" % data.player.health)
print("First item: %s" % data.player.inventory[0])
```
### Converting Data to YAML
```gdscript
# Convert Godot data to YAML
var enemy_data = {
"name": "Dragon",
"health": 500,
"attacks": ["Bite", "Fire Breath", "Tail Whip"]
}
var string_result = YAML.stringify(enemy_data)
if !string_result.has_error():
print(string_result.get_data())
```
### Working with Files
```gdscript
# Load YAML from a file
var result = YAML.load_file("res://data/level_data.yaml")
if result.has_error():
push_error("YAML parsing failed: " + result.get_error())
return
# Success - get the data and use it
var level_data = result.get_data()
print("Loaded level: " + level_data.name)
# Save data to a YAML file
var save_data = {
"player": {
"name": "Hero",
"level": 10,
"position": [25, 48]
},
"quests_completed": ["Rats in the Cellar", "Lost Artifact"]
}
var save_result = YAML.save_file(save_data, "user://save_game.yaml")
if !save_result.has_error():
print("Game saved successfully!")
else:
push_error("Save failed: " + save_result.get_error())
```
### Simplified API
The extension provides simplified methods that return direct results rather than YAMLResult objects:
```gdscript
# Quick parsing without error checking
var data = YAML.try_parse("""
weapon: Axe
damage: 25
""")
# Or YAML.try_load_file
if data:
print("Weapon: %s (Damage: %d)" % [data.weapon, data.damage])
else:
print("Failed to parse weapon data")
# Quick stringify
var npc = {
"name": "Merchant",
"dialog": "Welcome to my shop!",
"shop_items": ["Potion", "Map", "Torch"]
}
var yaml_text = YAML.try_stringify(npc)
# Or YAML.try_save_file
if yaml_text:
save_to_file(yaml_text)
```
## Schema Validation
Version 2.0.0 introduces powerful schema validation capabilities through the integration of [GDSchema](https://github.com/fimbul-works/gdschema). Define your data structures using industry-standard JSON Schema Draft-7 syntax, enhanced with YAML-specific features for an optimal validation experience.
### Why Use Schema Validation?
Schema validation ensures your YAML data meets specific requirements before your game uses it. This is invaluable for:
- **Configuration files**: Validate game settings, difficulty parameters, and preferences
- **User-generated content**: Ensure mod data and custom levels follow your specifications
- **Save files**: Verify save data integrity with automatic default values
- **Data interchange**: Validate API responses and external data sources
- **Development**: Catch data errors early with detailed validation reports
### Quick Start
The `YAML.parse_and_validate()` method combines parsing and validation in one step, returning a `YAMLResult` that may contain both parse errors and validation errors:
```gdscript
# Define a schema in YAML (more readable than JSON!)
var schema_yaml = """
type: object
properties:
username:
type: string
minLength: 3
maxLength: 20
email:
type: string
format: email
level:
type: integer
minimum: 1
default: 1
required:
- username
- email
"""
# Build the schema
var schema = YAML.load_schema_from_string(schema_yaml)
if not schema:
push_error("Failed to parse schema")
return
# Parse and validate YAML data
var player_yaml = """
username: hero
email: hero@example.com
"""
var result = YAML.parse_and_validate(player_yaml, schema)
# Check for parse errors first
if result.has_error():
push_error("Parse error: %s" % result.get_error())
return
# Then check for validation errors
if result.has_validation_errors():
print(result.get_validation_summary()) # Detailed error report
return
# Success - use the validated data with defaults applied
var player_data = result.get_data()
print("Level: %d" % player_data.level) # 1 (default applied!)
```
### YAML-Specific Schema Extensions
Godot YAML includes two powerful extensions to standard JSON Schema:
#### 1. The `default` Keyword
Automatically apply default values when properties are missing:
```gdscript
var schema = YAML.load_schema_from_string("""
type: object
properties:
difficulty:
type: string
enum: [easy, normal, hard]
default: normal
music_volume:
type: number
minimum: 0
maximum: 1
default: 0.8
show_tutorial:
type: boolean
default: true
""")
# Parse empty YAML - defaults are applied during validation!
var result = YAML.parse_and_validate('{}', schema)
if !result.has_validation_errors():
var settings = result.get_data()
print(settings.difficulty) # "normal"
print(settings.music_volume) # 0.8
print(settings.show_tutorial) # true
```
#### 2. The `x-yaml-tag` Keyword
Validate that values have the correct YAML type tag:
```gdscript
var schema = YAML.load_schema_from_string("""
type: object
properties:
player_sprite:
type: string
x-yaml-tag: Resource # Must be tagged with !Resource
custom_item:
type: object
x-yaml-tag: Item # Must be tagged with !Item
""")
# This YAML will validate successfully
var valid_yaml = """
player_sprite: !Resource "res://player.png"
custom_item: !Item
name: Sword
damage: 10
"""
var result = YAML.parse_and_validate(valid_yaml, schema)
if !result.has_validation_errors():
var data = result.get_data()
print("Sprite loaded: %s" % (data.player_sprite is Texture2D))
```
### Reusable Schemas with Global Registration
Schemas with an `$id` field are automatically registered globally, enabling modular schema design:
```gdscript
# Define a reusable schema (saved as user_schema.yaml)
var user_schema_yaml = """
$id: "http://mygame.com/schemas/user.yaml"
type: object
properties:
username:
type: string
minLength: 3
email:
type: string
format: email
avatar:
type: string
x-yaml-tag: Resource
required:
- username
- email
"""
# Load and auto-register the schema
var user_schema = YAML.load_schema_from_string(user_schema_yaml)
# Now reference it from other schemas!
var game_data_schema = YAML.load_schema_from_string("""
type: object
properties:
player:
$ref: "http://mygame.com/schemas/user.yaml"
high_score:
type: integer
minimum: 0
""")
# Parse and validate nested YAML data
var game_yaml = """
player:
username: alice
email: alice@example.com
high_score: 1000
"""
var result = YAML.parse_and_validate(game_yaml, game_data_schema)
if result.has_error():
push_error("Parse error: %s" % result.get_error())
elif result.has_validation_errors():
push_error(result.get_validation_summary())
else:
var game_data = result.get_data()
print("Player: %s, Score: %d" % [game_data.player.username, game_data.high_score])
```
### Parse and Validate in One Step
For the most streamlined workflow, use `YAML.parse_and_validate()` which returns a `YAMLResult` with integrated validation information:
```gdscript
# Load a schema file once at startup
var config_schema = YAML.load_schema_from_file("res://schemas/config_schema.yaml")
# Later, parse and validate user config in one call
var user_config = """
graphics:
resolution: 1920x1080
vsync: true
audio:
master_volume: 0.8
"""
var result = YAML.parse_and_validate(user_config, config_schema)
# Check for parse errors
if result.has_error():
push_error("YAML parse error: %s" % result.get_error())
return
# Check for validation errors using YAMLResult methods
if result.has_validation_errors():
# Show detailed error report to user
print("Configuration has %d error(s):" % result.get_validation_error_count())
print(result.get_validation_summary())
# Or iterate through individual errors
for error in result.get_validation_errors():
print(" - %s at %s" % [error.message, error.instance_path])
return
# Both parsing and validation succeeded
var config = result.get_data()
apply_settings(config)
```
### Auto-Discovery with `$schema`
Include a `$schema` field in your YAML to automatically validate against a registered schema:
```gdscript
# Register your schema once
YAML.load_schema_from_file("res://schemas/save_game.yaml")
# This schema has: $id: "http://mygame.com/schemas/save_game.yaml"
# YAML files can reference the schema directly
var save_data = """
$schema: "http://mygame.com/schemas/save_game.yaml"
player:
name: Hero
level: 10
checkpoint: forest_entrance
"""
# Parse and validate - no need to specify schema!
var result = YAML.parse_and_validate(save_data)
# YAMLResult provides validation checking methods
if result.has_error():
push_error("Parse error: %s" % result.get_error())
elif result.has_validation_errors():
push_error("Validation failed:\n%s" % result.get_validation_summary())
else:
# Safe to use - both parsing and validation succeeded
load_game(result.get_data())
```
### Advanced Schema Features
JSON Schema Draft-7 offers powerful composition and validation features:
```gdscript
var advanced_schema = YAML.load_schema_from_string("""
$defs:
# Reusable definitions
positive_integer:
type: integer
minimum: 1
item_base:
type: object
properties:
name:
type: string
minLength: 1
value:
$ref: "#/$defs/positive_integer"
required: [name, value]
type: object
properties:
inventory:
type: array
items:
# Logical composition - item must match base AND have quantity
allOf:
- $ref: "#/$defs/item_base"
- type: object
properties:
quantity:
$ref: "#/$defs/positive_integer"
equipment:
# Conditional validation
if:
properties:
type:
const: weapon
then:
properties:
damage:
type: integer
minimum: 1
required: [damage]
else:
properties:
defense:
type: integer
minimum: 1
required: [defense]
""")
```
### Detailed Validation Error Reports
When validation fails, `YAMLResult` provides comprehensive error information through dedicated methods:
```gdscript
var schema = YAML.load_schema_from_string("""
type: object
properties:
player:
type: object
properties:
name:
type: string
minLength: 3
age:
type: integer
minimum: 0
email:
type: string
format: email
required: [name, email]
""")
var invalid_yaml = """
player:
name: ab
age: -5
email: not-email
"""
var result = YAML.parse_and_validate(invalid_yaml, schema)
# Check parse error first
if result.has_error():
push_error("YAML syntax error: %s" % result.get_error())
return
# Then check validation with YAMLResult methods
if result.has_validation_errors():
# Get formatted summary for display
print(result.get_validation_summary())
# Schema validation failed with 3 error(s):
# [1] At '/player/name': String length 2 is less than minimum 3 (minLength)
# [2] At '/player/age': Value -5 is less than minimum 0 (minimum)
# [3] At '/player/email': String "not-email" does not match format email (format)
# Get error count
print("\nTotal errors: %d" % result.get_validation_error_count())
# Iterate through individual errors
for error in result.get_validation_errors():
print("\nError details:")
print(" Path: %s" % error.instance_path)
print(" Constraint: %s" % error.keyword)
print(" Message: %s" % error.message)
print(" Invalid value: %s" % error.invalid_value)
# Or use the full SchemaValidationResult for advanced inspection
var validation = result.get_validation_result()
print("\nAll error paths: %s" % validation.get_all_error_paths())
print("Violated constraints: %s" % validation.get_violated_constraints())
```
For more information on JSON Schema features, see the [GDSchema documentation](https://github.com/fimbul-works/gdschema).
### Understanding Parse vs Validation Errors
When using `YAML.parse_and_validate()`, the `YAMLResult` may contain two types of errors:
1. **Parse Errors** (checked with `has_error()`): YAML syntax errors that prevent parsing
2. **Validation Errors** (checked with `has_validation_errors()`): Schema validation failures after successful parsing
Always check parse errors first, as validation only happens if parsing succeeds:
```gdscript
var result = YAML.parse_and_validate(yaml_text, schema)
# Check parse error first
if result.has_error():
print("YAML syntax error at line %d: %s" % [
result.get_error_line(),
result.get_error_message()
])
return
# Then check validation
if result.has_validation_errors():
print("Data is valid YAML but doesn't match schema:")
print(result.get_validation_summary())
return
# Both checks passed - safe to use
var data = result.get_data()
```
## Multi-Document YAML Support
```gdscript
var yaml_text = """
# Player stats
name: Hero
health: 100
---
# Game settings
difficulty: hard
enable_tutorial: false
"""
var result = YAML.parse(yaml_text)
if !result.has_error():
# Check if the result contains multiple documents
print("Has multiple documents: %s" % result.has_multiple_documents())
# Check document count
var doc_count = result.get_document_count()
print("Found %d documents" % doc_count)
# Get the documents
var player_data = result.get_document(0)
var settings = result.get_document(1)
print("Player: %s (Health: %d)" % [player_data.name, player_data.health])
print("Difficulty: %s" % settings.difficulty)
# Get documents as an array
var docs = result.get_documents()
```
## Error Handling
The `YAMLResult` class provides detailed error information:
```gdscript
var result = YAML.parse(user_yaml)
if result.has_error():
push_error("YAML parse error: " + result.get_error())
# Example output: "parse error (line 3, column 5)"
# Get detailed error information
var error_message = result.get_error_message()
var error_line = result.get_error_line()
var error_column = result.get_error_column()
print("Error at line %d, column %d: %s" % [error_line, error_column, error_message])
# Highlight the error position
if error_line > 0 and error_column > 0:
var yaml_lines = yaml_text.split("\n")
var error_line_content = yaml_lines[error_line - 1]
print(error_line_content)
print(" ".repeat(error_column - 1) + "^ Error here")
```
## Custom Class Serialization
You can register your custom GDScript classes for seamless serialization:
```gdscript
# Define a custom class
class_name Item extends Resource
var name: String
var weight: float
var value: int
func _init(p_name = "", p_weight = 0.0, p_value = 0):
name = p_name
weight = p_weight
value = p_value
static func deserialize(data):
if typeof(data) != TYPE_DICTIONARY:
return YAMLResult.error("Item requires a dictionary")
return Item.new(
data.get("name", ""),
data.get("weight", 0.0),
data.get("value", 0)
)
func serialize():
return {
"name": name,
"weight": weight,
"value": value
}
# Register the class for YAML serialization
YAML.register_class(Item)
# Now we can serialize/deserialize Item objects
var sword = Item.new("Iron Sword", 5.0, 100)
var result = YAML.stringify(sword)
print(result.get_data())
# Output: !Item {name: Iron Sword, weight: 5.0, value: 100}
```
## Security Controls with YAMLSecurity
The `YAMLSecurity` class helps guard against unsafe loading of untrusted content:
```gdscript
var security = YAML.create_security()
# Only allow textures from the game's asset folder
security.allow_path("res://assets/textures", ["Texture2D"])
# Block scenes for safety
security.block_type("PackedScene")
# Parse YAML with custom security settings
var yaml_text = """
player:
name: Hero
sprite: !Resource 'res://assets/textures/player.png'
"""
var result = YAML.parse(yaml_text, security)
if result.has_error():
push_error(result.get_error())
else:
var data = result.get_data()
print("Player sprite loaded: " + str(data.player.sprite is Texture2D))
```
## Style Customization with YAMLStyle
Control the formatting and appearance of your YAML output:
```gdscript
# Create a new style configuration
var style = YAML.create_style()
# Set global string style to double-quoted
style.set_string_style(YAMLStyle.STRING_QUOTE_DOUBLE)
# Set integers to display in hexadecimal format
style.set_integer_format(YAMLStyle.INT_HEX)
# Define specific style for player inventory items to use flow style
var inventory_style = style.create_child("inventory")
inventory_style.set_flow_style(YAMLStyle.FLOW_SINGLE)
# Create some data to format
var player_data = {
"name": "Hero",
"level": 42,
"inventory": ["Sword", "Shield", "Potion"]
}
# Apply the style when stringifying
var result = YAML.stringify(player_data, style)
print(result.get_data())
# Output will look like:
# name: "Hero"
# level: 0x2A
# inventory: ["Sword", "Shield", "Potion"]
```
### Style Detection and Preservation
You can detect and preserve the style of existing YAML:
```gdscript
# Parse with style detection
var result = YAML.parse(yaml_text, null, true)
if !result.has_error() and result.has_style():
var data = result.get_data()
var style = result.get_style()
# Modify the data but preserve formatting
data.player.health = 200
# Reserialize with the same style
var output = YAML.stringify(data, style)
save_file("user://modified_config.yaml", output.get_data())
```
## Examples
Check out the [`examples/`](./addons/yaml/examples/) directory for comprehensive code samples covering:
- Basic parsing and stringification
- Multi-document YAML handling
- Custom class serialization
- **Schema validation workflows**
- Security configurations
- Style customization
- Error handling patterns
- And more!
## Installation
1. Download the plugin from the Godot Asset Library or from the GitHub repository
2. Extract the contents into your project's `addons/` directory
3. Enable the plugin in Project Settings → Plugins
## License
MIT License - See LICENSE file for details.
---
Built with ⚡ by [FimbulWorks](https://github.com/fimbul-works)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
class_name YAMLCodeEdit extends CodeEdit
var syntax_highlighter_script = preload("res://addons/yaml/editor/syntax_highlighting/syntax_highlighter.gd")
func _init() -> void:
# YAML indentation
set_indent_size(2)
set_indent_using_spaces(true)
indent_automatic = true
indent_automatic_prefixes = [":"]
# Syntax highlighting
if not syntax_highlighter:
syntax_highlighter = syntax_highlighter_script.new()
+1
View File
@@ -0,0 +1 @@
uid://bex4rlxea675g
+394
View File
@@ -0,0 +1,394 @@
@tool
class_name YAMLCodeEditor extends CodeEdit
signal content_changed
signal save_requested
signal close_requested
signal undo_requested
signal redo_requested
signal validation_requested
signal zoom_changed(zoom_level)
var error_indicators := {}
var snapshot_debounce_timer: Timer
var error_line_color: Color = Color(1.0, 0.3, 0.3, 0.1)
var syntax_highlighter_script = preload("res://addons/yaml/editor/syntax_highlighting/editor_syntax_highlighter.gd")
var suppress_text_changed: bool = false
# Zoom functionality variables
var zoom_level: float = 1.0 # 100%
var default_font_size: int = 14 # Default font size
func _ready() -> void:
# Clear text to reset the editor state
text = ""
# YAML indentation
set_indent_size(2)
set_indent_using_spaces(true)
indent_automatic_prefixes = [":"]
scroll_smooth = true
set_highlight_current_line(true)
# Syntax highlighting
if not syntax_highlighter:
syntax_highlighter = syntax_highlighter_script.new()
# Do not lose selection when focus is lost
deselect_on_focus_loss_enabled = false
set_focus_mode(Control.FOCUS_ALL)
# Create debounce timer for content changes
snapshot_debounce_timer = Timer.new()
add_child(snapshot_debounce_timer)
snapshot_debounce_timer.one_shot = true
snapshot_debounce_timer.wait_time = 0.3 # 300ms
snapshot_debounce_timer.timeout.connect(_on_snapshot_debounce_timeout)
# Connect signals
text_changed.connect(_on_text_changed)
gui_input.connect(_on_gui_input_focus)
# Register YAML code completion
register_yaml_code_completion()
# Apply initial font size
_update_font_size()
func _on_text_changed() -> void:
if suppress_text_changed:
return
# Clear error indicators when text changes
clear_error_indicators()
# Request a snapshot with debounce
snapshot_debounce_timer.start()
func _on_snapshot_debounce_timeout() -> void:
# Emit content changed signal
content_changed.emit()
# Request validation
validation_requested.emit()
func _on_gui_input_focus(event: InputEvent) -> void:
# Grab focus when clicked
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
grab_focus()
func cut_selection() -> void:
if has_selection():
# Cut the selected text to clipboard
DisplayServer.clipboard_set(get_selected_text())
delete_selection()
else:
# If no selection, cut the current line (like default script editor)
var line := get_caret_line()
var line_text := get_line(line)
DisplayServer.clipboard_set(line_text)
# Delete the current line
select(line, 0, line, line_text.length())
delete_selection()
# If this isn't the last line, also remove the line break
if line < get_line_count() - 1:
select(line, 0, line + 1, 0)
delete_selection()
# Trigger content changed
text_changed.emit()
func copy_selection() -> void:
if has_selection():
# Copy selected text to clipboard
DisplayServer.clipboard_set(get_selected_text())
else:
# If no selection, copy the current line
var line := get_caret_line()
var line_text := get_line(line)
DisplayServer.clipboard_set(line_text)
func paste_clipboard() -> void:
# Get clipboard content
var clipboard = DisplayServer.clipboard_get()
if clipboard.is_empty():
return
if has_selection():
# Replace selected text with clipboard content
delete_selection()
# Insert clipboard content at caret position
insert_text_at_caret(clipboard)
text_changed.emit()
# Zoom management functions
func zoom_in() -> void:
zoom_level = min(zoom_level + 0.07, 3.0) # Max 200%
_update_font_size()
zoom_changed.emit(zoom_level)
func zoom_out() -> void:
zoom_level = max(zoom_level - 0.07, 0.25) # Min 50%
_update_font_size()
zoom_changed.emit(zoom_level)
func zoom_reset() -> void:
zoom_level = 1.0
_update_font_size()
zoom_changed.emit(zoom_level)
func set_zoom(zoom: float) -> void:
zoom_level = max(0.25, min(3.0, zoom))
_update_font_size()
zoom_changed.emit(zoom_level)
func _update_font_size() -> void:
var new_size = int(default_font_size * zoom_level)
add_theme_font_size_override("font_size", new_size)
func _unhandled_key_input(event: InputEvent) -> void:
# Handle tab key before focus system gets it
if event is InputEventKey and event.pressed and has_focus():
match event.keycode:
KEY_TAB:
if event.shift_pressed:
# Handle Shift+Tab for unindent
_handle_unindent()
else:
# Handle Tab for indent
_handle_indent()
get_viewport().set_input_as_handled()
return
func _gui_input(event: InputEvent) -> void:
# Handle shortcuts for saving/closing
if event is InputEventKey and event.pressed:
match event.get_keycode_with_modifiers():
KEY_MASK_CTRL | KEY_S:
save_requested.emit()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_W:
close_requested.emit()
get_viewport().set_input_as_handled()
KEY_ENTER, KEY_KP_ENTER:
# Handle auto-continuation of YAML structures
_handle_enter_key()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_Z:
# Handle undo
undo_requested.emit()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_Y, KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_Z:
# Handle redo (supports both Ctrl+Y and Ctrl+Shift+Z)
redo_requested.emit()
get_viewport().set_input_as_handled()
KEY_MASK_CTRL | KEY_EQUAL, KEY_MASK_CTRL | KEY_KP_ADD:
zoom_in()
KEY_MASK_CTRL | KEY_MINUS, KEY_MASK_CTRL | KEY_KP_SUBTRACT:
zoom_out()
KEY_MASK_CTRL | KEY_0, KEY_MASK_CTRL | KEY_KP_0:
zoom_reset()
if event is InputEventMouseButton and event.pressed and event.is_command_or_control_pressed():
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom_in()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom_out()
func set_text_and_preserve_state(new_text: String, preserve_state: bool = true) -> void:
if preserve_state:
# Save current state
var previous_caret_pos := get_caret_column()
var previous_line := get_caret_line()
var previous_scroll_v := get_v_scroll_bar().value
var previous_scroll_h := get_h_scroll_bar().value
# Set text without triggering our own text_changed handler
suppress_text_changed = true
text = new_text
suppress_text_changed = false
# Restore state if possible
if previous_line < get_line_count():
set_caret_line(previous_line)
var line_length := get_line(previous_line).length()
if previous_caret_pos <= line_length:
set_caret_column(previous_caret_pos)
# Restore scroll position (with a small delay to ensure the text is updated first)
call_deferred("_restore_scroll_position", previous_scroll_v, previous_scroll_h)
else:
# Just set the text without preserving state
suppress_text_changed = true
text = new_text
suppress_text_changed = false
func _restore_scroll_position(v_scroll: float, h_scroll: float) -> void:
# Wait for one frame to ensure the text has been updated and rendered
if get_tree():
await get_tree().process_frame
get_v_scroll_bar().value = v_scroll
get_h_scroll_bar().value = h_scroll
func _handle_indent() -> void:
# Get current line and text
var line := get_caret_line()
var line_text := get_line(line)
# Get selection so we can handle multi-line indentation
var selection_active := has_selection()
var selection_from := get_selection_from_line()
var selection_to := get_selection_to_line()
if selection_active:
# Indent multiple lines
begin_complex_operation()
for i in range(selection_from, selection_to + 1):
set_line(i, " " + get_line(i))
end_complex_operation()
else:
# Simple indent - insert 2 spaces at caret position
insert_text_at_caret(" ")
# Trigger text changed to update the document
text_changed.emit()
func _handle_unindent() -> void:
# Get current line and text
var line := get_caret_line()
var text := get_line(line)
# Get selection so we can handle multi-line unindentation
var selection_active := has_selection()
var selection_from := get_selection_from_line()
var selection_to := get_selection_to_line()
if selection_active:
# Unindent multiple lines
begin_complex_operation()
for i in range(selection_from, selection_to + 1):
var line_text := get_line(i)
if line_text.begins_with(" "):
set_line(i, line_text.substr(2))
elif line_text.begins_with(" "):
set_line(i, line_text.substr(1))
end_complex_operation()
else:
# Simple unindent - remove up to 2 spaces from beginning of line
if text.begins_with(" "):
set_line(line, text.substr(2))
set_caret_column(max(0, get_caret_column() - 2))
elif text.begins_with(" "):
set_line(line, text.substr(1))
set_caret_column(max(0, get_caret_column() - 1))
func _handle_enter_key() -> void:
var line := get_caret_line()
var line_text := get_line(line)
# Auto-continuation for lists
if "- " in line_text:
var indent_level := 0
for c in line_text:
if c == ' ':
indent_level += 1
else:
break
# Insert the line with the same indentation and list marker
var new_line := "\n" + " ".repeat(indent_level) + "- "
insert_text_at_caret(new_line)
else:
# Regular line break with preserved indentation
var indent_level := 0
for c in line_text:
if c == ' ':
indent_level += 1
else:
break
# Increased indentation if the line ends with a colon
if line_text.strip_edges().ends_with(":"):
indent_level += 2
insert_text_at_caret("\n" + " ".repeat(indent_level))
func register_yaml_code_completion() -> void:
# Register common YAML keywords and patterns for code completion
var keyword_list: PackedStringArray = [
"true",
"false",
"null",
"~",
"INF",
"-INF"
]
# Add keywords and tags to completion
for keyword in keyword_list:
add_code_completion_option(CodeCompletionKind.KIND_CONSTANT, keyword, keyword)
## YAML tags
var tag_list: PackedStringArray = [
"!Resource",
"!AABB",
"!Basis",
"!Color",
"!NodePath",
"!PackedByteArray",
"!PackedColorArray",
"!PackedFloat32Array",
"!PackedFloat64Array",
"!PackedInt32Array",
"!PackedInt64Array",
"!PackedStringArray",
"!PackedVector2Array",
"!PackedVector3Array",
"!Plane",
"!Projection",
"!Quaternion",
"!Rect2",
"!Rect2i",
"!StringName",
"!Transform2D",
"!Transform3D",
"!Vector2",
"!Vector2i",
"!Vector3",
"!Vector3i",
"!Vector4",
"!Vector4i"
]
for tag in tag_list:
add_code_completion_option(CodeCompletionKind.KIND_CLASS, tag, tag)
func mark_error_line(line: int, message: String) -> void:
if line < 0 or line >= get_line_count():
return
# Set line background to error color
var error_color: Color = EditorInterface.get_editor_settings().get_setting("text_editor/theme/highlighting/mark_color")
set_line_background_color(line, error_color)
# Set gutter icon
var error_icon := get_theme_icon("StatusError", "EditorIcons")
if error_icon:
set_line_gutter_icon(line, 0, error_icon)
# Store for later reference
error_indicators[line] = message
func clear_error_indicators() -> void:
for line: int in error_indicators:
set_line_background_color(line, Color(0, 0, 0, 0))
set_line_gutter_icon(line, 0, null)
error_indicators.clear()
func get_current_line_col_info() -> Array[int]:
var line := get_caret_line() + 1
var col := get_caret_column() + 1
return [line, col]
+1
View File
@@ -0,0 +1 @@
uid://drkui6da5o1ou
+136
View File
@@ -0,0 +1,136 @@
@tool
class_name YAMLEditorDocument extends RefCounted
# Primary document data
var path: String
var content: String
var is_modified: bool = false
var validation_result: YAMLResult
# History management
class YAMLEditorHistoryState extends RefCounted:
var text: String
var caret_line: int = 0
var caret_column: int = 0
func _init(p_text: String, p_line: int = 0, p_column: int = 0) -> void:
text = p_text
caret_line = p_line
caret_column = p_column
func _to_string() -> String:
return "YAMLEditorHistoryState(text_length=%d, line=%d, column=%d)" % [text.length(), caret_line, caret_column]
# Limit history size to prevent excessive memory use
const MAX_HISTORY := 100
var history_states: Array[YAMLEditorHistoryState] = []
var current_history_index: int = -1
var saved_history_index: int = -1
# Signals
signal content_changed(document)
signal validation_changed(document)
signal modified_changed(document)
# Constructor
func _init(p_path: String, p_content: String = "") -> void:
path = p_path
content = p_content
validation_result = YAMLResult.new() # Empty result
# Take initial snapshot if content isn't empty
if not p_content.is_empty():
_add_history_state(YAMLEditorHistoryState.new(p_content))
# File path utilities
func get_file_name() -> String:
return path.get_file()
func is_untitled() -> bool:
return path.begins_with("untitled")
# Content management
func set_content(new_content: String, caret_line: int = 0, caret_column: int = 0) -> void:
if content == new_content:
return
content = new_content
_add_history_state(YAMLEditorHistoryState.new(new_content, caret_line, caret_column))
set_modified(true)
content_changed.emit(self)
# Modification state
func set_modified(modified: bool) -> void:
if is_modified == modified:
return
is_modified = modified
modified_changed.emit(self)
# Validation management
func set_validation_result(result: YAMLResult) -> void:
validation_result = result
validation_changed.emit(self)
func has_error() -> bool:
return validation_result and validation_result.has_error()
# History management
func can_undo() -> bool:
return current_history_index > 0 # Need at least one previous state
func can_redo() -> bool:
return current_history_index < history_states.size() - 1
func undo() -> YAMLEditorHistoryState:
if not can_undo():
return null
current_history_index -= 1
var state := history_states[current_history_index]
content = state.text
# Update modification state
set_modified(current_history_index != saved_history_index)
content_changed.emit(self)
return state
func redo() -> YAMLEditorHistoryState:
if not can_redo():
return null
current_history_index += 1
var state := history_states[current_history_index]
content = state.text
# Update modification state
set_modified(current_history_index != saved_history_index)
content_changed.emit(self)
return state
func mark_saved() -> void:
saved_history_index = current_history_index
set_modified(false)
func _add_history_state(state: YAMLEditorHistoryState) -> void:
# If we're not at the end of history, truncate future states
if current_history_index < history_states.size() - 1:
history_states = history_states.slice(0, current_history_index + 1)
# Add the new state
history_states.append(state)
current_history_index = history_states.size() - 1
if history_states.size() > MAX_HISTORY:
var excess := history_states.size() - MAX_HISTORY
history_states = history_states.slice(excess)
current_history_index -= excess
# Adjust saved index if needed
if saved_history_index >= 0:
saved_history_index -= excess
if saved_history_index < 0:
saved_history_index = -1
+1
View File
@@ -0,0 +1 @@
uid://5ow36wsuc7uj
+578
View File
@@ -0,0 +1,578 @@
@tool
class_name YAMLEditorDocumentManager extends Node
signal document_changed(document)
signal document_created(document)
signal document_closed(document)
# Dictionary of open documents: {path: YAMLEditorDocument}
var documents: Dictionary = {}
var current_document: YAMLEditorDocument = null
# UI components
var file_list: YAMLEditorFileList
var code_editor: YAMLCodeEditor
var file_popup_menu: PopupMenu
var editor_node: Control
# Reference to the singleton
var file_system: YAMLFileSystem
# Track recently saved files to avoid external update conflicts
var recently_saved_files: Dictionary = {}
var ignore_update_timer: Timer
func _init(_editor: Control) -> void:
editor_node = _editor
func _ready() -> void:
# Get singleton reference
file_system = YAMLFileSystem.get_singleton()
# Listen for external file updates
file_system.file_updated.connect(_on_external_file_updated)
file_system.file_renamed.connect(_on_file_renamed)
# Create file popup menu
file_popup_menu = PopupMenu.new()
add_child(file_popup_menu)
# Add menu items
file_popup_menu.add_item("Save", 0)
file_popup_menu.add_item("Save As...", 1)
file_popup_menu.add_separator()
file_popup_menu.add_item("Close", 2)
file_popup_menu.add_separator()
file_popup_menu.add_item("Show in FileSystem", 3)
# Connect popup menu signals
file_popup_menu.id_pressed.connect(_on_file_popup_menu_id_pressed)
# Create timer for clearing recent saves
ignore_update_timer = Timer.new()
add_child(ignore_update_timer)
ignore_update_timer.one_shot = true
ignore_update_timer.wait_time = 0.5 # 500ms
ignore_update_timer.timeout.connect(_on_ignore_update_timer_timeout)
# Update UI
update_ui()
func setup(p_file_list: YAMLEditorFileList, p_code_editor: YAMLCodeEditor) -> void:
file_list = p_file_list
code_editor = p_code_editor
# Connect signals from file list component
file_list.file_selected.connect(_on_file_selected)
file_list.file_context_requested.connect(_on_file_context_requested)
func create_document(path: String, content: String = "") -> YAMLEditorDocument:
var normalized_path = _normalize_path(path)
# Check if document already exists with normalized path
if documents.has(normalized_path):
return documents[normalized_path]
var document := YAMLEditorDocument.new(normalized_path, content)
# Connect document signals
document.content_changed.connect(_on_document_content_changed)
document.modified_changed.connect(_on_document_modified_changed)
# Store document
documents[normalized_path] = document
document_created.emit(document)
return document
func open_file(path: String) -> void:
var normalized_path = _normalize_path(path)
# Check if already open
if documents.has(normalized_path):
set_current_document(documents[normalized_path])
return
# Look for any document with the same base filename
var filename = normalized_path.get_file()
for existing_path in documents.keys():
if existing_path.get_file() == filename and existing_path != normalized_path:
# Check if they point to the same actual file
if _paths_point_to_same_file(normalized_path, existing_path):
set_current_document(documents[existing_path])
return
# Use the file system singleton to read the file
var content := file_system.read_file(normalized_path)
if typeof(content) == TYPE_INT: # Error code
push_error("Could not open file '%s': %s" % [normalized_path, error_string(content)])
return
# Create new document
var document := create_document(normalized_path, content)
document.mark_saved() # Initial state is saved
# Switch to the new document
set_current_document(document)
# Notify the file system
file_system.notify_file_opened(normalized_path)
func close_document(document: YAMLEditorDocument) -> bool:
if document == null:
return true
if document.is_modified:
# Show confirmation dialog for unsaved changes
var dialog := ConfirmationDialog.new()
dialog.title = "Unsaved Changes"
dialog.dialog_text = "Save changes to '" + document.get_file_name() + "' before closing?"
dialog.add_button("Don't Save", true, "dont_save")
dialog.add_cancel_button("Cancel")
dialog.confirmed.connect(
func():
# Save was chosen
if save_document(document):
_close_document_internal(document)
dialog.queue_free()
)
dialog.custom_action.connect(
func(action):
if action == "dont_save":
_close_document_internal(document)
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
return false
return _close_document_internal(document)
func _close_document_internal(document: YAMLEditorDocument) -> bool:
if document == null:
return false
# Find the document in our dictionary
var path_to_remove = ""
for path in documents.keys():
if documents[path] == document:
path_to_remove = path
break
if path_to_remove.is_empty():
return false
# Notify document is being closed
document_closed.emit(document)
# Remove document
documents.erase(document.path)
# If this was the current document, switch to another
if current_document == document:
current_document = null
# Select another document if available
if not documents.is_empty():
set_current_document(documents.values()[0])
else:
# Clear the editor if no documents left
code_editor.text = ""
# Update UI
update_ui()
# Notify the file system
file_system.notify_file_closed(document.path)
return true
func save_document(document: YAMLEditorDocument) -> bool:
if document == null:
return false
# Don't save untitled files directly
if document.is_untitled():
return false # Caller should handle "Save As" dialog
# Mark this file as recently saved to ignore update notifications
recently_saved_files[document.path] = Time.get_unix_time_from_system()
ignore_update_timer.start()
# Use the file system singleton to save the file
var result := file_system.save_file(document.path, document.content)
if result != OK:
push_error("Could not save file '%s': %s" % [document.path, error_string(result)])
recently_saved_files.erase(document.path) # Remove from recently saved if error
return false
# Mark document as saved
document.mark_saved()
# Update UI
update_ui()
return true
func save_document_as(document: YAMLEditorDocument, new_path: String) -> bool:
if document == null or new_path.is_empty():
return false
var normalized_new_path = _normalize_path(new_path)
# Check if we're trying to save to a path that's already open
if documents.has(normalized_new_path) and documents[normalized_new_path] != document:
push_error("Cannot save as '%s' - file is already open" % normalized_new_path)
return false
# Remember the old path
var old_path := document.path
# Update document path
document.path = normalized_new_path
# Update the documents dictionary
if old_path != normalized_new_path:
documents.erase(old_path)
documents[normalized_new_path] = document
# Save the document
if save_document(document):
# If old path was temporary, clean up
if old_path != normalized_new_path and old_path.begins_with("untitled"):
file_system.notify_file_closed(old_path)
# Update UI
update_ui()
# Notify file system
file_system.notify_file_opened(new_path)
return true
# Restore old path if save failed
if old_path != normalized_new_path:
document.path = old_path
documents.erase(normalized_new_path)
documents[old_path] = document
return false
func new_file() -> void:
# Create a new untitled file
var untitled_name := "untitled.yaml"
var index := 1
while documents.has(untitled_name):
index += 1
untitled_name = "untitled%d.yaml" % index
# Create a new document
var document := create_document(untitled_name)
document.set_modified(true) # New document is always modified
# Switch to the new document
set_current_document(document)
# Set focus to code editor
code_editor.grab_focus()
# Notify the file system
file_system.notify_file_opened(untitled_name)
func set_current_document(document: YAMLEditorDocument) -> void:
if document == null or document == current_document:
return
current_document = document
# Update editor content
if is_instance_valid(code_editor):
code_editor.set_text_and_preserve_state(document.content)
# Update UI
update_ui()
# Emit signal
document_changed.emit(document)
func update_document_content(document: YAMLEditorDocument, new_content: String) -> void:
if document == null:
return
var caret_line := 0
var caret_column := 0
if is_instance_valid(code_editor):
caret_line = code_editor.get_caret_line()
caret_column = code_editor.get_caret_column()
document.set_content(new_content, caret_line, caret_column)
func _normalize_path(path: String) -> String:
# Convert to absolute path and normalize
var normalized = path
# Handle different path formats
if normalized.begins_with("res://"):
normalized = ProjectSettings.globalize_path(normalized)
# Convert to canonical form
normalized = normalized.simplify_path()
# Convert back to res:// format if it was originally a project path
if path.begins_with("res://"):
normalized = ProjectSettings.localize_path(normalized)
return normalized
func _paths_point_to_same_file(path1: String, path2: String) -> bool:
# For project files, compare the res:// paths
if path1.begins_with("res://") and path2.begins_with("res://"):
return path1 == path2
# For absolute paths, normalize and compare
var abs_path1 = ProjectSettings.globalize_path(path1) if path1.begins_with("res://") else path1
var abs_path2 = ProjectSettings.globalize_path(path2) if path2.begins_with("res://") else path2
return abs_path1.simplify_path() == abs_path2.simplify_path()
func _on_document_content_changed(document: YAMLEditorDocument) -> void:
# Update UI if this is the current document
if document == current_document:
update_ui()
func _on_document_modified_changed(document: YAMLEditorDocument) -> void:
# Update UI if this is the current document
if document == current_document:
update_ui()
func update_ui() -> void:
# Toggle editor visibility
editor_node.visible = documents.size() > 0
if not is_instance_valid(file_list):
return
# Prepare file data for the file list component
var file_data := {}
for path in documents:
var document = documents[path]
file_data[path] = {
"name": document.get_file_name(),
"modified": document.is_modified
}
# Update the file list component
var current_path = current_document.path if current_document else ""
file_list.update_files(file_data, current_path)
func _on_file_selected(path: String) -> void:
if path.is_empty():
return
var normalized_path = _normalize_path(path)
if not documents.has(normalized_path):
return
set_current_document(documents[normalized_path])
func _on_file_context_requested(path: String, at_position: Vector2) -> void:
if path.is_empty():
return
var normalized_path = _normalize_path(path)
if not documents.has(normalized_path):
return
set_current_document(documents[normalized_path])
# Calculate the global position for the popup
var global_rect := Rect2(file_list.get_global_mouse_position(), Vector2.ZERO)
file_popup_menu.popup_on_parent(global_rect)
func _on_file_popup_menu_id_pressed(id: int) -> void:
var path := file_list.get_selected_file_path()
if path.is_empty():
return
var normalized_path = _normalize_path(path)
if not documents.has(normalized_path):
return
var document: YAMLEditorDocument = documents[normalized_path]
match id:
0: # Save
save_document(document)
1: # Save As
# Main editor should handle the save as dialog
set_current_document(document)
2: # Close
close_document(document)
3: # Show in FileSystem
if not document.is_untitled() and document.path.begins_with("res://"):
EditorInterface.get_file_system_dock().navigate_to_path(document.path)
func _on_external_file_updated(path: String) -> void:
var normalized_path = _normalize_path(path)
# Only process if the file is open and it's a YAML file
if documents.has(normalized_path) and file_system.is_yaml_file(normalized_path):
# Check if we just saved this file ourselves
if recently_saved_files.has(normalized_path):
var save_time: int = recently_saved_files[normalized_path]
var current_time := Time.get_unix_time_from_system()
# If saved less than 1 second ago, ignore this update
if current_time - save_time < 1.0:
return
var document: YAMLEditorDocument = documents[normalized_path]
# Check if the document has unsaved changes
if not document.is_modified:
# Document is not modified locally, safe to reload
var content = file_system.read_file(normalized_path)
if typeof(content) != TYPE_INT: # Not an error
# Update document content
document.content = content
document.mark_saved()
# If this is the current document, update the editor
if document == current_document:
# Preserve cursor position and scroll state
var previous_caret_line := code_editor.get_caret_line()
var previous_caret_column := code_editor.get_caret_column()
var previous_scroll_v := code_editor.get_v_scroll_bar().value
var previous_scroll_h := code_editor.get_h_scroll_bar().value
code_editor.text = content
# Restore position if possible
if previous_caret_line < code_editor.get_line_count():
code_editor.set_caret_line(previous_caret_line)
var line_length := code_editor.get_line(previous_caret_line).length()
if previous_caret_column <= line_length:
code_editor.set_caret_column(previous_caret_column)
# Restore scroll position
code_editor.get_v_scroll_bar().value = previous_scroll_v
code_editor.get_h_scroll_bar().value = previous_scroll_h
update_ui()
else:
# Document has unsaved changes, show conflict dialog
if document == current_document:
var dialog := ConfirmationDialog.new()
dialog.title = "External Changes Detected"
dialog.dialog_text = "The file '" + document.get_file_name() + "' has been modified externally. Do you want to reload it and lose your changes?"
dialog.confirmed.connect(
func():
var content := file_system.read_file(path)
if typeof(content) != TYPE_INT:
document.content = content
document.mark_saved()
if document == current_document:
code_editor.text = content
update_ui()
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
func _on_ignore_update_timer_timeout() -> void:
# Clear out any old saved entries
var current_time := Time.get_unix_time_from_system()
var keys_to_remove: PackedStringArray = []
for path in recently_saved_files:
var save_time = recently_saved_files[path]
if current_time - save_time >= 1.0:
keys_to_remove.append(path)
for path in keys_to_remove:
recently_saved_files.erase(path)
func _on_file_renamed(old_path: String, new_path: String) -> void:
var normalized_old_path = _normalize_path(old_path)
var normalized_new_path = _normalize_path(new_path)
# If we have this document open, update our references
if documents.has(normalized_old_path):
var document: YAMLEditorDocument = documents[normalized_old_path]
document.path = normalized_new_path
documents.erase(normalized_old_path)
documents[normalized_new_path] = document
update_ui()
func handle_filesystem_change() -> void:
# Check if any of our open res:// files no longer exist
var missing_files: PackedStringArray = []
for path in documents.keys():
if path.begins_with("res://") and not file_system.file_exists(path):
missing_files.append(path)
# Handle missing files
for old_path in missing_files:
var document: YAMLEditorDocument = documents[old_path]
# Try to find a file with the same name but different path in the filesystem
var filename := old_path.get_file()
var filesystem_root := EditorInterface.get_resource_filesystem().get_filesystem()
var new_path := file_system.find_file_in_filesystem(filesystem_root, filename)
if not new_path.is_empty():
var normalized_new_path = _normalize_path(new_path)
# Found potential match - update the document path
document.path = normalized_new_path
documents.erase(old_path)
documents[normalized_new_path] = document
# If this is the current document, emit signal
if document == current_document:
document_changed.emit(document)
# Update UI
update_ui()
# Notify file system
file_system.notify_file_closed(old_path)
file_system.notify_file_opened(normalized_new_path)
file_system.notify_file_renamed(old_path, normalized_new_path)
else:
# Keep it open but mark as potentially moved/deleted to avoid losing unsaved changes
pass
func has_unsaved_changes() -> bool:
for document in documents.values():
if document.is_modified:
return true
return false
func get_open_documents() -> Array:
return documents.values()
func get_open_paths() -> Array:
return documents.keys()
func has_document(path: String) -> bool:
return documents.has(path)
func get_document(path: String) -> YAMLEditorDocument:
return documents.get(path, null)
func get_current_document() -> YAMLEditorDocument:
return current_document
@@ -0,0 +1 @@
uid://cpm2siqxhgj8w
+166
View File
@@ -0,0 +1,166 @@
@tool
class_name YAMLEditorShortcuts
# This helper class registers editor shortcuts for the YAML editor
const SHORTCUTS = [
{
"name": "Save",
"shortcut": KEY_MASK_CTRL | KEY_S,
"callback": "_on_save_shortcut"
},
{
"name": "Save As",
"shortcut": KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_S,
"callback": "_on_save_as_shortcut"
},
{
"name": "Close File",
"shortcut": KEY_MASK_CTRL | KEY_W,
"callback": "_on_close_shortcut"
},
{
"name": "New File",
"shortcut": KEY_MASK_CTRL | KEY_N,
"callback": "_on_new_shortcut"
},
{
"name": "Open File",
"shortcut": KEY_MASK_CTRL | KEY_O,
"callback": "_on_open_shortcut"
},
{
"name": "Validate YAML",
"shortcut": KEY_F4,
"callback": "_on_validate_shortcut"
},
{
"name": "Undo",
"shortcut": KEY_MASK_CTRL | KEY_Z,
"callback": "_on_undo_shortcut"
},
{
"name": "Redo",
"shortcut": KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_Z,
"callback": "_on_redo_shortcut"
},
{
"name": "Cut",
"shortcut": KEY_MASK_CTRL | KEY_X,
"callback": "_on_cut_shortcut"
},
{
"name": "Copy",
"shortcut": KEY_MASK_CTRL | KEY_C,
"callback": "_on_copy_shortcut"
},
{
"name": "Paste",
"shortcut": KEY_MASK_CTRL | KEY_V,
"callback": "_on_paste_shortcut"
},
{
"name": "Select All",
"shortcut": KEY_MASK_CTRL | KEY_A,
"callback": "_on_select_all_shortcut"
},
{
"name": "Find",
"shortcut": KEY_MASK_CTRL | KEY_F,
"callback": "_on_find_shortcut"
},
{
"name": "Find Next",
"shortcut": KEY_F3,
"callback": "_on_find_next_shortcut"
},
{
"name": "Find Previous",
"shortcut": KEY_MASK_SHIFT | KEY_F3,
"callback": "_on_find_previous_shortcut"
},
{
"name": "Replace",
"shortcut": KEY_MASK_CTRL | KEY_R,
"callback": "_on_replace_shortcut"
},
# Zoom shortcuts
{
"name": "Zoom In",
"shortcut": KEY_MASK_CTRL | KEY_EQUAL,
"callback": "_on_zoom_in_shortcut"
},
{
"name": "Zoom In (Numpad)",
"shortcut": KEY_MASK_CTRL | KEY_KP_ADD,
"callback": "_on_zoom_in_shortcut"
},
{
"name": "Zoom Out",
"shortcut": KEY_MASK_CTRL | KEY_MINUS,
"callback": "_on_zoom_out_shortcut"
},
{
"name": "Zoom Out (Numpad)",
"shortcut": KEY_MASK_CTRL | KEY_KP_SUBTRACT,
"callback": "_on_zoom_out_shortcut"
},
{
"name": "Reset Zoom",
"shortcut": KEY_MASK_CTRL | KEY_0,
"callback": "_on_zoom_reset_shortcut"
},
{
"name": "Reset Zoom (Numpad)",
"shortcut": KEY_MASK_CTRL | KEY_KP_0,
"callback": "_on_zoom_reset_shortcut"
}
]
static func register_shortcuts(editor_plugin: EditorPlugin, target_object: Object) -> void:
# Create shortcut inputs for the YAML editor
var editor_settings := editor_plugin.get_editor_interface().get_editor_settings()
var shortcuts_settings := editor_settings.get_setting("shortcuts") if editor_settings.has_setting("shortcuts") else {}
# Create a unique editor name for our shortcuts
var editor_name := "YAML Editor"
# Register each shortcut
for shortcut_data in SHORTCUTS:
var shortcut_name: String = "yaml_editor/" + shortcut_data.name.to_lower().replace(" ", "_")
var input_event := InputEventKey.new()
input_event.keycode = shortcut_data.shortcut
# Create a shortcut
var shortcut := Shortcut.new()
shortcut.events = [input_event]
# Register the shortcut with Godot's input map
if not InputMap.has_action(shortcut_name):
InputMap.add_action(shortcut_name)
InputMap.action_add_event(shortcut_name, input_event)
# Connect to the target object's _unhandled_key_input method if it exists
if !target_object.has_method("_unhandled_key_input"):
# Create connections for shortcuts if the target doesn't handle key input directly
for shortcut_data in SHORTCUTS:
var shortcut_name: String = "yaml_editor/" + shortcut_data.name.to_lower().replace(" ", "_")
if target_object.has_method(shortcut_data.callback):
InputMap.action_add_event(shortcut_name, InputEventAction.new())
# Connect the shortcut action to the target's callback
var root := editor_plugin.get_tree().root
root.connect("input_event",
func(event):
if event is InputEventKey and event.pressed:
if event.get_keycode_with_modifiers() == shortcut_data.shortcut:
target_object.call(shortcut_data.callback)
print("called a thing")
root.get_viewport().set_input_as_handled()
)
static func unregister_shortcuts() -> void:
# Remove all registered shortcuts
for shortcut_data in SHORTCUTS:
var shortcut_name: String = "yaml_editor/" + shortcut_data.name.to_lower().replace(" ", "_")
if InputMap.has_action(shortcut_name):
InputMap.erase_action(shortcut_name)
@@ -0,0 +1 @@
uid://dgcufn1xonjkp
+107
View File
@@ -0,0 +1,107 @@
@tool
class_name YAMLEditorFileList extends VBoxContainer
signal file_selected(path)
signal file_context_requested(path, position)
# References to UI components
@export var filter_input: LineEdit
@export var file_list: ItemList
# File data
var files: Dictionary = {} # {path: {name, modified}}
var filtered_files: Array = []
var current_path: String = ""
func _ready() -> void:
# SessionManager will handle loading of the files
file_list.clear()
# Connect internal signals
if is_instance_valid(file_list):
file_list.item_selected.connect(_on_item_selected)
file_list.item_clicked.connect(_on_item_clicked)
if is_instance_valid(filter_input):
filter_input.text_changed.connect(_on_filter_text_changed)
filter_input.right_icon = get_theme_icon("Search", "EditorIcons")
# Public API
func update_files(p_files: Dictionary, p_current_path: String) -> void:
files = p_files.duplicate()
current_path = p_current_path
_update_ui()
func mark_file_modified(path: String, is_modified: bool) -> void:
if files.has(path):
files[path].modified = is_modified
_update_ui()
func get_selected_file_path() -> String:
if not is_instance_valid(file_list):
return ""
var selected_items := file_list.get_selected_items()
if selected_items.is_empty():
return ""
var selected_index := selected_items[0]
if selected_index >= 0 and selected_index < filtered_files.size():
return filtered_files[selected_index]
return ""
# UI update
func _update_ui() -> void:
if not is_instance_valid(file_list):
return
var current_selection := get_selected_file_path()
file_list.clear()
filtered_files.clear()
var filter_text := filter_input.text.to_lower() if is_instance_valid(filter_input) else ""
var current_index := -1
var index := 0
for path: String in files.keys():
var file_data: Dictionary = files[path]
var file_name := path.get_file()
if not filter_text.is_empty() and file_name.to_lower().find(filter_text) == -1:
continue
var display_name := file_name
if file_data.modified:
display_name += " (*)"
file_list.add_item(display_name)
filtered_files.append(path)
file_list.set_item_tooltip(index, path)
if path == current_path:
current_index = index
if path == current_selection:
file_list.select(index)
index += 1
if current_index >= 0 and (file_list.get_selected_items().is_empty() or current_path != current_selection):
file_list.select(current_index)
# Signal handlers
func _on_filter_text_changed(_text: String) -> void:
_update_ui()
func _on_item_selected(index: int) -> void:
if index >= 0 and index < filtered_files.size():
file_selected.emit(filtered_files[index])
func _on_item_clicked(index: int, at_position: Vector2, mouse_button_index: int) -> void:
if index >= 0 and index < filtered_files.size():
if mouse_button_index == MOUSE_BUTTON_RIGHT:
file_list.select(index)
file_context_requested.emit(filtered_files[index], at_position)
+1
View File
@@ -0,0 +1 @@
uid://c15odacm31d03
+93
View File
@@ -0,0 +1,93 @@
@tool
class_name YAMLFileSystem extends Node
signal file_opened(path)
signal file_saved(path)
signal file_updated(path)
signal file_closed(path)
signal file_renamed(old_path, new_path)
# Singleton pattern
static var _instance: YAMLFileSystem
static func get_singleton() -> YAMLFileSystem:
if not _instance:
_instance = YAMLFileSystem.new()
Engine.get_main_loop().root.call_deferred("add_child", _instance)
return _instance
func _init() -> void:
if _instance != null:
push_error("YAMLFileSystem singleton already exists")
return
_instance = self
# Mark as persistent so it doesn't get destroyed on scene changes
process_mode = Node.PROCESS_MODE_ALWAYS
# File operations with signals
func save_file(path: String, content: String) -> Error:
var was_new_file = not file_exists(path)
var file := FileAccess.open(path, FileAccess.WRITE)
if not file:
return FileAccess.get_open_error()
file.store_string(content)
file_saved.emit(path)
file_updated.emit(path)
# If this was a new file, notify Godot's filesystem
if was_new_file:
call_deferred("_refresh_filesystem", path)
return OK
func read_file(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return FileAccess.get_open_error()
return file.get_as_text()
# Check if a file exists
func file_exists(path: String) -> bool:
return FileAccess.file_exists(path)
# Utility to check if a path is a YAML file
func is_yaml_file(path: String) -> bool:
return path.get_extension().to_lower() in ["yaml", "yml"]
# For external updates, allow code to manually trigger the signal
func notify_file_updated(path: String) -> void:
file_updated.emit(path)
# Called when a file is opened in the editor
func notify_file_opened(path: String) -> void:
file_opened.emit(path)
# Called when a file is closed in the editor
func notify_file_closed(path: String) -> void:
file_closed.emit(path)
# Called when a file is renamed (by the filesystem or editor)
func notify_file_renamed(old_path: String, new_path: String) -> void:
file_renamed.emit(old_path, new_path)
# Find a file by name in the filesystem
func find_file_in_filesystem(dir: EditorFileSystemDirectory, filename: String) -> String:
# Check files in current directory
for i in range(dir.get_file_count()):
var file_path := dir.get_file_path(i)
if file_path.get_file() == filename:
return file_path
# Recursively check subdirectories
for i in range(dir.get_subdir_count()):
var subdir := dir.get_subdir(i)
var result := find_file_in_filesystem(subdir, filename)
if not result.is_empty():
return result
return ""
func _refresh_filesystem(path: String) -> void:
EditorInterface.get_resource_filesystem().update_file(path)
+1
View File
@@ -0,0 +1 @@
uid://p8fujg7vubpn
+496
View File
@@ -0,0 +1,496 @@
@tool
class_name YAMLEditorFindReplaceBar extends Control
signal replace_performed
signal replace_all_performed
@export_category("Find Panel Components")
@export var find_input: LineEdit
@export var matches_label: Label
@export var previous_button: Button
@export var next_button: Button
@export var match_case_checkbox: CheckBox
@export var whole_words_checkbox: CheckBox
@export var find_button_container: HBoxContainer
@export var find_options_container: HBoxContainer
@export_category("Replace Panel Components")
@export var replace_input: LineEdit
@export var replace_button: Button
@export var replace_all_button: Button
@export var selection_only_checkbox: CheckBox
@export var replace_button_container: HBoxContainer
@export var replace_options_container: HBoxContainer
@export_category("Visibility Toggle")
@export var hide_button: Button
@export_category("Node References")
@export var editor: YAMLCodeEditor
@export var vbox_container: VBoxContainer # Container holding both panels
@export var find_panel: Control # First row (find)
@export var replace_panel: Control # Second row (replace)
# Matching state
var matches: Array[Vector2i] = [] # Store line/column pairs of matches
var current_match_index: int = -1 # Index of current selection in matches array
var search_regex: RegEx = RegEx.new()
func _ready() -> void:
# Setup UI
previous_button.icon = get_theme_icon("MoveUp", "EditorIcons")
next_button.icon = get_theme_icon("MoveDown", "EditorIcons")
hide_button.icon = get_theme_icon("Close", "EditorIcons")
# Connect signals
find_input.text_changed.connect(_on_find_input_changed)
find_input.text_submitted.connect(_on_find_input_submitted)
previous_button.pressed.connect(_on_previous_button_pressed)
next_button.pressed.connect(_on_next_button_pressed)
match_case_checkbox.toggled.connect(_on_option_changed)
whole_words_checkbox.toggled.connect(_on_option_changed)
hide_button.pressed.connect(_on_hide_button_pressed)
replace_button.pressed.connect(_on_replace_button_pressed)
replace_all_button.pressed.connect(_on_replace_all_button_pressed)
selection_only_checkbox.toggled.connect(_on_option_changed)
# Disable buttons initially
previous_button.disabled = true
next_button.disabled = true
replace_button.disabled = true
replace_all_button.disabled = true
# Hide by default
visible = false
# Make sure editor preserves selection when focus changes
if editor:
editor.set_deselect_on_focus_loss_enabled(false)
var find_panel_visible: bool:
get(): return find_input.visible
set(value):
find_input.visible = value
find_button_container.visible = value
find_options_container.visible = value
var replace_panel_visible: bool:
get(): return replace_input.visible
set(value):
replace_input.visible = value
replace_button_container.visible = value
replace_options_container.visible = value
# Public methods
func show_find_panel() -> void:
if not is_instance_valid(editor):
return
visible = true
find_panel_visible = true
# If there's a selection, use it as search text
if editor.has_selection():
find_input.text = editor.get_selected_text()
# Run initial search and update UI
trigger_search()
# Focus the search input
find_input.grab_focus()
find_input.select_all()
func show_replace_panel() -> void:
if not is_instance_valid(editor):
return
visible = true
find_panel_visible = true
replace_panel_visible = true
# If there's a selection, use it as search text
if editor.has_selection():
find_input.text = editor.get_selected_text()
# Run initial search and update UI
trigger_search()
# Focus the search input
find_input.grab_focus()
find_input.select_all()
func hide_panel() -> void:
find_panel_visible = false
replace_panel_visible = false
visible = false
# Clear search when hiding
if is_instance_valid(editor):
editor.set_search_text("")
editor.set_search_flags(0)
editor.queue_redraw()
# Core functionality
func trigger_search() -> void:
if not is_instance_valid(editor):
return
# Store old cursor position to find closest match
var old_cursor_line = editor.get_caret_line()
var old_cursor_column = editor.get_caret_column()
# Update TextEdit search settings for highlighting
var search_text = find_input.text
editor.set_search_text(search_text if visible else "")
var flags = 0
if match_case_checkbox.button_pressed:
flags |= TextEdit.SEARCH_MATCH_CASE
if whole_words_checkbox.button_pressed:
flags |= TextEdit.SEARCH_WHOLE_WORDS
editor.set_search_flags(flags)
# Find all matches
matches.clear()
current_match_index = -1
if search_text.is_empty():
_update_match_label()
_update_button_states()
return
# Create regex pattern
_create_search_regex(search_text, match_case_checkbox.button_pressed, whole_words_checkbox.button_pressed)
# Find all matches using regex
for line_num in range(editor.get_line_count()):
var line_text = editor.get_line(line_num)
var search_results = search_regex.search_all(line_text)
for result in search_results:
matches.append(Vector2i(line_num, result.get_start()))
# Determine which match to select
if matches.is_empty():
current_match_index = -1
else:
# Find closest match to current cursor position
var best_distance = -1
var best_match = 0
for i in range(matches.size()):
var pos = matches[i]
# Check if this match is after cursor
if pos.x > old_cursor_line or (pos.x == old_cursor_line and pos.y >= old_cursor_column):
var distance = (pos.x - old_cursor_line) * 1000 + (pos.y - old_cursor_column)
if best_distance < 0 or distance < best_distance:
best_distance = distance
best_match = i
# If no match after cursor, wrap to first match
if best_distance < 0:
current_match_index = 0
else:
current_match_index = best_match
# Always ensure we have a selected match if there are any matches
if matches.size() > 0 and current_match_index == -1:
current_match_index = 0
# Select the current match if appropriate
if current_match_index >= 0 and not (selection_only_checkbox.button_pressed and replace_panel.visible):
_select_current_match()
# Update UI
_update_match_label()
_update_button_states()
func find_next() -> void:
if matches.is_empty() or not is_instance_valid(editor):
return
# Don't navigate if selection only is active
if replace_panel.visible and selection_only_checkbox.button_pressed:
return
# Move to next match
current_match_index = (current_match_index + 1) % matches.size()
_select_current_match()
_update_match_label()
func find_previous() -> void:
if matches.is_empty() or not is_instance_valid(editor):
return
# Don't navigate if selection only is active
if replace_panel.visible and selection_only_checkbox.button_pressed:
return
# Move to previous match
current_match_index = (current_match_index - 1 + matches.size()) % matches.size()
_select_current_match()
_update_match_label()
# Helper methods
func _create_search_regex(search_text: String, case_sensitive: bool, whole_words: bool) -> void:
# Escape special regex characters
var pattern = ""
for i in range(search_text.length()):
var c = search_text[i]
# Escape regex special characters
if c in "\\.*+?^$[](){}|":
pattern += "\\" + c
else:
pattern += c
# Add word boundary anchors if needed
if whole_words:
pattern = "\\b%s\\b" % pattern
if not case_sensitive:
pattern = "(?i)%s" % pattern
search_regex = RegEx.new()
search_regex.compile(pattern)
func _select_current_match() -> void:
if current_match_index < 0 or current_match_index >= matches.size():
return
var match_pos = matches[current_match_index]
var search_length = find_input.text.length()
# Select the text
editor.set_caret_line(match_pos.x)
editor.set_caret_column(match_pos.y)
editor.select(match_pos.x, match_pos.y, match_pos.x, match_pos.y + search_length)
# Center the view
editor.center_viewport_to_caret()
func _update_match_label() -> void:
if not visible:
return
var count = matches.size()
matches_label.visible = true
if count > 0:
matches_label.modulate = Color.WHITE
matches_label.text = "%d of %d matches" % [current_match_index + 1, count]
elif not find_input.text.is_empty():
matches_label.modulate = EditorInterface.get_editor_settings().get_setting("text_editor/theme/highlighting/brace_mismatch_color")
matches_label.text = "No matches"
else:
matches_label.visible = false
matches_label.text = ""
func _update_button_states() -> void:
var has_matches = matches.size() > 0
var selection_only_active = replace_panel.visible and selection_only_checkbox.button_pressed
# Disable navigation buttons if selection only is checked
previous_button.disabled = not has_matches or selection_only_active
next_button.disabled = not has_matches or selection_only_active
# Enable/disable replace buttons
if selection_only_active and editor.has_selection():
var has_matches_in_selection = get_matches_in_selection().size() > 0
replace_button.disabled = not has_matches_in_selection
replace_all_button.disabled = not has_matches_in_selection
else:
replace_button.disabled = not has_matches
replace_all_button.disabled = not has_matches
# Get matches within the current selection when Selection Only is active
func get_matches_in_selection() -> Array[Vector2i]:
var result: Array[Vector2i] = []
if not editor.has_selection() or not selection_only_checkbox.button_pressed:
return matches.duplicate()
var search_text = find_input.text
var selection_from_line = editor.get_selection_from_line()
var selection_from_column = editor.get_selection_from_column()
var selection_to_line = editor.get_selection_to_line()
var selection_to_column = editor.get_selection_to_column()
# Convert selection to absolute character index
var selection_start_index = _get_absolute_index(selection_from_line, selection_from_column)
var selection_end_index = _get_absolute_index(selection_to_line, selection_to_column)
for match_pos in matches:
# Convert match position to absolute character index
var match_start_index = _get_absolute_index(match_pos.x, match_pos.y)
var match_end_index = match_start_index + search_text.length()
# Check if match is fully contained in selection
if match_start_index >= selection_start_index and match_end_index <= selection_end_index:
result.append(match_pos)
return result
# Get the next match after the cursor that's inside the selection
func get_next_match_in_selection() -> Vector2i:
if not editor.has_selection() or not selection_only_checkbox.button_pressed:
return Vector2i(-1, -1)
var matches_in_selection = get_matches_in_selection()
if matches_in_selection.is_empty():
return Vector2i(-1, -1)
var cursor_line = editor.get_caret_line()
var cursor_column = editor.get_caret_column()
# Sort matches by position
matches_in_selection.sort_custom(func(a, b):
if a.x == b.x:
return a.y < b.y
return a.x < b.x
)
# Find the first match after cursor
for match_pos in matches_in_selection:
if match_pos.x > cursor_line or (match_pos.x == cursor_line and match_pos.y >= cursor_column):
return match_pos
# If no match after cursor, wrap to first match
return matches_in_selection[0]
func _get_absolute_index(line: int, column: int) -> int:
# Calculate absolute character index from line and column
var index = 0
for i in range(line):
index += editor.get_line(i).length() + 1 # +1 for newline
index += column
return index
# Signal handlers
func _on_find_input_changed(_text: String) -> void:
trigger_search()
func _on_find_input_submitted(_text: String) -> void:
find_next()
func _on_option_changed(_toggled: bool) -> void:
trigger_search()
func _on_previous_button_pressed() -> void:
find_previous()
func _on_next_button_pressed() -> void:
find_next()
func _on_hide_button_pressed() -> void:
hide_panel()
func _on_replace_button_pressed() -> void:
if not is_instance_valid(editor):
return
var search_text = find_input.text
if search_text.is_empty() or matches.is_empty():
return
var replace_text = replace_input.text
# Different behavior based on Selection Only mode
if selection_only_checkbox.button_pressed and editor.has_selection():
# Get next match in selection
var match_pos = get_next_match_in_selection()
if match_pos.x < 0: # No match in selection
return
# Replace the text
var line_text = editor.get_line(match_pos.x)
var new_line_text = line_text.substr(0, match_pos.y) + replace_text + line_text.substr(match_pos.y + search_text.length())
editor.set_line(match_pos.x, new_line_text)
# Move cursor to the end of the replaced text for next find
var cursor_line = match_pos.x
var cursor_column = match_pos.y + replace_text.length()
# Update the document's content
editor.text_changed.emit()
# Refresh search
trigger_search()
# Restore cursor position for next replacement
editor.set_caret_line(cursor_line)
editor.set_caret_column(cursor_column)
else:
# Normal replace mode - use the current highlighted match
if current_match_index < 0 or current_match_index >= matches.size():
return
# Get current match
var match_pos = matches[current_match_index]
# Replace the text
var line_text = editor.get_line(match_pos.x)
var new_line_text = line_text.substr(0, match_pos.y) + replace_text + line_text.substr(match_pos.y + search_text.length())
editor.set_line(match_pos.x, new_line_text)
# Update the document's content
editor.text_changed.emit()
# Refresh search
trigger_search()
replace_performed.emit()
func _on_replace_all_button_pressed() -> void:
if not is_instance_valid(editor):
return
var search_text = find_input.text
if search_text.is_empty() or matches.is_empty():
return
var replace_text = replace_input.text
# Determine which matches to replace
var matches_to_replace: Array[Vector2i]
if selection_only_checkbox.button_pressed and editor.has_selection():
matches_to_replace = get_matches_in_selection()
else:
matches_to_replace = matches.duplicate()
if matches_to_replace.is_empty():
return
# Sort matches in reverse order (to not affect positions of earlier matches)
matches_to_replace.sort_custom(func(a, b):
if a.x == b.x:
return a.y > b.y
return a.x > b.x
)
# Process replacements
var lines = editor.text.split("\n", false)
var replacements_count = 0
for match_pos in matches_to_replace:
# Replace text in the line
var line_text = lines[match_pos.x]
lines[match_pos.x] = line_text.substr(0, match_pos.y) + replace_text + line_text.substr(match_pos.y + search_text.length())
replacements_count += 1
# Only update if we made changes
if replacements_count > 0:
# Set the new text
editor.text = "\n".join(lines)
# Update the document's content
editor.text_changed.emit()
# Refresh search
trigger_search()
replace_all_performed.emit()
@@ -0,0 +1 @@
uid://bep06otwjntx1
+91
View File
@@ -0,0 +1,91 @@
@tool
class_name YAMLEditorMenuBar extends MenuBar
signal new_file
signal open_file
signal save_requested
signal save_as_requested
signal close_requested
signal undo_requested
signal redo_requested
signal cut_requested
signal copy_requested
signal paste_requested
signal select_all_requested
signal find_requested
signal find_next_requested
signal find_previous_requested
signal replace_requested
@export var file_menu: PopupMenu
@export var edit_menu: PopupMenu
@export var search_menu: PopupMenu
func _ready() -> void:
# Wait for UI to be ready
await get_tree().process_frame
# Set up the menu bar
_setup_menus()
func _setup_menus() -> void:
# File menu
file_menu.clear()
file_menu.add_item("New", 0, KEY_MASK_CTRL | KEY_N)
file_menu.add_item("Open...", 1, KEY_MASK_CTRL | KEY_O)
file_menu.add_separator()
file_menu.add_item("Save", 2, KEY_MASK_CTRL | KEY_S)
file_menu.add_item("Save As...", 3, KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_S)
file_menu.add_separator()
file_menu.add_item("Close", 4, KEY_MASK_CTRL | KEY_W)
# Edit menu
edit_menu.clear()
edit_menu.add_item("Undo", 0, KEY_MASK_CTRL | KEY_Z)
edit_menu.add_item("Redo", 1, KEY_MASK_CTRL | KEY_MASK_SHIFT | KEY_Z)
edit_menu.add_separator()
edit_menu.add_item("Cut", 2, KEY_MASK_CTRL | KEY_X)
edit_menu.add_item("Copy", 3, KEY_MASK_CTRL | KEY_C)
edit_menu.add_item("Paste", 4, KEY_MASK_CTRL | KEY_V)
edit_menu.add_separator()
edit_menu.add_item("Select All", 5, KEY_MASK_CTRL | KEY_A)
# Search menu
search_menu.clear()
search_menu.add_item("Find...", 0, KEY_MASK_CTRL | KEY_F)
search_menu.add_item("Find Next", 1, KEY_F3)
search_menu.add_item("Find Previous", 2, KEY_MASK_SHIFT | KEY_F3)
search_menu.add_separator()
search_menu.add_item("Replace...", 3, KEY_MASK_CTRL | KEY_R)
# Connect signals
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
search_menu.id_pressed.connect(_on_search_menu_id_pressed)
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0: new_file.emit()
1: open_file.emit()
2: save_requested.emit()
3: save_as_requested.emit()
4: close_requested.emit()
func _on_edit_menu_id_pressed(id: int) -> void:
match id:
0: undo_requested.emit()
1: redo_requested.emit()
2: cut_requested.emit()
3: copy_requested.emit()
4: paste_requested.emit()
5: select_all_requested.emit()
func _on_search_menu_id_pressed(id: int) -> void:
match id:
0: find_requested.emit()
1: find_next_requested.emit()
2: find_previous_requested.emit()
3: replace_requested.emit()
+1
View File
@@ -0,0 +1 @@
uid://b35lyu1onhcoj
+109
View File
@@ -0,0 +1,109 @@
@tool
class_name YAMLEditorSessionManager extends Node
const CONFIG_PATH := "res://.godot/yaml_editor_session.cfg"
const CONFIG_SECTION := "yaml_editor"
const CONFIG_KEY_OPEN_FILES := "open_files"
const CONFIG_KEY_SPLIT_OFFSET := "split_offset"
const CONFIG_KEY_CURRENT_FILE := "current_file"
var file_manager: YAMLEditorDocumentManager
var file_system: YAMLFileSystem
var config: ConfigFile
var autosave_timer: Timer
var resizable_container: HSplitContainer
func _ready() -> void:
file_system = YAMLFileSystem.get_singleton()
config = ConfigFile.new()
# Setup autosave timer
autosave_timer = Timer.new()
add_child(autosave_timer)
autosave_timer.wait_time = 10.0 # Save session every 10 seconds
autosave_timer.one_shot = false
autosave_timer.autostart = true
autosave_timer.timeout.connect(_on_autosave_timer_timeout)
func setup(p_file_manager: YAMLEditorDocumentManager, p_resizable_container: HSplitContainer) -> void:
file_manager = p_file_manager
resizable_container = p_resizable_container
# Connect to signals
file_manager.document_changed.connect(_on_session_changed)
file_manager.document_created.connect(_on_session_changed)
file_manager.document_closed.connect(_on_session_changed)
resizable_container.dragged.connect(_on_split_dragged)
func _on_split_dragged(_offset: int) -> void:
# The split position has changed, save the session
_on_session_changed()
func save_session() -> void:
# Don't save anything if we have no files
if not is_instance_valid(file_manager):
return
var documents: Array = file_manager.get_open_documents()
# Create array of persistent file paths (skip untitled files)
var persistent_files: PackedStringArray = []
for document in documents:
if not document.is_untitled():
persistent_files.append(document.path)
# Get current file path
var current_path := ""
var current_document := file_manager.get_current_document()
if current_document and not current_document.is_untitled():
current_path = current_document.path
# Save to config file
config.set_value(CONFIG_SECTION, CONFIG_KEY_OPEN_FILES, persistent_files)
config.set_value(CONFIG_SECTION, CONFIG_KEY_CURRENT_FILE, current_path)
# Save the split offset
if is_instance_valid(resizable_container):
config.set_value(CONFIG_SECTION, CONFIG_KEY_SPLIT_OFFSET, resizable_container.split_offset)
var error := config.save(CONFIG_PATH)
if error != OK:
push_error("Failed to save YAML editor session: %s" % error_string(error))
func load_session() -> void:
var error := config.load(CONFIG_PATH)
if error != OK:
# No saved session or error loading it
if error != ERR_FILE_NOT_FOUND:
push_error("Failed to load YAML editor session: ", error_string(error))
return
# Get saved file paths
var file_paths: PackedStringArray = config.get_value(CONFIG_SECTION, CONFIG_KEY_OPEN_FILES, [])
# Open each file
for path in file_paths:
if file_system.file_exists(path):
file_manager.open_file(path)
# Set current file
var last_current: String = config.get_value(CONFIG_SECTION, CONFIG_KEY_CURRENT_FILE, "")
if not last_current.is_empty() and file_manager.has_document(last_current):
var document := file_manager.get_document(last_current)
file_manager.set_current_document(document)
# Restore split offset (deferred to ensure UI is ready)
call_deferred("_restore_split_offset")
func _restore_split_offset() -> void:
if is_instance_valid(resizable_container):
var saved_offset: int = config.get_value(CONFIG_SECTION, CONFIG_KEY_SPLIT_OFFSET, resizable_container.split_offset)
resizable_container.split_offset = saved_offset
func _on_session_changed(_document = null) -> void:
# Set a short timer to prevent saving too frequently during batch operations
autosave_timer.start()
func _on_autosave_timer_timeout() -> void:
save_session()

Some files were not shown because too many files have changed in this diff Show More