From 9b4135b17542b3c4e53be9fc01a1b8f3d4051603 Mon Sep 17 00:00:00 2001 From: Elrinth Date: Thu, 22 Jan 2026 02:15:47 +0100 Subject: [PATCH] synced stuff --- src/scenes/player.tscn | 12 - src/scripts/attack_arrow.gd | 68 ++++-- src/scripts/boss_room_test.gd | 2 +- src/scripts/dungeon_generator.gd | 4 +- src/scripts/game_world.gd | 116 ++++++++-- src/scripts/player.gd | 377 +++++++++++++++++++++++++------ src/scripts/trap.gd | 59 ++++- 7 files changed, 518 insertions(+), 120 deletions(-) diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 38c06d6..2c7c1ca 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -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 diff --git a/src/scripts/attack_arrow.gd b/src/scripts/attack_arrow.gd index 967c1d2..aabd41e 100644 --- a/src/scripts/attack_arrow.gd +++ b/src/scripts/attack_arrow.gd @@ -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") diff --git a/src/scripts/boss_room_test.gd b/src/scripts/boss_room_test.gd index dc24022..8dfabdd 100644 --- a/src/scripts/boss_room_test.gd +++ b/src/scripts/boss_room_test.gd @@ -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 diff --git a/src/scripts/dungeon_generator.gd b/src/scripts/dungeon_generator.gd index 301ea75..b435f23 100644 --- a/src/scripts/dungeon_generator.gd +++ b/src/scripts/dungeon_generator.gd @@ -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), diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index ee6f4ff..c141936 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -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,15 +434,8 @@ 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. + # NOTE: Torches are already in dungeon_data and are spawned from the blob, so we don't need to sync them separately. - # 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) - ) - # Sync door states to the new client (wait until after dungeon chunks) get_tree().create_timer(2.0).timeout.connect(func(): if is_inside_tree(): @@ -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 diff --git a/src/scripts/player.gd b/src/scripts/player.gd index b8d0e3f..9c8957f 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -402,6 +402,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 @@ -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 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 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 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: @@ -1625,6 +1758,11 @@ func _physics_process(delta): print("Player ", name, " (server) - all clients now ready! (waited ", time_since_last_ready, "s after last client)") 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 @@ -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: diff --git a/src/scripts/trap.gd b/src/scripts/trap.gd index e57b617..c27fbf0 100644 --- a/src/scripts/trap.gd +++ b/src/scripts/trap.gd @@ -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)