Files
tekton/scripts/managers/goals_cycle_manager.gd
T

529 lines
17 KiB
GDScript

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."""
# 1. LOCAL OPTIMISTIC UPDATE (for smoothness)
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 continues...
_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.")
# Clear playerboard locally
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
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
SfxManager.play("complete_mission")
@rpc("any_peer")
func request_server_goal_completion(time_remaining: float, client_generated_goals: Array):
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)
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()