synced stuff

This commit is contained in:
2026-01-22 02:15:47 +01:00
parent eaf86b39fa
commit 9b4135b175
7 changed files with 518 additions and 120 deletions

View File

@@ -31,10 +31,6 @@
[ext_resource type="AudioStream" uid="uid://w6yon88kjfml" path="res://assets/audio/sfx/nickes/lift_sfx.ogg" id="28_pf23h"]
[ext_resource type="AudioStream" uid="uid://d4kjyb1olr74s" path="res://assets/audio/sfx/weapons/bow/bow_draw-02.mp3" id="30_gl8cc"]
[ext_resource type="AudioStream" uid="uid://mbtpqlb5n3gd" path="res://assets/audio/sfx/weapons/bow/bow_no_arrow.mp3" id="31_487ah"]
[ext_resource type="AudioStream" uid="uid://fm6hrpckfknc" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_03.wav" id="32_bj30b"]
[ext_resource type="AudioStream" uid="uid://be3uspidyqm3x" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_04.wav" id="33_jc3p3"]
[ext_resource type="AudioStream" uid="uid://dvttykynr671m" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_05.wav" id="34_hax0n"]
[ext_resource type="AudioStream" uid="uid://sejnuklu653m" path="res://assets/audio/sfx/player/unlock/padlock_wiggle_06.wav" id="35_t4otl"]
[sub_resource type="Gradient" id="Gradient_wqfne"]
colors = PackedColorArray(0, 0, 0, 1, 1, 0.13732082, 0.092538536, 1)
@@ -284,14 +280,6 @@ random_pitch = 1.0630184
streams_count = 1
stream_0/stream = ExtResource("31_487ah")
[sub_resource type="AudioStreamRandomizer" id="AudioStreamRandomizer_j2b1d"]
random_pitch = 1.1036249
streams_count = 4
stream_0/stream = ExtResource("32_bj30b")
stream_1/stream = ExtResource("33_jc3p3")
stream_2/stream = ExtResource("34_hax0n")
stream_3/stream = ExtResource("35_t4otl")
[node name="Player" type="CharacterBody2D" unique_id=937429705]
collision_mask = 67
motion_mode = 1

View File

@@ -164,6 +164,10 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
play_impact()
# CRITICAL: Stick to target on ALL clients FIRST (before damage check)
# This ensures the arrow stops on all clients, not just the authority
_stick_to_target(body)
# CRITICAL: Only the projectile owner (authority) should deal damage to players
if player_owner and player_owner.is_multiplayer_authority():
var attacker_pos = player_owner.global_position if player_owner else global_position
@@ -176,8 +180,6 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
else:
body.rpc_take_damage.rpc(20.0, attacker_pos)
# Stick to target on ALL clients (both authority and non-authority)
_stick_to_target(body)
return
# Deal damage to enemies
@@ -206,8 +208,9 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
# Add to hit_targets so we don't check this enemy again
hit_targets[body] = true
# Sync miss to all clients - arrow continues flying
if is_inside_tree():
_sync_arrow_miss.rpc(body.get_path())
# CRITICAL: Validate body is still valid and use name instead of path
if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree():
_sync_arrow_miss.rpc(body.name)
# Don't stick to target - let arrow continue flying
return
@@ -225,8 +228,9 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
# Add to hit_targets so we don't check this enemy again
hit_targets[body] = true
# Sync dodge to all clients - arrow continues flying
if is_inside_tree():
_sync_arrow_dodge.rpc(body.get_path())
# CRITICAL: Validate body is still valid and use name instead of path
if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree():
_sync_arrow_dodge.rpc(body.name)
# Don't stick to target - let arrow continue flying
print(body.name, " DODGED arrow! Arrow continues flying...")
return
@@ -246,8 +250,9 @@ func _on_arrow_area_body_entered(body: Node2D) -> void:
body.rpc_take_damage.rpc(damage, attacker_pos, false)
# Sync hit to all clients - arrow sticks
if is_inside_tree():
_sync_arrow_hit.rpc(body.get_path())
# CRITICAL: Validate body is still valid and use name instead of path
if is_inside_tree() and is_instance_valid(body) and body.is_inside_tree():
_sync_arrow_hit.rpc(body.name)
_stick_to_target(body)
return
@@ -293,11 +298,22 @@ func _sync_arrow_collected():
call_deferred("queue_free")
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_hit(target_path: NodePath):
func _sync_arrow_hit(target_name: String):
# Authority determined arrow HIT enemy - stick to it on all clients
var target = get_node_or_null(target_path)
# CRITICAL: Validate arrow is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
# Find target by name in Entities node
var target = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
if not target:
print("WARNING: Arrow hit target not found at path: ", target_path)
print("WARNING: Arrow hit target not found: ", target_name)
return
if target not in hit_targets:
@@ -307,17 +323,39 @@ func _sync_arrow_hit(target_path: NodePath):
print("Arrow synced as HIT to: ", target.name)
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_miss(target_path: NodePath):
func _sync_arrow_miss(target_name: String):
# Authority determined arrow MISSED enemy - continues flying on all clients
var target = get_node_or_null(target_path)
# CRITICAL: Validate arrow is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
# Find target by name in Entities node
var target = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
if target and target not in hit_targets:
hit_targets[target] = true
print("Arrow synced as MISS - continuing through: ", target.name if target else "unknown")
@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_dodge(target_path: NodePath):
func _sync_arrow_dodge(target_name: String):
# Authority determined enemy DODGED arrow - continues flying on all clients
var target = get_node_or_null(target_path)
# CRITICAL: Validate arrow is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
# Find target by name in Entities node
var target = null
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
target = entities_node.get_node_or_null(target_name)
if target and target not in hit_targets:
hit_targets[target] = true
print("Arrow synced as DODGE - continuing through: ", target.name if target else "unknown")

View File

@@ -17,7 +17,7 @@ func _ready() -> void:
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
func _process(_delta: float) -> void:
# Update camera to follow local players
_update_camera()
pass

View File

@@ -2784,8 +2784,8 @@ func _place_traps_in_dungeon(all_rooms: Array, start_room_index: int, exit_room_
if not too_close_to_door:
# Valid position - place trap
var trap_world_x = world_x * tile_size + tile_size / 2
var trap_world_y = world_y * tile_size + tile_size / 2
var trap_world_x = world_x * tile_size + tile_size / 2.0
var trap_world_y = world_y * tile_size + tile_size / 2.0
traps.append({
"position": Vector2(trap_world_x, trap_world_y),

View File

@@ -423,6 +423,10 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
# Wait a bit after dungeon sync to ensure objects are spawned first
call_deferred("_sync_existing_chest_states_to_client", peer_id)
# Sync existing trap states (detected/disarmed) to the new client
# Wait a bit after dungeon sync to ensure traps are spawned first
call_deferred("_sync_existing_trap_states_to_client", peer_id)
# Note: Dungeon-spawned enemies are already synced via _sync_dungeon RPC
# which includes dungeon_data.enemies and calls _spawn_enemies() on the client.
# So we don't need to sync them again with individual RPCs.
@@ -430,14 +434,7 @@ func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_
# Note: Interactable objects are also synced via _sync_dungeon RPC
# which includes dungeon_data.interactable_objects and calls _spawn_interactable_objects() on the client.
# However, chest open states and broken objects need to be synced separately since they change during gameplay.
# Sync existing torches to the new client
# Wait until AFTER dungeon chunks are sent (8 chunks * 0.15s = 1.2s, plus rooms/entities = ~1.6s)
# Add extra buffer to ensure chunks are complete
get_tree().create_timer(2.0).timeout.connect(func():
if is_inside_tree():
_sync_existing_torches_to_client(peer_id)
)
# NOTE: Torches are already in dungeon_data and are spawned from the blob, so we don't need to sync them separately.
# Sync door states to the new client (wait until after dungeon chunks)
get_tree().create_timer(2.0).timeout.connect(func():
@@ -3465,6 +3462,11 @@ func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, h
_spawn_blocking_doors()
print("GameWorld: Client - Blocking doors spawned")
# Spawn traps on client
print("GameWorld: Client - Spawning traps...")
_spawn_traps()
print("GameWorld: Client - Traps spawned")
# Spawn room triggers on client
print("GameWorld: Client - Spawning room triggers...")
_spawn_room_triggers()
@@ -3885,6 +3887,11 @@ func _reassemble_dungeon_blob():
_spawn_blocking_doors()
print("GameWorld: Client - Blocking doors spawned")
# Spawn traps on client
print("GameWorld: Client - Spawning traps from blob...")
_spawn_traps()
print("GameWorld: Client - Traps spawned")
print("GameWorld: Client - Spawning room triggers from blob...")
_spawn_room_triggers()
print("GameWorld: Client - Room triggers spawned")
@@ -4785,6 +4792,73 @@ func _sync_existing_chest_states_to_client(client_peer_id: int, retry_count: int
print("GameWorld: Synced ", opened_chest_count, " opened chests to client ", client_peer_id)
func _sync_existing_trap_states_to_client(client_peer_id: int, retry_count: int = 0):
# Sync trap states (detected/disarmed) to new client with retry logic
if not is_inside_tree():
return
if not multiplayer.is_server():
return
# Check if peer is recognized before sending RPC
if not _check_peer_recognized(client_peer_id):
if retry_count < 15: # Reduced from 30
get_tree().create_timer(0.2).timeout.connect(func():
if is_inside_tree() and multiplayer.is_server():
_sync_existing_trap_states_to_client(client_peer_id, retry_count + 1)
)
return
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var synced_trap_count = 0
for child in entities_node.get_children():
if child.is_in_group("trap"):
# Found a trap - sync its state (detected/disarmed) to the client
var trap_name = child.name
var is_detected = child.is_detected if "is_detected" in child else false
var is_disarmed = child.is_disarmed if "is_disarmed" in child else false
_sync_trap_state_by_name.rpc_id(client_peer_id, trap_name, is_detected, is_disarmed)
synced_trap_count += 1
print("GameWorld: Syncing trap ", trap_name, " state (detected: ", is_detected, ", disarmed: ", is_disarmed, ") to client ", client_peer_id)
print("GameWorld: Synced ", synced_trap_count, " trap states to client ", client_peer_id)
@rpc("authority", "reliable")
func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool):
# Client receives trap state sync by name (avoids node path RPC errors)
if not is_inside_tree():
return
if not multiplayer.is_server():
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
var trap = entities_node.get_node_or_null(trap_name)
if trap and trap.is_in_group("trap"):
# Update trap state
if "is_detected" in trap:
trap.is_detected = is_detected
if "is_disarmed" in trap:
trap.is_disarmed = is_disarmed
# Update visuals
if is_detected and "sprite" in trap and trap.sprite:
trap.sprite.modulate.a = 1.0
if is_disarmed:
if "sprite" in trap and trap.sprite:
trap.sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
if "activation_area" in trap and trap.activation_area:
trap.activation_area.monitoring = false
print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed)
else:
print("GameWorld: WARNING - Trap ", trap_name, " not found when syncing state")
func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0):
# Sync broken interactable objects to new client with retry logic
# Check if node is still valid and in tree
@@ -4907,18 +4981,28 @@ func _sync_existing_torches_to_client(client_peer_id: int, retry_count: int = 0)
func _sync_torch_spawn(torch_position: Vector2, torch_rotation: float):
# Clients spawn torch when server tells them to
if not multiplayer.is_server():
# CRITICAL: Check if torch already exists at this position to avoid duplicates
var entities_node = get_node_or_null("Entities")
if not entities_node:
return
# Check if a torch already exists at this position (within 1 pixel tolerance)
for child in entities_node.get_children():
if child.is_in_group("torch"):
if child.global_position.distance_to(torch_position) < 1.0:
# Torch already exists at this position - skip spawning duplicate
return
var torch_scene = preload("res://scenes/torch_wall.tscn")
if not torch_scene:
return
var entities_node = get_node_or_null("Entities")
if entities_node:
var torch = torch_scene.instantiate()
torch.name = "Torch_%d" % entities_node.get_child_count()
entities_node.add_child(torch)
torch.global_position = torch_position
torch.rotation_degrees = torch_rotation
print("Client spawned torch at ", torch_position, " with rotation ", torch_rotation)
var torch = torch_scene.instantiate()
torch.name = "Torch_%d" % entities_node.get_child_count()
entities_node.add_child(torch)
torch.global_position = torch_position
torch.rotation_degrees = torch_rotation
print("Client spawned torch at ", torch_position, " with rotation ", torch_rotation)
func _sync_existing_door_states_to_client(client_peer_id: int, retry_count: int = 0):
# Sync door states to new client with retry logic

View File

@@ -403,6 +403,12 @@ func _ready():
can_send_rpcs = true
print("Player ", name, " is now ready to send RPCs (is_server: ", multiplayer.is_server(), ")")
# When we become ready to send RPCs, re-sync appearance to ensure new clients get it
# This handles the case where appearance was set up before new clients connected
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and character_stats and character_stats.race != "":
# Emit character_changed to trigger appearance sync for any newly connected clients
character_stats.character_changed.emit(character_stats)
func _duplicate_sprite_materials():
# Duplicate shader materials for sprites that use tint parameters
# This prevents shared material state between players
@@ -469,6 +475,12 @@ func _initialize_character_stats():
func _reinitialize_appearance_with_seed(_seed_value: int):
# Re-initialize appearance with the correct dungeon_seed
# This is called when a joiner receives dungeon_seed after players were already spawned
# CRITICAL: Only the authority should re-initialize appearance!
# Non-authority players will receive appearance via race/equipment sync
if not is_multiplayer_authority():
remove_meta("needs_appearance_reset") # Clear flag even if we skip
return # Non-authority will receive appearance via sync
var game_world = get_tree().get_first_node_in_group("game_world")
if not game_world or game_world.dungeon_seed == 0:
return # Still no seed, skip
@@ -483,9 +495,8 @@ func _reinitialize_appearance_with_seed(_seed_value: int):
LogManager.log_error("Player " + str(name) + " _reinitialize_appearance_with_seed: character_stats is null!", LogManager.CATEGORY_GAMEPLAY)
return
# Save current state (race, stats, equipment) before re-initializing
# We need to preserve these because they might have been set correctly already
var saved_race = character_stats.race
# Save current state (stats, equipment) before re-initializing
# Race and appearance will be re-randomized with correct seed (deterministic)
var saved_stats = {
"str": character_stats.baseStats.str,
"dex": character_stats.baseStats.dex,
@@ -519,11 +530,12 @@ func _reinitialize_appearance_with_seed(_seed_value: int):
appearance_rng.seed = new_seed_value
# Re-run appearance setup with the correct seed
# This will re-randomize visual appearance (skin, hair, facial hair, eyes, etc.)
# This will re-randomize EVERYTHING including race, appearance, stats
# Since the seed is deterministic, this will match what was generated on other clients
_setup_player_appearance()
# Restore saved race, stats, and equipment (preserve them from before re-initialization)
character_stats.setRace(saved_race) # Restore original race
# Restore saved stats (but keep the newly randomized race/appearance from correct seed)
# The race/appearance from _setup_player_appearance() is now correct (deterministic seed)
character_stats.baseStats.str = saved_stats.str
character_stats.baseStats.dex = saved_stats.dex
character_stats.baseStats.int = saved_stats.int
@@ -540,7 +552,7 @@ func _reinitialize_appearance_with_seed(_seed_value: int):
character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp'
character_stats.level = saved_stats.level
# Restore equipment
# Restore equipment (but Elf starting equipment will be re-added by _setup_player_appearance)
for slot_name in saved_equipment.keys():
var item_data = saved_equipment[slot_name]
character_stats.equipment[slot_name] = Item.new(item_data) if item_data else null
@@ -762,6 +774,118 @@ func _setup_player_appearance():
print("Player ", name, " appearance set up: race=", character_stats.race)
func _setup_player_appearance_preserve_race():
# Same as _setup_player_appearance() but preserves existing race instead of randomizing it
# Ensure character_stats exists before setting appearance
if not character_stats:
LogManager.log_error("Player " + str(name) + " _setup_player_appearance_preserve_race: character_stats is null!", LogManager.CATEGORY_GAMEPLAY)
return
# Use existing race (don't randomize)
var selected_race = character_stats.race
if selected_race == "":
# Fallback: if no race set, randomize
var races = ["Dwarf", "Elf", "Human"]
selected_race = races[appearance_rng.randi() % races.size()]
character_stats.setRace(selected_race)
# Don't randomize stats - they should be synced separately
# Don't give starting equipment - that should be synced separately
# Randomize skin (human only for players)
var weights = [7, 6, 5, 4, 3, 2, 1]
var total_weight = 28
var random_value = appearance_rng.randi() % total_weight
var skin_index = 0
var cumulative = 0
for i in range(weights.size()):
cumulative += weights[i]
if random_value < cumulative:
skin_index = i
break
character_stats.setSkin(skin_index)
# Randomize hairstyle (0 = none, 1-12 = various styles)
var hair_style = appearance_rng.randi_range(0, 12)
character_stats.setHair(hair_style)
# Randomize hair color
var hair_colors = [
Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1),
Color(0.8, 0.6, 0.4), Color(0.6, 0.3, 0.1), Color(0.9, 0.7, 0.5),
Color(0.2, 0.2, 0.2), Color(0.5, 0.5, 0.5), Color(0.5, 0.8, 0.2),
Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2),
Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2), Color(0.2, 0.9, 0.9),
Color(0.6, 0.2, 0.9), Color(0.9, 0.7, 0.2), Color(0.3, 0.9, 0.3),
Color(0.2, 0.2, 0.9), Color(0.9, 0.4, 0.6), Color(0.5, 0.2, 0.8),
Color(0.9, 0.6, 0.1)
]
character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()])
# Randomize facial hair based on race constraints
var facial_hair_style = 0
match selected_race:
"Dwarf":
facial_hair_style = appearance_rng.randi_range(1, 3)
"Elf":
facial_hair_style = 0
"Human":
facial_hair_style = 3 if appearance_rng.randf() < 0.5 else 0
character_stats.setFacialHair(facial_hair_style)
# Randomize facial hair color
if facial_hair_style > 0:
if appearance_rng.randf() < 0.3:
character_stats.setFacialHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()])
else:
character_stats.setFacialHairColor(character_stats.hair_color)
# Randomize eyes
var eye_style = appearance_rng.randi_range(1, 14)
character_stats.setEyes(eye_style)
# Randomize eye color
var white_color = Color(0.9, 0.9, 0.9)
var other_eye_colors = [
Color(0.1, 0.1, 0.1), Color(0.2, 0.3, 0.8), Color(0.3, 0.7, 0.9),
Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9),
Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2),
Color(0.2, 0.9, 0.9), Color(0.6, 0.2, 0.9), Color(0.9, 0.7, 0.2),
Color(0.3, 0.9, 0.3), Color(0.2, 0.2, 0.9), Color(0.9, 0.4, 0.6),
Color(0.5, 0.2, 0.8), Color(0.9, 0.6, 0.1)
]
if appearance_rng.randf() < 0.75:
character_stats.setEyeColor(white_color)
else:
character_stats.setEyeColor(other_eye_colors[appearance_rng.randi() % other_eye_colors.size()])
# Randomize eyelashes
var eyelash_style = appearance_rng.randi_range(0, 8)
character_stats.setEyeLashes(eyelash_style)
# Randomize eyelash color
var eyelash_colors = [
Color(0.1, 0.1, 0.1), Color(0.2, 0.2, 0.2), Color(0.3, 0.2, 0.15),
Color(0.4, 0.3, 0.2), Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1),
Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8),
Color(0.9, 0.2, 0.2), Color(0.9, 0.9, 0.9), Color(0.6, 0.2, 0.9)
]
if eyelash_style > 0:
character_stats.setEyelashColor(eyelash_colors[appearance_rng.randi() % eyelash_colors.size()])
# Randomize ears/addons based on race
match selected_race:
"Elf":
var elf_ear_style = skin_index + 1
character_stats.setEars(elf_ear_style)
_:
character_stats.setEars(0)
# Apply appearance to sprite layers
_apply_appearance_to_sprites()
print("Player ", name, " appearance re-initialized (preserved race=", selected_race, ")")
func _apply_appearance_to_sprites():
# Apply character_stats appearance to sprite layers
if not character_stats:
@@ -1036,34 +1160,33 @@ func _on_character_changed(_char: CharacterStats):
equipment_data[slot_name] = null
_rpc_to_ready_peers("_sync_equipment", [equipment_data])
# Sync race and base stats to all clients (for proper display)
# ALWAYS sync race and base stats to all clients (for proper display)
# This ensures new clients get appearance data even if they connect after initial setup
_rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()])
# Sync equipment and inventory to client (when server adds/removes items for a client player)
# This ensures joiners see items they pick up and equipment changes
# This must be checked separately from the authority-based sync because on the server,
# a joiner's player has authority set to their peer_id, not the server's unique_id
if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and can_send_rpcs and is_inside_tree():
var the_peer_id = get_multiplayer_authority()
# Only sync if this is a client player (not server's own player)
if the_peer_id != 0 and the_peer_id != multiplayer.get_unique_id():
# Sync equipment
var equipment_data = {}
for slot_name in character_stats.equipment.keys():
var item = character_stats.equipment[slot_name]
if item:
equipment_data[slot_name] = item.save()
else:
equipment_data[slot_name] = null
_sync_equipment.rpc_id(the_peer_id, equipment_data)
# Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players
# This ensures remote players have the exact same appearance as authority
var appearance_data = {
"skin": character_stats.skin,
"hairstyle": character_stats.hairstyle,
"hair_color": character_stats.hair_color.to_html(true),
"facial_hair": character_stats.facial_hair,
"facial_hair_color": character_stats.facial_hair_color.to_html(true),
"eyes": character_stats.eyes,
"eye_color": character_stats.eye_color.to_html(true),
"eye_lashes": character_stats.eye_lashes,
"eyelash_color": character_stats.eyelash_color.to_html(true),
"add_on": character_stats.add_on
}
_rpc_to_ready_peers("_sync_appearance", [appearance_data])
# Sync inventory
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
_sync_inventory.rpc_id(the_peer_id, inventory_data)
print(name, " syncing equipment and inventory to client peer_id=", the_peer_id, " inventory_size=", inventory_data.size())
# Sync inventory changes to all clients
var inventory_data = []
for item in character_stats.inventory:
if item:
inventory_data.append(item.save())
_rpc_to_ready_peers("_sync_inventory", [inventory_data])
print(name, " synced equipment, race, and inventory to all clients. Inventory size: ", inventory_data.size())
func _get_player_color() -> Color:
# Legacy function - now returns white (no color tint)
@@ -1081,6 +1204,11 @@ func _is_player(obj) -> bool:
# Helper function to get consistent object name for network sync
func _get_object_name_for_sync(obj) -> String:
# Check if object is still valid
if not is_instance_valid(obj) or not obj.is_inside_tree():
# Object is invalid or not in tree - return empty string to skip sync
return ""
# For interactable objects, use the consistent name (InteractableObject_X)
if obj.name.begins_with("InteractableObject_"):
return obj.name
@@ -1091,6 +1219,11 @@ func _get_object_name_for_sync(obj) -> String:
if _is_player(obj):
return obj.name
# Last resort: use the node name (might be auto-generated like @CharacterBody2D@82)
# But only if it's a valid name (not auto-generated if possible)
if obj.name.begins_with("@") and obj.has_meta("object_index"):
# Try to use object_index instead of auto-generated name
var obj_index = obj.get_meta("object_index")
return "InteractableObject_%d" % obj_index
return obj.name
func _get_log_prefix() -> String:
@@ -1626,6 +1759,11 @@ func _physics_process(delta):
else:
print("Player ", name, " (server) - all clients now ready! (no ready times tracked)")
# Re-sync appearance to all clients now that they're all ready
# This ensures new clients get the correct appearance even if they connected after initial sync
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and character_stats and character_stats.race != "":
character_stats.character_changed.emit(character_stats)
# Sync position to all ready peers (clients and server)
# Only send if node is still valid and in tree
if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and is_instance_valid(self):
@@ -2057,6 +2195,11 @@ func _handle_interactions():
# Minimum charge time: 0.2 seconds, otherwise cancel
if charge_time < 0.2:
is_charging_bow = false
# CRITICAL: Sync bow charge end to other clients when cancelling
if multiplayer.has_multiplayer_peer():
_sync_bow_charge_end.rpc()
print(name, " cancelled arrow (released too quickly, need at least 0.2s)")
return
@@ -2189,9 +2332,11 @@ func _try_grab():
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
# Use consistent object name or index instead of path
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_initial_grab", [obj_name, grab_offset])
# Sync the grab state
_rpc_to_ready_peers("_sync_grab", [obj_name, is_lifting, push_axis])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_initial_grab", [obj_name, grab_offset])
# Sync the grab state
_rpc_to_ready_peers("_sync_grab", [obj_name, is_lifting, push_axis])
print("Grabbed: ", closest_body.name)
@@ -2240,7 +2385,9 @@ func _lift_object():
# Sync to network (non-blocking)
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis])
print("Lifted: ", held_object.name)
$SfxLift.play()
@@ -2282,7 +2429,9 @@ func _start_pushing():
# Sync push state to network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_grab", [obj_name, false, push_axis]) # false = pushing, not lifting
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_grab", [obj_name, false, push_axis]) # false = pushing, not lifting
print("Pushing: ", held_object.name, " along axis: ", push_axis, " facing dir: ", push_direction_locked)
@@ -2816,7 +2965,9 @@ func _update_lifted_object():
# Sync held object position over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
func _update_pushed_object():
if held_object and is_instance_valid(held_object):
@@ -2898,7 +3049,9 @@ func _update_pushed_object():
# Sync position over network
if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree():
var obj_name = _get_object_name_for_sync(held_object)
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
# CRITICAL: Validate object is still valid right before sending RPC
if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree():
_rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position])
# Send RPCs only to peers who are ready to receive them
func _rpc_to_ready_peers(method: String, args: Array = []):
@@ -4071,31 +4224,126 @@ func _sync_stats_update(kills_count: int, coins_count: int):
@rpc("any_peer", "reliable")
func _sync_race_and_stats(race: String, base_stats: Dictionary):
# Client receives race and base stats from authority player
if not is_multiplayer_authority():
# Accept initial sync (when race is empty), but reject changes if we're authority
print(name, " _sync_race_and_stats received: race=", race, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null")
# CRITICAL: If we're the authority for this player, we should NOT process race syncs
# The authority player manages its own appearance and only syncs to others
if is_multiplayer_authority():
# Only allow initial sync if race is empty (first time setup)
if character_stats and character_stats.race != "":
# We're authority and already have a race set - ignore updates
print(name, " _sync_race_and_stats REJECTED (we're authority and already have race)")
return
if character_stats:
character_stats.race = race
character_stats.baseStats = base_stats
# For remote players, we don't re-initialize appearance here
# Instead, we wait for _sync_appearance RPC which contains the full appearance data
# This ensures remote players have the exact same appearance as authority
# Update race-specific appearance parts (ears)
var skin_index = 0
if character_stats.skin != "":
var regex = RegEx.new()
regex.compile("Human(\\d+)\\.png")
var result = regex.search(character_stats.skin)
if result:
skin_index = int(result.get_string(1)) - 1
match race:
"Elf":
var elf_ear_style = skin_index + 1
character_stats.setEars(elf_ear_style)
# Give Elf starting bow and arrows to remote players
# (Authority players get this in _setup_player_appearance)
# Check if equipment is missing - give it regardless of whether race changed
if not is_multiplayer_authority():
var needs_equipment = false
if character_stats.equipment["mainhand"] == null or character_stats.equipment["offhand"] == null:
needs_equipment = true
else:
# Check if mainhand is not a bow or offhand is not arrows
var mainhand = character_stats.equipment["mainhand"]
var offhand = character_stats.equipment["offhand"]
if not mainhand or mainhand.item_name != "short_bow":
needs_equipment = true
elif not offhand or offhand.item_name != "arrow" or offhand.quantity < 3:
needs_equipment = true
if needs_equipment:
var starting_bow = ItemDatabase.create_item("short_bow")
var starting_arrows = ItemDatabase.create_item("arrow")
if starting_bow and starting_arrows:
starting_arrows.quantity = 3
character_stats.equipment["mainhand"] = starting_bow
character_stats.equipment["offhand"] = starting_arrows
_apply_appearance_to_sprites()
print("Elf player ", name, " (remote) received short bow and 3 arrows via race sync")
_:
character_stats.setEars(0)
print(name, " race and stats synced: race=", race, " STR=", base_stats.str, " PER=", base_stats.per)
@rpc("any_peer", "reliable")
func _sync_appearance(appearance_data: Dictionary):
# Client receives full appearance data from authority player
# Apply it directly without any randomization
if is_multiplayer_authority():
# We're authority - ignore appearance syncs for ourselves
return
if not character_stats:
return
# Apply the synced appearance data directly
if appearance_data.has("skin"):
character_stats.skin = appearance_data["skin"]
if appearance_data.has("hairstyle"):
character_stats.hairstyle = appearance_data["hairstyle"]
if appearance_data.has("hair_color"):
character_stats.hair_color = Color(appearance_data["hair_color"])
if appearance_data.has("facial_hair"):
character_stats.facial_hair = appearance_data["facial_hair"]
if appearance_data.has("facial_hair_color"):
character_stats.facial_hair_color = Color(appearance_data["facial_hair_color"])
if appearance_data.has("eyes"):
character_stats.eyes = appearance_data["eyes"]
if appearance_data.has("eye_color"):
character_stats.eye_color = Color(appearance_data["eye_color"])
if appearance_data.has("eye_lashes"):
character_stats.eye_lashes = appearance_data["eye_lashes"]
if appearance_data.has("eyelash_color"):
character_stats.eyelash_color = Color(appearance_data["eyelash_color"])
if appearance_data.has("add_on"):
character_stats.add_on = appearance_data["add_on"]
# Apply appearance to sprites
_apply_appearance_to_sprites()
print(name, " appearance synced from authority")
@rpc("any_peer", "reliable")
func _sync_equipment(equipment_data: Dictionary):
# Client receives equipment update from server or other clients
# Update equipment to match server/other players
# Server also needs to accept equipment sync from clients (joiners) to update server's copy of joiner's player
if not character_stats:
return
# CRITICAL: Don't accept equipment syncs for our own player
# Each client manages their own equipment locally
if is_multiplayer_authority():
print(name, " ignoring equipment sync (I'm the authority)")
return
# CRITICAL: Don't accept equipment syncs for our own player AFTER initial setup
# Accept initial sync (when all equipment is null), but reject changes if we're authority
var has_any_equipment = false
for slot in character_stats.equipment.values():
if slot != null:
has_any_equipment = true
break
# On server, only accept if this is a client player (not server's own player)
if multiplayer.is_server():
var the_peer_id = get_multiplayer_authority()
# If this is the server's own player, ignore (server's own changes are handled differently)
if the_peer_id == 0 or the_peer_id == multiplayer.get_unique_id():
return
if is_multiplayer_authority() and has_any_equipment:
# We're authority and already have equipment - ignore updates
return
# Update equipment from data
for slot_name in equipment_data.keys():
@@ -4105,6 +4353,13 @@ func _sync_equipment(equipment_data: Dictionary):
else:
character_stats.equipment[slot_name] = null
# If we received equipment but don't have race yet, request race sync
# This handles the case where equipment sync arrives before race sync
if character_stats.race == "" and not is_multiplayer_authority():
# Request race sync from authority (they should send it, but if not, this ensures it)
# Actually, race should come via _sync_race_and_stats, so just wait for it
pass
# Update appearance
_apply_appearance_to_sprites()
print(name, " equipment synced: ", equipment_data.size(), " slots")
@@ -4113,19 +4368,15 @@ func _sync_equipment(equipment_data: Dictionary):
func _sync_inventory(inventory_data: Array):
# Client receives inventory update from server
# Update inventory to match server's inventory
# CRITICAL: Don't accept inventory syncs for our own player
# Each client manages their own inventory locally (same as equipment)
if is_multiplayer_authority():
print(name, " ignoring inventory sync (I'm the authority)")
# Accept initial sync (when inventory is empty), but reject changes if we're authority
if is_multiplayer_authority() and character_stats and character_stats.inventory.size() > 0:
# We're authority and already have items - ignore updates
return
if multiplayer.is_server():
return # Server ignores this (it's the sender)
if not character_stats:
return
# Clear and rebuild inventory from server data (only for OTHER players we're viewing)
# Clear and rebuild inventory from server data (only for OTHER players or initial sync)
character_stats.inventory.clear()
for item_data in inventory_data:
if item_data != null:
@@ -4133,7 +4384,7 @@ func _sync_inventory(inventory_data: Array):
# Emit character_changed to update UI
character_stats.character_changed.emit(character_stats)
print(name, " inventory synced from server: ", character_stats.inventory.size(), " items")
print(name, " inventory synced: ", character_stats.inventory.size(), " items")
func heal(amount: float):
if is_dead:

View File

@@ -140,17 +140,26 @@ func _detect_trap(detecting_player: Node) -> void:
sprite.modulate.a = 1.0
# Sync detection to all clients (including server with call_local)
if multiplayer.has_multiplayer_peer() and is_inside_tree():
# CRITICAL: Validate trap is still valid before sending RPC
# Use GameWorld RPC to avoid node path issues
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
if multiplayer.is_server():
_sync_trap_detected.rpc()
# Use GameWorld RPC with trap name instead of path
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_trap_state_by_name"):
game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false
print(detecting_player.name, " detected trap at ", global_position)
@rpc("authority", "call_local", "reliable")
func _sync_trap_detected() -> void:
# Client receives trap detection notification
# CRITICAL: Validate trap is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
is_detected = true
sprite.modulate.a = 1.0
if sprite:
sprite.modulate.a = 1.0
func _on_disarm_area_body_entered(body: Node) -> void:
# Show "DISARM" text if player is Dwarf and trap is detected
@@ -160,9 +169,23 @@ func _on_disarm_area_body_entered(body: Node) -> void:
if not is_detected or is_disarmed:
return
# Check if player is Dwarf
if body.character_stats and body.character_stats.race == "Dwarf":
_show_disarm_text(body)
# CRITICAL: Only show disarm text for LOCAL players who are Dwarves
# Check if this player is the local player (has authority matching local peer ID)
var is_local = false
if body.has_method("is_multiplayer_authority") and body.is_multiplayer_authority():
# This player is controlled by the local client
is_local = true
elif multiplayer.has_multiplayer_peer():
# Check if this player's authority matches our local peer ID
var player_authority = body.get_multiplayer_authority()
var local_peer_id = multiplayer.get_unique_id()
if player_authority == local_peer_id:
is_local = true
if is_local:
# Check if player is Dwarf
if body.character_stats and body.character_stats.race == "Dwarf":
_show_disarm_text(body)
func _on_disarm_area_body_exited(body: Node) -> void:
# Hide disarm text when player leaves area
@@ -234,17 +257,26 @@ func _complete_disarm() -> void:
sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
# Sync disarm to all clients
if multiplayer.has_multiplayer_peer() and is_inside_tree():
# CRITICAL: Validate trap is still valid before sending RPC
# Use GameWorld RPC to avoid node path issues
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
if multiplayer.is_server():
_sync_trap_disarmed.rpc()
# Use GameWorld RPC with trap name instead of path
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_trap_state_by_name"):
game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true
print("Trap disarmed!")
@rpc("authority", "call_local", "reliable")
func _sync_trap_disarmed() -> void:
# Client receives trap disarm notification
# CRITICAL: Validate trap is still valid before processing
if not is_instance_valid(self) or not is_inside_tree():
return
is_disarmed = true
sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
if sprite:
sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
if activation_area:
activation_area.monitoring = false
@@ -278,9 +310,14 @@ func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_
if not is_detected:
is_detected = true
sprite.modulate.a = 1.0
if multiplayer.has_multiplayer_peer() and is_inside_tree():
# CRITICAL: Validate trap is still valid before sending RPC
# Use GameWorld RPC to avoid node path issues
if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self):
if multiplayer.is_server():
_sync_trap_detected.rpc()
# Use GameWorld RPC with trap name instead of path
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_trap_state_by_name"):
game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false
# Deal damage to player (with luck-based avoidance)
_deal_trap_damage(body)