extends Node # GoalsCycleManager - Handles 60-second goal cycles, scoring, and goal regeneration # Also handles global match timer that ends the game const CYCLE_DURATION: float = 30.0 const BASE_SCORE: int = 1000 const TIME_BONUS_MULTIPLIER: float = 0.0 # User implied flat 1000, setting bonus to 0 effectively or I can just ignore it in calc. # Cycle timer state (30-second cycles) var current_cycle_timer: float = 0.0 var is_cycle_active: bool = false # Global match timer state var global_match_timer: float = 0.0 var match_duration: float = 180.0 # Default 3 minutes var is_match_active: bool = false # Score tracking: peer_id -> score var player_scores: Dictionary = {} var player_goal_counts: Dictionary = {} # peer_id -> count var stop_n_go_winner_id: int = -1 # Track winner for Stop n Go sorting # Reference to main scene var main_scene: Node = null signal cycle_started() signal cycle_ended() signal timer_updated(time_remaining: float) signal score_updated(peer_id: int, new_score: int) signal goal_count_updated(peer_id: int, count: int) signal leaderboard_updated(sorted_scores: Array) # Global match signals signal match_started() signal match_ended() signal global_timer_updated(time_remaining: float) func _ready(): set_process(false) func initialize(main: Node): main_scene = main if LobbyManager: LobbyManager.enable_cycle_timer_changed.connect(_on_enable_cycle_timer_changed) func _on_enable_cycle_timer_changed(enabled: bool): # If disabled mid-cycle, the timer will just freeze in _process # If enabled mid-cycle, it will resume pass func _process(delta): # Update global match timer if active if is_match_active: global_match_timer -= delta if global_match_timer <= 0: global_match_timer = 0 _on_match_end() else: emit_signal("global_timer_updated", global_match_timer) # Server broadcasts global timer sync every second if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and int(global_match_timer) != int(global_match_timer + delta): rpc("sync_global_timer", global_match_timer) # Update cycle timer if cycle is active if not is_cycle_active: return # Skip countdown if timer is disabled var lobby_manager = get_tree().get_root().get_node_or_null("Main/LobbyManager") # Note: LobbyManager is an Autoload, so we can access it directly via 'LobbyManager' if LobbyManager and not LobbyManager.get_enable_cycle_timer(): # If timer is disabled, we just don't decrement. # But we still keep is_cycle_active = true so the phase is "active" (allowing actions) # just without the clock ticking down. return current_cycle_timer -= delta if current_cycle_timer <= 0: current_cycle_timer = 0 _on_cycle_end() else: emit_signal("timer_updated", current_cycle_timer) # Server broadcasts timer sync every second if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and int(current_cycle_timer) != int(current_cycle_timer + delta): rpc("sync_timer", current_cycle_timer) # ============================================================================= # Global Match Control # ============================================================================= func start_match(duration_seconds: float, start_cycles: bool = true): """Start the global match timer. Called by server when game starts.""" match_duration = duration_seconds global_match_timer = duration_seconds is_match_active = true set_process(true) emit_signal("match_started") if multiplayer.is_server(): rpc("sync_match_start", duration_seconds) # Only start the first cycle if requested if start_cycles: start_cycle() func _on_match_end(): """Called when global match timer reaches zero - game over!""" is_match_active = false is_cycle_active = false if multiplayer.is_server(): # FINAL SCORING: Process any points remaining on board _process_cycle_end_for_all_players() # Sync final scores THEN end match on clients rpc("sync_final_scores") else: emit_signal("match_ended") @rpc("authority", "call_local", "reliable") func sync_match_start(duration_seconds: float): match_duration = duration_seconds global_match_timer = duration_seconds is_match_active = true set_process(true) emit_signal("match_started") @rpc("authority", "call_local", "reliable") func sync_final_scores(): """Called by server at match end. Signals clients to stop.""" is_match_active = false is_cycle_active = false # Request final leaderboard refresh _update_leaderboard() emit_signal("match_ended") @rpc("authority", "call_local", "unreliable") func sync_global_timer(time_remaining: float): global_match_timer = time_remaining emit_signal("global_timer_updated", time_remaining) func get_global_time_remaining() -> float: return global_match_timer func is_match_running() -> bool: return is_match_active # ============================================================================= # Cycle Control # ============================================================================= func start_cycle(): current_cycle_timer = CYCLE_DURATION is_cycle_active = true set_process(true) emit_signal("cycle_started") if multiplayer.is_server(): # Initialize scores for all connected players _initialize_player_scores() rpc("sync_cycle_start") func _initialize_player_scores(): """Initialize scores for all connected players to 0.""" var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: # Use name.to_int() for ID because bots share authority 1 but have unique node names var peer_id = player.name.to_int() if not player_scores.has(peer_id): player_scores[peer_id] = 0 _update_leaderboard() @rpc("authority", "call_local", "reliable") func sync_cycle_start(): current_cycle_timer = CYCLE_DURATION is_cycle_active = true set_process(true) emit_signal("cycle_started") @rpc("authority", "call_local", "unreliable") func sync_timer(time_remaining: float): current_cycle_timer = time_remaining emit_signal("timer_updated", current_cycle_timer) func _on_cycle_end(): is_cycle_active = false emit_signal("cycle_ended") if multiplayer.is_server(): # Clear all playerboards and convert matches to score _process_cycle_end_for_all_players() rpc("sync_cycle_end") # Only start new cycle if match is still active if is_match_active: await get_tree().create_timer(2.0).timeout if is_match_active: # Check again in case match ended during delay start_cycle() @rpc("authority", "call_local", "reliable") func sync_cycle_end(): is_cycle_active = false emit_signal("cycle_ended") # ============================================================================= # Goal Completion & Scoring # ============================================================================= func on_goal_completed(player: Node, time_remaining: float): """Called when a player completes their goal pattern.""" # 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 if not multiplayer.is_server(): return # SERVER LOGIC _process_goal_completion(player, time_remaining) func _handle_local_goal_completion(player: Node, time_remaining: float): print("[GoalsCycle] Client: Goal completed — clearing board and requesting server sync.") # Clear playerboard locally for immediate visual feedback (empty board) player.playerboard.fill(-1) # 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() # 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 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() if sender_id == 0: sender_id = 1 var player_node = main_scene.get_node_or_null(str(sender_id)) if player_node: _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.""" # Use name.to_int() for ID because bots share authority 1 var peer_id = player.name.to_int() # Calculate score: base + time bonus var time_bonus = int(time_remaining * TIME_BONUS_MULTIPLIER) var score_earned = BASE_SCORE + time_bonus # Update player score if not player_scores.has(peer_id): player_scores[peer_id] = 0 player_scores[peer_id] += score_earned # Update goal count if not player_goal_counts.has(peer_id): player_goal_counts[peer_id] = 0 player_goal_counts[peer_id] += 1 emit_signal("score_updated", peer_id, player_scores[peer_id]) emit_signal("goal_count_updated", peer_id, player_goal_counts[peer_id]) _update_leaderboard() # Sync score to all clients rpc("sync_player_score", peer_id, player_scores[peer_id]) rpc("sync_goal_count", peer_id, player_goal_counts[peer_id]) # Clear playerboard tiles player.playerboard.fill(-1) if main_scene: main_scene.rpc("sync_playerboard", peer_id, player.playerboard) # Regenerate goals for this player (or use provided ones) if provided_goals.size() > 0: player.goals = provided_goals if main_scene: main_scene.rpc("sync_player_goals", peer_id, provided_goals) else: regenerate_goals_for_player(player) # Randomize tiles around player _randomize_tiles_around_player(player) # Only play RPC if not already handled locally by that player SfxManager.rpc("play_rpc", "complete_mission") print("[GoalsCycle] Player %d completed goal! +%d points (base: %d, time bonus: %d)" % [peer_id, score_earned, BASE_SCORE, time_bonus]) @rpc("authority", "call_local", "reliable") func sync_player_score(peer_id: int, total_score: int): player_scores[peer_id] = total_score emit_signal("score_updated", peer_id, total_score) _update_leaderboard() @rpc("authority", "call_local", "reliable") func sync_goal_count(peer_id: int, count: int): player_goal_counts[peer_id] = count emit_signal("goal_count_updated", peer_id, count) @rpc("any_peer", "call_local", "reliable") func request_add_score(amount: int): """RPC for clients to request score addition (trusted).""" if not multiplayer.is_server(): return var sender_id = multiplayer.get_remote_sender_id() # If called locally by server, sender_id might be 0 or 1. if sender_id == 0: sender_id = 1 add_score(sender_id, amount) func add_score(peer_id: int, amount: int): """Add points to a specific player (Server only).""" if not multiplayer.is_server(): return if not player_scores.has(peer_id): player_scores[peer_id] = 0 player_scores[peer_id] += amount # Sync if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED: rpc("sync_player_score", peer_id, player_scores[peer_id]) print("[GoalsCycle] Added %d points to Player %d. Total: %d" % [amount, peer_id, player_scores[peer_id]]) func _update_leaderboard(): # Sort players by score (descending) var sorted_scores = [] for peer_id in player_scores.keys(): sorted_scores.append({"peer_id": peer_id, "score": player_scores[peer_id]}) # Custom Sort for Stop n Go if stop_n_go_winner_id != -1: sorted_scores.sort_custom(func(a, b): if a.peer_id == stop_n_go_winner_id: return true if b.peer_id == stop_n_go_winner_id: return false return a.score > b.score ) else: sorted_scores.sort_custom(func(a, b): return a.score > b.score) emit_signal("leaderboard_updated", sorted_scores) # ============================================================================= # Cycle End Processing # ============================================================================= func _process_cycle_end_for_all_players(): """Server-side: Clear playerboards and convert matching tiles to score.""" var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: # Use name.to_int() for ID because bots share authority 1 var peer_id = player.name.to_int() var match_score = _calculate_match_score(player) if match_score > 0: if not player_scores.has(peer_id): player_scores[peer_id] = 0 player_scores[peer_id] += match_score rpc("sync_player_score", peer_id, player_scores[peer_id]) # Clear playerboard player.playerboard.fill(-1) # Use main scene's RPC which properly looks up player by ID if main_scene: main_scene.rpc("sync_playerboard", peer_id, player.playerboard) # Generate new goals regenerate_goals_for_player(player) _update_leaderboard() func _calculate_match_score(player: Node) -> int: """Calculate score from matching tiles in playerboard to goals.""" var matching_tiles = 0 var goals = player.goals var playerboard = player.playerboard # Check center 3x3 of playerboard against goals for i in range(3): for j in range(3): var goal_idx = i * 3 + j var board_idx = (i + 1) * 5 + (j + 1) # Center 3x3 in 5x5 board if goal_idx < goals.size() and board_idx < playerboard.size(): if goals[goal_idx] != -1 and playerboard[board_idx] == goals[goal_idx]: matching_tiles += 1 # 10 points per matching tile return matching_tiles * 10 # ============================================================================= # Goal Regeneration # ============================================================================= func regenerate_goals_for_player(player: Node): """Generate new random goals for a player.""" if not multiplayer.is_server(): return 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 # Use main scene's RPC which properly looks up player by ID on each client var peer_id = player.name.to_int() if main_scene: main_scene.rpc("sync_player_goals", peer_id, int_goals) # ============================================================================= # Tile Randomization # ============================================================================= func _randomize_tiles_around_player(player: Node): """Randomize 9 tiles in 3x3 area around player position.""" if not main_scene: return var enhanced_gridmap = main_scene.get_node_or_null("EnhancedGridMap") if not enhanced_gridmap: return var center = player.current_position var rng = RandomNumberGenerator.new() rng.randomize() # 3x3 area around player for dx in range(-1, 2): for dz in range(-1, 2): var pos = Vector2i(center.x + dx, center.y + dz) var cell = Vector3i(pos.x, 1, pos.y) # Check if position is valid if not enhanced_gridmap.is_position_valid(pos): continue # EXTRA CHECK: Do not spawn tiles on void (-1) or walls (4) on Floor 0 var floor_0_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if floor_0_item == -1 or floor_0_item == 4: continue # Check if there are tiles nearby or if empty var current_item = enhanced_gridmap.get_cell_item(cell) # IMMUTABLE CHECK: Do not randomize/delete walls or special items on Floor 1 if current_item in enhanced_gridmap.immutable_items: continue # Decide: delete, spawn, or randomize var action = rng.randi() % 3 match action: 0: # Delete tile if current_item != -1: main_scene.rpc("sync_grid_item", cell.x, cell.y, cell.z, -1) 1: # Spawn new tile if current_item == -1: var new_tile = rng.randi_range(7, 10) main_scene.rpc("sync_grid_item", cell.x, cell.y, cell.z, new_tile) 2: # Randomize existing if current_item != -1: var new_tile = rng.randi_range(7, 10) while new_tile == current_item: new_tile = rng.randi_range(7, 10) main_scene.rpc("sync_grid_item", cell.x, cell.y, cell.z, new_tile) # ============================================================================= # Score Getters # ============================================================================= func get_player_score(peer_id: int) -> int: return player_scores.get(peer_id, 0) func get_leaderboard() -> Array: var sorted_scores = [] for peer_id in player_scores.keys(): sorted_scores.append({"peer_id": peer_id, "score": player_scores[peer_id]}) # Custom Sort for Stop n Go if stop_n_go_winner_id != -1: sorted_scores.sort_custom(func(a, b): if a.peer_id == stop_n_go_winner_id: return true if b.peer_id == stop_n_go_winner_id: return false return a.score > b.score ) else: sorted_scores.sort_custom(func(a, b): return a.score > b.score) return sorted_scores func get_time_remaining() -> float: return current_cycle_timer func reset_scores(): player_scores.clear() _update_leaderboard()