feat: expand multiplayer, chat, and release support

- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
This commit is contained in:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

View File

@@ -16,11 +16,12 @@ const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560)
const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392)
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5
const COMPANION_NAMEPLATE_FONT_SIZE: int = 24
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
const COMPANION_NAMEPLATE_FONT_SIZE: int = 16
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 15
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
const COMPANION_NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const COMPANION_NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const NPC_NAMEPLATE_OFFSET_Y: float = -136.0
const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
@@ -36,6 +37,7 @@ const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
var _isChangingScene: bool = false
var _lastRecruitmentClickMsec: int = 0
var _companionNicknameFont: FontFile
func _ready() -> void:
_align_service_occupants()
@@ -252,34 +254,20 @@ func _configure_companion_nameplate(label: Label, personaName: String, offsetY:
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
label.add_theme_constant_override("shadow_offset_x", 0)
label.add_theme_constant_override("shadow_offset_y", 1)
label.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
label.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
label.add_theme_constant_override("outline_size", 3)
label.add_theme_font_override("font", _get_companion_nickname_font())
label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE)
label.add_theme_stylebox_override("normal", _create_companion_nameplate_style())
label.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
func _companion_nameplate_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28
return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
var measuredWidth := _get_companion_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, COMPANION_NAMEPLATE_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * COMPANION_NAMEPLATE_RENDER_SCALE + 20.0)
return clampi(visualWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
func _create_companion_nameplate_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.content_margin_left = 12
style.content_margin_top = 4
style.content_margin_right = 12
style.content_margin_bottom = 4
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
style.shadow_size = 4
style.shadow_offset = Vector2(0, 2)
return style
func _get_companion_nickname_font() -> FontFile:
if _companionNicknameFont == null:
_companionNicknameFont = COMPANION_NAMEPLATE_FONT_ZH.duplicate() as FontFile
_companionNicknameFont.fallbacks = [COMPANION_NAMEPLATE_FONT_LATIN]
return _companionNicknameFont

View File

@@ -7,6 +7,7 @@ extends Node
# ============================================================================
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn")
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const DEFAULT_MAP_ID: String = "whale_port"
const POSITION_SEND_INTERVAL: float = 0.12
@@ -19,19 +20,26 @@ const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25
@export var map_id: String = DEFAULT_MAP_ID
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers")
@export var network_npcs_root_path: NodePath = NodePath("../Characters/Npcs")
var _local_player: Node2D
var _remote_players_root: Node2D
var _network_npcs_root: Node2D
var _remote_players: Dictionary = {}
var _network_npcs: Dictionary = {}
var _pending_remote_players: Dictionary = {}
var _last_sent_position: Vector2 = Vector2.INF
var _last_sent_at_msec: int = 0
var _last_sent_direction: String = ""
var _last_sent_movement_state: String = ""
var _movement_sequence: int = 0
var _last_private_chat_interaction_msec: int = 0
var _last_friend_request_interaction_msec: int = 0
func _ready() -> void:
_local_player = get_node_or_null(local_player_path) as Node2D
_remote_players_root = get_node_or_null(remote_players_root_path) as Node2D
_network_npcs_root = get_node_or_null(network_npcs_root_path) as Node2D
if _local_player == null:
push_warning("MapMultiplayerController: local player not found.")
@@ -46,6 +54,9 @@ func _ready() -> void:
eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
eventSystem.call("connect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
@@ -78,6 +89,9 @@ func _exit_tree() -> void:
eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
@@ -175,7 +189,9 @@ func _on_chat_login_success(_data: Dictionary) -> void:
func _on_local_player_moved(data: Dictionary) -> void:
var position_variant: Variant = data.get("position", null)
if position_variant is Vector2:
_send_position(position_variant as Vector2)
var direction := str(data.get("direction", _get_local_direction()))
var movementState := str(data.get("movement_state", "walk"))
_send_position(position_variant as Vector2, direction, movementState)
func _send_world_ready() -> void:
if _local_player == null:
@@ -183,13 +199,16 @@ func _send_world_ready() -> void:
var chatManager := _get_chat_manager()
if chatManager == null or not chatManager.has_method("mark_world_ready"):
return
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position)
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position, _get_local_direction(), "idle", _movement_sequence)
func _send_position(position: Vector2, force: bool = false) -> void:
func _send_position(position: Vector2, direction: String, movementState: String, force: bool = false) -> void:
var normalizedDirection := _normalize_direction(direction)
var normalizedMovementState := _normalize_movement_state(movementState)
var stateChanged := normalizedDirection != _last_sent_direction or normalizedMovementState != _last_sent_movement_state
var now := Time.get_ticks_msec()
if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
if not force and not stateChanged and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
return
if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
if not force and not stateChanged and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
return
var chatManager := _get_chat_manager()
@@ -200,7 +219,10 @@ func _send_position(position: Vector2, force: bool = false) -> void:
_last_sent_position = position
_last_sent_at_msec = now
chatManager.call("update_player_position", position.x, position.y, _get_map_id())
_last_sent_direction = normalizedDirection
_last_sent_movement_state = normalizedMovementState
_movement_sequence += 1
chatManager.call("update_player_position", position.x, position.y, _get_map_id(), normalizedDirection, normalizedMovementState, _movement_sequence)
func _on_remote_player_joined(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
@@ -210,8 +232,8 @@ func _on_remote_player_joined(data: Dictionary) -> void:
return
var remote_player := _ensure_remote_player(user_id, data)
var position_variant: Variant = data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
if remote_player != null and position_variant is Vector2:
_update_remote_player_position(remote_player, position_variant as Vector2, data)
func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
@@ -230,8 +252,8 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
seen_user_ids[user_id] = true
var remote_player := _ensure_remote_player(user_id, player_data)
var position_variant: Variant = player_data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
if remote_player != null and position_variant is Vector2:
_update_remote_player_position(remote_player, position_variant as Vector2, player_data)
for user_id_variant in _remote_players.keys().duplicate():
var user_id := str(user_id_variant)
@@ -246,6 +268,70 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not seen_user_ids.has(pendingUserId):
_pending_remote_players.erase(pendingUserId)
func _on_npc_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
if _network_npcs_root == null:
return
var seenNpcIds: Dictionary = {}
var npcsVariant: Variant = data.get("npcs", [])
if npcsVariant is Array:
for npcVariant in npcsVariant:
if not (npcVariant is Dictionary):
continue
var npcData: Dictionary = npcVariant
var npcId := str(npcData.get("npc_id", npcData.get("npcId", ""))).strip_edges()
if npcId.is_empty():
continue
seenNpcIds[npcId] = true
var networkNpc := _ensure_network_npc(npcId)
if networkNpc != null and networkNpc.has_method("apply_snapshot"):
networkNpc.call("apply_snapshot", npcData)
for npcIdVariant in _network_npcs.keys().duplicate():
var npcId := str(npcIdVariant)
if seenNpcIds.has(npcId):
continue
var networkNpc := _network_npcs.get(npcId) as Node
_network_npcs.erase(npcId)
if is_instance_valid(networkNpc):
networkNpc.queue_free()
func _ensure_network_npc(npcId: String) -> Node2D:
if _network_npcs.has(npcId):
var existingNpc := _network_npcs.get(npcId) as Node2D
if is_instance_valid(existingNpc):
return existingNpc
_network_npcs.erase(npcId)
var networkNpc := NETWORK_NPC_SCENE.instantiate() as Node2D
if networkNpc == null:
return null
networkNpc.name = "NetworkNpc_%s" % npcId
networkNpc.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_network_npcs_root.add_child(networkNpc)
_network_npcs[npcId] = networkNpc
return networkNpc
func _on_npc_action_started(data: Dictionary) -> void:
_apply_npc_action(data, "apply_action_started")
func _on_npc_action_completed(data: Dictionary) -> void:
_apply_npc_action(data, "apply_action_completed")
func _apply_npc_action(data: Dictionary, methodName: String) -> void:
if not _is_event_for_current_map(data):
return
var npcId := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if npcId.is_empty():
return
var networkNpc := _network_npcs.get(npcId) as Node
if networkNpc == null:
networkNpc = _ensure_network_npc(npcId)
if networkNpc != null and networkNpc.has_method(methodName):
networkNpc.call(methodName, data)
func _on_remote_player_position_updated(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
@@ -260,8 +346,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void:
return
var remote_player := _ensure_remote_player(user_id, data)
if remote_player != null and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
if remote_player != null:
_update_remote_player_position(remote_player, position_variant as Vector2, data)
func _on_remote_player_left(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
@@ -357,8 +443,8 @@ func _on_remote_skin_ready(data: Dictionary) -> void:
_pending_remote_players.erase(userId)
var remotePlayer := _ensure_remote_player(userId, pendingData)
var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
remotePlayer.call("update_position", positionVariant as Vector2)
if remotePlayer != null and positionVariant is Vector2:
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
func _on_remote_skin_failed(data: Dictionary) -> void:
var skinId := str(data.get("skin_id", "")).strip_edges()
@@ -372,14 +458,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void:
continue
_pending_remote_players.erase(userId)
var fallbackData := pendingData.duplicate(true)
fallbackData["skin_id"] = ""
fallbackData["skinId"] = ""
fallbackData["skin_id"] = "classic_whale"
fallbackData["skinId"] = "classic_whale"
fallbackData.erase("skin_asset")
fallbackData.erase("skinAsset")
var remotePlayer := _ensure_remote_player(userId, fallbackData)
var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
remotePlayer.call("update_position", positionVariant as Vector2)
if remotePlayer != null and positionVariant is Vector2:
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
func _find_private_chat_target() -> Node2D:
if _local_player == null or _remote_players.is_empty():
@@ -436,9 +522,31 @@ func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> v
var username := str(data.get("username", "")).strip_edges()
if not username.is_empty():
remote_player.set("username", username)
if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"):
if remote_player.has_method("setup"):
remote_player.call("setup", data)
if remote_player.has_method("update_metadata"):
remote_player.call("update_metadata", data)
func _update_remote_player_position(remotePlayer: Node2D, position: Vector2, data: Dictionary) -> void:
if not remotePlayer.has_method("update_position"):
return
remotePlayer.call(
"update_position",
position,
str(data.get("direction", "")),
str(data.get("movement_state", data.get("movementState", "walk"))),
int(data.get("sequence", -1))
)
func _get_local_direction() -> String:
if _local_player != null:
return _normalize_direction(str(_local_player.get("lastDirection")))
return "down"
func _normalize_direction(value: String) -> String:
var normalized := value.strip_edges().to_lower()
return normalized if normalized in ["down", "up", "right", "left"] else "down"
func _normalize_movement_state(value: String) -> String:
return "walk" if value.strip_edges().to_lower() == "walk" else "idle"
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()

View File

@@ -20,6 +20,9 @@ const CAMERA_LIMIT_BOTTOM: int = 960
func _ready() -> void:
_apply_spawn_point()
_configure_camera()
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")):
player.set_spectator_mode(true)
func _apply_spawn_point() -> void:
var spawnName: String = SceneManager.get_next_spawn_name()

View File

@@ -146,6 +146,9 @@ disabled = true
[node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484]
y_sort_enabled = true
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
y_sort_enabled = true
[node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")]
position = Vector2(-472, -291)

View File

@@ -12,9 +12,7 @@
[ext_resource type="Texture2D" uid="uid://c8ubnxut51f0r" path="res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png" id="12_lefttask"]
[ext_resource type="Texture2D" uid="uid://b4bvp0r5hua1k" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="13_rightsvc"]
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="14_u1t8b"]
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="17_qog0y"]
[ext_resource type="Texture2D" uid="uid://c0kgmsaach8wh" path="res://assets/maps/square/v1/props/foliage_v13_concept_broadleaf_single/broadleaf_tree_v13_clean.png" id="19_edt5w"]
[ext_resource type="PackedScene" path="res://scenes/characters/crayfish_npc.tscn" id="20_lemld"]
[ext_resource type="Texture2D" uid="uid://q3vldth6fyvw" path="res://assets/maps/square/v1/props/foliage_v14_concept_evergreen_single/evergreen_tree_v14_clean.png" id="20_rixdf"]
[ext_resource type="Texture2D" uid="uid://cbqj51mpelsr4" path="res://assets/maps/square/v1/props/top_fence_v1/top_fence_v1_clean.png" id="22_jagxe"]
[ext_resource type="Texture2D" uid="uid://csxvgs1v5puvt" path="res://assets/maps/square/v1/props/guild_left_tree_flowers_v1/guild_left_tree_flowers_v1_clean.png" id="24_8hf0h"]
@@ -35,6 +33,7 @@
[ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="38_multiplayer"]
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="39_playerhud"]
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="40_friendpanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/WorldBulletinPanel.tscn" id="45_world_bulletin"]
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="41_settingspanel"]
[ext_resource type="Script" uid="uid://dr6uk7m4fsr4l" path="res://scenes/Maps/ScenePortal.gd" id="42_sceneportal"]
[ext_resource type="Script" uid="uid://biu0igl6133q5" path="res://scenes/Maps/Square.gd" id="43_square"]
@@ -134,6 +133,9 @@ layer = 10
[node name="FriendListPanel" parent="UILayer" unique_id=914934559 instance=ExtResource("40_friendpanel")]
[node name="WorldBulletinPanel" parent="UILayer" instance=ExtResource("45_world_bulletin")]
z_index = 1000
[node name="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")]
[node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890]
@@ -385,20 +387,6 @@ y_sort_enabled = true
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880]
y_sort_enabled = true
[node name="GuildReceptionNpc" parent="YSortWorld/Characters/Npcs" unique_id=442345899 instance=ExtResource("17_qog0y")]
texture_filter = 1
position = Vector2(-199, -515)
npcName = "范鲸晶"
dialogue = "欢迎来到 WhaleTown 广场。上方是公会大厅,南边是小镇入口,左边通往码头。"
showNameplate = true
nameplateOffsetY = -72.0
[node name="DockGuideNpc" parent="YSortWorld/Characters/Npcs" unique_id=626857126 instance=ExtResource("20_lemld")]
texture_filter = 1
position = Vector2(-825, 437)
showNameplate = true
nameplateOffsetY = -66.0
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571]
script = ExtResource("38_multiplayer")

View File

@@ -241,6 +241,9 @@ position = Vector2(0, 921)
y_sort_enabled = true
position = Vector2(0, -1)
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
y_sort_enabled = true
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959]
script = ExtResource("26_multiplayer")
map_id = "work_zone"