Compare commits
2 Commits
68b6d4d1dc
...
jon_multi
| Author | SHA1 | Date | |
|---|---|---|---|
| db1bad562d | |||
| dce694dac4 |
+208
-208
@@ -1,209 +1,209 @@
|
|||||||
extends MultiplayerSynchronizer
|
extends MultiplayerSynchronizer
|
||||||
#
|
|
||||||
### Networked sync component for a pickable item. Added as a child literally
|
## Networked sync component for a pickable item. Added as a child literally
|
||||||
### named "NetPickable" (network_manager.gd's authority RPCs already expect
|
## named "NetPickable" (network_manager.gd's authority RPCs already expect
|
||||||
### this) of every net-synced pickable, replicating its transform and held
|
## this) of every net-synced pickable, replicating its transform and held
|
||||||
### state. Only the current multiplayer authority (the server while loose, or
|
## state. Only the current multiplayer authority (the server while loose, or
|
||||||
### whichever peer is holding it) actually simulates physics for the item;
|
## whichever peer is holding it) actually simulates physics for the item;
|
||||||
### every other peer freezes their local copy and just follows the synced
|
## every other peer freezes their local copy and just follows the synced
|
||||||
### transform.
|
## transform.
|
||||||
#
|
|
||||||
### 0 = loose/server-simulated; otherwise the peer id currently holding it.
|
## 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.
|
## 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 net_held_by: int = 0: set = _set_net_held_by
|
||||||
#
|
|
||||||
#var _pickable: XRToolsPickable
|
var _pickable: XRToolsPickable
|
||||||
#
|
|
||||||
## This item's own baked freeze_mode (e.g. plate.tscn bakes KINEMATIC, not the
|
# 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
|
# RigidBody3D default of STATIC) — captured once so it can be restored when
|
||||||
## this peer regains ownership, instead of getting stuck on whatever
|
# this peer regains ownership, instead of getting stuck on whatever
|
||||||
## apply_held_state() last forced it to while non-authority.
|
# apply_held_state() last forced it to while non-authority.
|
||||||
#var _original_freeze_mode: int
|
var _original_freeze_mode: int
|
||||||
#
|
|
||||||
## Same idea for the pickable's authored `enabled` flag, which the non-authority
|
# 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.
|
# branch of apply_held_state() clears while someone else is holding the item.
|
||||||
#var _original_enabled: bool
|
var _original_enabled: bool
|
||||||
#
|
|
||||||
## Whether the "our own hand still holds this" guard has already been logged for
|
# 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 current grab. apply_held_state() runs every network tick, so without this
|
||||||
## the guard message repeats for as long as you hold the item.
|
# the guard message repeats for as long as you hold the item.
|
||||||
#var _grab_race_logged := false
|
var _grab_race_logged := false
|
||||||
#
|
|
||||||
#
|
|
||||||
#func _ready() -> void:
|
func _ready() -> void:
|
||||||
#_pickable = get_parent() as XRToolsPickable
|
_pickable = get_parent() as XRToolsPickable
|
||||||
#if not _pickable:
|
if not _pickable:
|
||||||
#push_error("NetPickable must be a child of an XRToolsPickable")
|
push_error("NetPickable must be a child of an XRToolsPickable")
|
||||||
#return
|
return
|
||||||
#_original_freeze_mode = _pickable.freeze_mode
|
_original_freeze_mode = _pickable.freeze_mode
|
||||||
#_original_enabled = _pickable.enabled
|
_original_enabled = _pickable.enabled
|
||||||
#_pickable.picked_up.connect(_on_picked_up)
|
_pickable.picked_up.connect(_on_picked_up)
|
||||||
#_pickable.dropped.connect(_on_dropped)
|
_pickable.dropped.connect(_on_dropped)
|
||||||
## Deferred: the pickable root captures its own original_collision_mask/
|
# Deferred: the pickable root captures its own original_collision_mask/
|
||||||
## original_collision_layer via @onready, which runs AFTER this child's
|
# original_collision_layer via @onready, which runs AFTER this child's
|
||||||
## _ready() but BEFORE the root's _ready() body. Calling apply_held_state
|
# _ready() but BEFORE the root's _ready() body. Calling apply_held_state
|
||||||
## synchronously here would freeze/mask the item before that capture runs,
|
# synchronously here would freeze/mask the item before that capture runs,
|
||||||
## permanently corrupting the "restore on drop" values.
|
# permanently corrupting the "restore on drop" values.
|
||||||
#apply_held_state.call_deferred()
|
apply_held_state.call_deferred()
|
||||||
#
|
|
||||||
#
|
|
||||||
#func _set_net_held_by(value: int) -> void:
|
func _set_net_held_by(value: int) -> void:
|
||||||
#var old := net_held_by
|
var old := net_held_by
|
||||||
#net_held_by = value
|
net_held_by = value
|
||||||
#if old != value and NetworkManager.is_online():
|
if old != value and NetworkManager.is_online():
|
||||||
#print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [
|
print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [
|
||||||
#_pickable.name, old, value, _holder_desc(), get_multiplayer_authority()
|
_pickable.name, old, value, _holder_desc(), get_multiplayer_authority()
|
||||||
#])
|
])
|
||||||
#apply_held_state()
|
apply_held_state()
|
||||||
#
|
|
||||||
#
|
|
||||||
### Puts the item in the right physics state for whether this peer currently
|
## 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
|
## owns it. Called locally after net_held_by changes, and directly by
|
||||||
### NetworkManager._set_item_authority right after an authority handoff.
|
## NetworkManager._set_item_authority right after an authority handoff.
|
||||||
###
|
##
|
||||||
### IMPORTANT: this runs on every network tick, not just on a real change.
|
## 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
|
## 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
|
## 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
|
## 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
|
## 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
|
## line and rewrites four physics properties every tick on every peer that
|
||||||
### doesn't own it.
|
## doesn't own it.
|
||||||
#func apply_held_state() -> void:
|
func apply_held_state() -> void:
|
||||||
#if not _pickable:
|
if not _pickable:
|
||||||
#return
|
return
|
||||||
#if not NetworkManager.is_online() or is_multiplayer_authority():
|
if not NetworkManager.is_online() or is_multiplayer_authority():
|
||||||
#_grab_race_logged = false
|
_grab_race_logged = false
|
||||||
## We own this item's simulation (offline, loose+server, or currently
|
# 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
|
# 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
|
# sure it isn't still left frozen/collision-less from a previous
|
||||||
## non-authority period (e.g. right after regaining authority when a
|
# non-authority period (e.g. right after regaining authority when a
|
||||||
## client released it) — while actually held, XRToolsPickable's own
|
# client released it) — while actually held, XRToolsPickable's own
|
||||||
## pick_up()/let_go() already manage these fields, so leave those be.
|
# pick_up()/let_go() already manage these fields, so leave those be.
|
||||||
#if not _pickable.is_picked_up():
|
if not _pickable.is_picked_up():
|
||||||
#var changed := _pickable.freeze_mode != _original_freeze_mode \
|
var changed := _pickable.freeze_mode != _original_freeze_mode \
|
||||||
#or _pickable.collision_mask != _pickable.original_collision_mask
|
or _pickable.collision_mask != _pickable.original_collision_mask
|
||||||
#if changed:
|
if changed:
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print(
|
print(
|
||||||
#"%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [
|
"%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [
|
||||||
#_pickable.name, _pickable.freeze_mode, _original_freeze_mode,
|
_pickable.name, _pickable.freeze_mode, _original_freeze_mode,
|
||||||
#_pickable.collision_mask, _pickable.original_collision_mask
|
_pickable.collision_mask, _pickable.original_collision_mask
|
||||||
#]
|
]
|
||||||
#)
|
)
|
||||||
#_pickable.freeze_mode = _original_freeze_mode
|
_pickable.freeze_mode = _original_freeze_mode
|
||||||
#_pickable.collision_mask = _pickable.original_collision_mask
|
_pickable.collision_mask = _pickable.original_collision_mask
|
||||||
## Unlike freeze/collision (which XRToolsPickable manages itself while
|
# Unlike freeze/collision (which XRToolsPickable manages itself while
|
||||||
## held), `enabled` is only ever written by the non-authority branch
|
# 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
|
# 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
|
# client grabbed this item, every other peer set enabled=false, and
|
||||||
## regaining authority left it that way. On the server that silently
|
# regaining authority left it that way. On the server that silently
|
||||||
## broke everything downstream — hands couldn't pick the item up again,
|
# broke everything downstream — hands couldn't pick the item up again,
|
||||||
## and a station snap zone would "snap" it (emitting has_picked_up, so
|
# 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
|
# 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
|
# the disabled item, leaving the zone holding an item with no grab
|
||||||
## driver that then fell out of the station.
|
# driver that then fell out of the station.
|
||||||
#if _pickable.enabled != _original_enabled:
|
if _pickable.enabled != _original_enabled:
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print("%s: reclaiming ownership, restoring enabled %s->%s" % [
|
print("%s: reclaiming ownership, restoring enabled %s->%s" % [
|
||||||
#_pickable.name, _pickable.enabled, _original_enabled
|
_pickable.name, _pickable.enabled, _original_enabled
|
||||||
#])
|
])
|
||||||
#_pickable.enabled = _original_enabled
|
_pickable.enabled = _original_enabled
|
||||||
#return
|
return
|
||||||
## A net_held_by/position sync update can race ahead of the
|
# 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
|
# authority-handoff RPC that's about to confirm a grab we just made
|
||||||
## optimistically (they travel on different channels with no ordering
|
# optimistically (they travel on different channels with no ordering
|
||||||
## guarantee). Don't let a stale sync value yank an item out of our own
|
# 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
|
# hand mid-grab — only an explicit force_release_item rejection, or
|
||||||
## actually losing authority for real, should end a grab we initiated.
|
# actually losing authority for real, should end a grab we initiated.
|
||||||
#if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup:
|
if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup:
|
||||||
## Log once per grab, not once per tick.
|
# Log once per grab, not once per tick.
|
||||||
#if NetworkManager.is_online() and not _grab_race_logged:
|
if NetworkManager.is_online() and not _grab_race_logged:
|
||||||
#_grab_race_logged = true
|
_grab_race_logged = true
|
||||||
#print(
|
print(
|
||||||
#"%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [
|
"%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [
|
||||||
#_pickable.name, net_held_by
|
_pickable.name, net_held_by
|
||||||
#]
|
]
|
||||||
#)
|
)
|
||||||
#return
|
return
|
||||||
#_grab_race_logged = false
|
_grab_race_logged = false
|
||||||
## Someone else owns it: stop simulating locally, just follow the sync.
|
# Someone else owns it: stop simulating locally, just follow the sync.
|
||||||
#if _pickable.is_picked_up():
|
if _pickable.is_picked_up():
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print(
|
print(
|
||||||
#"%s: was held by %s on this peer, but authority now says peer %d owns it — force-dropping" % [
|
"%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.name, _holder_desc(), net_held_by
|
||||||
#]
|
]
|
||||||
#)
|
)
|
||||||
#_pickable.drop()
|
_pickable.drop()
|
||||||
## Bail out when we're already in the follow-the-sync state. Without this the
|
# 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
|
# 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
|
# 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
|
# 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()
|
# still re-apply if something else perturbs the state (e.g. let_go()
|
||||||
## restoring the collision mask after a force-drop).
|
# restoring the collision mask after a force-drop).
|
||||||
#var want_enabled := (net_held_by == 0)
|
var want_enabled := (net_held_by == 0)
|
||||||
#if _pickable.freeze \
|
if _pickable.freeze \
|
||||||
#and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \
|
and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \
|
||||||
#and _pickable.collision_mask == 0 \
|
and _pickable.collision_mask == 0 \
|
||||||
#and _pickable.enabled == want_enabled:
|
and _pickable.enabled == want_enabled:
|
||||||
#return
|
return
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by])
|
print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by])
|
||||||
#_pickable.freeze = true
|
_pickable.freeze = true
|
||||||
#_pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
_pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
||||||
#_pickable.collision_mask = 0
|
_pickable.collision_mask = 0
|
||||||
#_pickable.enabled = want_enabled
|
_pickable.enabled = want_enabled
|
||||||
#
|
|
||||||
#
|
|
||||||
### Local hand grab (not a station snap zone, which is server-only): request
|
## 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
|
## authority immediately so the throw/drop can be reconciled, but let the grab
|
||||||
### happen instantly here rather than waiting on the round trip.
|
## happen instantly here rather than waiting on the round trip.
|
||||||
#func _on_picked_up(_p) -> void:
|
func _on_picked_up(_p) -> void:
|
||||||
#var by := _pickable.get_picked_up_by()
|
var by := _pickable.get_picked_up_by()
|
||||||
#if not (by is XRToolsFunctionPickup):
|
if not (by is XRToolsFunctionPickup):
|
||||||
## e.g. a station snap zone grabbed it (server-side auto-snap, or the
|
# 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
|
# addon's own "grab out of a snap zone" shortcut mid-cascade) — not a
|
||||||
## player-initiated hand grab, so no authority request from here.
|
# player-initiated hand grab, so no authority request from here.
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()])
|
print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()])
|
||||||
#return
|
return
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by])
|
print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by])
|
||||||
#NetworkManager.request_item_authority_from(_pickable.get_path())
|
NetworkManager.request_item_authority_from(_pickable.get_path())
|
||||||
#
|
|
||||||
#
|
|
||||||
#func _on_dropped(_p) -> void:
|
func _on_dropped(_p) -> void:
|
||||||
## Only forward if we're actually still the authority — a drop caused by
|
# Only forward if we're actually still the authority — a drop caused by
|
||||||
## apply_held_state() losing authority (see above) must not re-report.
|
# apply_held_state() losing authority (see above) must not re-report.
|
||||||
#if not is_multiplayer_authority():
|
if not is_multiplayer_authority():
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by])
|
print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by])
|
||||||
#return
|
return
|
||||||
#if NetworkManager.is_online():
|
if NetworkManager.is_online():
|
||||||
#print("%s dropped, reporting release to server (lin=%s ang=%s)" % [
|
print("%s dropped, reporting release to server (lin=%s ang=%s)" % [
|
||||||
#_pickable.name, _pickable.linear_velocity, _pickable.angular_velocity
|
_pickable.name, _pickable.linear_velocity, _pickable.angular_velocity
|
||||||
#])
|
])
|
||||||
## Send our own final transform too: we were the authority until now, and the
|
# 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.
|
# server's copy may not have received our last position sync yet.
|
||||||
#NetworkManager.release_item_authority_from(
|
NetworkManager.release_item_authority_from(
|
||||||
#_pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity,
|
_pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity,
|
||||||
#_pickable.global_transform
|
_pickable.global_transform
|
||||||
#)
|
)
|
||||||
#
|
|
||||||
#
|
|
||||||
### Human-readable description of what's currently holding this item on THIS
|
## 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"
|
## 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
|
## bookkeeping and the item's actual grab state (see the snap-zone-grab race
|
||||||
### in NetworkManager._try_snap_into_station for a real example).
|
## in NetworkManager._try_snap_into_station for a real example).
|
||||||
#func _holder_desc() -> String:
|
func _holder_desc() -> String:
|
||||||
#if not _pickable or not _pickable.is_picked_up():
|
if not _pickable or not _pickable.is_picked_up():
|
||||||
#return "loose"
|
return "loose"
|
||||||
#var by := _pickable.get_picked_up_by()
|
var by := _pickable.get_picked_up_by()
|
||||||
#if not by:
|
if not by:
|
||||||
#return "held(no grabber?)"
|
return "held(no grabber?)"
|
||||||
#if by is XRToolsFunctionPickup:
|
if by is XRToolsFunctionPickup:
|
||||||
#return "hand(%s)" % by.get_path()
|
return "hand(%s)" % by.get_path()
|
||||||
#if by is XRToolsSnapZone:
|
if by is XRToolsSnapZone:
|
||||||
#var station := by.get_parent()
|
var station := by.get_parent()
|
||||||
#return "zone(%s)" % (station.name if station else str(by.get_path()))
|
return "zone(%s)" % (station.name if station else str(by.get_path()))
|
||||||
#return "other(%s: %s)" % [by.get_class(), by.get_path()]
|
return "other(%s: %s)" % [by.get_class(), by.get_path()]
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
extends MultiplayerSpawner
|
extends MultiplayerSpawner
|
||||||
|
|
||||||
|
|
||||||
@export var kitchen_scene: PackedScene
|
@export var kitchen_scene: PackedScene
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if not kitchen_scene:
|
if not kitchen_scene:
|
||||||
push_error("StationSpawner missing kitchen_scene")
|
push_error("StationSpawner missing kitchen_scene")
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func _process(delta: float) -> void:
|
|||||||
# if we're held or moving, reset timer and return
|
# if we're held or moving, reset timer and return
|
||||||
if _pickable.get_picked_up_by() or _rigid.linear_velocity.length_squared() > pow(minimum_speed_square,2):
|
if _pickable.get_picked_up_by() or _rigid.linear_velocity.length_squared() > pow(minimum_speed_square,2):
|
||||||
_time_left = time_to_despawn
|
_time_left = time_to_despawn
|
||||||
_set_visible(true)
|
get_parent().visible(true)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -50,14 +50,4 @@ func _process(delta: float) -> void:
|
|||||||
|
|
||||||
# Make item blink in and out before despawning
|
# Make item blink in and out before despawning
|
||||||
if _time_left < time_to_despawn / 2:
|
if _time_left < time_to_despawn / 2:
|
||||||
_set_visible(int(_time_left * 5.0) % 2 == 0)
|
get_parent().visible(int(_time_left * 5.0) % 2 == 0)
|
||||||
|
|
||||||
|
|
||||||
# `visible` is not a replicated property, so a blink driven only here would make
|
|
||||||
# the item flicker on the host and stay solid on clients. Until it is synced,
|
|
||||||
# only warn when there is nobody else to disagree with.
|
|
||||||
func _set_visible(value: bool) -> void: # TODO: Fix blinking
|
|
||||||
if true:# NetworkManager.is_online():
|
|
||||||
get_parent().visible = true
|
|
||||||
return
|
|
||||||
get_parent().visible = value
|
|
||||||
|
|||||||
@@ -68,17 +68,9 @@ shape = SubResource("BoxShape3D_arao0")
|
|||||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023)
|
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023)
|
||||||
shape = SubResource("BoxShape3D_arao0")
|
shape = SubResource("BoxShape3D_arao0")
|
||||||
|
|
||||||
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565]
|
|
||||||
_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn")
|
|
||||||
spawn_path = NodePath("../Players")
|
|
||||||
|
|
||||||
[node name="Players" type="Node3D" parent="." unique_id=1595463693]
|
|
||||||
|
|
||||||
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248]
|
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248]
|
||||||
_spawnable_scenes = PackedStringArray("uid://dpot5qie20vf6", "uid://b3m2ag8g5rj4r", "uid://c0lknik4noobs", "uid://b3m2ag8g5rj4r", "uid://cfc4ho67u4r5e", "uid://e4i6o5oriecx")
|
_spawnable_scenes = PackedStringArray("uid://dpot5qie20vf6", "uid://b3m2ag8g5rj4r", "uid://c0lknik4noobs", "uid://b3m2ag8g5rj4r", "uid://cfc4ho67u4r5e", "uid://e4i6o5oriecx")
|
||||||
spawn_path = NodePath("../Items")
|
spawn_path = NodePath("..")
|
||||||
|
|
||||||
[node name="Items" type="Node3D" parent="." unique_id=291550153]
|
|
||||||
|
|
||||||
[node name="StationsSpawner" type="MultiplayerSpawner" parent="." unique_id=240083159]
|
[node name="StationsSpawner" type="MultiplayerSpawner" parent="." unique_id=240083159]
|
||||||
_spawnable_scenes = PackedStringArray("uid://c6rift56ql3f8", "uid://efaec6ymgabo", "uid://bbg7dwsbxxh1t", "uid://cnjwtnhwh0i8q", "uid://j7caslh27nor", "uid://ck5tuftqmyiue", "uid://cwnwo4i28upap", "uid://dvrk268s7gkxh", "uid://caf0xanmxbshy")
|
_spawnable_scenes = PackedStringArray("uid://c6rift56ql3f8", "uid://efaec6ymgabo", "uid://bbg7dwsbxxh1t", "uid://cnjwtnhwh0i8q", "uid://j7caslh27nor", "uid://ck5tuftqmyiue", "uid://cwnwo4i28upap", "uid://dvrk268s7gkxh", "uid://caf0xanmxbshy")
|
||||||
@@ -87,3 +79,9 @@ script = ExtResource("5_36d77")
|
|||||||
kitchen_scene = ExtResource("6_uvo4r")
|
kitchen_scene = ExtResource("6_uvo4r")
|
||||||
|
|
||||||
[node name="Stations" type="Node3D" parent="." unique_id=1991170324]
|
[node name="Stations" type="Node3D" parent="." unique_id=1991170324]
|
||||||
|
|
||||||
|
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565]
|
||||||
|
_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn")
|
||||||
|
spawn_path = NodePath("../Players")
|
||||||
|
|
||||||
|
[node name="Players" type="Node3D" parent="." unique_id=1595463693]
|
||||||
|
|||||||
+23
-36
@@ -6,8 +6,8 @@ extends Control
|
|||||||
## directly.
|
## directly.
|
||||||
|
|
||||||
const JON_SCENE := "res://Scenes/jon_scene.tscn"
|
const JON_SCENE := "res://Scenes/jon_scene.tscn"
|
||||||
const MULTIPLAYER_SCENE := "res://Scenes/multiplayer.tscn"
|
|
||||||
const SETTINGS_PATH := "user://vryhungry_settings.cfg"
|
const SETTINGS_PATH := "user://vryhungry_settings.cfg"
|
||||||
|
const MULTIPLAYER_SCENE := "res://Scenes/multiplayer.tscn"
|
||||||
|
|
||||||
var _ip := "127.0.0.1"
|
var _ip := "127.0.0.1"
|
||||||
|
|
||||||
@@ -22,11 +22,12 @@ var _ip := "127.0.0.1"
|
|||||||
@onready var _join_btn: Button = %JoinButton
|
@onready var _join_btn: Button = %JoinButton
|
||||||
@onready var _back_btn: Button = %BackButton
|
@onready var _back_btn: Button = %BackButton
|
||||||
|
|
||||||
|
# store original text so we can say "connecting..." while pinging server
|
||||||
|
var original_join_text: String
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
print("Menu Panel _ready")
|
print("Menu Panel _ready")
|
||||||
if _bypass_menu_for_cmdline():
|
|
||||||
return
|
|
||||||
for child in _keypad.get_children():
|
for child in _keypad.get_children():
|
||||||
if child is Button:
|
if child is Button:
|
||||||
child.pressed.connect(_on_key.bind(child.text))
|
child.pressed.connect(_on_key.bind(child.text))
|
||||||
@@ -35,29 +36,12 @@ func _ready() -> void:
|
|||||||
_join_menu_btn.pressed.connect(_show_join_view)
|
_join_menu_btn.pressed.connect(_show_join_view)
|
||||||
_join_btn.pressed.connect(_on_join_pressed)
|
_join_btn.pressed.connect(_on_join_pressed)
|
||||||
_back_btn.pressed.connect(_show_root_view)
|
_back_btn.pressed.connect(_show_root_view)
|
||||||
|
|
||||||
|
NetworkManager.connection_failed.connect(_handle_connection_failed)
|
||||||
_load_ip()
|
_load_ip()
|
||||||
_show_root_view()
|
_show_root_view()
|
||||||
_refresh_ip()
|
_refresh_ip()
|
||||||
|
original_join_text = _join_btn.text
|
||||||
|
|
||||||
## --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:
|
func _show_root_view() -> void:
|
||||||
print("Menu Panel show root view")
|
print("Menu Panel show root view")
|
||||||
@@ -69,7 +53,8 @@ func _show_join_view() -> void:
|
|||||||
print("Menu Panel show join view")
|
print("Menu Panel show join view")
|
||||||
_root_view.visible = false
|
_root_view.visible = false
|
||||||
_join_view.visible = true
|
_join_view.visible = true
|
||||||
set_status("Enter host IP, then Join")
|
_status.text= "Enter host IP, then Join"
|
||||||
|
_status.add_theme_color_override("font_color", Color.WHITE)
|
||||||
|
|
||||||
|
|
||||||
func _on_key(key: String) -> void:
|
func _on_key(key: String) -> void:
|
||||||
@@ -95,17 +80,26 @@ func _on_john_pressed() -> void:
|
|||||||
func _on_host_pressed() -> void:
|
func _on_host_pressed() -> void:
|
||||||
print("Menu Panel press host")
|
print("Menu Panel press host")
|
||||||
NetworkManager.host()
|
NetworkManager.host()
|
||||||
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
|
|
||||||
|
|
||||||
|
|
||||||
func _on_join_pressed() -> void:
|
func _on_join_pressed() -> void:
|
||||||
print("Menu Panel press host")
|
print("Menu Panel press join")
|
||||||
if _ip.is_empty():
|
if _ip.is_empty():
|
||||||
set_status("Enter an IP first")
|
_status.text= "Enter an IP first"
|
||||||
|
_status.add_theme_color_override("font_color", Color.ORANGE)
|
||||||
return
|
return
|
||||||
_save_ip()
|
_save_ip()
|
||||||
NetworkManager.request_join(_ip)
|
_join_btn.disabled = true
|
||||||
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
|
_join_btn.text = "Connecting..."
|
||||||
|
NetworkManager.join(_ip)
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_connection_failed():
|
||||||
|
print("Menu Panel _handle_connection_failed")
|
||||||
|
_status.text= "Connection failed"
|
||||||
|
_status.add_theme_color_override("font_color", Color.RED)
|
||||||
|
_join_btn.disabled = false
|
||||||
|
_join_btn.text = original_join_text
|
||||||
|
|
||||||
|
|
||||||
## Remembers the last IP the player tried to join, so the keypad starts
|
## Remembers the last IP the player tried to join, so the keypad starts
|
||||||
@@ -121,10 +115,3 @@ func _save_ip() -> void:
|
|||||||
cfg.load(SETTINGS_PATH)
|
cfg.load(SETTINGS_PATH)
|
||||||
cfg.set_value("network", "last_ip", _ip)
|
cfg.set_value("network", "last_ip", _ip)
|
||||||
cfg.save(SETTINGS_PATH)
|
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
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ extends Node
|
|||||||
|
|
||||||
const DEFAULT_PORT := 24565
|
const DEFAULT_PORT := 24565
|
||||||
const MAX_CLIENTS := 7
|
const MAX_CLIENTS := 7
|
||||||
|
const MULTIPLAYER_SCENE := "res://Scenes/multiplayer.tscn"
|
||||||
|
|
||||||
|
|
||||||
signal player_joined(peer_id: int)
|
signal player_joined(peer_id: int)
|
||||||
@@ -37,6 +38,7 @@ func host(port: int = DEFAULT_PORT) -> Error:
|
|||||||
multiplayer.multiplayer_peer = peer
|
multiplayer.multiplayer_peer = peer
|
||||||
print("NetworkManager HOST started on port %d (peer id %d)" % [port, multiplayer.get_unique_id()])
|
print("NetworkManager HOST started on port %d (peer id %d)" % [port, multiplayer.get_unique_id()])
|
||||||
session_started.emit(true)
|
session_started.emit(true)
|
||||||
|
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
|
||||||
return OK
|
return OK
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +71,189 @@ func _go_offline() -> void:
|
|||||||
unregister_world()
|
unregister_world()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- Item grab-authority transfer -----------------------------------------
|
||||||
|
|
||||||
|
## Called by NetPickable when this peer grabs an item by hand. Godot rejects
|
||||||
|
## rpc_id() targeting your own peer id ("RPC on yourself is not allowed"), so
|
||||||
|
## 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")
|
||||||
|
func _request_item_authority_rpc(item_path: NodePath) -> void:
|
||||||
|
if not is_server():
|
||||||
|
return
|
||||||
|
_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.
|
||||||
|
print("NetworkManager 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
|
||||||
|
print("NetworkManager 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
|
||||||
|
# snap zone won't re-grab it), then release it from any station.
|
||||||
|
_set_item_authority.rpc(item_path, sender)
|
||||||
|
if item:
|
||||||
|
_release_from_snap_zones(item)
|
||||||
|
|
||||||
|
|
||||||
|
## Called by NetPickable when this peer releases an item, forwarding its throw
|
||||||
|
## velocity so the server can resume simulating it. Same self-RPC issue as
|
||||||
|
## 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")
|
||||||
|
func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
|
||||||
|
if not is_server():
|
||||||
|
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:
|
||||||
|
print("NetworkManager release_item_authority: %s released by peer %d" % [str(item_path), sender])
|
||||||
|
_set_item_authority.rpc(item_path, 1)
|
||||||
|
var item := get_node_or_null(item_path)
|
||||||
|
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.linear_velocity = lin
|
||||||
|
item.angular_velocity = ang
|
||||||
|
_try_snap_into_station.call_deferred(item)
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
var zones := []
|
||||||
|
for station in get_tree().get_nodes_in_group("station"):
|
||||||
|
for child in station.get_children():
|
||||||
|
if child is XRToolsSnapZone:
|
||||||
|
zones.append(child)
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
# If the item is snapped into any station, drop it from that station.
|
||||||
|
func _release_from_snap_zones(item: Node) -> void:
|
||||||
|
for zone in _station_snap_zones():
|
||||||
|
if zone.picked_up_object == item:
|
||||||
|
print("NetworkManager releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name])
|
||||||
|
zone.drop_object()
|
||||||
|
# Make the zone forget the item as well. These zones are snap_mode=RANGE,
|
||||||
|
# so every frame they re-grab anything still listed in their grab area
|
||||||
|
# that can be picked up — and Jolt does not emit body_exited when let_go()
|
||||||
|
# switches the item's collision layer back out of the zone's mask, so the
|
||||||
|
# entry goes stale and never clears. The station then snatches the item
|
||||||
|
# straight back off the player who just took it, teleporting it home.
|
||||||
|
# Bringing it near again re-adds it properly (a held item is on the layer
|
||||||
|
# the zone watches), and releasing next to a station is handled
|
||||||
|
# explicitly by _try_snap_into_station.
|
||||||
|
if zone._object_in_grab_area.has(item):
|
||||||
|
zone._object_in_grab_area.erase(item)
|
||||||
|
|
||||||
|
|
||||||
|
# Snap the item into the nearest empty station snap zone within grab range.
|
||||||
|
#
|
||||||
|
# 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:
|
||||||
|
if not (item is Node3D):
|
||||||
|
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()
|
||||||
|
print("NetworkManager 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():
|
||||||
|
if is_instance_valid(zone.picked_up_object):
|
||||||
|
continue
|
||||||
|
if zone.global_position.distance_to(item.global_position) <= zone.grab_distance:
|
||||||
|
print("NetworkManager snapped %s into %s" % [item.name, zone.get_parent().name])
|
||||||
|
zone.pick_up_object(item)
|
||||||
|
return
|
||||||
|
print("NetworkManager 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
|
||||||
|
# item (set_multiplayer_authority is a local call and must run everywhere).
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _set_item_authority(item_path: NodePath, peer: int) -> void:
|
||||||
|
var item := get_node_or_null(item_path)
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
print("NetworkManager _set_item_authority: %s -> peer %d" % [item.name, peer])
|
||||||
|
item.set_multiplayer_authority(peer) # recursive: item + synchronizer + NetPickable
|
||||||
|
var np := item.get_node_or_null("NetPickable")
|
||||||
|
if np:
|
||||||
|
np.net_held_by = 0 if peer == 1 else peer
|
||||||
|
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:
|
||||||
|
print("NetworkManager 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()
|
||||||
|
|
||||||
@@ -142,6 +327,8 @@ func _on_peer_disconnected(peer_id: int) -> void:
|
|||||||
func _on_connected_to_server() -> void:
|
func _on_connected_to_server() -> void:
|
||||||
print("NetworkManager connected_to_server (my id=%d)" % multiplayer.get_unique_id())
|
print("NetworkManager connected_to_server (my id=%d)" % multiplayer.get_unique_id())
|
||||||
session_started.emit(false)
|
session_started.emit(false)
|
||||||
|
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
|
||||||
|
|
||||||
|
|
||||||
func _on_connection_failed() -> void:
|
func _on_connection_failed() -> void:
|
||||||
print("NetworkManager connection_failed")
|
print("NetworkManager connection_failed")
|
||||||
@@ -156,6 +343,7 @@ func _on_server_disconnected() -> void:
|
|||||||
|
|
||||||
# --- Command-line driven test bootstrap -----------------------------------
|
# --- Command-line driven test bootstrap -----------------------------------
|
||||||
func _handle_cmdline() -> void:
|
func _handle_cmdline() -> void:
|
||||||
|
print("NetworkManager _handle_cmdline")
|
||||||
var args := OS.get_cmdline_user_args()
|
var args := OS.get_cmdline_user_args()
|
||||||
if args.has("--server"):
|
if args.has("--server"):
|
||||||
print("NetworkManager cmdline: --server")
|
print("NetworkManager cmdline: --server")
|
||||||
|
|||||||
Reference in New Issue
Block a user