diff --git a/src/scenes/door.tscn b/src/scenes/door.tscn index 68efdc8..7193563 100644 --- a/src/scenes/door.tscn +++ b/src/scenes/door.tscn @@ -23,6 +23,7 @@ stream_0/stream = ExtResource("7_pg2b6") stream_1/stream = ExtResource("7_kgbum") [node name="Door" type="StaticBody2D" unique_id=371155975] +y_sort_enabled = true collision_layer = 64 script = ExtResource("1_uvdjg") diff --git a/src/scenes/floating_text.tscn b/src/scenes/floating_text.tscn index d0cf827..fa78935 100644 --- a/src/scenes/floating_text.tscn +++ b/src/scenes/floating_text.tscn @@ -8,6 +8,7 @@ default_font = ExtResource("2_dmg_font") default_font_size = 12 [node name="FloatingText" type="Node2D" unique_id=1350559946] +z_index = 3 script = ExtResource("1") [node name="ItemSprite" type="Sprite2D" parent="." unique_id=1657362510] @@ -15,6 +16,7 @@ visible = false offset = Vector2(0, -8) [node name="Label" type="Label" parent="." unique_id=1387220833] +z_index = 3 offset_right = 64.0 offset_bottom = 24.0 theme = SubResource("Theme_floating_text") diff --git a/src/scenes/ingame_hud.tscn b/src/scenes/ingame_hud.tscn index 2629e60..883e5ff 100644 --- a/src/scenes/ingame_hud.tscn +++ b/src/scenes/ingame_hud.tscn @@ -386,7 +386,7 @@ ignore_texture_size = true stretch_mode = 5 script = ExtResource("8_cu5yl") touchscreen_only = true -input_action = "attack" +input_action = "inventory" metadata/_custom_type_script = "uid://bh5a3ydiu51eo" [connection signal="analogic_changed" from="MobileInput/VirtualJoystick" to="MobileInput" method="_on_virtual_joystick_analogic_changed"] diff --git a/src/scenes/player.tscn b/src/scenes/player.tscn index 1415ce6..2c7c1ca 100644 --- a/src/scenes/player.tscn +++ b/src/scenes/player.tscn @@ -309,7 +309,7 @@ collision_mask = 16384 shape = SubResource("CircleShape2D_pf23h") [node name="Shadow" type="Sprite2D" parent="." unique_id=937683521] -position = Vector2(0, 8) +position = Vector2(0, 7) texture = SubResource("GradientTexture2D_jej6c") script = ExtResource("3") diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index 980d452..499687c 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -579,54 +579,64 @@ func forceUpdate(): emit_signal("character_changed", self) pass -func equip_item(iItem:Item): +func equip_item(iItem:Item, insert_index: int = -1): + # insert_index: if >= 0, place old item at this index instead of at the end if iItem.equipment_type == Item.EquipmentType.NONE: return + + var old_item = null + var item_index = self.inventory.find(iItem) + match iItem.equipment_type: Item.EquipmentType.MAINHAND: if equipment["mainhand"] != null: - self.inventory.push_back(equipment["mainhand"]) - # If we equip different weapon than bow and we have ammunition in offhand, remove, the offhand. - # If we equip two handed weapon, remove offhand... - #if equipment["offhand"] != null: - #(equipment["offhand"] as Item).equipment_type == Item.WeaponType.AMMUNITION - #if iItem.WeaponType.BOW - + old_item = equipment["mainhand"] equipment["mainhand"] = iItem pass pass Item.EquipmentType.OFFHAND: if equipment["offhand"] != null: - self.inventory.push_back(equipment["offhand"]) + old_item = equipment["offhand"] equipment["offhand"] = iItem pass pass Item.EquipmentType.HEADGEAR: if equipment["headgear"] != null: - self.inventory.push_back(equipment["headgear"]) + old_item = equipment["headgear"] equipment["headgear"] = iItem pass pass Item.EquipmentType.ARMOUR: if equipment["armour"] != null: - self.inventory.push_back(equipment["armour"]) + old_item = equipment["armour"] equipment["armour"] = iItem pass pass Item.EquipmentType.BOOTS: if equipment["boots"] != null: - self.inventory.push_back(equipment["boots"]) + old_item = equipment["boots"] equipment["boots"] = iItem pass pass Item.EquipmentType.ACCESSORY: if equipment["accessory"] != null: - self.inventory.push_back(equipment["accessory"]) + old_item = equipment["accessory"] equipment["accessory"] = iItem pass pass - self.inventory.remove_at(self.inventory.find(iItem)) + + # Remove the item being equipped from inventory first + if item_index >= 0: + self.inventory.remove_at(item_index) + + # Add old item back to inventory at the specified position (or end if -1) + if old_item != null: + if insert_index >= 0 and insert_index <= self.inventory.size(): + self.inventory.insert(insert_index, old_item) + else: + self.inventory.push_back(old_item) + emit_signal("character_changed", self) pass diff --git a/src/scripts/enemy_base.gd b/src/scripts/enemy_base.gd index 6d8ff02..40bb28d 100644 --- a/src/scripts/enemy_base.gd +++ b/src/scripts/enemy_base.gd @@ -661,8 +661,8 @@ func _spawn_loot(): loot_rng.seed = loot_seed var random_angle = loot_rng.randf() * PI * 2 - var random_force = loot_rng.randf_range(50.0, 100.0) - var random_velocity_z = loot_rng.randf_range(80.0, 120.0) + var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed + var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed # Generate initial velocity (same on all clients via RPC) var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force @@ -738,8 +738,8 @@ func _spawn_loot(): loot_rng.seed = real_loot_seed # Regenerate velocity with correct seed var real_random_angle = loot_rng.randf() * PI * 2 - var real_random_force = loot_rng.randf_range(50.0, 100.0) - var real_random_velocity_z = loot_rng.randf_range(80.0, 120.0) + var real_random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed + var real_random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed initial_velocity = Vector2(cos(real_random_angle), sin(real_random_angle)) * real_random_force random_velocity_z = real_random_velocity_z # Update loot with correct velocity diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 5ea663e..3a2ad68 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -8,6 +8,7 @@ extends Node2D var local_players = [] const BASE_CAMERA_ZOOM: float = 4.0 +const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices const REFERENCE_ASPECT: float = 16.0 / 9.0 # Fog of war @@ -20,11 +21,30 @@ const FOG_UPDATE_INTERVAL: float = 0.1 const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag const FOG_DEBUG_DRAW: bool = false var fog_update_timer: float = 0.0 +var peer_cleanup_timer: float = 0.0 +const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds + +# Tab visibility and buffer overflow protection +var was_tab_visible: bool = true +var tab_inactive_time: float = 0.0 +var last_tab_state_change: int = 0 # Time of last tab state change (for debouncing) +const TAB_STATE_DEBOUNCE_MS: int = 500 # Debounce tab state changes (500ms) +var last_sound_play_time: Dictionary = {} # sound_name -> time +const SOUND_RATE_LIMIT: float = 0.05 # Only play same sound every 50ms (20 sounds/second max) +const MAX_BUFFER_SIZE: int = 2 * 1024 * 1024 # 2MB buffer threshold +var last_buffer_check_time: float = 0.0 +const BUFFER_CHECK_INTERVAL: float = 0.1 # Check buffer every 100ms +var client_buffer_states: Dictionary = {} # peer_id -> {buffered: int, last_check: float, skip_until: float} +const CLIENT_BUFFER_CHECK_INTERVAL: float = 0.2 # Check client buffers every 200ms +const CLIENT_BUFFER_SKIP_THRESHOLD: int = 1024 * 512 # Skip sending if buffer > 512KB +const CLIENT_BUFFER_SKIP_DURATION: float = 2.0 # Skip sending for 2 seconds if buffer is full var fog_node: Node2D = null var cached_corridor_mask: PackedInt32Array = PackedInt32Array() var cached_corridor_rooms: Array = [] var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1) var cached_corridor_allowed_room_ids: Dictionary = {} +var was_in_corridor: bool = false # Track previous corridor state to detect transitions +var last_corridor_fog_update: float = 0.0 # Time of last fog update in corridor var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen) var combined_seen: PackedInt32Array = PackedInt32Array() var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored @@ -62,7 +82,7 @@ var dungeon_chunk_acks: Dictionary = {} # peer_id -> {chunk_idx -> bool, next_ch # Pre-packed dungeon blob (server only) - packed once, sent many times var dungeon_blob_chunks: Array = [] # Array of PackedByteArray chunks (<16KB each) -var dungeon_blob_metadata: Dictionary = {} # seed, level, map_size, host_room +var dungeon_blob_metadata: Dictionary = {} # Static metadata: seed, level, map_size (host_room collected dynamically) # Level complete tracking var level_complete_triggered: bool = false # Prevent multiple level complete triggers @@ -137,6 +157,8 @@ func _ready(): # Notify server that GameWorld is ready (client only) if multiplayer.has_multiplayer_peer() and not multiplayer.is_server(): call_deferred("_send_gameworld_ready") + + # Peer cleanup is handled in _process() func _send_gameworld_ready(): # Client notifies server that GameWorld is ready to receive RPCs @@ -506,12 +528,25 @@ func _rpc_to_ready_peers(method: String, args: Array = []): if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED client_gameworld_ready.erase(peer_id) continue + + # Check if client's buffer is full - skip sending if so + if _should_skip_client_due_to_buffer(peer_id): + continue # Skip sending to this client - their buffer is full # All checks passed, send RPC # Note: Even with all checks, there's still a tiny race condition window, # but this minimizes it significantly. If channel closes between check and send, # Godot will log the error but it won't crash. - callv("rpc_id", [peer_id, method] + args) + # Additional last-moment check: verify peer is still in current peers + var final_peers = multiplayer.get_peers() + if peer_id not in final_peers: + client_gameworld_ready.erase(peer_id) + continue + + # Try to send - if it fails, we'll detect it via periodic cleanup + var _result = callv("rpc_id", [peer_id, method] + args) + # Note: rpc_id doesn't return an error code in GDScript, so we rely on + # periodic cleanup and the extensive pre-checks above to prevent most errors func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): # Send RPC from a node to all clients whose GameWorld is ready @@ -599,7 +634,16 @@ func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []): # Note: Even with all checks, there's still a tiny race condition window, # but this minimizes it significantly. If channel closes between check and send, # Godot will log the error but it won't crash. - node.callv("rpc_id", [peer_id, method] + args) + # Additional last-moment check: verify peer is still in current peers + var final_peers = multiplayer.get_peers() + if peer_id not in final_peers: + client_gameworld_ready.erase(peer_id) + continue + + # Try to send - if it fails, we'll detect it via periodic cleanup + var _result = node.callv("rpc_id", [peer_id, method] + args) + # Note: rpc_id doesn't return an error code in GDScript, so we rely on + # periodic cleanup and the extensive pre-checks above to prevent most errors func _is_peer_connected(peer_id: int) -> bool: """Check if a peer is still connected and has open data channels""" @@ -672,12 +716,100 @@ func _sync_existing_enemies_to_client(client_peer_id: int): _sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type) print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type) +func _cleanup_disconnected_peers(): + """Periodically check and remove disconnected peers from client_gameworld_ready""" + if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server(): + return + + # Get current peers list + var current_peers = multiplayer.get_peers() + + # Check all peers in client_gameworld_ready + var peers_to_remove: Array[int] = [] + + for peer_id in client_gameworld_ready.keys(): + # First check: is peer still in get_peers()? + if peer_id not in current_peers: + peers_to_remove.append(peer_id) + continue + + # Second check: for WebRTC, verify connection state + if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + if not webrtc_peer.has_peer(peer_id): + peers_to_remove.append(peer_id) + continue + + var peer_info = webrtc_peer.get_peer(peer_id) + if not peer_info: + peers_to_remove.append(peer_id) + continue + + # Check connection state + var is_net_connected = peer_info.get("connected", false) + if not is_net_connected: + peers_to_remove.append(peer_id) + continue + + var connection_obj = peer_info.get("connection") + if connection_obj != null and typeof(connection_obj) == TYPE_INT: + var connection_val = int(connection_obj) + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED + peers_to_remove.append(peer_id) + continue + + # Check data channels + var channels = peer_info.get("channels", []) + if channels is Array and channels.size() > 0: + var all_channels_open = true + for channel in channels: + if channel != null: + var ready_state = -1 + if channel.has_method("get_ready_state"): + ready_state = channel.get_ready_state() + elif "ready_state" in channel: + ready_state = channel.get("ready_state") + # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + if ready_state != 1: # Not OPEN + all_channels_open = false + break + if not all_channels_open: + peers_to_remove.append(peer_id) + continue + + # Also check matchbox_client connection state + if network_manager and network_manager.matchbox_client: + var matchbox = network_manager.matchbox_client + if "peer_connections" in matchbox: + var pc = matchbox.peer_connections.get(peer_id) + if pc and pc.has_method("get_connection_state"): + var matchbox_conn_state = pc.get_connection_state() + # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED + if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED + peers_to_remove.append(peer_id) + continue + + # Remove disconnected peers + for peer_id in peers_to_remove: + if client_gameworld_ready.has(peer_id): + client_gameworld_ready.erase(peer_id) + print("GameWorld: Cleanup removed disconnected peer ", peer_id, " from client_gameworld_ready") + if clients_ready.has(peer_id): + clients_ready.erase(peer_id) + if dungeon_sync_in_progress.has(peer_id): + dungeon_sync_in_progress.erase(peer_id) + if dungeon_chunk_acks.has(peer_id): + dungeon_chunk_acks.erase(peer_id) + func _on_player_disconnected(peer_id: int, player_info: Dictionary): print("GameWorld: Player disconnected - ", peer_id) - # Clean up ready status for disconnected peer + # Immediately clean up ready status for disconnected peer + # This is critical to prevent RPC errors after disconnection if client_gameworld_ready.has(peer_id): client_gameworld_ready.erase(peer_id) + print("GameWorld: Removed peer ", peer_id, " from client_gameworld_ready") if clients_ready.has(peer_id): clients_ready.erase(peer_id) @@ -685,6 +817,10 @@ func _on_player_disconnected(peer_id: int, player_info: Dictionary): if dungeon_sync_in_progress.has(peer_id): dungeon_sync_in_progress.erase(peer_id) + # Clear any chunk acknowledgments for this peer + if dungeon_chunk_acks.has(peer_id): + dungeon_chunk_acks.erase(peer_id) + # Send disconnect message to chat (only on server to avoid duplicates) if multiplayer.is_server(): _send_player_disconnect_message(peer_id, player_info) @@ -992,6 +1128,10 @@ func _sync_enemy_death(enemy_name: String, enemy_index: int): if multiplayer.is_server(): return # Server ignores this (it's the sender) + # Skip processing if tab just became active (may be stale messages from when tab was inactive) + if not was_tab_visible: + return # Tab was inactive, skip old/stale RPCs to prevent sound spam + var entities_node = get_node_or_null("Entities") if not entities_node: return @@ -1022,6 +1162,10 @@ func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amou if multiplayer.is_server(): return # Server ignores this (it's the sender) + # Skip processing if tab just became active (may be stale messages) + if not was_tab_visible: + return # Tab was inactive, skip old/stale RPCs + var entities_node = get_node_or_null("Entities") if not entities_node: return @@ -1330,10 +1474,179 @@ func _sync_loot_remove(loot_id: int, loot_position: Vector2): # Loot not found - might already be freed or never spawned print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position) +func _check_tab_visibility(): + # Check if browser tab is visible (web only) + if OS.get_name() == "Web": + # Use DisplayServer to check if window is focused/visible + # This is a simple check - on web, when tab is inactive, window loses focus + var is_visible = DisplayServer.window_get_mode() != DisplayServer.WINDOW_MODE_MINIMIZED + # Also check if we can poll (if process is running, tab is likely active) + # On web, when tab is inactive, _process may still run but less frequently + # We'll track time between _process calls as a proxy for tab activity + var current_time = Time.get_ticks_msec() + if not has_meta("last_process_time"): + set_meta("last_process_time", current_time) + return true + + var time_since_last_process = current_time - get_meta("last_process_time", current_time) + set_meta("last_process_time", current_time) + + # If more than 1 second passed, tab was likely inactive + if time_since_last_process > 1000: + return false + + return is_visible + return true # Always visible on non-web platforms + func _process(_delta): + # Check tab visibility for buffer overflow protection (clients only) + if not multiplayer.is_server(): + var is_tab_visible = _check_tab_visibility() + var current_time = Time.get_ticks_msec() + + # Debounce tab state changes to avoid rapid toggling + if (is_tab_visible != was_tab_visible) and (current_time - last_tab_state_change > TAB_STATE_DEBOUNCE_MS): + last_tab_state_change = current_time + + if not is_tab_visible and was_tab_visible: + # Tab just became inactive + tab_inactive_time = current_time + print("GameWorld: Tab became inactive - will flush old messages when active again") + was_tab_visible = false + elif is_tab_visible and not was_tab_visible: + # Tab just became active again - skip stale RPCs for a short time + var inactive_duration = current_time - tab_inactive_time + print("GameWorld: Tab became active again after ", inactive_duration, "ms - will skip stale RPCs for 1 second to prevent buffer overflow") + + # Clear sound rate limiting + last_sound_play_time.clear() + + # Keep was_tab_visible as false temporarily to skip stale RPCs + # This prevents processing hundreds of queued RPCs from when tab was inactive + get_tree().create_timer(1.0).timeout.connect(func(): + if is_inside_tree(): + was_tab_visible = true + print("GameWorld: Tab reactivation complete - now processing fresh RPCs") + ) + elif is_tab_visible == was_tab_visible: + # State is stable, update was_tab_visible if needed + was_tab_visible = is_tab_visible + + # Check client buffers on server and skip sending to clients with full buffers + if multiplayer.is_server() and multiplayer.has_multiplayer_peer(): + var current_time_sec = Time.get_ticks_msec() / 1000.0 + _check_client_buffers(current_time_sec) + + # Check WebRTC buffer size periodically (clients only) + if not multiplayer.is_server() and multiplayer.has_multiplayer_peer(): + var current_time = Time.get_ticks_msec() + if current_time - last_buffer_check_time > BUFFER_CHECK_INTERVAL * 1000: + last_buffer_check_time = current_time + _check_and_handle_buffer_overflow() + # Update camera to follow local players _update_camera() _update_fog_of_war(_delta) + + # Periodic cleanup of disconnected peers (server only) + if multiplayer.is_server() and multiplayer.has_multiplayer_peer(): + peer_cleanup_timer += _delta + if peer_cleanup_timer >= PEER_CLEANUP_INTERVAL: + peer_cleanup_timer = 0.0 + _cleanup_disconnected_peers() + +func _check_client_buffers(current_time: float): + # Check all client buffers and mark which ones should be skipped + if not multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + return + + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + var peers = multiplayer.get_peers() + + for peer_id in peers: + if not webrtc_peer.has_peer(peer_id): + continue + + var peer_info = webrtc_peer.get_peer(peer_id) + if not peer_info: + continue + + # Only check buffers periodically to avoid performance impact + if not client_buffer_states.has(peer_id): + client_buffer_states[peer_id] = {"buffered": 0, "last_check": 0.0, "skip_until": 0.0} + + var buffer_state = client_buffer_states[peer_id] + if current_time - buffer_state.last_check < CLIENT_BUFFER_CHECK_INTERVAL: + continue # Skip this check - too soon + + buffer_state.last_check = current_time + + # Check buffer size + var channels = peer_info.get("channels", []) + var total_buffered = 0 + if channels is Array: + for channel in channels: + if channel != null and channel.has_method("get_buffered_amount"): + total_buffered += channel.get_buffered_amount() + + buffer_state.buffered = total_buffered + + # If buffer is too full, mark client to skip for a duration + if total_buffered > CLIENT_BUFFER_SKIP_THRESHOLD: + if buffer_state.skip_until < current_time: + buffer_state.skip_until = current_time + CLIENT_BUFFER_SKIP_DURATION + print("GameWorld: HOST - Client ", peer_id, " buffer full (", total_buffered, " bytes) - skipping RPCs for ", CLIENT_BUFFER_SKIP_DURATION, " seconds") + +func _should_skip_client_due_to_buffer(peer_id: int) -> bool: + # Check if we should skip sending to this client due to full buffer + if not client_buffer_states.has(peer_id): + return false + + var buffer_state = client_buffer_states[peer_id] + var current_time = Time.get_ticks_msec() / 1000.0 + + # Skip if we're in the skip period + if buffer_state.skip_until > current_time: + return true + + # If skip period expired, check if buffer is still full + if buffer_state.buffered > CLIENT_BUFFER_SKIP_THRESHOLD: + # Buffer still full, extend skip period + buffer_state.skip_until = current_time + CLIENT_BUFFER_SKIP_DURATION + return true + + return false + +func _check_and_handle_buffer_overflow(): + # Check WebRTC data channel buffer size and handle overflow + if not multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + return + + var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + var server_peer_id = 1 # Server is always peer 1 + + if not webrtc_peer.has_peer(server_peer_id): + return + + var peer_info = webrtc_peer.get_peer(server_peer_id) + if not peer_info: + return + + var channels = peer_info.get("channels", {}) + if not channels is Array: + return + + var total_buffered = 0 + for channel in channels: + if channel != null and channel.has_method("get_buffered_amount"): + var buffered = channel.get_buffered_amount() + total_buffered += buffered + + if total_buffered > MAX_BUFFER_SIZE: + print("GameWorld: WARNING - Buffer overflow detected! Buffered: ", total_buffered, " bytes (max: ", MAX_BUFFER_SIZE, ")") + print("GameWorld: Tab may have been inactive - old messages will be processed but may cause issues") + # Note: We can't actually clear the buffer from Godot, but we've logged it + # The browser will eventually process messages, but they may be old/stale func _update_camera(): local_players = player_manager.get_local_players() @@ -1358,7 +1671,14 @@ func _update_camera(): # Wider than 16:9 -> zoom out to show more aspect_factor = REFERENCE_ASPECT / aspect - var target_zoom = BASE_CAMERA_ZOOM * aspect_factor + # Detect mobile/touchscreen and use higher zoom for better visibility + var is_mobile = false + var os_name = OS.get_name().to_lower() + if os_name in ["android", "ios"] or DisplayServer.is_touchscreen_available(): + is_mobile = true + + var base_zoom = BASE_CAMERA_ZOOM_MOBILE if is_mobile else BASE_CAMERA_ZOOM + var target_zoom = base_zoom * aspect_factor # Adjust zoom based on player spread (for split-screen effect) if local_players.size() > 1: @@ -1407,6 +1727,8 @@ func _init_fog_of_war(): cached_corridor_rooms.clear() cached_corridor_player_tile = Vector2i(-1, -1) cached_corridor_allowed_room_ids.clear() + was_in_corridor = false + last_corridor_fog_update = 0.0 func _update_fog_of_war(delta: float) -> void: if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"): @@ -1418,6 +1740,29 @@ func _update_fog_of_war(delta: float) -> void: var p_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE)) in_corridor = _find_room_at_tile(p_tile).is_empty() + # Detect corridor entry/exit transitions + var corridor_state_changed = (in_corridor != was_in_corridor) + if corridor_state_changed: + # State changed - clear cache and force update + cached_corridor_mask.clear() + cached_corridor_rooms.clear() + cached_corridor_player_tile = Vector2i(-1, -1) + cached_corridor_allowed_room_ids.clear() + was_in_corridor = in_corridor + print("GameWorld: Corridor state changed - was_in_corridor: ", !in_corridor, " -> in_corridor: ", in_corridor) + + # In corridors: only update when entering/exiting or when player moves significantly + # Skip expensive updates if we're stationary in a corridor + if in_corridor and not corridor_state_changed: + # Check if player moved significantly (more than 1 tile) + var player_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE)) + var player_moved = cached_corridor_player_tile.distance_to(player_tile) > 1 + + # Only update if player moved significantly OR enough time has passed (much longer interval) + var time_since_last_update = Time.get_ticks_msec() / 1000.0 - last_corridor_fog_update + if not player_moved and time_since_last_update < 1.0: # Skip if stationary and updated recently + return # Skip expensive fog update - player is stationary in corridor + var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL fog_update_timer += delta if fog_update_timer < update_interval: @@ -1473,10 +1818,10 @@ func _update_fog_of_war(delta: float) -> void: cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, player_tile) cached_corridor_player_tile = player_tile - # Build a set of allowed room IDs for fast lookup + # Build a set of allowed room IDs for fast lookup cached_corridor_allowed_room_ids = {} for room in cached_corridor_rooms: - var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h) + var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h) cached_corridor_allowed_room_ids[room_id] = true corridor_mask = cached_corridor_mask @@ -1505,12 +1850,15 @@ func _update_fog_of_war(delta: float) -> void: if was_explored: _mark_room_visible(room) # Explicitly clear combined_seen for ANY tile not in corridor mask or allowed rooms + # OPTIMIZATION: Only do expensive per-tile checks when corridor state changed or player moved significantly + var needs_tile_clear = corridor_state_changed or should_rebuild_corridor + if needs_tile_clear: for y in range(map_size.y): for x in range(map_size.x): var idx = x + y * map_size.x if idx < 0 or idx >= combined_seen.size(): continue - var tile_in_corridor = idx < corridor_mask.size() and corridor_mask[idx] == 1 + var tile_in_corridor = idx < corridor_mask.size() and corridor_mask[idx] == 1 # Check if this tile is in a room, and if so, is it an allowed room? var tile_room = _find_room_at_tile(Vector2i(x, y)) var in_allowed_room = false @@ -1518,8 +1866,11 @@ func _update_fog_of_war(delta: float) -> void: var room_id = str(tile_room.x) + "," + str(tile_room.y) + "," + str(tile_room.w) + "," + str(tile_room.h) in_allowed_room = allowed_room_ids.has(room_id) # Clear combined_seen for any tile not in corridor or allowed rooms - if not tile_in_corridor and not in_allowed_room: + if not tile_in_corridor and not in_allowed_room: combined_seen[idx] = 0 + + # Update last corridor fog update time + last_corridor_fog_update = Time.get_ticks_msec() / 1000.0 if fog_node.has_method("set_maps"): fog_node.set_maps(explored_map, combined_seen) @@ -2468,6 +2819,81 @@ func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, _pack_dungeon_blob() call_deferred("_send_dungeon_blob_sync", client_peer_id) +func _collect_current_world_metadata() -> Dictionary: + # Collect current world state metadata on-demand + # This is called whenever syncing to a new client to ensure they get the latest state + if not multiplayer.is_server(): + return {} + + # Get CURRENT defeated enemies + var defeated_list = [] + for enemy_index in defeated_enemies.keys(): + defeated_list.append(enemy_index) + print("GameWorld: _collect_current_world_metadata() - Collecting ", defeated_list.size(), " defeated enemies: ", defeated_list) + + # Get CURRENT broken objects + var broken_list = [] + for obj_index in broken_objects.keys(): + broken_list.append(obj_index) + + # Get CURRENT door states (re-collect from entities) + var door_states_list = [] + var entities_node = get_node_or_null("Entities") + if entities_node: + for child in entities_node.get_children(): + if child.is_in_group("blocking_door") or child.has_method("_sync_door_open"): + var door_state = { + "door_name": child.name, + "is_closed": child.is_closed if "is_closed" in child else true, + "puzzle_solved": child.puzzle_solved if "puzzle_solved" in child else false, + "key_used": child.key_used if "key_used" in child else false, + "position": child.position if "position" in child else Vector2.ZERO, + "closed_position": child.closed_position if "closed_position" in child else Vector2.ZERO, + "open_offset": child.open_offset if "open_offset" in child else Vector2.ZERO + } + door_states_list.append(door_state) + + # Get CURRENT opened chests + var opened_chests_list = [] + if entities_node: + for child in entities_node.get_children(): + if child.is_in_group("interactable_object"): + if "object_type" in child and child.object_type == "Chest": + if "is_chest_opened" in child and child.is_chest_opened: + opened_chests_list.append(child.name) + print("GameWorld: _collect_current_world_metadata() - Found opened chest: ", child.name) + print("GameWorld: _collect_current_world_metadata() - Collecting ", opened_chests_list.size(), " opened chests: ", opened_chests_list) + + # Get CURRENT existing loot + var loot_list = [] + if entities_node: + for child in entities_node.get_children(): + if child.is_in_group("loot"): + var current_velocity = child.velocity if "velocity" in child else Vector2.ZERO + var current_velocity_z = child.velocity_z if "velocity_z" in child else 0.0 + var loot_data = { + "position": child.global_position, + "loot_type": child.loot_type if "loot_type" in child else 0, + "loot_id": child.get_meta("loot_id") if child.has_meta("loot_id") else -1, + "velocity": current_velocity, + "velocity_z": current_velocity_z + } + # For item loot, include item data + if child.loot_type == child.LootType.ITEM and "item" in child and child.item: + var item = child.item + if item.has_method("save"): + loot_data["item_data"] = item.save() + loot_list.append(loot_data) + + return { + "defeated_enemies": defeated_list, + "broken_objects": broken_list, + "door_states": door_states_list, + "opened_chests": opened_chests_list, + "existing_loot": loot_list, + "host_room": _get_host_room() + } + func _send_dungeon_blob_sync(client_peer_id: int): # Send pre-packed dungeon blob chunks with acknowledgment-based flow control if not is_inside_tree() or not multiplayer.is_server(): @@ -2480,16 +2906,29 @@ func _send_dungeon_blob_sync(client_peer_id: int): var total_chunks = dungeon_blob_chunks.size() print("GameWorld: HOST - Starting dungeon blob sync to peer ", client_peer_id, " (", total_chunks, " chunks)") - # Send metadata first - var metadata = dungeon_blob_metadata - print("GameWorld: HOST - [CHUNK 0] Sending metadata...") - # Include all state data in metadata so client knows what's already changed - var defeated_list = metadata.get("defeated_enemies", []) - var broken_list = metadata.get("broken_objects", []) - var door_states_list = metadata.get("door_states", []) - var opened_chests_list = metadata.get("opened_chests", []) - var loot_list = metadata.get("existing_loot", []) - _sync_dungeon_blob_metadata.rpc_id(client_peer_id, metadata.seed, metadata.level, metadata.map_size, metadata.host_room, total_chunks, defeated_list, broken_list, door_states_list, opened_chests_list, loot_list) + # Get static metadata (from packed blob) + var static_metadata = dungeon_blob_metadata + + # Collect CURRENT dynamic world state metadata (always fresh, not from when blob was packed) + var dynamic_metadata = _collect_current_world_metadata() + + print("GameWorld: HOST - [CHUNK 0] Sending metadata with current world state...") + print("GameWorld: HOST - Current state: ", dynamic_metadata.defeated_enemies.size(), " defeated enemies, ", dynamic_metadata.broken_objects.size(), " broken objects, ", dynamic_metadata.door_states.size(), " door states, ", dynamic_metadata.opened_chests.size(), " opened chests, ", dynamic_metadata.existing_loot.size(), " existing loot") + + # Send metadata with current state + _sync_dungeon_blob_metadata.rpc_id( + client_peer_id, + static_metadata.seed, + static_metadata.level, + static_metadata.map_size, + dynamic_metadata.host_room, + total_chunks, + dynamic_metadata.defeated_enemies, + dynamic_metadata.broken_objects, + dynamic_metadata.door_states, + dynamic_metadata.opened_chests, + dynamic_metadata.existing_loot + ) # Initialize acknowledgment tracking var chunk_acks = {} @@ -2573,7 +3012,7 @@ func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, var bytes_per_row = map_size.x * 12 # 8 bytes Vector2i + 4 bytes int var chunk_size_bytes = ROWS_PER_CHUNK * bytes_per_row print("GameWorld: HOST - Chunk calculation: ", map_size.x, " cols * 12 bytes/row = ", bytes_per_row, " bytes/row") - print("GameWorld: HOST - 10 rows per chunk = ", chunk_size_bytes, " bytes (~", chunk_size_bytes / 1024, "KB) per chunk") + print("GameWorld: HOST - 10 rows per chunk = ", chunk_size_bytes, " bytes (~", chunk_size_bytes / 1024.0, "KB) per chunk") print("GameWorld: HOST - Starting chunked dungeon sync to peer ", client_peer_id) print("GameWorld: HOST - Map size: ", map_size, ", Total rows: ", total_rows, ", Chunks: ", total_chunks) @@ -2734,80 +3173,20 @@ func _pack_dungeon_blob(): print("GameWorld: HOST - Packing dungeon blob with: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects") LogManager.log("GameWorld: Packing dungeon blob - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count), LogManager.CATEGORY_DUNGEON) - # Store metadata separately (small, sent first) - # Include defeated enemies so clients know which enemies not to spawn - var defeated_indices = [] - for enemy_index in defeated_enemies.keys(): - defeated_indices.append(enemy_index) - - # Collect broken object indices - var broken_indices = [] - for obj_index in broken_objects.keys(): - broken_indices.append(obj_index) - - # Collect door states - var door_states_list = [] - var entities_node = get_node_or_null("Entities") - if entities_node: - for child in entities_node.get_children(): - if child.is_in_group("blocking_door") or child.has_method("_sync_door_open"): - var door_state = { - "door_name": child.name, - "is_closed": child.is_closed if "is_closed" in child else true, - "puzzle_solved": child.puzzle_solved if "puzzle_solved" in child else false, - "key_used": child.key_used if "key_used" in child else false, - "position": child.position if "position" in child else Vector2.ZERO, - "closed_position": child.closed_position if "closed_position" in child else Vector2.ZERO, - "open_offset": child.open_offset if "open_offset" in child else Vector2.ZERO - } - door_states_list.append(door_state) - - # Collect opened chest names - var opened_chest_names = [] - if entities_node: - for child in entities_node.get_children(): - if child.is_in_group("interactable_object"): - if "object_type" in child and child.object_type == "Chest": - if "is_chest_opened" in child and child.is_chest_opened: - opened_chest_names.append(child.name) - - # Collect existing loot - var loot_list = [] - if entities_node: - for child in entities_node.get_children(): - if child.is_in_group("loot"): - var current_velocity = child.velocity if "velocity" in child else Vector2.ZERO - var current_velocity_z = child.velocity_z if "velocity_z" in child else 0.0 - var loot_data = { - "position": child.global_position, - "loot_type": child.loot_type if "loot_type" in child else 0, - "loot_id": child.get_meta("loot_id") if child.has_meta("loot_id") else -1, - "velocity": current_velocity, - "velocity_z": current_velocity_z - } - # For item loot, include item data - if child.loot_type == child.LootType.ITEM and "item" in child and child.item: - var item = child.item - if item.has_method("save"): - loot_data["item_data"] = item.save() - loot_list.append(loot_data) - + # Store only STATIC metadata (dynamic state is collected on-demand when syncing to clients) + # This ensures joiners always get the current world state, not what it was when blob was packed dungeon_blob_metadata = { "seed": dungeon_seed, "level": current_level, - "map_size": full_dungeon_data.map_size, - "host_room": _get_host_room(), - "defeated_enemies": defeated_indices, - "broken_objects": broken_indices, - "door_states": door_states_list, - "opened_chests": opened_chest_names, - "existing_loot": loot_list + "map_size": full_dungeon_data.map_size + # host_room, defeated_enemies, broken_objects, door_states, opened_chests, existing_loot + # are collected dynamically in _collect_current_world_metadata() when syncing } # Serialize to bytes var blob_bytes = var_to_bytes(full_dungeon_data) var blob_size = blob_bytes.size() - print("GameWorld: HOST - Packed dungeon blob: ", blob_size, " bytes (", blob_size / 1024, "KB)") + print("GameWorld: HOST - Packed dungeon blob: ", blob_size, " bytes (", blob_size / 1024.0, "KB)") # Chunk the bytes into <16KB pieces const MAX_CHUNK_SIZE = 14 * 1024 # 14KB to leave room for overhead @@ -2979,6 +3358,8 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec print("=== _sync_dungeon_blob_metadata RPC RECEIVED on client ===") print("=== [CHUNK 0] Client received blob metadata - Level: ", level, ", Map size: ", map_size_sync, ", Total chunks: ", total_chunks) print("=== Defeated enemies: ", defeated_enemies_list.size(), ", Broken objects: ", broken_objects_list.size(), ", Door states: ", door_states_list.size(), ", Opened chests: ", opened_chests_list.size(), ", Existing loot: ", existing_loot_list.size(), " ===") + print("=== [CLIENT] Defeated enemies list: ", defeated_enemies_list) + print("=== [CLIENT] Opened chests list: ", opened_chests_list) if not multiplayer.is_server(): # Store defeated enemies BEFORE dungeon is unpacked and enemies are spawned defeated_enemies.clear() @@ -2986,6 +3367,8 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec defeated_enemies[enemy_index] = true if defeated_enemies_list.size() > 0: print("GameWorld: Client - Received ", defeated_enemies_list.size(), " defeated enemy indices before dungeon spawn") + print("GameWorld: Client - Defeated enemy indices: ", defeated_enemies_list) + print("GameWorld: Client - defeated_enemies dictionary now has ", defeated_enemies.size(), " entries") LogManager.log("GameWorld: Client received " + str(defeated_enemies_list.size()) + " defeated enemy indices", LogManager.CATEGORY_NETWORK) # Store broken objects BEFORE objects are spawned @@ -2994,14 +3377,42 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec broken_objects[obj_index] = true if broken_objects_list.size() > 0: print("GameWorld: Client - Received ", broken_objects_list.size(), " broken object indices before dungeon spawn") + print("GameWorld: Client - Broken object indices: ", broken_objects_list) + print("GameWorld: Client - broken_objects dictionary now has ", broken_objects.size(), " entries") LogManager.log("GameWorld: Client received " + str(broken_objects_list.size()) + " broken object indices", LogManager.CATEGORY_NETWORK) # Store door states (will be applied after doors spawn) + # Convert format: server sends is_closed, but _apply_pending_door_state expects open/close pending_door_states.clear() for door_state in door_states_list: var door_name = door_state.get("door_name", "") if door_name != "": - pending_door_states[door_name] = door_state + # Convert is_closed to open/close format expected by _apply_pending_door_state + var converted_state = {} + var is_closed = door_state.get("is_closed", true) + if is_closed: + converted_state["close"] = true + else: + converted_state["open"] = true + + # Add puzzle_solved if present + if door_state.has("puzzle_solved"): + converted_state["puzzle_solved"] = door_state.get("puzzle_solved", false) + + # Store key_used for KeyDoors (needed for proper state restoration) + if door_state.has("key_used"): + converted_state["key_used"] = door_state.get("key_used", false) + + # Store position info for fallback matching + if door_state.has("position"): + converted_state["position"] = door_state.get("position") + if door_state.has("closed_position"): + converted_state["closed_position"] = door_state.get("closed_position") + if door_state.has("open_offset"): + converted_state["open_offset"] = door_state.get("open_offset") + + pending_door_states[door_name] = converted_state + print("GameWorld: Client - Stored door state for ", door_name, ": is_closed=", is_closed, ", puzzle_solved=", converted_state.get("puzzle_solved", false)) if door_states_list.size() > 0: print("GameWorld: Client - Received ", door_states_list.size(), " door states before dungeon spawn") LogManager.log("GameWorld: Client received " + str(door_states_list.size()) + " door states", LogManager.CATEGORY_NETWORK) @@ -3016,6 +3427,8 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec } if opened_chests_list.size() > 0: print("GameWorld: Client - Received ", opened_chests_list.size(), " opened chest names before dungeon spawn") + print("GameWorld: Client - Opened chest names: ", opened_chests_list) + print("GameWorld: Client - pending_chest_opens dictionary now has ", pending_chest_opens.size(), " entries") LogManager.log("GameWorld: Client received " + str(opened_chests_list.size()) + " opened chest names", LogManager.CATEGORY_NETWORK) # Store existing loot (will be spawned after dungeon is loaded) @@ -3033,6 +3446,13 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec # Clear any ongoing syncs dungeon_sync_in_progress.clear() + # CRITICAL: Store defeated enemies, broken objects, opened chests, and door states in temporary variables + # because _clear_level() will clear them, but we need them for the new level + var temp_defeated_enemies = defeated_enemies.duplicate() + var temp_broken_objects = broken_objects.duplicate() + var temp_pending_chest_opens = pending_chest_opens.duplicate() + var temp_pending_door_states = pending_door_states.duplicate() + # Reset blob sync state # Store metadata including state data for later use dungeon_sync_metadata = { @@ -3067,8 +3487,17 @@ func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vec dungeon_sync_complete = true return - # Clear previous level + # Clear previous level (this will clear defeated_enemies and pending_chest_opens) _clear_level() + + # CRITICAL: Restore defeated enemies, broken objects, opened chests, and door states AFTER _clear_level() + # These were received in the metadata and need to be preserved for the new level + defeated_enemies = temp_defeated_enemies + broken_objects = temp_broken_objects + pending_chest_opens = temp_pending_chest_opens + pending_door_states = temp_pending_door_states + print("GameWorld: Client - Restored ", defeated_enemies.size(), " defeated enemies, ", broken_objects.size(), " broken objects, ", pending_chest_opens.size(), " opened chests, and ", pending_door_states.size(), " door states after _clear_level()") + await get_tree().process_frame await get_tree().process_frame @@ -3114,7 +3543,7 @@ func _sync_dungeon_metadata(map_size_sync: Vector2i, seed_value: int, level: int print("GameWorld: Client - [CHUNK 0] Expecting ", dungeon_sync_total_chunks, " tile chunks (", total_rows, " rows total)") var bytes_per_row = map_size_sync.x * 12 # 8 bytes Vector2i + 4 bytes int var chunk_size_bytes = 10 * bytes_per_row - print("GameWorld: Client - [CHUNK 0] Chunk size: ~", chunk_size_bytes / 1024, "KB per chunk (", map_size_sync.x, " cols * 12 bytes/row * 10 rows)") + print("GameWorld: Client - [CHUNK 0] Chunk size: ~", chunk_size_bytes / 1024.0, "KB per chunk (", map_size_sync.x, " cols * 12 bytes/row * 10 rows)") LogManager.log("GameWorld: Client received dungeon metadata for level " + str(level) + " (map size: " + str(map_size_sync) + ")", LogManager.CATEGORY_DUNGEON) # Check if reconnecting to same level @@ -3254,6 +3683,11 @@ func _reassemble_dungeon_blob(): _render_dungeon() print("GameWorld: Client - Dungeon rendered") + # Initialize fog of war after dungeon is rendered (CRITICAL for joiner to see the map) + print("GameWorld: Client - Initializing fog of war...") + _init_fog_of_war() + print("GameWorld: Client - Fog of war initialized") + # Spawn all entities print("GameWorld: Client - Spawning torches from blob...") _spawn_torches() @@ -3264,6 +3698,7 @@ func _reassemble_dungeon_blob(): print("GameWorld: Client - dungeon_data has enemies: ", dungeon_data.enemies.size(), " enemies") else: print("GameWorld: Client - WARNING: dungeon_data has NO 'enemies' key!") + print("GameWorld: Client - Before spawning enemies, defeated_enemies dictionary has ", defeated_enemies.size(), " entries: ", defeated_enemies.keys()) _spawn_enemies() print("GameWorld: Client - Enemies spawned") @@ -3272,6 +3707,7 @@ func _reassemble_dungeon_blob(): print("GameWorld: Client - dungeon_data has interactable_objects: ", dungeon_data.interactable_objects.size(), " objects") else: print("GameWorld: Client - WARNING: dungeon_data has NO 'interactable_objects' key!") + print("GameWorld: Client - Before spawning objects, pending_chest_opens dictionary has ", pending_chest_opens.size(), " entries: ", pending_chest_opens.keys()) _spawn_interactable_objects() print("GameWorld: Client - Interactable objects spawned") @@ -3389,7 +3825,7 @@ func _check_and_render_dungeon(): var chunk = dungeon_sync_chunks[chunk_idx] var start_row = chunk.start_row - var end_row = chunk.end_row + var _end_row = chunk.end_row var tile_grid_chunk = chunk.tile_grid var grid_chunk = chunk.grid @@ -3597,6 +4033,9 @@ func _spawn_enemies(): if is_instance_valid(enemy): enemy.queue_free() + # Wait a frame for enemies to be removed before spawning new ones + await get_tree().process_frame + # Spawn enemies if not dungeon_data.has("enemies"): LogManager.log("GameWorld: WARNING: dungeon_data has no 'enemies' key!", LogManager.CATEGORY_DUNGEON) @@ -3608,13 +4047,21 @@ func _spawn_enemies(): return LogManager.log("GameWorld: Spawning " + str(enemies.size()) + " enemies (is_server: " + str(is_server) + ")", LogManager.CATEGORY_DUNGEON) + print("GameWorld: _spawn_enemies() - Starting spawn, defeated_enemies dictionary has ", defeated_enemies.size(), " entries") + if defeated_enemies.size() > 0: + print("GameWorld: _spawn_enemies() - Defeated enemy indices: ", defeated_enemies.keys()) for i in range(enemies.size()): # Check if this enemy was already defeated (for clients joining mid-game) + # CRITICAL: Check BEFORE creating any enemy instance if defeated_enemies.has(i): + print("GameWorld: [SKIP] Skipping spawn of defeated enemy with index " + str(i) + " (defeated_enemies has this index)") + print("GameWorld: [SKIP] Enemy at array index ", i, " will NOT be spawned") LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(i), LogManager.CATEGORY_NETWORK) continue # Don't spawn defeated enemies + print("GameWorld: [SPAWN] Spawning enemy at index ", i, " (not in defeated_enemies)") + var enemy_data = enemies[i] if not enemy_data is Dictionary: push_error("ERROR: Enemy data at index ", i, " is not a Dictionary! Type: ", typeof(enemy_data)) @@ -3706,6 +4153,46 @@ func _spawn_enemies(): LogManager.log("GameWorld: Server spawned " + str(enemies.size()) + " enemies", LogManager.CATEGORY_DUNGEON) else: LogManager.log("GameWorld: Client spawned " + str(enemies.size()) + " enemies", LogManager.CATEGORY_DUNGEON) + + # After spawning all enemies, explicitly remove any defeated enemies that might still exist + # This handles edge cases where defeated enemies weren't properly removed (e.g., joining mid-game) + if defeated_enemies.size() > 0: + print("GameWorld: [CLEANUP] Checking for defeated enemies to remove (defeated_enemies has ", defeated_enemies.size(), " entries)") + print("GameWorld: [CLEANUP] Defeated enemy indices in dictionary: ", defeated_enemies.keys()) + var defeated_enemies_found = [] + var all_enemies = [] + for child in entities_node.get_children(): + if child.is_in_group("enemy"): + all_enemies.append(child) + var enemy_index = -1 + if child.has_meta("enemy_index"): + enemy_index = child.get_meta("enemy_index") + else: + # Try to extract index from name (e.g., "Enemy_5" -> 5) + var name_parts = child.name.split("_") + if name_parts.size() >= 2: + var index_str = name_parts[name_parts.size() - 1] + if index_str.is_valid_int(): + enemy_index = int(index_str) + + print("GameWorld: [CLEANUP] Checking enemy: ", child.name, " (index: ", enemy_index, ", in_defeated: ", (enemy_index >= 0 and defeated_enemies.has(enemy_index)), ")") + + if enemy_index >= 0 and defeated_enemies.has(enemy_index): + defeated_enemies_found.append(child) + print("GameWorld: [CLEANUP] Found defeated enemy to remove: ", child.name, " (index: ", enemy_index, ")") + + print("GameWorld: [CLEANUP] Total enemies in scene: ", all_enemies.size(), ", Defeated enemies found: ", defeated_enemies_found.size()) + + # Remove all defeated enemies that are still in the scene + for defeated_enemy in defeated_enemies_found: + if is_instance_valid(defeated_enemy): + print("GameWorld: [CLEANUP] Removing defeated enemy that was still in scene: ", defeated_enemy.name) + defeated_enemy.queue_free() + + if defeated_enemies_found.size() > 0: + print("GameWorld: [CLEANUP] Removed ", defeated_enemies_found.size(), " defeated enemies that were still in scene") + else: + print("GameWorld: [CLEANUP] No defeated enemies found in scene to remove (all were properly skipped during spawn)") func _spawn_interactable_objects(): # Spawn interactable objects from dungeon data @@ -3810,11 +4297,20 @@ func _spawn_interactable_objects(): obj.add_to_group("interactable_object") # Apply any pending chest open sync that arrived before this chest spawned - if obj.has_method("setup_chest") and pending_chest_opens.has(obj.name): + # IMPORTANT: Check after setup_function is called so object_type is set + # Check if it's a chest by checking object_type (setup_chest sets object_type to "Chest") + var is_chest = false + if "object_type" in obj and obj.object_type == "Chest": + is_chest = true + + if is_chest and pending_chest_opens.has(obj.name): + print("GameWorld: Found pending chest open for ", obj.name, " (object_type: ", obj.object_type, "), applying now") var chest_state = pending_chest_opens[obj.name] - if obj.has_method("_sync_chest_open"): - var item_data = chest_state.get("item_data", {}) - obj._sync_chest_open(chest_state.loot_type, chest_state.player_peer_id, item_data) + var obj_ref = obj # Capture reference for deferred call + var chest_name = obj.name # Capture name for logging + # Use call_deferred to ensure chest is fully initialized (sprite, chest_opened_frame, etc.) + # This happens after setup_chest is called, so sprite and frames should be ready + call_deferred("_open_pending_chest", obj_ref, chest_state, chest_name) pending_chest_opens.erase(obj.name) # Apply pending state sync if it arrived before this object spawned @@ -3826,11 +4322,67 @@ func _spawn_interactable_objects(): # If this object is already broken (for clients joining mid-game), break it immediately # Check broken_objects after object is fully spawned if broken_objects.has(i): - # Use call_deferred to break after object is fully ready - obj.call_deferred("_sync_break") + print("GameWorld: Object at index ", i, " (name: ", obj.name, ") is marked as broken, breaking it now") + print("GameWorld: Object state - is_broken: ", obj.is_broken if "is_broken" in obj else "N/A", ", has _sync_break: ", obj.has_method("_sync_break")) + # Use timer to break after object is fully ready (wait a bit to ensure sprite is set up) + # Use call_deferred as well, but with a timer backup to ensure it happens + var obj_ref = obj # Capture reference + var obj_index = i # Capture index for logging + # Use timer to ensure object is fully initialized (wait 2 frames worth of time) + get_tree().create_timer(0.05).timeout.connect(func(): + if is_instance_valid(obj_ref): + _break_spawned_object(obj_ref, obj_index) + ) LogManager.log("GameWorld: Spawned " + str(objects.size()) + " interactable objects", LogManager.CATEGORY_DUNGEON) +func _break_spawned_object(obj: Node, obj_index: int): + # Helper to break an object that was already broken when client joined mid-game + if not is_instance_valid(obj): + print("GameWorld: Cannot break object at index ", obj_index, " - object is invalid") + return + if obj.is_queued_for_deletion(): + print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") is already queued for deletion") + return + if "is_broken" in obj and obj.is_broken: + print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") is already broken") + return + if obj.has_method("_sync_break"): + print("GameWorld: Breaking object at index ", obj_index, " (name: ", obj.name, ")") + print("GameWorld: Object state before break - is_broken: ", obj.is_broken if "is_broken" in obj else "N/A", ", is_queued_for_deletion: ", obj.is_queued_for_deletion(), ", has sprite: ", "sprite" in obj and obj.sprite != null) + obj._sync_break(true) # silent=true to avoid duplicate sounds + # Verify it worked + if "is_broken" in obj: + print("GameWorld: Object state after _sync_break - is_broken: ", obj.is_broken) + if obj.is_queued_for_deletion(): + print("GameWorld: Successfully broke object ", obj.name, " at index ", obj_index, " (object is queued for deletion)") + else: + print("GameWorld: WARNING - Object ", obj.name, " was not queued for deletion after _sync_break!") + else: + print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") does not have _sync_break method") + +func _open_pending_chest(obj: Node, chest_state: Dictionary, chest_name: String): + # Helper to open a chest that was already opened when client joined mid-game + if not is_instance_valid(obj): + print("GameWorld: Cannot open chest ", chest_name, " - chest is invalid") + return + if obj.is_chest_opened: + print("GameWorld: Chest ", chest_name, " is already opened") + return + if obj.has_method("_sync_chest_open"): + var item_data = chest_state.get("item_data", {}) + print("GameWorld: Opening chest ", chest_name, " with loot_type: ", chest_state.get("loot_type", "coin")) + obj._sync_chest_open(chest_state.get("loot_type", "coin"), chest_state.get("player_peer_id", 0), item_data) + print("GameWorld: Successfully opened chest ", chest_name) + else: + print("GameWorld: WARNING - Chest ", chest_name, " does not have _sync_chest_open method") + # Fallback: try to set the sprite frame directly if available + if "sprite" in obj and "chest_opened_frame" in obj and obj.chest_opened_frame >= 0: + obj.is_chest_opened = true + if obj.sprite: + obj.sprite.frame = obj.chest_opened_frame + print("GameWorld: Opened chest ", chest_name, " using fallback method (sprite frame)") + func _sync_existing_dungeon_enemies_to_client(client_peer_id: int): # Sync existing dungeon-spawned enemies to newly connected client # Use dungeon_data.enemies array (like torches) instead of searching scene tree @@ -4526,28 +5078,62 @@ func _sync_door_puzzle_solved_by_name(door_name: String, is_solved: bool): pending_door_states[door_name]["puzzle_solved"] = is_solved func _apply_pending_door_state(door: Node): - if not door or not pending_door_states.has(door.name): + if not door: + return + + var state = null + var state_key = door.name + + # Try to find state by door name first + if pending_door_states.has(door.name): + state = pending_door_states[door.name] + state_key = door.name + else: # Try fallback key if name was auto-renamed - if door and door.has_meta("door_index"): + if door.has_meta("door_index"): var fallback_key = "BlockingDoor_%d" % door.get_meta("door_index") if pending_door_states.has(fallback_key): - var fallback_state = pending_door_states[fallback_key] - if fallback_state.has("open") and fallback_state.open and door.has_method("_sync_door_open"): - door._sync_door_open() - if fallback_state.has("close") and fallback_state.close and door.has_method("_sync_door_close"): - door._sync_door_close() - if fallback_state.has("puzzle_solved") and door.has_method("_sync_puzzle_solved"): - door._sync_puzzle_solved(fallback_state.puzzle_solved) - pending_door_states.erase(fallback_key) + state = pending_door_states[fallback_key] + state_key = fallback_key + # Try position-based matching as last resort + # position is a built-in property of Node2D, so we can access it directly + if not state and door is Node2D: + var door_pos = door.position + for key in pending_door_states.keys(): + var test_state = pending_door_states[key] + if test_state.has("position"): + var state_pos = test_state.position + if door_pos.distance_to(state_pos) < 1.0: # Same position + state = test_state + state_key = key + print("GameWorld: Matched door state by position for ", door.name, " (matched key: ", key, ")") + break + + if not state: + print("GameWorld: No pending door state found for ", door.name) return - var state = pending_door_states[door.name] + + print("GameWorld: Applying door state to ", door.name, ": open=", state.get("open", false), ", close=", state.get("close", false), ", puzzle_solved=", state.get("puzzle_solved", false)) + + # Apply door state if state.has("open") and state.open and door.has_method("_sync_door_open"): door._sync_door_open() + print("GameWorld: Applied open state to door ", door.name) if state.has("close") and state.close and door.has_method("_sync_door_close"): door._sync_door_close() + print("GameWorld: Applied close state to door ", door.name) if state.has("puzzle_solved") and door.has_method("_sync_puzzle_solved"): door._sync_puzzle_solved(state.puzzle_solved) - pending_door_states.erase(door.name) + print("GameWorld: Applied puzzle_solved=", state.puzzle_solved, " to door ", door.name) + + # For KeyDoors, also restore key_used state if present + # Check if door has key_used property using "key_used" in door (works for script properties) + if state.has("key_used") and "key_used" in door: + door.key_used = state.key_used + print("GameWorld: Applied key_used=", state.key_used, " to door ", door.name) + + # Remove state after applying + pending_door_states.erase(state_key) @rpc("authority", "reliable") func _sync_chest_open_by_name(chest_name: String, loot_type_str: String, player_peer_id: int, item_data: Dictionary = {}): @@ -5450,6 +6036,10 @@ func _sync_object_break(obj_name: String): LogManager.log("GameWorld: Node not in tree, ignoring box break RPC for " + obj_name, LogManager.CATEGORY_NETWORK) return + # Skip processing if tab was just inactive (may be stale messages from buffer overflow) + if not multiplayer.is_server() and not was_tab_visible: + return # Tab was inactive, skip old/stale RPCs to prevent sound spam + var entities_node = get_node_or_null("Entities") if not entities_node: LogManager.log("GameWorld: Entities node not found, ignoring box break RPC for " + obj_name, LogManager.CATEGORY_NETWORK) diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index fdd72d8..687aa72 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -233,6 +233,7 @@ func _create_equipment_slots(): button.size_flags_horizontal = 0 button.size_flags_vertical = 0 button.connect("pressed", _on_equipment_slot_pressed.bind(slot_name)) + button.connect("gui_input", _on_equipment_slot_gui_input.bind(slot_name)) # Connect focus_entered like inspiration system (for keyboard navigation) if local_player and local_player.character_stats: var equipped_item = local_player.character_stats.equipment[slot_name] @@ -288,6 +289,19 @@ func _on_equipment_slot_pressed(slot_name: String): _update_selection_highlight() _update_selection_rectangle() +func _on_equipment_slot_gui_input(event: InputEvent, slot_name: String): + # Handle double-click to unequip + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + if event.double_click and event.pressed: + # Double-click detected - unequip the item + selected_slot = slot_name + selected_item = local_player.character_stats.equipment[slot_name] if local_player and local_player.character_stats else null + selected_type = "equipment" if selected_item else "" + + if selected_type == "equipment" and selected_slot != "": + # Use the same logic as F key to unequip + _handle_f_key() + func _update_selection_highlight(): # This function is kept for compatibility but now uses _update_selection_rectangle() _update_selection_rectangle() @@ -488,6 +502,7 @@ func _update_ui(): button.flat = false # Use styleboxes button.focus_mode = Control.FOCUS_ALL # Allow button to receive focus button.connect("pressed", _on_inventory_item_pressed.bind(item)) + button.connect("gui_input", _on_inventory_item_gui_input.bind(item)) # Connect focus_entered like inspiration system (for keyboard navigation) # Note: focus_entered will trigger when we call grab_focus(), but _on_inventory_item_pressed # just updates selection state, so it should be safe @@ -540,12 +555,27 @@ func _update_ui(): inventory_selection_col = max(0, row.get_child_count() - 1) # Update selection only if selected_type is already set (don't auto-update during initialization) - if selected_type != "": + # Don't call _set_selection() here if we already have a valid selection - it will reset to 0,0 + # Only call it if selection is empty or invalid + var should_reset_selection = false + if selected_type == "": + should_reset_selection = true + elif selected_type == "item": + # Check if current selection is still valid + if inventory_selection_row < 0 or inventory_selection_row >= inventory_rows_list.size(): + should_reset_selection = true + elif inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col < 0 or inventory_selection_col >= row.get_child_count(): + should_reset_selection = true + + if should_reset_selection: + _set_selection() + elif selected_type != "": + # Selection is valid, just update it _update_selection_from_navigation() _update_selection_rectangle() _update_info_panel() - - _set_selection() # Reset update flag is_updating_ui = false @@ -553,15 +583,30 @@ func _update_ui(): func _set_selection(): # NOW check for items AFTER UI is updated # Initialize selection - prefer inventory, but if empty, check equipment + # Only set initial selection if not already set, or if current selection is invalid + var needs_initial_selection = false + if selected_type == "": + needs_initial_selection = true + elif selected_type == "item": + # Check if current selection is still valid + if inventory_selection_row >= inventory_rows_list.size() or inventory_selection_row < 0: + needs_initial_selection = true + elif inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col >= row.get_child_count() or inventory_selection_col < 0: + needs_initial_selection = true + # Check if we have inventory items if inventory_rows_list.size() > 0 and inventory_items_list.size() > 0: - selected_type = "item" - inventory_selection_row = 0 - inventory_selection_col = 0 - # Ensure selection is set correctly + if needs_initial_selection: + # Only reset to 0 if we need initial selection + selected_type = "item" + inventory_selection_row = 0 + inventory_selection_col = 0 + # Ensure selection is set correctly (preserves existing selection if valid) _update_selection_from_navigation() # Debug: Print selection state - print("InventoryUI: Initial selection - type: ", selected_type, " row: ", inventory_selection_row, " col: ", inventory_selection_col, " item: ", selected_item) + print("InventoryUI: Selection - type: ", selected_type, " row: ", inventory_selection_row, " col: ", inventory_selection_col, " item: ", selected_item) # Now set focus - buttons should be ready await _update_selection_rectangle() # Await to ensure focus is set _update_info_panel() @@ -859,6 +904,40 @@ func _on_inventory_item_pressed(item: Item): _update_selection_highlight() _update_selection_rectangle() +func _on_inventory_item_gui_input(event: InputEvent, item: Item): + # Handle double-click to equip/consume and right-click to drop + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT and event.double_click and event.pressed: + # Double-click detected - equip or consume the item + selected_item = item + selected_slot = "" + selected_type = "item" + + # Update navigation position first + var item_index = inventory_items_list.find(item) + if item_index >= 0: + var items_per_row: int = 8 + inventory_selection_row = floor(item_index / float(items_per_row)) + inventory_selection_col = item_index % items_per_row + + # Use the same logic as F key to equip/consume + _handle_f_key() + elif event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: + # Right-click detected - drop the item + selected_item = item + selected_slot = "" + selected_type = "item" + + # Update navigation position first + var item_index = inventory_items_list.find(item) + if item_index >= 0: + var items_per_row: int = 8 + inventory_selection_row = floor(item_index / float(items_per_row)) + inventory_selection_col = item_index % items_per_row + + # Use the same logic as E key to drop + _handle_e_key() + func _on_character_changed(_char: CharacterStats): # Always update stats when character changes (even if inventory is closed) # Equipment changes affect max HP/MP which should be reflected everywhere @@ -984,25 +1063,90 @@ func _handle_f_key(): Item.EquipmentType.ACCESSORY: target_slot_name = "accessory" + # Remember current item position before equipping + var items_per_row = 8 + var current_item_index = inventory_selection_row * items_per_row + inventory_selection_col + + # Check if target slot has an item (will be placed back in inventory) + var slot_has_item = char_stats.equipment[target_slot_name] != null + # Check if this is the last item in inventory (before equipping) var was_last_item = char_stats.inventory.size() == 1 - char_stats.equip_item(selected_item) + # Equip the item, placing old item at the same position if slot had an item + var insert_index = current_item_index if slot_has_item else -1 + char_stats.equip_item(selected_item, insert_index) # Play armour sound when equipping if sfx_armour: sfx_armour.play() - # If this was the last item, set selection state BEFORE _update_ui() - # so that _update_selection_from_navigation() works correctly - if was_last_item and target_slot_name != "": + # Update UI first + _update_ui() + + # If slot had an item, keep selection at the same position (old item is now there) + if slot_has_item and current_item_index < char_stats.inventory.size(): + # Keep selection at the same position + selected_type = "item" + selected_slot = "" + # Recalculate row/col from index (may have changed if rows shifted) + inventory_selection_row = floor(current_item_index / float(items_per_row)) + inventory_selection_col = current_item_index % items_per_row + # Ensure row/col are within bounds + if inventory_selection_row >= inventory_rows_list.size(): + inventory_selection_row = max(0, inventory_rows_list.size() - 1) + if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col >= row.get_child_count(): + inventory_selection_col = max(0, row.get_child_count() - 1) + _update_selection_from_navigation() + elif was_last_item and target_slot_name != "": + # Last item was equipped, move selection to equipment slot if target_slot_name in equipment_slots_list: selected_type = "equipment" selected_slot = target_slot_name equipment_selection_index = equipment_slots_list.find(target_slot_name) selected_item = char_stats.equipment[target_slot_name] - - _update_ui() + _update_selection_from_navigation() + else: + # Item was removed, try to keep selection at same position if possible + if current_item_index < char_stats.inventory.size(): + # Item at next position moved up, keep selection there + selected_type = "item" + selected_slot = "" + inventory_selection_row = floor(current_item_index / float(items_per_row)) + inventory_selection_col = current_item_index % items_per_row + if inventory_selection_row >= inventory_rows_list.size(): + inventory_selection_row = max(0, inventory_rows_list.size() - 1) + if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col >= row.get_child_count(): + inventory_selection_col = max(0, row.get_child_count() - 1) + _update_selection_from_navigation() + elif current_item_index > 0: + # Move to previous position (current_item_index - 1) if current is out of bounds + var previous_index = current_item_index - 1 + inventory_selection_row = floor(previous_index / float(items_per_row)) + inventory_selection_col = previous_index % items_per_row + if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col >= row.get_child_count(): + inventory_selection_col = max(0, row.get_child_count() - 1) + selected_type = "item" + selected_slot = "" + _update_selection_from_navigation() + else: + # Previous position is also out of bounds, move to equipment if available + selected_type = "" + selected_slot = "" + selected_item = null + _update_selection_from_navigation() + else: + # No items left, move to equipment if available + selected_type = "" + selected_slot = "" + selected_item = null + _update_selection_from_navigation() # Update selection rectangle and info panel _update_selection_rectangle() @@ -1017,6 +1161,10 @@ func _use_consumable_item(item: Item): var char_stats = local_player.character_stats + # Remember current item position before consuming + var items_per_row = 8 + var current_item_index = inventory_selection_row * items_per_row + inventory_selection_col + # Determine if it's a potion or food based on item name var is_potion = "potion" in item.item_name.to_lower() @@ -1041,6 +1189,43 @@ func _use_consumable_item(item: Item): if index >= 0: char_stats.inventory.remove_at(index) char_stats.character_changed.emit(char_stats) + + # Update UI first + _update_ui() + + # Try to keep selection at the same position if possible + if current_item_index < char_stats.inventory.size(): + # Item at next position moved up, keep selection there + selected_type = "item" + selected_slot = "" + inventory_selection_row = floor(current_item_index / float(items_per_row)) + inventory_selection_col = current_item_index % items_per_row + if inventory_selection_row >= inventory_rows_list.size(): + inventory_selection_row = max(0, inventory_rows_list.size() - 1) + if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col >= row.get_child_count(): + inventory_selection_col = max(0, row.get_child_count() - 1) + _update_selection_from_navigation() + _update_selection_rectangle() + elif current_item_index > 0: + # Move to previous position if current is out of bounds + current_item_index = char_stats.inventory.size() - 1 + inventory_selection_row = floor(current_item_index / float(items_per_row)) + inventory_selection_col = current_item_index % items_per_row + if inventory_selection_row >= 0 and inventory_selection_row < inventory_rows_list.size(): + var row = inventory_rows_list[inventory_selection_row] + if inventory_selection_col >= row.get_child_count(): + inventory_selection_col = max(0, row.get_child_count() - 1) + _update_selection_from_navigation() + _update_selection_rectangle() + else: + # No items left, clear selection + selected_type = "" + selected_slot = "" + selected_item = null + _update_selection_from_navigation() + _update_selection_rectangle() print(local_player.name, " used item: ", item.item_name) diff --git a/src/scripts/item_loot_helper.gd b/src/scripts/item_loot_helper.gd index 9c9500e..40bf2a4 100644 --- a/src/scripts/item_loot_helper.gd +++ b/src/scripts/item_loot_helper.gd @@ -27,8 +27,8 @@ static func spawn_item_loot(item: Item, position: Vector2, entities_node: Node, loot_rng.seed = loot_seed var random_angle = loot_rng.randf() * PI * 2 - var random_force = loot_rng.randf_range(50.0, 100.0) - var random_velocity_z = loot_rng.randf_range(80.0, 120.0) + var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed + var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force # Find safe spawn position if game_world is provided diff --git a/src/scripts/matchbox_client.gd b/src/scripts/matchbox_client.gd index 16f7624..1394888 100644 --- a/src/scripts/matchbox_client.gd +++ b/src/scripts/matchbox_client.gd @@ -641,15 +641,28 @@ func _create_peer_connection(peer_id: int) -> WebRTCPeerConnection: # Add TURN server as separate entry with credentials if not TURN_SERVER.is_empty(): - # For TURN, create URLs array with both UDP and TCP transports + # For TURN, create URLs array with appropriate transports + # Note: libjuice (native builds) only supports UDP transport + # WebAssembly (browser) supports both UDP and TCP/TLS var turn_urls = [] + var is_web = OS.get_name() == "Web" + if TURN_SERVER.begins_with("turns:"): - # TURNS over TLS: primarily TCP - turn_urls.append(TURN_SERVER + "?transport=tcp") + # TURNS over TLS: TCP transport (browser only, libjuice doesn't support it) + if is_web: + turn_urls.append(TURN_SERVER + "?transport=tcp") + else: + # Native builds: skip TLS (libjuice limitation), use UDP fallback if available + log_print("MatchboxClient: Skipping TLS transport for native build (libjuice limitation)") + # Convert turns: to turn: and use UDP transport for native builds + var udp_turn_server = TURN_SERVER.replace("turns:", "turn:") + turn_urls.append(udp_turn_server + "?transport=udp") else: - # Standard TURN: try both UDP and TCP + # Standard TURN: UDP works everywhere, TCP only on browser turn_urls.append(TURN_SERVER + "?transport=udp") - turn_urls.append(TURN_SERVER + "?transport=tcp") + if is_web: + # Add TCP transport for browsers (can help with restrictive firewalls) + turn_urls.append(TURN_SERVER + "?transport=tcp") # Create TURN server configuration var turn_config = { @@ -1324,4 +1337,4 @@ func add_peer_to_mesh(peer_id: int): log_error("MatchboxClient: Failed to add peer to mesh: " + str(error)) return - log_print("MatchboxClient: Added peer " + str(peer_id) + " to WebRTC mesh") \ No newline at end of file + log_print("MatchboxClient: Added peer " + str(peer_id) + " to WebRTC mesh") diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index 79e79ac..ed49923 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -820,15 +820,28 @@ func create_peer_connection() -> WebRTCPeerConnection: # Add TURN server as separate entry with credentials if not TURN_SERVER.is_empty(): - # For TURN, create URLs array with both UDP and TCP transports + # For TURN, create URLs array with appropriate transports + # Note: libjuice (native builds) only supports UDP transport + # WebAssembly (browser) supports both UDP and TCP/TLS var turn_urls = [] + var is_web = OS.get_name() == "Web" + if TURN_SERVER.begins_with("turns:"): - # TURNS over TLS: primarily TCP - turn_urls.append(TURN_SERVER + "?transport=tcp") + # TURNS over TLS: TCP transport (browser only, libjuice doesn't support it) + if is_web: + turn_urls.append(TURN_SERVER + "?transport=tcp") + else: + # Native builds: skip TLS (libjuice limitation), use UDP fallback if available + print("NetworkManager: Skipping TLS transport for native build (libjuice limitation)") + # Convert turns: to turn: and use UDP transport for native builds + var udp_turn_server = TURN_SERVER.replace("turns:", "turn:") + turn_urls.append(udp_turn_server + "?transport=udp") else: - # Standard TURN: try both UDP and TCP + # Standard TURN: UDP works everywhere, TCP only on browser turn_urls.append(TURN_SERVER + "?transport=udp") - turn_urls.append(TURN_SERVER + "?transport=tcp") + if is_web: + # Add TCP transport for browsers (can help with restrictive firewalls) + turn_urls.append(TURN_SERVER + "?transport=tcp") # Create TURN server configuration var turn_config = { diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 2d9bbce..d3323f2 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -3415,9 +3415,14 @@ func take_damage(amount: float, attacker_position: Vector2): actual_damage = amount print(name, " took ", amount, " damage! Health: ", current_health) - # Play damage sound effect + # Play damage sound effect (rate limited to prevent spam when tab becomes active) + var game_world = get_tree().get_first_node_in_group("game_world") if sfx_take_damage: - sfx_take_damage.play() + if game_world and game_world.has_method("can_play_sound"): + if game_world.can_play_sound("player_damage_" + str(get_instance_id())): + sfx_take_damage.play() + else: + sfx_take_damage.play() # Play damage animation _set_animation("DAMAGE") @@ -3991,9 +3996,14 @@ func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = fa _show_damage_number(0.0, attacker_position, false, false, true) return - # Play damage sound and effects + # Play damage sound and effects (rate limited to prevent spam when tab becomes active) + var game_world = get_tree().get_first_node_in_group("game_world") if sfx_take_damage: - sfx_take_damage.play() + if game_world and game_world.has_method("can_play_sound"): + if game_world.can_play_sound("player_damage_sync_" + str(get_instance_id())): + sfx_take_damage.play() + else: + sfx_take_damage.play() # Play damage animation _set_animation("DAMAGE")