feat: fix some bug

This commit is contained in:
2026-04-24 22:56:11 +08:00
parent 8c5004d535
commit b76dd2e737
9 changed files with 105 additions and 156 deletions
+9
View File
@@ -1,5 +1,14 @@
## [NEXT]
## [2.1.8] — 2026-04-24
- Optimized network synchronization with deterministic tile slots and granular board sync
- Removed lag-sensitive server adjacency checks to fix "disappearing tiles" on high-latency connections
- Fixed "No multiplayer peer assigned" crash during host disconnection and cleanup
- Finalized AP system removal, transitioning to a fully real-time authoritative model
- Restored bot mission completion logic
- Stabilized Nakama socket cleanup during match-to-lobby transitions
## [2.1.7] — 2026-04-24
- Upgraded Gacha interface with dynamic CSGO-style sequential reveal animations
- Added an animation skip button for faster 10x multi-pulls
+10 -6
View File
@@ -109,6 +109,7 @@ var admin_panel_instance: Control
var current_match_id: String = ""
var leaderboard_panel_instance: Control
var shop_panel_instance: Control
# Bot name tracking keyed by slot index to avoid re-generating on each update
var _bot_names: Dictionary = {}
@@ -787,13 +788,16 @@ func _on_shop_pressed() -> void:
connection_status.text = "Must be logged in"
return
var shop_scene = load("res://scenes/ui/shop_panel.tscn")
if shop_scene:
var shop = shop_scene.instantiate()
add_child(shop)
if not shop_panel_instance:
var shop_scene = load("res://scenes/ui/shop_panel.tscn")
if shop_scene:
shop_panel_instance = shop_scene.instantiate()
add_child(shop_panel_instance)
shop_panel_instance.closed.connect(func(): if main_menu_panel: main_menu_panel.show())
if shop_panel_instance:
if main_menu_panel: main_menu_panel.hide()
shop.closed.connect(func(): if main_menu_panel: main_menu_panel.show())
shop.show_panel()
shop_panel_instance.show_panel()
func _on_banner1_pressed() -> void:
var gacha_scene = load("res://scenes/ui/gacha_panel.tscn")
+19 -10
View File
@@ -985,10 +985,10 @@ func _assign_stop_n_go_spawn_positions(all_players: Array):
# Collect valid walkable spawn positions from the leftmost columns
var valid_spawns: Array[Vector2i] = []
for col in range(0, min(5, enhanced_gridmap.columns)): # Check first 5 columns
for col in range(0, min(5, enhanced_gridmap.columns)): # Check first 5 columns
for row in range(enhanced_gridmap.rows):
var tile = enhanced_gridmap.get_cell_item(Vector3i(col, 0, row))
if tile == 0 or tile == 3: # Walkable or Start
if tile == 0 or tile == 3: # Walkable or Start
valid_spawns.append(Vector2i(col, row))
if valid_spawns.size() >= all_players.size():
break
@@ -1036,7 +1036,7 @@ func _assign_portal_mode_spawn_positions(all_players: Array):
for dx in range(-radius, radius + 1):
for dz in range(-radius, radius + 1):
# Only check the "ring" at the current radius
if abs(dx) != radius and abs(dz) != radius and radius > 0:
if abs(dx) != radius and abs(dz) != radius and radius > 0:
continue
var test_pos = center_pos + Vector2i(dx, dz)
@@ -1056,7 +1056,7 @@ func _assign_portal_mode_spawn_positions(all_players: Array):
if abs(test_pos.x - reserved.x) <= 2 and abs(test_pos.y - reserved.y) <= 2:
is_reserved = true
break
if is_reserved:
if is_reserved:
continue
# 4. Check if occupied by another already-assigned player
@@ -1408,6 +1408,7 @@ func add_newly_connected_player_character(new_peer_id: int):
func _on_peer_disconnected(peer_id: int):
if not is_inside_tree(): return
if not multiplayer.has_multiplayer_peer(): return
if multiplayer.is_server():
print("[Main] Peer %d disconnected. Checking for bot replacement..." % peer_id)
@@ -1506,7 +1507,6 @@ func set_current_turn(player_id: int):
player.is_my_turn = is_current_turn
if is_current_turn and not (player.is_bot or player.is_in_group("Bots")):
player.action_points = 2
player.has_moved_this_turn = false
player.has_performed_action = false
player.start_turn()
@@ -1585,6 +1585,20 @@ func sync_playerboard(player_id: int, new_playerboard: Array):
if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character:
ui_manager.update_playerboard_ui()
@rpc("any_peer", "call_local", "reliable")
func sync_playerboard_slot(player_id: int, slot_index: int, item_id: int):
"""Patch a single playerboard slot without touching other slots.
Used by _execute_grab on grab confirmation to avoid overwriting concurrent
in-flight optimistic grab updates on high-latency clients."""
var player = get_node_or_null(str(player_id))
if player and slot_index >= 0 and slot_index < player.playerboard.size():
player.playerboard[slot_index] = item_id
# Update UI for local player only
if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character:
ui_manager.update_playerboard_ui()
# =============================================================================
@@ -2076,11 +2090,6 @@ func _on_match_ended():
is_match_ended = true
print("[Main] Match ended! Showing game over screen...")
# Disable player controls
var local_player = GameStateManager.local_player_character
if local_player:
local_player.action_points = 0
# Signal Global Game End (Stops Bot ticks and logic)
GameStateManager.end_game()
+5 -9
View File
@@ -123,7 +123,7 @@ var _is_processing_action: bool = false
var selected_gridmap_position = Vector2i(-1, -1)
var selected_playerboard_slot = -1
var targeted_playerboard_slot = -1
var action_points: int = 2
#var has_performed_action: bool = false
# Modifier for player models
var rotation_speed: float = 10.0
@@ -1784,7 +1784,6 @@ func grid_to_world(grid_position: Vector2i) -> Vector3:
return world_position
func start_turn():
action_points = 2
has_moved_this_turn = false
has_performed_action = false
is_my_turn = true
@@ -1996,7 +1995,7 @@ func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int):
# This function runs on the server when requested by a client
# -----------------------------------------------------------------
@rpc("any_peer", "reliable")
func request_server_grab(grid_pos: Vector2i, x: int, y: int, z: int, item_id: int):
func request_server_grab(grid_pos: Vector2i, x: int, y: int, z: int, item_id: int, expected_slot: int = -1):
# 1. Only the server (peer 1) should process this
if not multiplayer.is_server():
return
@@ -2006,8 +2005,9 @@ func request_server_grab(grid_pos: Vector2i, x: int, y: int, z: int, item_id: in
push_error("Security: Non-authority tried to grab item!")
return
# 3. Call the execution logic
playerboard_manager._execute_grab(grid_pos, Vector3i(x, y, z), item_id)
# 3. Call the execution logic (pass expected_slot for deterministic placement)
playerboard_manager._execute_grab(grid_pos, Vector3i(x, y, z), item_id, expected_slot)
# -----------------------------------------------------------------
# Auto-put: no manual selection needed
@@ -2074,7 +2074,6 @@ func request_server_put(grid_position: Vector2i, slot_index: int, x: int, y: int
# Update player state
has_performed_action = true
action_points -= 1
selected_playerboard_slot = -1
# Notify about action completion
@@ -2161,9 +2160,6 @@ func highlight_adjacent_cells():
func highlight_empty_adjacent_cells():
action_manager.highlight_empty_adjacent_cells()
@rpc("any_peer", "call_local")
func sync_action_points(points: int):
action_manager.sync_action_points(points)
func highlight_random_valid_cells():
action_manager.highlight_random_valid_cells()
+2 -30
View File
@@ -247,21 +247,12 @@ func _run_ai_tick():
var goals_achv = _is_goals_achieved()
if actor.action_points > 1: # Only print if they have multi-AP
print("[BotController] %s AI Tick. AP: %d, GoalsAchieved: %s, Board: %s" % [actor.name, actor.action_points, str(goals_achv), str(board_fullness)])
# Only stop completely if objectives are met AND we are at the finish line (if applicable)
# Only stop completely if objectives are met and at finish (or standard mode)
if goals_achv:
if is_sng and actor.current_position.x >= 21:
return
elif not is_sng:
return # In standard mode, goal achievement = game over usually
# STALL PREVENTION: If we have AP but couldn't do anything, we are stuck.
# Skip turn to prevent game freeze in turn-based mode.
if TurnManager.turn_based_mode and actor.action_points > 0:
print("[BotController] %s is STUCK with AP %d! Skipping turn to proceed flow." % [actor.name, actor.action_points])
actor.consume_action_points(actor.action_points)
return
# =============================================================================
# Power-Up / Sabotage
@@ -501,9 +492,6 @@ func _find_nearest_victim() -> Node3D:
func _try_grab() -> bool:
"""Try to grab a tile from the grid using direct player control."""
# Check AP only if turn-based
if TurnManager.turn_based_mode and actor.action_points <= 0:
return false
if _is_playerboard_full():
return false
@@ -572,8 +560,6 @@ func _find_tile_to_grab(tiles_needed: Array) -> Dictionary:
func _try_move() -> bool:
"""Try to move toward needed tiles taking single steps like a player."""
if TurnManager.turn_based_mode and actor.action_points <= 0:
return false
if _is_goals_achieved() and LobbyManager.game_mode != "Stop n Go":
return false
@@ -700,9 +686,6 @@ func _try_unstuck_move() -> bool:
func _try_put(high_priority: bool = false) -> bool:
"""Try to put a tile from playerboard onto grid."""
if actor.action_points <= 0:
return false
var put_slot = -1
# Check for Panic Mode
@@ -718,21 +701,17 @@ func _try_put(high_priority: bool = false) -> bool:
is_panic = true
if is_panic:
# Aggressive dumping
put_slot = strategic_planner.get_unneeded_tile_slot_panic()
else:
# Normal smart dumping
put_slot = strategic_planner.get_unneeded_tile_slot()
if put_slot == -1:
return false
# Find empty adjacent cell
var put_position = _find_empty_adjacent_cell()
if not put_position:
return false
# Execute put
_is_processing_action = true
_current_action = "putting"
@@ -742,7 +721,6 @@ func _try_put(high_priority: bool = false) -> bool:
actor.playerboard[put_slot] = -1
actor.rpc("sync_grid_item", cell.x, cell.y, cell.z, item)
actor.rpc("sync_playerboard", actor.playerboard)
actor.action_points -= 1
print("[BotController] %s put unneeded tile %d at %s (Panic: %s)" % [actor.name, item, put_position, is_panic])
await _wait_with_variance(action_delay)
@@ -773,18 +751,13 @@ func _find_empty_adjacent_cell() -> Vector2i:
func _try_arrange() -> bool:
"""Try to rearrange tiles in playerboard."""
if actor.action_points < 2:
return false
if _is_goals_achieved():
return false
# Find misplaced item and better position
var arrangement = _find_best_arrangement()
if arrangement.is_empty():
return false
# Execute arrangement
_is_processing_action = true
_current_action = "arranging"
@@ -793,7 +766,6 @@ func _try_arrange() -> bool:
actor.playerboard[arrangement.target_slot] = item
actor.playerboard[arrangement.source_slot] = -1
actor.rpc("sync_playerboard", actor.playerboard)
actor.action_points -= 2
print("[BotController] %s arranged slot %d -> %d" % [actor.name, arrangement.source_slot, arrangement.target_slot])
await _wait_with_variance(action_delay)
+17 -25
View File
@@ -215,7 +215,9 @@ func sync_cycle_end():
func on_goal_completed(player: Node, time_remaining: float):
"""Called when a player completes their goal pattern."""
# 1. LOCAL OPTIMISTIC UPDATE (for smoothness)
# CLIENT PATH: clear board immediately for visual responsiveness,
# then let server send back the single authoritative new goals.
# Do NOT generate goals locally — that caused rollback/blinking.
if player.is_multiplayer_authority() and not multiplayer.is_server():
_handle_local_goal_completion(player, time_remaining)
return
@@ -223,48 +225,38 @@ func on_goal_completed(player: Node, time_remaining: float):
if not multiplayer.is_server():
return
# SERVER LOGIC continues...
# SERVER LOGIC
_process_goal_completion(player, time_remaining)
func _handle_local_goal_completion(player: Node, time_remaining: float):
print("[GoalsCycle] Client: Handling goal completion locally for smoothness.")
print("[GoalsCycle] Client: Goal completed — clearing board and requesting server sync.")
# Clear playerboard locally
# Clear playerboard locally for immediate visual feedback (empty board)
player.playerboard.fill(-1)
# Generate new goals locally (optimistic)
var new_goals = GoalManager.initialize_random_goals(9, 7, 10, 1.0)
var int_goals: Array[int] = []
for g in new_goals:
int_goals.append(g)
player.goals = int_goals
# Update UI immediately
# Update UI immediately so the board shows empty rather than stale
if main_scene and main_scene.ui_manager:
main_scene.ui_manager.update_playerboard_ui()
# Notify server to sync score and broadcast to others
rpc_id(1, "request_server_goal_completion", time_remaining, int_goals)
# Visual/Sfx
# Play sound locally — server will also trigger it, but client plays first for responsiveness
SfxManager.play("complete_mission")
# Notify server — server will validate, award score, and broadcast authoritative new goals
rpc_id(1, "request_server_goal_completion", time_remaining)
@rpc("any_peer")
func request_server_goal_completion(time_remaining: float, client_generated_goals: Array):
if not multiplayer.is_server(): return
func request_server_goal_completion(time_remaining: float):
"""Client notifies server of goal completion. Server validates, awards score,
and broadcasts authoritative new goals back to all peers."""
if not multiplayer.is_server():
return
var sender_id = multiplayer.get_remote_sender_id()
# Bots call it locally, so sender_id might be 1 or 0
if sender_id == 0: sender_id = 1
var player_node = main_scene.get_node_or_null(str(sender_id))
if player_node:
# Use provided goals from client to ensure sync
var int_goals: Array[int] = []
for g in client_generated_goals: int_goals.append(g)
# Process completion with provided goals
_process_goal_completion(player_node, time_remaining, int_goals)
_process_goal_completion(player_node, time_remaining)
func _process_goal_completion(player: Node, time_remaining: float, provided_goals: Array = []):
"""Internal server-side logic for completion rewards and broadcasting."""
+3 -23
View File
@@ -11,29 +11,9 @@ func initialize(p_player: Node3D, p_gridmap: Node):
player = p_player
enhanced_gridmap = p_gridmap
# =============================================================================
# Action Point Management
# =============================================================================
func consume_action_points(points: int):
if not is_instance_valid(player) or not player.is_multiplayer_authority():
return
var main = player.get_tree().get_root().get_node_or_null("Main")
if not main:
return
# Non-turn-based mode: unlimited action points for real-time fast-paced gameplay
if not TurnManager.turn_based_mode:
after_action_completed()
return
# Turn-based mode: consume action points normally
player.action_points -= points
if player.action_points <= 0:
main.request_next_turn()
func consume_action_points(_points: int):
# Action points are no longer used (real-time mode only).
# Kept as a no-op stub so call sites don't need mass refactoring.
after_action_completed()
func after_action_completed():
+1 -1
View File
@@ -306,7 +306,7 @@ func _on_movement_finished():
emit_signal("movement_finished")
func move_to_clicked_position(grid_position: Vector2i) -> bool:
if not player.is_multiplayer_authority() or is_moving or player.action_points <= 0:
if not player.is_multiplayer_authority() or is_moving:
return false
# Check if player is frozen
+39 -52
View File
@@ -31,14 +31,9 @@ func _normalize_tile(tile: int) -> int:
# =============================================================================
func grab_item(grid_position: Vector2i) -> bool:
var has_ap = player.action_points > 0 if TurnManager.turn_based_mode else true
if not enhanced_gridmap:
print("[Grab] Failed for %s: enhanced_gridmap is null" % player.name)
return false
if not has_ap:
print("[Grab] Failed for %s: no AP (%d)" % [player.name, player.action_points])
return false
if player.get("is_frozen"):
print("[Grab] Failed for %s: player is frozen" % player.name)
@@ -109,19 +104,15 @@ func grab_item(grid_position: Vector2i) -> bool:
if sng_manager and sng_manager.has_method("update_mission_progress"):
sng_manager.update_mission_progress(player.name.to_int(), item)
# Update UI immediately for responsiveness
# Update UI immediately for responsiveness
var main = player.get_tree().get_root().get_node_or_null("Main")
if main and main.ui_manager and not (player.is_bot or player.is_in_group("Bots")):
main.ui_manager.update_playerboard_ui()
# Check if goal is completed after grabbing
_check_goal_completion()
# === Server Sync ===
if multiplayer.is_server():
# HOST/SERVER: Broadcast to all clients
# HOST/SERVER: Check goal, then broadcast authoritative state to all clients
_check_goal_completion()
main.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1)
var peer_id = player.name.to_int()
main.rpc("sync_playerboard", peer_id, player.playerboard)
@@ -129,17 +120,21 @@ func grab_item(grid_position: Vector2i) -> bool:
player.consume_action_points(1)
player.rpc("force_action_state_none")
else:
# CLIENT: Optimistic Local Update for lag compensation
# CLIENT: Optimistic local visual only — do NOT check goal completion here.
# The server checks authoritatively in _execute_grab and broadcasts results.
# This prevents double-award and the goal-blink caused by optimistic rollback.
player.has_performed_action = true
player.consume_action_points(1)
# Do NOT consume action points here — server does it and syncs back via
# sync_action_points (or force_sync_to_client on failure), preventing double-decrement.
player.force_action_state_none()
# Send RPC request to server for validation
player.rpc_id(1, "request_server_grab", grid_position, cell.x, cell.y, cell.z, item)
# Send RPC to server — include the slot we used locally so server places in same slot,
# preventing the slot mismatch that caused board overwrites on rapid grabs.
player.rpc_id(1, "request_server_grab", grid_position, cell.x, cell.y, cell.z, item, target_slot)
return true # Action applied locally
return true # Action applied locally (grid visual only)
func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int):
func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int, expected_slot: int = -1):
var main = player.get_tree().get_root().get_node_or_null("Main")
if not main:
push_error("Server: Main node not found.")
@@ -153,31 +148,32 @@ func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int):
# 1. Server-side Validation
var server_item = server_gridmap.get_cell_item(cell)
# Check if item is still there
# Check if item is still there (critical — prevents grabbing already-taken tiles)
if server_item != item_id:
print("Server: Item mismatch or already taken. Server has ", server_item)
_force_sync_to_client(cell, server_item)
return false
# Check action points
if player.action_points <= 0:
print("Server: Player has no action points.")
_force_sync_to_client(cell, server_item)
return false
# Check adjacency
if grid_pos != player.current_position:
var neighbors = server_gridmap.get_neighbors(player.current_position, 0)
if not neighbors.any(func(n): return n.position == grid_pos):
print("Server: Player is not adjacent to item.")
_force_sync_to_client(cell, server_item)
return false
# NOTE: Adjacency check intentionally removed.
# The client validates adjacency locally before sending the RPC.
# With high-latency connections the server's player.current_position lags,
# causing false rejections that make tiles "disappear" from the floor without
# registering on the board. Item-existence + slot-availability checks are sufficient.
# 2. Server-side Auto-Arrange
# 2. Server-side slot resolution
var is_powerup = (item_id >= 11 and item_id <= 14)
var target_slot = -1
if not is_powerup:
target_slot = find_best_goal_slot_for_item(item_id)
# Honour the client's expected slot if it is still empty on the server.
# This ensures client and server agree on placement during rapid multi-grabs,
# so sync_playerboard_slot (slot-only update) is safe and unambiguous.
if expected_slot >= 0 and expected_slot < player.playerboard.size() \
and player.playerboard[expected_slot] == -1 \
and not (expected_slot in HIDDEN_SLOTS):
target_slot = expected_slot
else:
# Fallback: find the best available slot (e.g. client sent stale slot)
target_slot = find_best_goal_slot_for_item(item_id)
if target_slot == -1:
print("Server: Player has no valid slot for item.")
_force_sync_to_client(cell, server_item)
@@ -193,7 +189,7 @@ func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int):
var special_tiles_manager = player.get_node_or_null("SpecialTilesManager")
if special_tiles_manager:
special_tiles_manager.add_powerup_from_item(item_id)
# Do not add to playerboard
# Powerup consumed instantly — no board slot update needed
else:
player.playerboard[target_slot] = item_id
@@ -202,24 +198,21 @@ func _execute_grab(grid_pos: Vector2i, cell: Vector3i, item_id: int):
if sng_manager and sng_manager.has_method("update_mission_progress"):
sng_manager.update_mission_progress(player.name.to_int(), item_id)
# 3c. Broadcast the new playerboard state to all clients
# 3c. Broadcast ONLY THE CHANGED SLOT to all clients.
# Using sync_playerboard_slot instead of full sync_playerboard prevents
# a rapid second grab (still in-flight on client) from being overwritten.
var peer_id = player.name.to_int()
main.rpc("sync_playerboard", peer_id, player.playerboard)
if not is_powerup:
main.rpc("sync_playerboard_slot", peer_id, target_slot, item_id)
# 3d. Check if goal is completed (SERVER-SIDE - this triggers goal regeneration for clients!)
# Logic only runs if board changed, but theoretically powerup pickup shouldn't trigger goal
# 3d. Check if goal is completed (SERVER-SIDE)
if not is_powerup:
_check_goal_completion()
# 3e. Consume action points
player.has_performed_action = true
player.consume_action_points(1)
# 3f. Reset the UI for the player who acted
# Reset the UI for the player who acted
player.rpc("force_action_state_none")
# 4. Check if we need to respawn tiles (Scarcity Logic)
# "during no more tiles" -> Refill if floor is empty
_check_and_refill_grid_if_needed(server_gridmap)
return true
@@ -281,14 +274,10 @@ func _force_sync_to_client(cell: Vector3i, server_item: int):
# Sync their Playerboard (which they thought they updated)
main.rpc_id(peer_id, "sync_playerboard", peer_id, player.playerboard)
# Restore Action Points locally for the client (sync action points back)
# player is the caller peer since this is the server instance of the player's manager
player.rpc_id(peer_id, "sync_action_points", player.action_points)
print("Server: Forced sync to client %d due to action failure." % peer_id)
func bot_try_grab_item() -> bool:
if not enhanced_gridmap or player.action_points <= 0:
if not enhanced_gridmap:
return false
# First check current position
@@ -326,7 +315,6 @@ func bot_try_grab_item() -> bool:
main.rpc("sync_grid_item", current_cell.x, current_cell.y, current_cell.z, -1)
main.rpc("sync_playerboard", player.name.to_int(), player.playerboard)
player.has_performed_action = true
player.action_points -= 1
_check_goal_completion()
return true
@@ -357,7 +345,6 @@ func bot_try_grab_item() -> bool:
main.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1)
main.rpc("sync_playerboard", player.name.to_int(), player.playerboard)
player.has_performed_action = true
player.action_points -= 1
return true
return false
@@ -368,7 +355,7 @@ func bot_try_grab_item() -> bool:
func auto_put_item() -> bool:
# Check AP only if in turn-based mode
var has_ap = player.action_points > 0 if TurnManager.turn_based_mode else true
var has_ap = true # Action points removed; put always allowed
if not enhanced_gridmap or not has_ap or player.is_bot or player.is_in_group("Bots") or player.get("is_frozen"):
return false