7380161743
Version bump to 2.3.6. New game mode features 20×20 arena with central cannon obstacle, three escalating phases (Open Arena, Route Pressure, Survival), and collectible tiles (Hearts, Diamonds, Stars, Coins) with pattern-matching missions. Players dodge candy volleys while completing collection goals. Updated export paths and version strings across all platforms (Windows, Android, Web, Linux).
2787 lines
100 KiB
GDScript
2787 lines
100 KiB
GDScript
# -------------------------------------------------------------------------------------
|
|
# Tekton Dash - Multiplayer Board Game - 2024
|
|
# -------------------------------------------------------------------------------------
|
|
|
|
extends Node3D
|
|
|
|
# Manager references
|
|
var ui_manager
|
|
var goals_cycle_manager
|
|
var screen_shake_manager
|
|
var touch_controls
|
|
var camera_context_manager
|
|
var stop_n_go_manager
|
|
var stop_n_go_winner_id: int = -1 # Track who finished first in Stop n Go mode
|
|
var portal_mode_winner_id: int = -1
|
|
var is_match_ended: bool = false
|
|
var obstacle_manager
|
|
var portal_mode_manager
|
|
var gauntlet_manager
|
|
var vfx_manager
|
|
|
|
# Minimal local state
|
|
var _connection_check_timer: float = 0.0
|
|
var reserved_static_positions: Array[Vector2i] = []
|
|
var _unstuck_cooldown_remaining: float = 0.0
|
|
const UNSTUCK_COOLDOWN = 120.0 # 2 minutes
|
|
|
|
func _can_rpc() -> bool:
|
|
if not is_inside_tree(): return false
|
|
if not multiplayer.has_multiplayer_peer(): return false
|
|
if multiplayer.multiplayer_peer.get_class() == "OfflineMultiplayerPeer": return false
|
|
if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false
|
|
return true
|
|
|
|
func _ready():
|
|
# Initialize scene managers
|
|
_init_managers()
|
|
|
|
# Connect to multiplayer signals
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
LobbyManager.host_disconnected.connect(_on_host_disconnected)
|
|
LobbyManager.game_starting.connect(_on_rematch_starting)
|
|
|
|
# Connect to Nakama signals
|
|
NakamaManager.match_joined.connect(_on_match_joined)
|
|
|
|
# Setup visual elevations for effects
|
|
_setup_effect_elevation()
|
|
|
|
# Start background music for the game mode
|
|
MusicManager.start_music()
|
|
|
|
# Setup UI
|
|
ui_manager.setup_playerboard_ui()
|
|
ui_manager.setup_timer_labels(self )
|
|
ui_manager.setup_playerboard_label(self ) # NEW
|
|
ui_manager.setup_leaderboard_ui(self )
|
|
ui_manager.setup_powerup_bar_ui(self )
|
|
# GlobalMatchTimer is now static in main.tscn - no setup needed
|
|
# NetworkPanel is visible during gameplay
|
|
|
|
# Auto-start game if coming from lobby (already connected to match)
|
|
# Works for both Nakama mode and LAN direct mode (ENet).
|
|
var is_lan_connected = LobbyManager.is_lan_mode and multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
|
if (NakamaManager.is_connected_to_nakama() or is_lan_connected) and multiplayer.get_unique_id() != 0:
|
|
print("Coming from lobby - auto-starting game...")
|
|
await get_tree().process_frame
|
|
_auto_start_from_lobby()
|
|
|
|
# Hide MessageBar (User Request)
|
|
if message_bar:
|
|
message_bar.visible = false
|
|
|
|
|
|
# Ensure grid is randomized with Scarcity if server
|
|
if multiplayer.is_server():
|
|
randomize_game_grid()
|
|
|
|
# Setup global multiplayer spawners (Stands, etc.)
|
|
_setup_multiplayer_spawners()
|
|
|
|
# Apply Arena Background
|
|
_apply_arena_background()
|
|
|
|
# HUD Settings connection is now handled internally by TouchControlsManager
|
|
# which calls _toggle_pause_menu() on this scene.
|
|
|
|
# Programmatically connect Pause Menu Settings button to ensure it works
|
|
var pause_settings = get_node_or_null("PauseMenu/Panel/VBox/SettingsBtn")
|
|
if pause_settings:
|
|
if pause_settings.pressed.is_connected(_on_settings_pressed):
|
|
pause_settings.pressed.disconnect(_on_settings_pressed)
|
|
pause_settings.pressed.connect(_on_settings_pressed)
|
|
|
|
# Connect the new top-level Help and Settings buttons
|
|
var top_help_btn = get_node_or_null("TopMenuUI/HelpBtn")
|
|
if top_help_btn:
|
|
if not top_help_btn.pressed.is_connected(_on_how_to_play_pressed):
|
|
top_help_btn.pressed.connect(_on_how_to_play_pressed)
|
|
|
|
# SettingsBtn opens the PauseMenu (acts as a pause/menu toggle)
|
|
var top_settings_btn = get_node_or_null("TopMenuUI/SettingsBtn")
|
|
if top_settings_btn:
|
|
if not top_settings_btn.pressed.is_connected(_toggle_pause_menu):
|
|
top_settings_btn.pressed.connect(_toggle_pause_menu)
|
|
|
|
# Tutorial Override
|
|
if LobbyManager.get("is_tutorial_mode"):
|
|
var tutorial_script = load("res://scripts/managers/tutorial_manager.gd")
|
|
if tutorial_script:
|
|
var tutorial_node = Node.new()
|
|
tutorial_node.set_script(tutorial_script)
|
|
tutorial_node.name = "TutorialManager"
|
|
add_child(tutorial_node)
|
|
|
|
func _setup_multiplayer_spawners():
|
|
# Setup MultiplayerSpawner for Static Tekton Stands
|
|
# Create a container node for strict pathing
|
|
if not has_node("Stands"):
|
|
var stands_container = Node3D.new()
|
|
stands_container.name = "Stands"
|
|
add_child(stands_container)
|
|
|
|
if not has_node("StandSpawner"):
|
|
var stand_spawner = MultiplayerSpawner.new()
|
|
stand_spawner.name = "StandSpawner"
|
|
stand_spawner.spawn_path = NodePath("../Stands") # Relative to Spawner, finding sibling
|
|
stand_spawner.add_spawnable_scene("res://scenes/static_tekton_stand.tscn")
|
|
add_child(stand_spawner)
|
|
|
|
func _apply_arena_background():
|
|
var arena_bg = get_node_or_null("ArenaBG")
|
|
|
|
var selected_area = LobbyManager.selected_area
|
|
var texture_path = ""
|
|
|
|
match selected_area:
|
|
"Colloseum":
|
|
texture_path = "res://assets/graphics/level_bg/level_bg_colloseum.jpg"
|
|
"Stop N Go Arena":
|
|
texture_path = "res://assets/graphics/level_bg/placeholder_stop_n_go.jpg"
|
|
_instantiate_3d_arena("res://scenes/arena/stop_n_go.scn")
|
|
_hide_ground_tiles()
|
|
"Freemode Arena":
|
|
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg" # Fallback texture
|
|
_instantiate_3d_arena("res://scenes/arena/freemode.tscn")
|
|
_hide_ground_tiles()
|
|
"Tekton Doors Arena":
|
|
texture_path = "res://assets/graphics/level_bg/placeholder_tekton_doors.jpg"
|
|
"Gauntlet Arena":
|
|
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
|
|
_hide_ground_tiles()
|
|
"Classic", _:
|
|
texture_path = "res://assets/graphics/level_bg/placeholder_classic.jpg"
|
|
|
|
if arena_bg and texture_path != "":
|
|
if ResourceLoader.exists(texture_path):
|
|
var tex = load(texture_path)
|
|
if tex:
|
|
arena_bg.texture = tex
|
|
else:
|
|
print("Arena bg texture not found: ", texture_path)
|
|
|
|
func _instantiate_3d_arena(scene_path: String):
|
|
if ResourceLoader.exists(scene_path):
|
|
var arena_scene = load(scene_path)
|
|
if arena_scene:
|
|
var arena_instance = arena_scene.instantiate()
|
|
arena_instance.name = "ArenaEnvironment3D"
|
|
add_child(arena_instance)
|
|
move_child(arena_instance, 0)
|
|
print("Instantiated 3D Arena: ", scene_path)
|
|
|
|
func _hide_ground_tiles():
|
|
# Make normal and auxiliary ground floors invisible
|
|
# by shrinking their scale to 0. We EXCLUDE Item 4 (Wall) and 5 (Freeze)
|
|
# so they can still be seen above the 3D arena.
|
|
var em = $EnhancedGridMap
|
|
if em and em.mesh_library:
|
|
var ml = em.mesh_library.duplicate()
|
|
for id in [0, 6]:
|
|
# Scale to 0 to hide it without triggering invalid mesh errors
|
|
ml.set_item_mesh_transform(id, Transform3D().scaled(Vector3.ZERO))
|
|
|
|
em.mesh_library = ml
|
|
print("[Main] Hide tiles 0, 6 via zero-scale transform.")
|
|
|
|
func _setup_effect_elevation():
|
|
var em = get_node_or_null("EnhancedGridMap")
|
|
if em and em.mesh_library:
|
|
# USER REQUEST: Do not apply visual Y-elevation for walls in Stop n Go mode
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
print("[Main] Stop n Go mode detected: Skipping effect elevation for walls.")
|
|
return
|
|
|
|
var ml = em.mesh_library.duplicate()
|
|
|
|
# Height 0.8: Above 3D arena, but below pickups (Y=1.0)
|
|
var lift_transform = Transform3D().translated(Vector3(0, 0.28, 0))
|
|
|
|
# Lift Wall (4) and Freeze (5)
|
|
ml.set_item_mesh_transform(4, lift_transform)
|
|
ml.set_item_mesh_transform(5, lift_transform)
|
|
|
|
em.mesh_library = ml
|
|
print("[Main] MeshLibrary elevation applied: Wall(4) and Freeze(5) at Y=0.8")
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func sync_portal_configs(configs: Array):
|
|
if portal_mode_manager:
|
|
# Temporarily store the configs and trigger spawn
|
|
# Note: We use a custom property in manager to pass this
|
|
portal_mode_manager.set_meta("door_configs", configs)
|
|
portal_mode_manager._spawn_portal_doors()
|
|
|
|
# Force gridmap cell size to match player logic (1, 0.05, 1) - >0.001 to avoid errors
|
|
var em = $EnhancedGridMap
|
|
if em:
|
|
em.cell_size = Vector3(1, 0.05, 1)
|
|
|
|
func _on_goal_count_updated(peer_id: int, count: int):
|
|
# Only update for local player
|
|
if peer_id == multiplayer.get_unique_id():
|
|
ui_manager.update_goal_count_label(count)
|
|
|
|
func _init_managers():
|
|
# Create and attach scene managers
|
|
ui_manager = load("res://scripts/managers/ui_manager.gd").new()
|
|
ui_manager.name = "UIManager"
|
|
add_child(ui_manager)
|
|
ui_manager.initialize(self )
|
|
|
|
# Goals cycle manager for 60-second timer and scoring
|
|
goals_cycle_manager = load("res://scripts/managers/goals_cycle_manager.gd").new()
|
|
goals_cycle_manager.name = "GoalsCycleManager"
|
|
add_child(goals_cycle_manager)
|
|
goals_cycle_manager.initialize(self )
|
|
|
|
# Stop n Go manager for phase-based gameplay
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new()
|
|
stop_n_go_manager.name = "StopNGoManager"
|
|
add_child(stop_n_go_manager)
|
|
# No direct initialize() yet, but we'll call start_game_mode later
|
|
|
|
# Portal manager for Tekton Doors mode
|
|
if LobbyManager.game_mode == "Tekton Doors":
|
|
portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new()
|
|
portal_mode_manager.name = "PortalModeManager"
|
|
add_child(portal_mode_manager)
|
|
portal_mode_manager.initialize(self , $EnhancedGridMap)
|
|
|
|
# Gauntlet manager for Candy Cannon Survival mode
|
|
if LobbyManager.game_mode == "Candy Cannon Survival":
|
|
gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new()
|
|
gauntlet_manager.name = "GauntletManager"
|
|
add_child(gauntlet_manager)
|
|
gauntlet_manager.initialize(self, $EnhancedGridMap)
|
|
|
|
# Screen shake manager for impact feedback
|
|
screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new()
|
|
screen_shake_manager.name = "ScreenShakeManager"
|
|
add_child(screen_shake_manager)
|
|
screen_shake_manager.initialize($Camera3D200)
|
|
|
|
# Touch controls for mobile
|
|
touch_controls = get_node_or_null("TouchLayer/TouchControls")
|
|
if not touch_controls:
|
|
touch_controls = get_node_or_null("TouchControls") # fallback
|
|
if not touch_controls:
|
|
print("TouchControls node not found in scene, creating instance...")
|
|
touch_controls = load("res://scripts/managers/touch_controls.gd").new()
|
|
touch_controls.name = "TouchControls"
|
|
add_child(touch_controls)
|
|
|
|
touch_controls.initialize(self )
|
|
|
|
# NEW: Camera Context Manager for dynamic camera position
|
|
camera_context_manager = load("res://scripts/managers/camera_context_manager.gd").new()
|
|
camera_context_manager.name = "CameraContextManager"
|
|
add_child(camera_context_manager)
|
|
camera_context_manager.initialize($Camera3D200, screen_shake_manager)
|
|
|
|
# Obstacle manager for dynamic walls
|
|
obstacle_manager = load("res://scripts/managers/obstacle_manager.gd").new()
|
|
obstacle_manager.name = "ObstacleManager"
|
|
add_child(obstacle_manager)
|
|
obstacle_manager.initialize(self , $EnhancedGridMap)
|
|
|
|
# NEW: VFX Animation Manager for Ready-Go, Stop-Phase, etc.
|
|
vfx_manager = load("res://scenes/animation.tscn").instantiate()
|
|
vfx_manager.name = "VFXManager"
|
|
add_child(vfx_manager)
|
|
|
|
# Connect signals for UI updates
|
|
goals_cycle_manager.timer_updated.connect(_on_timer_updated)
|
|
goals_cycle_manager.score_updated.connect(_on_score_updated)
|
|
goals_cycle_manager.goal_count_updated.connect(_on_goal_count_updated) # NEW
|
|
goals_cycle_manager.leaderboard_updated.connect(_on_leaderboard_updated)
|
|
goals_cycle_manager.global_timer_updated.connect(_on_global_timer_updated)
|
|
goals_cycle_manager.match_ended.connect(_on_match_ended)
|
|
|
|
# Message Bar Configuration
|
|
const MAX_MESSAGES := 5
|
|
const MESSAGE_DURATION := 4.0
|
|
|
|
@onready var message_bar: PanelContainer = $MessageBar
|
|
@onready var message_container: VBoxContainer = $MessageBar/MarginContainer/MessageContainer
|
|
|
|
var last_messages = {} # {player_name: {text: String, time: int}}
|
|
|
|
# Message types for different styling
|
|
enum MessageType {NORMAL, POWERUP, GOAL, CYCLE, WARNING}
|
|
|
|
func add_message_to_bar(player_name: String, message: String, type: int = MessageType.NORMAL):
|
|
# Deduplication check
|
|
var current_time = Time.get_ticks_msec()
|
|
if player_name in last_messages:
|
|
var last = last_messages[player_name]
|
|
# Ignore if same message within 2 seconds
|
|
if last.text == message and current_time - last.time < 2000:
|
|
return
|
|
|
|
last_messages[player_name] = {"text": message, "time": current_time}
|
|
|
|
if not message_container:
|
|
return
|
|
|
|
# Create message label with rich styling
|
|
var label = Label.new()
|
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
#label.custom_minimum_size = Vector2(400, 0) # Width constraint for wrapping
|
|
label.add_theme_font_size_override("font_size", 16)
|
|
label.modulate.a = 0.0 # Start invisible for fade-in
|
|
|
|
# Style based on message type
|
|
var icon = ""
|
|
var color = Color.WHITE
|
|
match type:
|
|
MessageType.POWERUP:
|
|
icon = "âš¡ "
|
|
color = Color(0.4, 1.0, 0.4) # Bright green
|
|
MessageType.GOAL:
|
|
icon = "🎯 "
|
|
color = Color(1.0, 0.85, 0.2) # Gold
|
|
MessageType.CYCLE:
|
|
icon = "â±ï¸ "
|
|
color = Color(0.4, 0.8, 1.0) # Light blue
|
|
MessageType.WARNING:
|
|
icon = "âš ï¸ "
|
|
color = Color(1.0, 0.5, 0.3) # Orange
|
|
_:
|
|
icon = "💬 "
|
|
color = Color(0.9, 0.9, 0.9) # Light gray
|
|
|
|
# Include player name in message if provided
|
|
if player_name and player_name != "":
|
|
label.text = "%s[%s] %s" % [icon, player_name, message]
|
|
else:
|
|
label.text = "%s%s" % [icon, message]
|
|
label.add_theme_color_override("font_color", color)
|
|
|
|
# Add shadow for better visibility
|
|
label.add_theme_constant_override("shadow_offset_x", 2)
|
|
label.add_theme_constant_override("shadow_offset_y", 2)
|
|
label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
|
|
|
|
# Add to container
|
|
message_container.add_child(label)
|
|
|
|
# Show the message bar with fade
|
|
if not message_bar.visible:
|
|
message_bar.visible = true
|
|
message_bar.modulate.a = 0.0
|
|
var bar_tween = create_tween()
|
|
bar_tween.tween_property(message_bar, "modulate:a", 1.0, 0.2)
|
|
|
|
# Animate label entrance (slide in + fade)
|
|
label.position.x = -50
|
|
var entrance_tween = create_tween()
|
|
entrance_tween.set_parallel(true)
|
|
entrance_tween.tween_property(label, "modulate:a", 1.0, 0.3)
|
|
entrance_tween.tween_property(label, "position:x", 0.0, 0.3).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
|
|
|
# Powerup gets extra pulse effect
|
|
if type == MessageType.POWERUP:
|
|
await entrance_tween.finished
|
|
if is_instance_valid(label):
|
|
var pulse_tween = create_tween()
|
|
pulse_tween.set_loops(2)
|
|
pulse_tween.tween_property(label, "scale", Vector2(1.1, 1.1), 0.15).set_trans(Tween.TRANS_SINE)
|
|
pulse_tween.tween_property(label, "scale", Vector2(1.0, 1.0), 0.15).set_trans(Tween.TRANS_SINE)
|
|
|
|
# Remove oldest messages if over limit
|
|
while message_container.get_child_count() > MAX_MESSAGES:
|
|
var oldest = message_container.get_child(0)
|
|
message_container.remove_child(oldest)
|
|
oldest.queue_free()
|
|
|
|
# Auto-remove after duration with fade-out
|
|
await get_tree().create_timer(MESSAGE_DURATION).timeout
|
|
if is_instance_valid(label):
|
|
var exit_tween = create_tween()
|
|
exit_tween.set_parallel(true)
|
|
exit_tween.tween_property(label, "modulate:a", 0.0, 0.3)
|
|
exit_tween.tween_property(label, "position:x", 50.0, 0.3)
|
|
await exit_tween.finished
|
|
if is_instance_valid(label):
|
|
label.queue_free()
|
|
|
|
# Hide bar when empty with fade
|
|
await get_tree().process_frame
|
|
if message_container.get_child_count() == 0:
|
|
var hide_tween = create_tween()
|
|
hide_tween.tween_property(message_bar, "modulate:a", 0.0, 0.3)
|
|
await hide_tween.finished
|
|
message_bar.visible = false
|
|
|
|
@rpc("any_peer", "call_local")
|
|
func broadcast_message(player_name: String, message: String, type: int = MessageType.NORMAL):
|
|
add_message_to_bar(player_name, message, type)
|
|
|
|
func _start_pre_game_countdown():
|
|
"""Trigger the premium Ready-Go animation on all clients."""
|
|
if can_rpc():
|
|
rpc("sync_ready_go")
|
|
|
|
# Delay game start until the 4.0s animation finishes
|
|
await get_tree().create_timer(4.0).timeout
|
|
|
|
@rpc("call_local", "reliable")
|
|
func sync_ready_go():
|
|
if vfx_manager and vfx_manager.has_method("play_ready_go"):
|
|
vfx_manager.play_ready_go()
|
|
|
|
@rpc("call_local", "reliable")
|
|
func sync_countdown(text: String):
|
|
# Use a CanvasLayer to ensure the countdown is on top of everything
|
|
var countdown_layer = get_node_or_null("CountdownLayerUI")
|
|
if not countdown_layer:
|
|
countdown_layer = CanvasLayer.new()
|
|
countdown_layer.name = "CountdownLayerUI"
|
|
countdown_layer.layer = 100 # Very high priority
|
|
add_child(countdown_layer)
|
|
|
|
var label = countdown_layer.get_node_or_null("CountdownLabel")
|
|
if not label and text != "":
|
|
label = Label.new()
|
|
label.name = "CountdownLabel"
|
|
countdown_layer.add_child(label)
|
|
|
|
# Center and Style
|
|
label.anchors_preset = Control.PRESET_CENTER
|
|
label.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
|
label.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
|
label.grow_vertical = Control.GROW_DIRECTION_BOTH
|
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
|
|
label.add_theme_font_size_override("font_size", 140)
|
|
label.add_theme_color_override("font_outline_color", Color.BLACK)
|
|
label.add_theme_constant_override("outline_size", 20)
|
|
label.add_theme_color_override("font_color", Color.YELLOW)
|
|
|
|
# Use Nougat font if available
|
|
var nougat = load("res://assets/fonts/Nougat-ExtraBlack.ttf")
|
|
if nougat:
|
|
label.add_theme_font_override("font", nougat)
|
|
|
|
if label:
|
|
label.text = text
|
|
if text == "":
|
|
countdown_layer.queue_free()
|
|
elif text == "GO!":
|
|
label.add_theme_color_override("font_color", Color.GREEN)
|
|
|
|
func _setup_global_match_timer_ui():
|
|
"""Create the global match timer display at the top of the screen."""
|
|
# Check if timer check is enabled in lobby settings OR if in Stop n Go mode
|
|
if not LobbyManager.enable_cycle_timer and LobbyManager.game_mode != "Stop n Go":
|
|
var existing = get_node_or_null("GlobalMatchTimer")
|
|
if existing:
|
|
existing.visible = false
|
|
return
|
|
|
|
var existing = get_node_or_null("GlobalMatchTimer")
|
|
if existing:
|
|
existing.visible = true
|
|
return
|
|
|
|
# Create timer panel
|
|
var panel = PanelContainer.new()
|
|
panel.name = "GlobalMatchTimer"
|
|
|
|
# Position at top center
|
|
panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
|
panel.offset_left = -80
|
|
panel.offset_right = 80
|
|
panel.offset_top = 10
|
|
panel.offset_bottom = 60
|
|
|
|
# Style
|
|
var style = StyleBoxFlat.new()
|
|
style.bg_color = Color(0.1, 0.1, 0.15, 0.9)
|
|
style.border_width_left = 2
|
|
style.border_width_top = 2
|
|
style.border_width_right = 2
|
|
style.border_width_bottom = 2
|
|
style.border_color = Color(0.647, 0.996, 0.224, 0.8)
|
|
style.corner_radius_top_left = 8
|
|
style.corner_radius_top_right = 8
|
|
style.corner_radius_bottom_right = 8
|
|
style.corner_radius_bottom_left = 8
|
|
panel.add_theme_stylebox_override("panel", style)
|
|
|
|
# VBox for content
|
|
var vbox = VBoxContainer.new()
|
|
vbox.name = "VBox"
|
|
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
panel.add_child(vbox)
|
|
|
|
# Label
|
|
var label = Label.new()
|
|
label.name = "TimerLabel"
|
|
label.text = "03:00"
|
|
label.add_theme_font_size_override("font_size", 28)
|
|
label.add_theme_color_override("font_color", Color(0.647, 0.996, 0.224))
|
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox.add_child(label)
|
|
|
|
add_child(panel)
|
|
|
|
func _process(delta):
|
|
if not is_inside_tree(): return
|
|
if not check_multiplayer(): return
|
|
|
|
# Tick down unstuck cooldown and update button label
|
|
if _unstuck_cooldown_remaining > 0.0:
|
|
_unstuck_cooldown_remaining -= delta
|
|
var unstuck_btn = get_node_or_null("PauseMenu/Panel/VBox/UnstuckBtn")
|
|
if unstuck_btn:
|
|
if _unstuck_cooldown_remaining > 0.0:
|
|
unstuck_btn.text = "Unstuck (%ds)" % ceil(_unstuck_cooldown_remaining)
|
|
unstuck_btn.disabled = true
|
|
else:
|
|
_unstuck_cooldown_remaining = 0.0
|
|
unstuck_btn.text = "Unstuck"
|
|
unstuck_btn.disabled = false
|
|
|
|
if ui_manager and get_tree():
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
if all_players.size() > 0:
|
|
ui_manager.update_live_leaderboard(all_players)
|
|
|
|
if multiplayer.is_server() and GameStateManager.is_game_started():
|
|
if TurnManager.turn_based_mode:
|
|
rpc("sync_turn_index", TurnManager.current_turn_index)
|
|
|
|
_connection_check_timer += delta
|
|
if _connection_check_timer >= 5.0:
|
|
_connection_check_timer = 0.0
|
|
verify_all_connections()
|
|
|
|
# =============================================================================
|
|
# Network Callbacks
|
|
# =============================================================================
|
|
|
|
func _on_match_joined(match_id: String):
|
|
var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel")
|
|
if network_panel:
|
|
network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id())
|
|
if multiplayer.is_server():
|
|
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Server (Match: %s)" % match_id
|
|
_setup_host_game()
|
|
else:
|
|
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client"
|
|
_setup_client_game()
|
|
else:
|
|
if multiplayer.is_server():
|
|
_setup_host_game()
|
|
else:
|
|
_setup_client_game()
|
|
|
|
# =============================================================================
|
|
# Game Setup
|
|
# =============================================================================
|
|
|
|
func _setup_host_game():
|
|
# Generate goals
|
|
GoalManager.generate_preset_goals(GameStateManager.max_players)
|
|
|
|
# Add host player
|
|
var player_id = 1
|
|
var player_character = PlayerManager.add_player_character(player_id)
|
|
add_child(player_character)
|
|
player_character.add_to_group("Players", true)
|
|
GameStateManager.add_player(player_id)
|
|
GameStateManager.local_player_character = player_character
|
|
ui_manager.set_local_player(player_character)
|
|
if touch_controls:
|
|
touch_controls.set_player(player_character)
|
|
if camera_context_manager:
|
|
camera_context_manager.set_player(player_character)
|
|
|
|
# Set host name
|
|
player_character.display_name = LobbyManager.local_player_name
|
|
|
|
# Spawn client players that joined via lobby (need to add them first)
|
|
var lobby_players = LobbyManager.get_players()
|
|
for lobby_player in lobby_players:
|
|
var peer_id = lobby_player.get("id", 0)
|
|
if peer_id != 1 and peer_id != 0: # Skip host (1) and invalid (0)
|
|
print("Spawning lobby player: ", peer_id)
|
|
_spawn_lobby_client_sync(peer_id)
|
|
|
|
# Spawning and arena setup
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
|
|
stop_n_go_manager._setup_arena()
|
|
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
|
portal_mode_manager.setup_arena_locally()
|
|
elif LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
|
gauntlet_manager._setup_arena()
|
|
else:
|
|
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation
|
|
randomize_game_grid()
|
|
|
|
# 1. PVT: Pre-calculate Static Tekton positions AFTER arena size is known
|
|
_precalculate_static_positions()
|
|
|
|
# Wait for players to be fully ready (player.gd has 0.1s await in _ready before managers init)
|
|
# Faster for LAN mode
|
|
var setup_delay = 0.1 if LobbyManager.is_lan_mode else 0.3
|
|
await get_tree().create_timer(setup_delay).timeout
|
|
|
|
# Set host goals - get goals directly from GoalManager
|
|
var host_goals = GoalManager.get_goals_for_player(0)
|
|
player_character.goals = host_goals
|
|
rpc("sync_player_goals", player_id, host_goals)
|
|
|
|
rpc("sync_preset_goals", GoalManager.preset_goals)
|
|
|
|
# Update the goals UI immediately for the host
|
|
ui_manager.update_playerboard_ui()
|
|
|
|
# Set goals for lobby client players
|
|
var player_index = 1
|
|
for lobby_player in lobby_players:
|
|
var peer_id = lobby_player.get("id", 0)
|
|
if peer_id != 1 and peer_id != 0:
|
|
var client_player = get_node_or_null(str(peer_id))
|
|
if client_player and player_index < GoalManager.preset_goals.size():
|
|
var client_goals = GoalManager.preset_goals[player_index].duplicate()
|
|
client_player.goals = client_goals
|
|
call_deferred("_deferred_set_player_goals", peer_id, client_goals)
|
|
player_index += 1
|
|
|
|
# Add bots to fill remaining slots (regardless of player count)
|
|
if GameStateManager.enable_bots:
|
|
var current_players = lobby_players.size()
|
|
for i in range(current_players + 1, GameStateManager.max_players + 1):
|
|
_add_bot(i)
|
|
|
|
# Ensure Bots are in the tree before assigning positions
|
|
await get_tree().process_frame
|
|
|
|
_start_game()
|
|
|
|
func _spawn_lobby_client_sync(peer_id: int):
|
|
"""Spawn a client player synchronously (no await)."""
|
|
if has_node(str(peer_id)):
|
|
return
|
|
|
|
var player_character = PlayerManager.add_player_character(peer_id)
|
|
add_child(player_character)
|
|
player_character.add_to_group("Players", true)
|
|
GameStateManager.add_player(peer_id)
|
|
|
|
# Set name from LobbyManager data if available
|
|
var lobby_players = LobbyManager.get_players()
|
|
for p_data in lobby_players:
|
|
if p_data.get("id") == peer_id:
|
|
player_character.display_name = p_data.get("name", "Player")
|
|
break
|
|
|
|
# Tell all clients to create this player
|
|
rpc("add_newly_connected_player_character", peer_id)
|
|
|
|
# Goals will be assigned after players are ready in _setup_host_game
|
|
|
|
func _setup_client_game():
|
|
"""Setup client when transitioning from lobby."""
|
|
var my_id = multiplayer.get_unique_id()
|
|
print("Client setup - my peer ID: ", my_id)
|
|
|
|
# INITIALIZE ARENA SIZE for Stop n Go locally to prevent out-of-bounds before sync arrives
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
if not stop_n_go_manager:
|
|
stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new()
|
|
stop_n_go_manager.name = "StopNGoManager"
|
|
add_child(stop_n_go_manager)
|
|
stop_n_go_manager._apply_arena_setup()
|
|
|
|
# Pre-spawn ALL players known from LobbyManager (including Host ID 1)
|
|
# This ensures nodes exist to receive RPCs (like 'set_spawn_position') that might arrive before full sync
|
|
var lobby_players = LobbyManager.get_players()
|
|
for player_data in lobby_players:
|
|
var p_id = player_data.get("id", 0)
|
|
if p_id != 0:
|
|
add_player_character(p_id)
|
|
print("Client: Pre-spawned player ", p_id)
|
|
|
|
# Pre-spawn potential bots (IDs from count+1 to MaxPlayers) to prevent RPC "Node not found" errors
|
|
# Bots use small integer IDs (e.g. 2, 3, 4...) while clients use large unique IDs
|
|
if GameStateManager.enable_bots:
|
|
# Server spawns bots starting after the last human player index
|
|
# So if we have 2 humans, bots start at ID 3.
|
|
var start_bot_id = lobby_players.size() + 1
|
|
for i in range(start_bot_id, GameStateManager.max_players + 1):
|
|
# Only spawn if not already existing (e.g. if a human somehow got this ID, though unlikely)
|
|
if not has_node(str(i)):
|
|
# Spawning as BOT
|
|
add_player_character(i, true)
|
|
print("Client: Pre-spawned potential bot ", i)
|
|
|
|
# Initialize arena locally for Tekton Doors
|
|
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
|
portal_mode_manager.setup_arena_locally()
|
|
|
|
# Initialize arena locally for Candy Cannon Survival
|
|
if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
|
gauntlet_manager._apply_arena_setup()
|
|
|
|
# Ensure local player setup (UI, controls) is verified
|
|
var player_character = get_node_or_null(str(my_id))
|
|
if player_character:
|
|
# If we just spawned it above, we need to set these locally too
|
|
if GameStateManager.local_player_character != player_character:
|
|
GameStateManager.local_player_character = player_character
|
|
ui_manager.set_local_player(player_character)
|
|
if touch_controls:
|
|
touch_controls.set_player(player_character)
|
|
if camera_context_manager:
|
|
camera_context_manager.set_player(player_character)
|
|
print("Client: Configured local player ", my_id)
|
|
|
|
# ALWAYS setup PowerUpUI when we have the local player, just in case
|
|
var powerup_ui = get_node_or_null("TouchLayer/TouchControls/PowerUpInventoryUI")
|
|
if powerup_ui:
|
|
powerup_ui.setup(player_character)
|
|
print("Client: PowerUpInventoryUI setup forced for ", my_id)
|
|
|
|
# Wait for host to be ready, then request full sync
|
|
# Snappier for LAN mode as peer is already established
|
|
var client_setup_delay = 0.2 if LobbyManager.is_lan_mode else 1.0
|
|
await get_tree().create_timer(client_setup_delay).timeout
|
|
|
|
if check_multiplayer():
|
|
# Ensure we see the server (Peer 1)
|
|
if 1 in multiplayer.get_peers():
|
|
rpc_id(1, "request_full_player_sync", my_id)
|
|
rpc_id(1, "request_full_grid_sync")
|
|
else:
|
|
print("Client: Connected but Peer 1 not found yet. Retrying in 1s...")
|
|
await get_tree().create_timer(0.5).timeout
|
|
if check_multiplayer() and 1 in multiplayer.get_peers():
|
|
rpc_id(1, "request_full_player_sync", my_id)
|
|
rpc_id(1, "request_full_grid_sync")
|
|
|
|
|
|
func _auto_start_from_lobby():
|
|
"""Called when main.tscn is loaded from lobby - game is already connected."""
|
|
# Get match ID from LobbyManager
|
|
var match_id = LobbyManager.current_room.get("match_id", "")
|
|
var short_id = match_id.substr(0, 8) if match_id.length() > 8 else match_id
|
|
|
|
# Update NetworkPanel in PauseMenu (if exists)
|
|
var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel")
|
|
if network_panel:
|
|
network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id())
|
|
if multiplayer.is_server():
|
|
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Host (Match: %s)" % short_id
|
|
else:
|
|
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client (Match: %s)" % short_id
|
|
|
|
if multiplayer.is_server():
|
|
print("Auto-starting as HOST - Match: ", short_id)
|
|
_setup_host_game()
|
|
else:
|
|
print("Auto-starting as CLIENT - Match: ", short_id)
|
|
_setup_client_game()
|
|
|
|
func _start_game():
|
|
if multiplayer.is_server():
|
|
# Delay spawn assignment to allow clients to finish instantiating Player nodes
|
|
# via MultiplayerSpawner. If called too early, the RPC is dropped and clients
|
|
# are left misplaced at the default starting position.
|
|
await get_tree().create_timer(1.0).timeout
|
|
|
|
# NOW assign spawn positions for EVERYONE (Host, Client, Bots)
|
|
_assign_random_spawn_positions()
|
|
|
|
# Wait for Nakama websocket to actually be open, up to 5 seconds
|
|
# SKIP THIS FOR LAN MODE
|
|
if not LobbyManager.is_lan_mode:
|
|
var nakama = get_node_or_null("/root/NakamaManager")
|
|
if nakama and nakama.has_method("is_connected_to_nakama"):
|
|
var wait_time = 0.0
|
|
while not nakama.is_connected_to_nakama() and wait_time < 5.0:
|
|
await get_tree().create_timer(0.2).timeout
|
|
wait_time += 0.2
|
|
|
|
# Stabilization delay to allow clients to finish loading and spawning
|
|
# We wait 0.5s to ensure the remainder of the 1.2s loading screen buffer has finished
|
|
# before the countdown starts.
|
|
await get_tree().create_timer(0.5).timeout
|
|
|
|
# PRE-GAME COUNTDOWN (3s)
|
|
# Spawn static obstacles before countdown starts (Stop n Go only)
|
|
if obstacle_manager and LobbyManager.game_mode == "Stop n Go":
|
|
# obstacle_manager.spawn_random_obstacles(15) # Disabled: Using fixed obstacles now
|
|
pass
|
|
|
|
# Spawn mission and power-up tiles BEFORE countdown but AFTER walls (Stop n Go only)
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
|
|
stop_n_go_manager.setup_mission_tiles()
|
|
stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go
|
|
|
|
# Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown
|
|
if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager:
|
|
gauntlet_manager.setup_mission_tiles()
|
|
|
|
# Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only)
|
|
# Exclude for Stop n Go and Tekton Doors
|
|
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival":
|
|
spawn_static_tektons()
|
|
|
|
# Tekton Doors: Randomize connections BEFORE countdown so colors show
|
|
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
|
portal_mode_manager._randomize_connections()
|
|
|
|
# STOP N GO: Rotate players to face East BEFORE countdown
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
|
|
stop_n_go_manager.rotate_players_to_start()
|
|
|
|
await _start_pre_game_countdown()
|
|
|
|
GameStateManager.start_game()
|
|
if can_rpc():
|
|
rpc("sync_game_start", GameStateManager.players, TurnManager.turn_based_mode)
|
|
if TurnManager.turn_based_mode:
|
|
TurnManager.reset_turn()
|
|
var next_player = TurnManager.next_turn(GameStateManager.players)
|
|
if can_rpc():
|
|
rpc("set_current_turn", next_player)
|
|
|
|
# Start the global match timer (this also starts the first cycle)
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
# Only Server starts the mode logic (arena setup, missions, etc)
|
|
if stop_n_go_manager:
|
|
stop_n_go_manager.start_game_mode()
|
|
|
|
# Also start global match timer for Stop n Go
|
|
if goals_cycle_manager:
|
|
var match_duration = LobbyManager.get_match_duration()
|
|
goals_cycle_manager.start_match(float(match_duration), false) # No cycles for Stop n Go
|
|
elif LobbyManager.game_mode == "Tekton Doors":
|
|
if portal_mode_manager:
|
|
portal_mode_manager.start_game_mode()
|
|
|
|
if goals_cycle_manager:
|
|
var match_duration = LobbyManager.get_match_duration()
|
|
goals_cycle_manager.start_match(float(match_duration))
|
|
elif LobbyManager.game_mode == "Candy Cannon Survival":
|
|
if gauntlet_manager:
|
|
gauntlet_manager.start_game_mode()
|
|
|
|
if goals_cycle_manager:
|
|
var match_duration = LobbyManager.get_match_duration()
|
|
goals_cycle_manager.start_match(float(match_duration), true) # Enable cycles for 3x3 pattern missions
|
|
elif goals_cycle_manager:
|
|
var match_duration = LobbyManager.get_match_duration()
|
|
goals_cycle_manager.start_match(float(match_duration))
|
|
|
|
# Initialize leaderboard with all players
|
|
if ui_manager:
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
ui_manager.initialize_leaderboard_with_players(all_players)
|
|
|
|
# Spawn Tekton NPC
|
|
spawn_tekton_npc()
|
|
|
|
|
|
# =============================================================================
|
|
# Spawn Zone System - Prevents edge gaps with 1-tile perimeter buffer
|
|
# =============================================================================
|
|
|
|
const PERIMETER_BUFFER = 1 # 1-tile safe zone on all sides
|
|
|
|
enum SpawnZone {
|
|
TOP_LEFT_CORNER,
|
|
TOP_CENTER,
|
|
TOP_RIGHT_CORNER,
|
|
MIDDLE_LEFT,
|
|
MIDDLE_CENTER,
|
|
MIDDLE_RIGHT,
|
|
BOTTOM_LEFT_CORNER,
|
|
BOTTOM_CENTER,
|
|
BOTTOM_RIGHT_CORNER
|
|
}
|
|
|
|
func _get_spawn_zones(gridmap: Node) -> Dictionary:
|
|
"""Returns a dictionary of spawn zones based on 3x3 grid layout.
|
|
Corner zones are for Tektons, middle zones are for players."""
|
|
var width = gridmap.columns
|
|
var height = gridmap.rows
|
|
|
|
# Apply perimeter buffer
|
|
var safe_width = width - (PERIMETER_BUFFER * 2)
|
|
var safe_height = height - (PERIMETER_BUFFER * 2)
|
|
|
|
# Divide safe area into 3x3 grid
|
|
var zone_w = safe_width / 3
|
|
var zone_h = safe_height / 3
|
|
|
|
var zones = {}
|
|
|
|
# Define 9 zones with buffer offsets
|
|
for row in range(3):
|
|
for col in range(3):
|
|
var zone_idx = row * 3 + col
|
|
var zone_rect = Rect2i(
|
|
PERIMETER_BUFFER + (col * zone_w),
|
|
PERIMETER_BUFFER + (row * zone_h),
|
|
zone_w,
|
|
zone_h
|
|
)
|
|
zones[zone_idx] = zone_rect
|
|
|
|
return zones
|
|
|
|
func _is_position_in_zone(pos: Vector2i, zone: Rect2i) -> bool:
|
|
"""Check if position is within a spawn zone."""
|
|
return zone.has_point(pos)
|
|
|
|
func _get_tekton_spawn_zones(zones: Dictionary) -> Array:
|
|
"""Returns corner zones for Tekton spawning."""
|
|
return [
|
|
zones[SpawnZone.TOP_LEFT_CORNER],
|
|
zones[SpawnZone.TOP_RIGHT_CORNER],
|
|
zones[SpawnZone.BOTTOM_LEFT_CORNER],
|
|
zones[SpawnZone.BOTTOM_RIGHT_CORNER]
|
|
]
|
|
|
|
func _get_player_spawn_zones(zones: Dictionary) -> Array:
|
|
"""Returns middle zones for player spawning."""
|
|
return [
|
|
zones[SpawnZone.TOP_CENTER],
|
|
zones[SpawnZone.MIDDLE_LEFT],
|
|
zones[SpawnZone.MIDDLE_CENTER],
|
|
zones[SpawnZone.MIDDLE_RIGHT],
|
|
zones[SpawnZone.BOTTOM_CENTER]
|
|
]
|
|
|
|
func _assign_random_spawn_positions():
|
|
"""Assign spawn positions distributed across middle zones (avoiding corners reserved for Tektons)."""
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap:
|
|
return
|
|
|
|
# Get spawn zones with perimeter buffer
|
|
var spawn_zones = _get_spawn_zones(enhanced_gridmap)
|
|
var player_zones = _get_player_spawn_zones(spawn_zones)
|
|
|
|
# Lists for player spawns in each zone
|
|
var spawns_by_zone = {} # zone_rect -> [positions]
|
|
for zone in player_zones:
|
|
spawns_by_zone[zone] = []
|
|
var all_spawns = [] # Fallback
|
|
|
|
# Stop n Go Custom Spawn Logic
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
_assign_stop_n_go_spawn_positions(all_players)
|
|
return
|
|
|
|
# Tekton Doors Custom Spawn Logic
|
|
if LobbyManager.game_mode == "Tekton Doors":
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
_assign_portal_mode_spawn_positions(all_players)
|
|
return
|
|
|
|
# If static positions were not calculated yet, do it now to avoid players spawning in them
|
|
if reserved_static_positions.is_empty() and LobbyManager.game_mode != "Stop n Go":
|
|
if not static_tekton_manager:
|
|
static_tekton_manager = preload("res://scripts/managers/static_tekton_manager.gd").new()
|
|
reserved_static_positions = static_tekton_manager.calculate_spawn_points(3, enhanced_gridmap)
|
|
|
|
# Scan grid for walkable positions within player zones (respecting buffer)
|
|
for x in range(PERIMETER_BUFFER, enhanced_gridmap.columns - PERIMETER_BUFFER):
|
|
for z in range(PERIMETER_BUFFER, enhanced_gridmap.rows - PERIMETER_BUFFER):
|
|
var ground = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
if ground == 0: # Walkable
|
|
var pos = Vector2i(x, z)
|
|
|
|
# SAFETY CHECK: Is this reserved for a Static Tekton Stand?
|
|
var is_safe = true
|
|
for reserved in reserved_static_positions:
|
|
if abs(x - reserved.x) <= 1 and abs(z - reserved.y) <= 1:
|
|
is_safe = false
|
|
break
|
|
|
|
if not is_safe:
|
|
continue
|
|
|
|
# Check if position is in any player zone
|
|
var in_player_zone = false
|
|
for zone in player_zones:
|
|
if _is_position_in_zone(pos, zone):
|
|
spawns_by_zone[zone].append(pos)
|
|
in_player_zone = true
|
|
break
|
|
|
|
# Add to fallback list regardless of zone
|
|
if in_player_zone:
|
|
all_spawns.append(pos)
|
|
|
|
# Shuffle each zone's spawn list for randomization
|
|
for zone in spawns_by_zone:
|
|
spawns_by_zone[zone].shuffle()
|
|
|
|
# Fallback shuffle
|
|
all_spawns.shuffle()
|
|
|
|
# Get all players and sort them for deterministic assignment
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int())
|
|
|
|
var spawn_index = 0
|
|
|
|
# Round-robin assignment across player zones
|
|
var zone_list = spawns_by_zone.keys()
|
|
var zone_arrays = spawns_by_zone.values()
|
|
|
|
for player in all_players:
|
|
var assigned_pos = Vector2i(-1, -1)
|
|
|
|
# Try to get from the current zone (round-robin)
|
|
var zone_idx = spawn_index % zone_arrays.size()
|
|
var zone_spawns = zone_arrays[zone_idx]
|
|
|
|
if zone_spawns.size() > 0:
|
|
assigned_pos = zone_spawns.pop_back()
|
|
else:
|
|
# Fallback: Try other zones if preferred one is empty
|
|
for zone_arr in zone_arrays:
|
|
if zone_arr.size() > 0:
|
|
assigned_pos = zone_arr.pop_back()
|
|
break
|
|
|
|
# Ultimate fallback: Random from anywhere
|
|
if assigned_pos == Vector2i(-1, -1) and all_spawns.size() > 0:
|
|
assigned_pos = all_spawns.pop_back()
|
|
|
|
if assigned_pos != Vector2i(-1, -1):
|
|
# Set position and sync to all clients
|
|
player.current_position = assigned_pos
|
|
player.position = player.grid_to_world(assigned_pos)
|
|
player.is_player_moving = false
|
|
player.spawn_point_selected = true
|
|
if can_rpc():
|
|
player.rpc("set_spawn_position", assigned_pos)
|
|
else:
|
|
print("Critical: No spawn point found for player ", player.name)
|
|
|
|
spawn_index += 1
|
|
print("Assigned spawn %s to player %s" % [assigned_pos, player.name])
|
|
|
|
func _assign_stop_n_go_spawn_positions(all_players: Array):
|
|
"""Assigns spawns in the left columns of the Stop N Go arena, validating walkability."""
|
|
# Sort players for deterministic assignment based on ID
|
|
all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int())
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap:
|
|
return
|
|
|
|
# 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 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
|
|
valid_spawns.append(Vector2i(col, row))
|
|
if valid_spawns.size() >= all_players.size():
|
|
break
|
|
|
|
# Fallback: if somehow no valid spawns found, use old logic
|
|
if valid_spawns.is_empty():
|
|
for row in range(3, 3 + all_players.size()):
|
|
valid_spawns.append(Vector2i(0, row))
|
|
|
|
for i in range(all_players.size()):
|
|
var player = all_players[i]
|
|
var assigned_pos = valid_spawns[i % valid_spawns.size()]
|
|
|
|
# Ensure immediate sync
|
|
player.position = player.grid_to_world(assigned_pos)
|
|
player.current_position = assigned_pos
|
|
player.is_player_moving = false
|
|
player.spawn_point_selected = true
|
|
if can_rpc():
|
|
player.rpc("set_spawn_position", assigned_pos)
|
|
|
|
print("[StopNGo] Assigned spawn %s to player %s" % [assigned_pos, player.name])
|
|
|
|
func _assign_portal_mode_spawn_positions(all_players: Array):
|
|
"""Assigns spawns to different quadrants for Tekton Doors mode, avoiding stands and intersections."""
|
|
if not portal_mode_manager:
|
|
_assign_random_spawn_positions() # Fallback
|
|
return
|
|
|
|
# Sort players for deterministic assignment
|
|
all_players.sort_custom(func(a, b): return a.name.to_int() < b.name.to_int())
|
|
|
|
# Get baseline quadrant centers (3,3), (10,3), etc.
|
|
var base_spawn_points = portal_mode_manager.get_spawn_points()
|
|
var spawn_index = 0
|
|
var assigned_positions: Array[Vector2i] = []
|
|
|
|
for player in all_players:
|
|
var center_pos = base_spawn_points[spawn_index % base_spawn_points.size()]
|
|
var assigned_pos = center_pos # Fallback position
|
|
|
|
# Spiral search for a valid spot (walkable, not in stand zone, not occupied)
|
|
var found = false
|
|
for radius in range(0, 5): # Increase search radius
|
|
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:
|
|
continue
|
|
|
|
var test_pos = center_pos + Vector2i(dx, dz)
|
|
|
|
# 1. Check map bounds
|
|
var em = $EnhancedGridMap
|
|
if not em or test_pos.x < 0 or test_pos.x >= em.columns or test_pos.y < 0 or test_pos.y >= em.rows:
|
|
continue
|
|
|
|
# 2. Check if walkable floor (Floor 0, ID 0)
|
|
if em.get_cell_item(Vector3i(test_pos.x, 0, test_pos.y)) != 0:
|
|
continue
|
|
|
|
# 3. Check if reserved for a Static Tekton Stand (3x3 area, use 2-tile buffer)
|
|
var is_reserved = false
|
|
for reserved in reserved_static_positions:
|
|
if abs(test_pos.x - reserved.x) <= 2 and abs(test_pos.y - reserved.y) <= 2:
|
|
is_reserved = true
|
|
break
|
|
if is_reserved:
|
|
continue
|
|
|
|
# 4. Check if occupied by another already-assigned player
|
|
if assigned_positions.has(test_pos):
|
|
continue
|
|
|
|
assigned_pos = test_pos
|
|
found = true
|
|
break
|
|
if found: break
|
|
if found: break
|
|
|
|
assigned_positions.append(assigned_pos)
|
|
|
|
# Sync and place
|
|
player.position = player.grid_to_world(assigned_pos)
|
|
player.current_position = assigned_pos
|
|
player.is_player_moving = false
|
|
player.spawn_point_selected = true
|
|
|
|
if can_rpc():
|
|
player.rpc("set_spawn_position", assigned_pos)
|
|
|
|
spawn_index += 1
|
|
print("[PortalMode] Assigned Quadrant Pos %s to player %s" % [assigned_pos, player.name])
|
|
|
|
# =============================================================================
|
|
# Tekton NPC Management
|
|
# =============================================================================
|
|
|
|
const StaticTektonManager = preload("res://scripts/managers/static_tekton_manager.gd")
|
|
var static_tekton_manager
|
|
|
|
func spawn_tekton_npc():
|
|
"""Spawn Tektons in corner zones only (avoiding player spawn areas)."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap: return
|
|
|
|
# Get corner zones for Tekton spawning
|
|
var spawn_zones = _get_spawn_zones(enhanced_gridmap)
|
|
var tekton_zones = _get_tekton_spawn_zones(spawn_zones)
|
|
|
|
# Collect valid spawn positions in corner zones
|
|
var valid_positions = []
|
|
for zone in tekton_zones:
|
|
for x in range(zone.position.x, zone.position.x + zone.size.x):
|
|
for y in range(zone.position.y, zone.position.y + zone.size.y):
|
|
var cell = Vector3i(x, 0, y)
|
|
if enhanced_gridmap.get_cell_item(cell) == 0: # Walkable floor
|
|
# Ensure not occupied by static tekton stand
|
|
var item_id = enhanced_gridmap.get_cell_item(Vector3i(x, 1, y))
|
|
if item_id == 4: continue # Wall/Stand
|
|
|
|
# Check RESERVED positions (static tekton stands)
|
|
var is_safe = true
|
|
for reserved in reserved_static_positions:
|
|
if abs(x - reserved.x) <= 1 and abs(y - reserved.y) <= 1:
|
|
is_safe = false
|
|
break
|
|
if not is_safe: continue
|
|
|
|
valid_positions.append(Vector2i(x, y))
|
|
|
|
# Shuffle and spawn 3 Roaming Tektons
|
|
valid_positions.shuffle()
|
|
var spawned_count = 0
|
|
|
|
for pos in valid_positions:
|
|
if spawned_count >= 3: break
|
|
|
|
# Generate a consistent ID/Name for sync
|
|
var tekton_id = Time.get_ticks_msec() + spawned_count
|
|
_create_tekton(pos, tekton_id)
|
|
|
|
# Only broadcast to clients if there are remote peers connected
|
|
if can_rpc() and multiplayer.get_peers().size() > 0:
|
|
rpc("sync_spawn_tekton", pos, tekton_id)
|
|
|
|
spawned_count += 1
|
|
print("[Main] Spawned Tekton %d at %s (Corner Zone)" % [spawned_count, pos])
|
|
|
|
@rpc("call_remote", "reliable")
|
|
func sync_spawn_tekton(pos: Vector2i, tekton_id: int):
|
|
# Safety: only create if scene is fully ready
|
|
if not is_inside_tree(): return
|
|
_create_tekton(pos, tekton_id)
|
|
|
|
func _create_tekton(pos: Vector2i, tekton_id: int, is_static: bool = false):
|
|
var node_name = "Tekton_%d" % tekton_id
|
|
if has_node(node_name): return
|
|
|
|
var tekton_scene = load("res://scenes/tekton.tscn")
|
|
var tekton = tekton_scene.instantiate()
|
|
tekton.name = node_name
|
|
add_child(tekton)
|
|
|
|
# Initialize
|
|
if is_static:
|
|
tekton.is_static_turret = true
|
|
|
|
if tekton.has_method("initialize"):
|
|
if has_node("EnhancedGridMap"):
|
|
tekton.initialize(pos, $EnhancedGridMap)
|
|
|
|
# If Static, swap controller
|
|
if is_static:
|
|
# tekton.is_static_turret = true # Already set above
|
|
var old_controller = tekton.get_node_or_null("TektonController")
|
|
if old_controller:
|
|
old_controller.queue_free()
|
|
|
|
var static_controller = load("res://scripts/static_tekton_controller.gd").new()
|
|
static_controller.name = "StaticTektonController"
|
|
tekton.add_child(static_controller)
|
|
print("[Main] Spawned STATIC Tekton at %s (ID: %d)" % [pos, tekton_id])
|
|
else:
|
|
print("[Main] Spawned Tekton at %s (ID: %d)" % [pos, tekton_id])
|
|
|
|
|
|
func _precalculate_static_positions():
|
|
"""Calculate and reserve Static Tekton positions early."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
# Static Tekton stands only exist in Free Mode
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
reserved_static_positions = []
|
|
return
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap: return
|
|
|
|
if not static_tekton_manager:
|
|
static_tekton_manager = StaticTektonManager.new()
|
|
|
|
# Calculate 3 spots and STORE them
|
|
var points: Array[Vector2i] = []
|
|
points = static_tekton_manager.calculate_spawn_points(5, enhanced_gridmap)
|
|
reserved_static_positions = points
|
|
print("[Main] Pre-calculated Static Tekton Positions: %s" % str(reserved_static_positions))
|
|
|
|
func spawn_static_tektons():
|
|
"""Spawn fixed static tektons using StaticTektonManager."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
# Disable for Stop n Go mode
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
return
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap: return
|
|
|
|
print("[Main] Initializing StaticTektonManager...")
|
|
if not static_tekton_manager:
|
|
static_tekton_manager = StaticTektonManager.new()
|
|
|
|
# Use pre-calculated points if available
|
|
var spawn_points: Array[Vector2i] = []
|
|
if not reserved_static_positions.is_empty():
|
|
# Pick exactly 3 from the 5 reserved potential spots as requested
|
|
var possible_points = reserved_static_positions.duplicate()
|
|
possible_points.shuffle()
|
|
spawn_points = possible_points.slice(0, 3)
|
|
else:
|
|
# Fallback if not pre-calculated
|
|
var raw_points = static_tekton_manager.calculate_spawn_points(5, enhanced_gridmap)
|
|
raw_points.shuffle()
|
|
spawn_points = raw_points.slice(0, 3)
|
|
reserved_static_positions = raw_points # Save all 5 for player avoidance
|
|
|
|
print("[Main] Spawning Static Tektons at: %s" % str(spawn_points))
|
|
|
|
for i in range(spawn_points.size()):
|
|
var pos = spawn_points[i]
|
|
# ID: 99000 + i (Consistent IDs for Static Tektons)
|
|
var id = 99000 + i
|
|
|
|
# Pick Shape on Server (0: Seat 1, 1: Seat 2)
|
|
var shape_idx = randi() % 2
|
|
|
|
# Spawn on Server AND Sync to Clients (call_local handles both)
|
|
rpc("sync_spawn_static_setup", pos, id, shape_idx)
|
|
|
|
@rpc("call_local", "reliable")
|
|
func sync_spawn_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int):
|
|
# Call local creation logic on all peers.
|
|
# Server: Spawns Stand + Void + Tekton
|
|
# Client: Avoids Stand (Spawner) + Void + Tekton
|
|
_create_static_setup(pos, tekton_id, shape_idx)
|
|
|
|
func _create_static_setup(pos: Vector2i, tekton_id: int, shape_idx: int):
|
|
"""Creates both the Stand and the Static Tekton at the position."""
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
|
|
# 1. Create Stand (Server Only - Synced via Spawner)
|
|
# IMPORTANT: Clients receive the Stand via MultiplayerSpawner.
|
|
# They MUST NOT spawn it manually here or we get duplicates.
|
|
if multiplayer.is_server():
|
|
var stands_container = get_node_or_null("Stands")
|
|
if stands_container:
|
|
var stand_name = "StaticStand_%d" % tekton_id
|
|
if not stands_container.has_node(stand_name):
|
|
var stand_scene = load("res://scenes/static_tekton_stand.tscn")
|
|
if stand_scene:
|
|
var stand = stand_scene.instantiate()
|
|
stand.name = stand_name
|
|
|
|
# Set Shape Index BEFORE adding to tree (so _ready picks it up/syncs)
|
|
if "shape_index" in stand:
|
|
stand.shape_index = shape_idx
|
|
|
|
stands_container.add_child(stand)
|
|
|
|
# Position Stand
|
|
if enhanced_gridmap:
|
|
# Convert grid to world
|
|
var world_pos = Vector3(pos.x + 0.5, 0, pos.y + 0.5)
|
|
if "cell_size" in enhanced_gridmap:
|
|
world_pos = Vector3(
|
|
pos.x * enhanced_gridmap.cell_size.x + enhanced_gridmap.cell_size.x / 2,
|
|
0.28, # Match the 0.28 elevation of the arena floor
|
|
pos.y * enhanced_gridmap.cell_size.z + enhanced_gridmap.cell_size.z / 2
|
|
)
|
|
stand.global_position = world_pos
|
|
|
|
# 2. Modify Base (Void) - Runs on ALL peers to update local GridMap visual/collision
|
|
if enhanced_gridmap:
|
|
var floor_count = 3
|
|
if "floors" in enhanced_gridmap:
|
|
floor_count = enhanced_gridmap.floors
|
|
|
|
for dx in range(-1, 2):
|
|
for dy in range(-1, 2):
|
|
var tile_pos_x = pos.x + dx
|
|
var tile_pos_z = pos.y + dy
|
|
|
|
# Clear ALL vertical layers (Ground, Items, etc.)
|
|
for f in range(floor_count):
|
|
var tile_pos = Vector3i(tile_pos_x, f, tile_pos_z)
|
|
enhanced_gridmap.set_cell_item(tile_pos, -1) # -1 = Empty/Void
|
|
|
|
# CRITICAL: Force AStar update so Bots and Pathfinding know about the new walls
|
|
if enhanced_gridmap.has_method("update_astar_costs"):
|
|
enhanced_gridmap.update_astar_costs()
|
|
|
|
# 3. Create Tekton Visual - Runs on ALL peers
|
|
# NOTE: Tekton NPC is currently not managed by a specialized Spawner for static setup?
|
|
# Or it is? If _create_tekton adds it to a path watched by a spawner, we should duplicate check.
|
|
# _create_tekton instantiates 'tekton.tscn' and adds to 'Main'.
|
|
# Main usually has a MultiplayerSpawner for 'Players' etc., but let's check.
|
|
# The original logic spawned it everywhere, so we keep that behavior to be safe.
|
|
# But we add a check to avoid duplicates if it already came in via sync.
|
|
if not has_node("Tekton_%d" % tekton_id):
|
|
_create_tekton(pos, tekton_id, true)
|
|
|
|
|
|
# =============================================================================
|
|
# Player Management
|
|
# =============================================================================
|
|
|
|
func _add_bot(bot_id: int):
|
|
rpc("create_bot", bot_id)
|
|
|
|
@rpc("call_local")
|
|
func create_bot(bot_id: int):
|
|
if not GameStateManager.enable_bots:
|
|
return
|
|
|
|
if has_node(str(bot_id)):
|
|
return
|
|
|
|
var bot_character = PlayerManager.create_bot(bot_id)
|
|
call_deferred("add_child", bot_character)
|
|
bot_character.add_to_group("Players", true)
|
|
bot_character.add_to_group("Bots", true)
|
|
|
|
if multiplayer.is_server():
|
|
GameStateManager.add_bot(bot_id)
|
|
|
|
var goal_index = bot_id - 1
|
|
if goal_index < GoalManager.preset_goals.size():
|
|
# Wait for bot managers to be ready (race_manager is created at T=0.5)
|
|
await get_tree().create_timer(0.75).timeout
|
|
bot_character.goals = GoalManager.preset_goals[goal_index].duplicate()
|
|
# Use deferred goals sync to avoid timing issues
|
|
call_deferred("_deferred_set_player_goals", bot_id, bot_character.goals)
|
|
|
|
@rpc("any_peer", "call_local")
|
|
func add_player_character(peer_id: int, is_bot: bool = false):
|
|
print("[Main] add_player_character called for %d (is_bot: %s)" % [peer_id, is_bot])
|
|
if has_node(str(peer_id)):
|
|
print("[Main] Player %d already exists! Skipping spawn." % peer_id)
|
|
return
|
|
|
|
var player_character
|
|
if is_bot:
|
|
player_character = PlayerManager.create_bot(peer_id)
|
|
player_character.add_to_group("Bots", true)
|
|
else:
|
|
player_character = PlayerManager.add_player_character(peer_id)
|
|
|
|
# Set properties BEFORE adding to tree (ensure _ready sees correct state)
|
|
# create_bot already sets is_bot=true, but we ensure consistency
|
|
player_character.is_bot = is_bot
|
|
|
|
add_child(player_character)
|
|
player_character.add_to_group("Players", true)
|
|
|
|
GameStateManager.add_player(peer_id)
|
|
|
|
# Try to set name from LobbyManager data if available
|
|
var lobby_players = LobbyManager.get_players()
|
|
for p_data in lobby_players:
|
|
if p_data.get("id") == peer_id:
|
|
var p_name = p_data.get("name", "Player")
|
|
player_character.display_name = p_name
|
|
print("[Main] Set player %d name to %s from Lobby data" % [peer_id, p_name])
|
|
break
|
|
|
|
if peer_id == multiplayer.get_unique_id():
|
|
GameStateManager.local_player_character = player_character
|
|
ui_manager.set_local_player(player_character)
|
|
if touch_controls:
|
|
touch_controls.set_player(player_character)
|
|
if camera_context_manager:
|
|
camera_context_manager.set_player(player_character)
|
|
ui_manager.update_playerboard_ui()
|
|
|
|
func _on_peer_connected(new_peer_id: int):
|
|
if not is_inside_tree(): return
|
|
if multiplayer.is_server():
|
|
await get_tree().create_timer(0.1).timeout
|
|
add_player_character(new_peer_id)
|
|
rpc("add_newly_connected_player_character", new_peer_id)
|
|
|
|
# Wait for player to be ready then assign goals
|
|
await get_tree().create_timer(0.3).timeout
|
|
var player = get_node_or_null(str(new_peer_id))
|
|
if player:
|
|
# Get the next available goal set for this player
|
|
var player_index = GameStateManager.players.find(new_peer_id)
|
|
if player_index >= 0 and player_index < GoalManager.preset_goals.size():
|
|
var player_goals = GoalManager.preset_goals[player_index].duplicate()
|
|
player.goals = player_goals
|
|
# Update goals UI for all clients
|
|
call_deferred("_deferred_set_player_goals", new_peer_id, player_goals)
|
|
|
|
@rpc
|
|
func add_newly_connected_player_character(new_peer_id: int):
|
|
add_player_character(new_peer_id)
|
|
|
|
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)
|
|
|
|
var player_node = get_node_or_null(str(peer_id))
|
|
if player_node and not player_node.is_bot:
|
|
# Cache state before removing
|
|
var pos = player_node.current_position
|
|
var p_score = player_node.score
|
|
var p_goals = player_node.goals.duplicate()
|
|
var p_char = player_node.selected_character
|
|
|
|
# Remove human player
|
|
GameStateManager.remove_player(peer_id)
|
|
player_node.queue_free()
|
|
|
|
# Add replacement bot
|
|
if GameStateManager.enable_bots:
|
|
var next_bot_id = PlayerManager.get_next_available_bot_id(GameStateManager.max_players, GameStateManager.players)
|
|
if next_bot_id != -1:
|
|
print("[Main] Replacing Player %d with Bot %d" % [peer_id, next_bot_id])
|
|
_replace_player_with_bot(next_bot_id, pos, p_score, p_goals, p_char)
|
|
else:
|
|
GameStateManager.remove_player(peer_id)
|
|
|
|
func _replace_player_with_bot(bot_id: int, pos: Vector2i, p_score: int, p_goals: Array, p_char: String):
|
|
"""Creates a bot to replace a disconnected player and restores their state."""
|
|
rpc("create_bot_with_state", bot_id, pos, p_score, p_goals, p_char)
|
|
|
|
@rpc("call_local")
|
|
func create_bot_with_state(bot_id: int, pos: Vector2i, p_score: int, p_goals: Array, p_char: String):
|
|
if not GameStateManager.enable_bots:
|
|
return
|
|
|
|
if has_node(str(bot_id)):
|
|
return
|
|
|
|
var bot_character = PlayerManager.create_bot(bot_id)
|
|
call_deferred("add_child", bot_character)
|
|
bot_character.add_to_group("Players", true)
|
|
bot_character.add_to_group("Bots", true)
|
|
|
|
# Apply transferred state
|
|
bot_character.current_position = pos
|
|
bot_character.score = p_score
|
|
bot_character.goals = p_goals
|
|
bot_character.selected_character = p_char
|
|
|
|
if multiplayer.is_server():
|
|
GameStateManager.add_bot(bot_id)
|
|
# Ensure position is synced
|
|
bot_character.update_player_position(pos)
|
|
|
|
func _on_host_disconnected():
|
|
if not is_inside_tree(): return
|
|
"""Called when the host leaves. Returns clients to the lobby."""
|
|
print("[Main] Host disconnected. Match terminated. Cleaning up and returning to lobby...")
|
|
LobbyManager.leave_room()
|
|
|
|
# Use loading screen to return to lobby
|
|
var loading_screen_scene = load("res://scenes/loading_screen/loading_screen.tscn")
|
|
if loading_screen_scene:
|
|
var loading_screen = loading_screen_scene.instantiate()
|
|
get_tree().root.add_child(loading_screen)
|
|
loading_screen.load_level("res://scenes/lobby.tscn")
|
|
else:
|
|
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
|
|
|
func _on_rematch_starting():
|
|
if not is_inside_tree(): return
|
|
"""Called when a rematch is triggered. Reloads the game scene."""
|
|
print("[Main] Rematch starting. Resetting local state...")
|
|
|
|
# Reset singletons/managers that persist across scene reloads
|
|
GameStateManager.reset()
|
|
GoalManager.reset()
|
|
TurnManager.reset()
|
|
|
|
is_match_ended = false
|
|
get_tree().change_scene_to_file("res://scenes/main.tscn")
|
|
|
|
# =============================================================================
|
|
# Turn Management (RPC Handlers)
|
|
# =============================================================================
|
|
|
|
@rpc("reliable")
|
|
func sync_turn_index(index: int):
|
|
TurnManager.current_turn_index = index
|
|
|
|
@rpc("any_peer", "call_local")
|
|
func set_current_turn(player_id: int):
|
|
if not TurnManager.turn_based_mode:
|
|
return
|
|
|
|
for player in get_tree().get_nodes_in_group("Players"):
|
|
var is_current_turn = player.name == str(player_id)
|
|
player.is_my_turn = is_current_turn
|
|
|
|
if is_current_turn and not (player.is_bot or player.is_in_group("Bots")):
|
|
player.has_moved_this_turn = false
|
|
player.has_performed_action = false
|
|
player.start_turn()
|
|
player.clear_highlights()
|
|
player.clear_playerboard_highlights()
|
|
else:
|
|
player.is_my_turn = false
|
|
|
|
@rpc("call_local")
|
|
func sync_game_start(player_list: Array, is_turn_based: bool):
|
|
GameStateManager.players = player_list
|
|
TurnManager.turn_based_mode = is_turn_based
|
|
GameStateManager.start_game()
|
|
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
if not stop_n_go_manager:
|
|
stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new()
|
|
stop_n_go_manager.name = "StopNGoManager"
|
|
add_child(stop_n_go_manager)
|
|
stop_n_go_manager.activate_client_side()
|
|
elif LobbyManager.game_mode == "Tekton Doors":
|
|
if portal_mode_manager:
|
|
portal_mode_manager.activate_client_side()
|
|
|
|
# Initialize leaderboard for all peers (after a delay to ensure players loaded)
|
|
call_deferred("_deferred_init_leaderboard")
|
|
|
|
# =============================================================================
|
|
# UI / Action State Management
|
|
# =============================================================================
|
|
|
|
|
|
# =============================================================================
|
|
# Goal & Playerboard Sync
|
|
# =============================================================================
|
|
|
|
@rpc("reliable")
|
|
func sync_preset_goals(goals_list: Array):
|
|
GoalManager.preset_goals = goals_list
|
|
|
|
@rpc("any_peer", "call_local")
|
|
func sync_player_goals(player_id: int, goals: Array):
|
|
var player = get_node_or_null(str(player_id))
|
|
if player:
|
|
# Defer the goal setting to ensure managers are ready
|
|
call_deferred("_deferred_set_player_goals", player_id, goals)
|
|
|
|
func _deferred_set_player_goals(player_id: int, goals: Array):
|
|
# The player node waits 0.5s in its _ready() function before initializing managers.
|
|
# We MUST cleanly wait until it finishes creating them, instead of silently aborting.
|
|
var player = get_node_or_null(str(player_id))
|
|
|
|
# Wait until player and its race_manager thoroughly exist
|
|
var wait_limit = 2.0 # Safety limit
|
|
var waited = 0.0
|
|
while (not player or not player.race_manager) and waited < wait_limit:
|
|
await get_tree().create_timer(0.1).timeout
|
|
waited += 0.1
|
|
player = get_node_or_null(str(player_id))
|
|
|
|
if player and player.race_manager:
|
|
player.goals = goals.duplicate()
|
|
# UI UPDATE FIX: explicitly refresh local UI upon receiving starting goals
|
|
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(player_id: int, new_playerboard: Array):
|
|
# Find the player and update their playerboard
|
|
var player = get_node_or_null(str(player_id))
|
|
if player:
|
|
player.playerboard = new_playerboard.duplicate()
|
|
|
|
# Update UI for local player
|
|
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()
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# =============================================================================
|
|
# Connection Verification
|
|
# =============================================================================
|
|
|
|
func verify_all_connections():
|
|
if multiplayer.is_server():
|
|
for peer_id in GameStateManager.players:
|
|
# Skip host (1) and bots (bots don't have real network connections)
|
|
if peer_id != 1 and not peer_id in GameStateManager.bots:
|
|
rpc_id(peer_id, "connection_verify", GameStateManager.players)
|
|
|
|
@rpc
|
|
func connection_verify(expected_players: Array):
|
|
for peer_id in expected_players:
|
|
if peer_id != multiplayer.get_unique_id() and not has_node(str(peer_id)):
|
|
rpc_id(1, "request_specific_player_data", peer_id)
|
|
|
|
@rpc("any_peer")
|
|
func request_specific_player_data(requested_peer_id: int):
|
|
if multiplayer.is_server():
|
|
var player = get_node_or_null(str(requested_peer_id))
|
|
if player:
|
|
var player_data = {
|
|
"peer_id": requested_peer_id,
|
|
"position": player.current_position,
|
|
"goals": player.goals,
|
|
"playerboard": player.playerboard,
|
|
"is_bot": player.is_bot || player.is_in_group("Bots")
|
|
}
|
|
rpc_id(multiplayer.get_remote_sender_id(), "create_specific_player", player_data)
|
|
|
|
@rpc("any_peer")
|
|
func request_full_player_sync(requesting_peer_id: int):
|
|
if multiplayer.is_server():
|
|
for peer_id in GameStateManager.players:
|
|
var player = get_node_or_null(str(peer_id))
|
|
if player:
|
|
var player_data = {
|
|
"peer_id": peer_id,
|
|
"position": player.current_position,
|
|
"name": player.display_name,
|
|
"goals": player.goals,
|
|
"playerboard": player.playerboard,
|
|
"spawn_point_selected": player.spawn_point_selected
|
|
}
|
|
if _can_rpc():
|
|
rpc_id(requesting_peer_id, "create_specific_player", player_data)
|
|
await get_tree().create_timer(0.1).timeout
|
|
|
|
@rpc("reliable")
|
|
func create_specific_player(data: Dictionary):
|
|
var peer_id = data["peer_id"]
|
|
var player_character = null
|
|
var node_already_exists = has_node(str(peer_id))
|
|
|
|
if node_already_exists:
|
|
# Player already exists, just get the reference
|
|
player_character = get_node(str(peer_id))
|
|
else:
|
|
# Create new player
|
|
player_character = PlayerManager.add_player_character(peer_id)
|
|
player_character.current_position = data["position"]
|
|
add_child(player_character)
|
|
player_character.add_to_group("Players", true)
|
|
|
|
# Set display name
|
|
if data.has("name"):
|
|
player_character.display_name = data["name"]
|
|
|
|
if data["is_bot"]:
|
|
player_character.add_to_group("Bots", true)
|
|
player_character.is_bot = true
|
|
|
|
# Set spawn flag and visibility for BOTH new and existing players
|
|
if data.has("spawn_point_selected") and data["spawn_point_selected"]:
|
|
player_character.spawn_point_selected = true
|
|
player_character.visible = true
|
|
|
|
# Ensure visual position matches logical
|
|
var new_pos = player_character.grid_to_world(data["position"])
|
|
player_character.global_position = new_pos
|
|
player_character.target_visual_position = new_pos
|
|
|
|
# Force collision update
|
|
if player_character.has_node("CollisionShape3D"):
|
|
player_character.get_node("CollisionShape3D").disabled = false
|
|
|
|
# Check if this is the local player (client's own player)
|
|
var is_local_player = (peer_id == multiplayer.get_unique_id())
|
|
if is_local_player and GameStateManager.local_player_character == null:
|
|
GameStateManager.local_player_character = player_character
|
|
ui_manager.set_local_player(player_character)
|
|
|
|
# Wait for player managers to initialize (player.gd has 0.1s await in _ready)
|
|
await get_tree().create_timer(0.2).timeout
|
|
|
|
# Now set goals and playerboard after managers are ready
|
|
var goals_to_set = data["goals"].duplicate() if data.has("goals") else []
|
|
if goals_to_set.size() > 0 and player_character.race_manager:
|
|
player_character.goals = goals_to_set
|
|
|
|
var playerboard_to_set = data["playerboard"].duplicate() if data.has("playerboard") else []
|
|
if playerboard_to_set.size() > 0 and player_character.race_manager:
|
|
player_character.playerboard = playerboard_to_set
|
|
|
|
# Always update position (including for existing nodes, so client sees host correctly)
|
|
player_character.current_position = data["position"]
|
|
|
|
# Use the player's own grid_to_world to respect cell_size (1,1,1)
|
|
var new_world_pos = player_character.grid_to_world(data["position"])
|
|
player_character.global_position = new_world_pos
|
|
player_character.target_visual_position = new_world_pos
|
|
|
|
# Update playerboard UI for local player
|
|
if is_local_player:
|
|
ui_manager.update_playerboard_ui()
|
|
|
|
|
|
# =============================================================================
|
|
# Grid Item Randomization
|
|
# =============================================================================
|
|
|
|
func randomize_item_at_position(grid_position: Vector2i):
|
|
if not multiplayer.is_server():
|
|
rpc_id(1, "request_randomize_item", grid_position)
|
|
return
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if enhanced_gridmap:
|
|
var cell = Vector3i(grid_position.x, 1, grid_position.y)
|
|
var current_item = enhanced_gridmap.get_cell_item(cell)
|
|
|
|
# If current item exists, replace it (scarcity aware)
|
|
# If current item exists OR we are forcing a spawn on valid ground
|
|
var floor_0_item = enhanced_gridmap.get_cell_item(Vector3i(grid_position.x, 0, grid_position.y))
|
|
var is_ground = (floor_0_item != -1 and floor_0_item != 4) # Skip walls (4) and empty space (-1)
|
|
|
|
# Prevent stacking on players
|
|
if is_ground:
|
|
for player in get_tree().get_nodes_in_group("Players"):
|
|
if Vector2i(player.current_position.x, player.current_position.y) == grid_position:
|
|
is_ground = false
|
|
break
|
|
|
|
if is_ground:
|
|
var new_item = 7
|
|
|
|
var get_mode_specific_tile = func():
|
|
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors":
|
|
# 60% Chance for Common (7-10), 40% for PowerUp
|
|
if randf() <= 0.6:
|
|
return [7, 8, 9, 10].pick_random()
|
|
else:
|
|
return ScarcityModel.SPECIAL_TILES.pick_random()
|
|
return ScarcityController.get_random_tile_id()
|
|
|
|
new_item = get_mode_specific_tile.call()
|
|
|
|
# If we are replacing an existing item, try to ensure it changes
|
|
if current_item != -1:
|
|
var max_retries = 3
|
|
while new_item == current_item and max_retries > 0:
|
|
new_item = get_mode_specific_tile.call()
|
|
max_retries -= 1
|
|
|
|
sync_grid_item(cell.x, cell.y, cell.z, new_item)
|
|
rpc("sync_grid_item", cell.x, cell.y, cell.z, new_item)
|
|
|
|
@rpc("any_peer")
|
|
func request_randomize_item(grid_position: Vector2i):
|
|
if multiplayer.is_server():
|
|
randomize_item_at_position(grid_position)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func sync_grid_item(x: int, y: int, z: int, item: int):
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if enhanced_gridmap:
|
|
# FLOOR ENFORCEMENT: Visual tiles (IDs 7-20) must ALWAYS be on layer Y=1.
|
|
# If somehow sent to Y=0, redirect them to Y=1.
|
|
if item >= 7 and item <= 20 and y == 0:
|
|
push_warning("[Main] Tile %d was sent to floor Y=0 at (%d,0,%d). Redirecting to Y=1." % [item, x, z])
|
|
y = 1
|
|
|
|
# PROTECTED FLOOR CHECK: Block tiles (7-20) from being placed on walls (4) or void (-1)
|
|
# Note: We allow spawning on Safe Zones, Start, and Finish as it's on Layer 1.
|
|
if y == 1 and item >= 7 and item <= 20:
|
|
var f0 = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
var f1 = enhanced_gridmap.get_cell_item(Vector3i(x, 1, z))
|
|
|
|
# Block if Layer 0 is Wall (4) or Void (-1)
|
|
# OR Layer 1 is already a wall (4)
|
|
if f0 in [4, -1] or f1 == 4:
|
|
return
|
|
|
|
# TEKTON DOORS: Prevent placing items on portal doors
|
|
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS):
|
|
var doors = get_tree().get_nodes_in_group("PortalDoors")
|
|
for door in doors:
|
|
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
|
|
if door_grid.x == x and door_grid.z == z:
|
|
return
|
|
|
|
enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item)
|
|
# Force visual update
|
|
if enhanced_gridmap.has_method("update_grid_data"):
|
|
enhanced_gridmap.update_grid_data()
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func sync_grid_items_batch(data: Array):
|
|
# data is an array of dictionaries: [{x: int, y: int, z: int, item: int}, ...]
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap:
|
|
return
|
|
|
|
for entry in data:
|
|
var x = entry.get("x", 0)
|
|
var y = entry.get("y", 0)
|
|
var z = entry.get("z", 0)
|
|
var item = entry.get("item", -1)
|
|
# FLOOR ENFORCEMENT: Visual tiles (IDs 7-20) must ALWAYS be on layer Y=1.
|
|
if item >= 7 and item <= 20 and y == 0:
|
|
y = 1
|
|
|
|
# PROTECTED FLOOR CHECK
|
|
if y == 1 and item >= 7 and item <= 20:
|
|
var f0 = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
var f1 = enhanced_gridmap.get_cell_item(Vector3i(x, 1, z))
|
|
if f0 in [4, -1] or f1 == 4:
|
|
continue
|
|
|
|
# TEKTON DOORS: Prevent placing items on portal doors
|
|
if LobbyManager.is_game_mode(GameMode.Mode.TEKTON_DOORS) and y == 1:
|
|
var doors = get_tree().get_nodes_in_group("PortalDoors")
|
|
var on_door = false
|
|
for door in doors:
|
|
var door_grid = enhanced_gridmap.local_to_map(enhanced_gridmap.to_local(door.global_position))
|
|
if door_grid.x == x and door_grid.z == z:
|
|
on_door = true
|
|
break
|
|
if on_door: continue
|
|
|
|
enhanced_gridmap.set_cell_item(Vector3i(x, y, z), item)
|
|
|
|
# Force visual update ONCE after batch
|
|
if enhanced_gridmap.has_method("update_grid_data"):
|
|
enhanced_gridmap.update_grid_data()
|
|
|
|
func randomize_game_grid():
|
|
if LobbyManager.game_mode == "Stop n Go" or LobbyManager.game_mode == "Tekton Doors":
|
|
return # These modes manage their own arena setup and item spawning
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if enhanced_gridmap:
|
|
# FIRST: Ensure Floor 0 is entirely filled with walkable ground (ID 0)
|
|
# This ensures StaticTektonManager calculation (which checks Floor 0) succeeds.
|
|
for x in range(enhanced_gridmap.columns):
|
|
for z in range(enhanced_gridmap.rows):
|
|
var f0_item = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
# If empty OR it's a tile/powerup (7-20) accidentally stuck on the ground layer, replace with Base Floor (0)
|
|
if f0_item == -1 or (f0_item >= 7 and f0_item <= 20):
|
|
enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), 0)
|
|
|
|
# Custom spawn ratio for Free Mode: 80% common tiles, 20% empty tiles (start of game)
|
|
var density_callable = func():
|
|
if randf() <= 0.8:
|
|
return ScarcityModel.STANDARD_TILES.pick_random()
|
|
else:
|
|
return -1 # Empty
|
|
|
|
enhanced_gridmap.randomize_floor(1, density_callable)
|
|
|
|
# Sync with all clients immediately
|
|
if multiplayer.is_server():
|
|
var grid_data = enhanced_gridmap.get_floor_data(1)
|
|
rpc("sync_full_grid_data", grid_data)
|
|
print("[Main] Server: Randomized grid (80%% density) and broadcasted to clients.")
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_full_grid_data_stop_n_go(floor0: PackedInt32Array, floor1: PackedInt32Array, cols: int, rows: int):
|
|
# Deprecated: Kept for signature compatibility but disabled to prevent MTU payload overflow.
|
|
pass
|
|
|
|
@rpc("any_peer")
|
|
func request_full_grid_sync():
|
|
if multiplayer.is_server():
|
|
var sender_id = multiplayer.get_remote_sender_id()
|
|
print("[Main] Grid sync requested by %d" % sender_id)
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if enhanced_gridmap:
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_manager:
|
|
# Resync Phase and Missions since they might have missed the initial broadcast
|
|
var phase_name = "GO" if stop_n_go_manager.current_phase == stop_n_go_manager.Phase.GO else "STOP"
|
|
stop_n_go_manager.rpc_id(sender_id, "sync_phase", phase_name, stop_n_go_manager.phase_timer)
|
|
|
|
if stop_n_go_manager.player_missions.has(sender_id):
|
|
var mission_dict = {sender_id: stop_n_go_manager.player_missions[sender_id]}
|
|
stop_n_go_manager.rpc_id(sender_id, "sync_missions", mission_dict)
|
|
|
|
# For all modes, only sync Floor 1 (Items) to prevent MTU packet overflow.
|
|
# Floor 0 logic is deterministic and generated locally on level load.
|
|
var grid_data = enhanced_gridmap.get_floor_data(1)
|
|
print("[Main] Server: Prepared grid data. Size: %d. Sending to %d..." % [grid_data.size(), sender_id])
|
|
|
|
# Delay slightly to ensure socket stability after player syncs
|
|
await get_tree().create_timer(0.2).timeout
|
|
|
|
if sender_id in multiplayer.get_peers():
|
|
rpc_id(sender_id, "sync_full_grid_data", grid_data)
|
|
print("[Main] Server: Sent grid sync rpc_id to %d" % sender_id)
|
|
|
|
# If Tekton Doors, sync portal connections too
|
|
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
|
portal_mode_manager.sync_to_client(sender_id)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_full_grid_data(data: PackedInt32Array):
|
|
print("[Main] sync_full_grid_data received. Items: %d" % (data.size() / 3))
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap:
|
|
print("[Main] Error: EnhancedGridMap not found!")
|
|
return
|
|
|
|
# Reapply deterministic Floor 0 before syncing Floor 1 items
|
|
if LobbyManager.game_mode == "Stop n Go":
|
|
if not stop_n_go_manager:
|
|
stop_n_go_manager = load("res://scripts/managers/stop_n_go_manager.gd").new()
|
|
stop_n_go_manager.name = "StopNGoManager"
|
|
add_child(stop_n_go_manager)
|
|
stop_n_go_manager._apply_arena_setup()
|
|
elif LobbyManager.game_mode == "Tekton Doors":
|
|
if not portal_mode_manager:
|
|
portal_mode_manager = load("res://scripts/managers/portal_mode_manager.gd").new()
|
|
portal_mode_manager.name = "PortalModeManager"
|
|
add_child(portal_mode_manager)
|
|
portal_mode_manager.initialize(self , enhanced_gridmap)
|
|
portal_mode_manager.setup_arena_locally()
|
|
else:
|
|
# Freemode: Ensure Floor 0 is entirely walkable (reset stale state from previous modes)
|
|
for x in range(enhanced_gridmap.columns):
|
|
for z in range(enhanced_gridmap.rows):
|
|
var f0_item = enhanced_gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
if f0_item == -1 or (f0_item >= 7 and f0_item <= 20):
|
|
enhanced_gridmap.set_cell_item(Vector3i(x, 0, z), 0)
|
|
|
|
# Apply the synced data to Floor 1
|
|
enhanced_gridmap.set_floor_data(1, data)
|
|
enhanced_gridmap.update_grid_data()
|
|
enhanced_gridmap.initialize_astar()
|
|
print("[Main] Grid sync complete.")
|
|
|
|
|
|
# =============================================================================
|
|
# Goals Cycle & Leaderboard UI
|
|
# =============================================================================
|
|
|
|
func _on_timer_updated(time_remaining: float):
|
|
# Update standalone timer display
|
|
# DISABLED: Now used for PowerUp Cooldown (handled in UIManager)
|
|
# var time_text = str(int(time_remaining))
|
|
#
|
|
# var timer_panel = get_node_or_null("GoalsTimer")
|
|
# if timer_panel:
|
|
# var timer_label = timer_panel.get_node_or_null("VBox/TimerLabel")
|
|
# if timer_label:
|
|
# timer_label.text = time_text
|
|
pass
|
|
|
|
func _on_score_updated(peer_id: int, new_score: int):
|
|
# Update player's score display
|
|
var player = get_node_or_null(str(peer_id))
|
|
if player:
|
|
player.score = new_score
|
|
|
|
# Update leaderboard UI
|
|
_update_leaderboard_display()
|
|
|
|
func _on_leaderboard_updated(sorted_scores: Array):
|
|
# Update the leaderboard panel locally
|
|
_update_leaderboard_display()
|
|
|
|
# Server broadcasts updated leaderboard to all clients
|
|
if multiplayer.is_server():
|
|
var player_data = []
|
|
for p in get_tree().get_nodes_in_group("Players"):
|
|
player_data.append({
|
|
# Use name.to_int() to correctly identify bots (Authority 1) vs Players
|
|
"peer_id": p.name.to_int(),
|
|
"name": p.display_name if not p.display_name.is_empty() else str(p.name),
|
|
"score": goals_cycle_manager.get_player_score(p.name.to_int()) if goals_cycle_manager else 0
|
|
})
|
|
rpc("sync_leaderboard_data", player_data)
|
|
|
|
# Update player rank visuals for everyone (Client + Server)
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
var sorted_players = []
|
|
for p in all_players:
|
|
var peer_id = p.name.to_int()
|
|
var score = goals_cycle_manager.get_player_score(peer_id) if goals_cycle_manager else 0
|
|
sorted_players.append({"node": p, "score": score})
|
|
|
|
# Sort by score descending (with Stop n Go winner priority)
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1:
|
|
sorted_players.sort_custom(func(a, b):
|
|
var a_id = a.node.name.to_int()
|
|
var b_id = b.node.name.to_int()
|
|
if a_id == stop_n_go_winner_id: return true
|
|
if b_id == stop_n_go_winner_id: return false
|
|
return a.score > b.score
|
|
)
|
|
else:
|
|
sorted_players.sort_custom(func(a, b): return a.score > b.score)
|
|
|
|
# Assign rank
|
|
for i in range(sorted_players.size()):
|
|
var p_node = sorted_players[i].node
|
|
var rank = i + 1
|
|
if p_node.has_method("update_rank_visuals"):
|
|
p_node.update_rank_visuals(rank)
|
|
|
|
func _on_global_timer_updated(time_remaining: float):
|
|
"""Update the global match timer display."""
|
|
var global_timer_panel = get_node_or_null("GlobalMatchTimer")
|
|
if global_timer_panel:
|
|
var timer_label = global_timer_panel.get_node_or_null("VBox/TimerLabel")
|
|
if timer_label:
|
|
var minutes = int(time_remaining) / 60
|
|
var seconds = int(time_remaining) % 60
|
|
timer_label.text = "%02d:%02d" % [minutes, seconds]
|
|
|
|
# Trigger countdown animations (30s and 15s)
|
|
if int(time_remaining) == 30 and int(time_remaining + 0.1) > 30:
|
|
if vfx_manager and vfx_manager.has_method("play_countdown_30s"):
|
|
vfx_manager.play_countdown_30s()
|
|
elif int(time_remaining) == 15 and int(time_remaining + 0.1) > 15:
|
|
if vfx_manager and vfx_manager.has_method("play_countdown_15s"):
|
|
vfx_manager.play_countdown_15s()
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func sync_game_end_stop_n_go(winner_id: int):
|
|
print("[STOP n GO] Game ended! Winner: ", winner_id)
|
|
stop_n_go_winner_id = winner_id
|
|
if goals_cycle_manager:
|
|
goals_cycle_manager.stop_n_go_winner_id = winner_id
|
|
|
|
var winner_name = "Player " + str(winner_id)
|
|
var player_node = get_node_or_null(str(winner_id))
|
|
if player_node:
|
|
winner_name = player_node.display_name
|
|
|
|
# Broadcast win (Validation already done in check_win_condition)
|
|
add_message_to_bar("MATCH COMPLETE", winner_name + " Wins with 8 Missions!", MessageType.GOAL)
|
|
|
|
# Stop logic
|
|
if stop_n_go_manager:
|
|
stop_n_go_manager.is_active = false
|
|
|
|
# Trigger match end
|
|
_on_match_ended()
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func sync_game_end_portal_mode(winner_id: int):
|
|
print("[TEKTON DOORS] Game ended! Winner: ", winner_id)
|
|
portal_mode_winner_id = winner_id
|
|
|
|
var winner_name = "Player " + str(winner_id)
|
|
var player_node = get_node_or_null(str(winner_id))
|
|
if player_node:
|
|
winner_name = player_node.display_name
|
|
|
|
# Broadcast win
|
|
add_message_to_bar("MATCH COMPLETE", winner_name + " Wins with 8 Missions!", MessageType.GOAL)
|
|
|
|
# Stop logic
|
|
if portal_mode_manager:
|
|
if portal_mode_manager.swap_timer: portal_mode_manager.swap_timer.stop()
|
|
if portal_mode_manager.tile_refresh_timer: portal_mode_manager.tile_refresh_timer.stop()
|
|
|
|
# Trigger match end
|
|
_on_match_ended()
|
|
|
|
func _on_match_ended():
|
|
"""Called when the global match timer ends - show game over screen."""
|
|
if is_match_ended:
|
|
return
|
|
|
|
is_match_ended = true
|
|
print("[Main] Match ended! Showing game over screen...")
|
|
|
|
# Signal Global Game End (Stops Bot ticks and logic)
|
|
GameStateManager.end_game()
|
|
|
|
# Freeze all game actors (Players, Bots, Tektons)
|
|
_freeze_all_game_actors()
|
|
|
|
# Show game over overlay
|
|
_show_game_over_panel()
|
|
|
|
func _freeze_all_game_actors():
|
|
"""Manually stop all game entities from processing without pausing the UI."""
|
|
print("[Main] Freezing all game actors recursively...")
|
|
|
|
var groups = ["Players", "Bots", "Tektons", "StaticTektonStands"]
|
|
for group_name in groups:
|
|
var nodes = get_tree().get_nodes_in_group(group_name)
|
|
for node in nodes:
|
|
_freeze_node_recursive(node)
|
|
|
|
func _freeze_node_recursive(node: Node):
|
|
"""Recursively disable processing and stop tweens for a node and its children."""
|
|
if node.has_method("set_physics_process"):
|
|
node.set_physics_process(false)
|
|
if node.has_method("set_process"):
|
|
node.set_process(false)
|
|
|
|
# Kill movement tweens if it's a character
|
|
if "_movement_tween" in node and node._movement_tween:
|
|
node._movement_tween.kill()
|
|
node._movement_tween = null
|
|
|
|
# Recursive call for all children
|
|
for child in node.get_children():
|
|
_freeze_node_recursive(child)
|
|
|
|
func _show_game_over_panel():
|
|
"""Instantiate and display the game over panel scene."""
|
|
# Check if panel already exists
|
|
var existing_layer = get_node_or_null("GameOverLayer")
|
|
if existing_layer:
|
|
existing_layer.show()
|
|
return
|
|
|
|
# Hide Gameplay UI
|
|
var actions_btn = get_node_or_null("TouchControls/TouchControls/ActionsBtn")
|
|
if actions_btn: actions_btn.hide()
|
|
|
|
var touch_layer = get_node_or_null("TouchLayer")
|
|
if touch_layer: touch_layer.hide()
|
|
|
|
var top_menu_ui = get_node_or_null("TopMenuUI")
|
|
if top_menu_ui: top_menu_ui.hide()
|
|
|
|
if stop_n_go_manager and stop_n_go_manager.hud_layer:
|
|
stop_n_go_manager.hud_layer.hide()
|
|
|
|
if portal_mode_manager and portal_mode_manager.hud_layer:
|
|
portal_mode_manager.hud_layer.hide()
|
|
|
|
# =========================================================================
|
|
# Gather + sort player data
|
|
# =========================================================================
|
|
var all_player_scores = []
|
|
for p in get_tree().get_nodes_in_group("Players"):
|
|
var pid = p.name.to_int()
|
|
all_player_scores.append({
|
|
"peer_id": pid,
|
|
"name": p.display_name if not p.display_name.is_empty() else str(p.name),
|
|
"score": goals_cycle_manager.get_player_score(pid) if goals_cycle_manager else 0,
|
|
"goal_count": goals_cycle_manager.player_goal_counts.get(pid, 0) if goals_cycle_manager else 0,
|
|
"character": p._selected_character if "_selected_character" in p else "Masbro"
|
|
})
|
|
|
|
# Sort players by score (with winner priority for special modes)
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1:
|
|
all_player_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
|
|
)
|
|
elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_winner_id != -1:
|
|
all_player_scores.sort_custom(func(a, b):
|
|
if a.peer_id == portal_mode_winner_id: return true
|
|
if b.peer_id == portal_mode_winner_id: return false
|
|
return a.score > b.score
|
|
)
|
|
else:
|
|
all_player_scores.sort_custom(func(a, b): return a.score > b.score)
|
|
|
|
# =========================================================================
|
|
# Instantiate the scene
|
|
# =========================================================================
|
|
var panel_scene = load("res://scenes/ui/game_over_panel.tscn")
|
|
if not panel_scene:
|
|
push_error("[Main] Failed to load game_over_panel.tscn")
|
|
return
|
|
|
|
# CanvasLayer for proper z-ordering
|
|
var canvas_layer = CanvasLayer.new()
|
|
canvas_layer.name = "GameOverLayer"
|
|
canvas_layer.layer = 50
|
|
add_child(canvas_layer)
|
|
|
|
var panel = panel_scene.instantiate()
|
|
canvas_layer.add_child(panel)
|
|
|
|
# Populate data
|
|
var local_peer_id = multiplayer.get_unique_id()
|
|
panel.setup(all_player_scores, local_peer_id)
|
|
|
|
# --- REPORT NAKAMA MATCH STATS ---
|
|
var local_player_won = false
|
|
var local_player_score = 0
|
|
|
|
if all_player_scores.size() > 0:
|
|
# all_player_scores is sorted descending by score or winner ID, so index 0 is the winner
|
|
if all_player_scores[0].peer_id == local_peer_id:
|
|
local_player_won = true
|
|
|
|
for data in all_player_scores:
|
|
if data.peer_id == local_peer_id:
|
|
local_player_score = data.score
|
|
break
|
|
|
|
# Send match result to Nakama storage via UserProfileManager (runs async)
|
|
if get_node_or_null("/root/UserProfileManager"):
|
|
UserProfileManager.record_game_result(local_player_won, local_player_score)
|
|
# ---------------------------------
|
|
|
|
# Connect signals
|
|
panel.back_pressed.connect(_on_back_to_menu_pressed)
|
|
panel.rematch_pressed.connect(func():
|
|
LobbyManager.request_rematch.rpc(multiplayer.get_unique_id())
|
|
)
|
|
|
|
LobbyManager.rematch_votes_updated.connect(func(count, required):
|
|
if is_instance_valid(panel):
|
|
panel.update_rematch_votes(count, required)
|
|
)
|
|
|
|
func _on_back_to_menu_pressed():
|
|
"""Return to lobby/main menu and clean up game state."""
|
|
print("[Main] Returning to lobby...")
|
|
|
|
# Proper ordered cleanup to avoid ghost players and desync
|
|
LobbyManager.leave_room()
|
|
|
|
# Small delay to let cleanup settle
|
|
await get_tree().create_timer(0.2).timeout
|
|
|
|
# Go back to lobby
|
|
if get_tree():
|
|
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
|
|
|
func _cleanup_multiplayer():
|
|
"""Properly leave Nakama match and cleanup multiplayer state."""
|
|
NakamaManager.cleanup()
|
|
|
|
func _deferred_init_leaderboard():
|
|
"""Initialize leaderboard after a delay to ensure all players are loaded."""
|
|
# Longer delay ensures players are synced
|
|
await get_tree().create_timer(1.5).timeout
|
|
|
|
# Request leaderboard sync from server for accurate data
|
|
if not multiplayer.is_server():
|
|
if 1 in multiplayer.get_peers():
|
|
rpc_id(1, "request_leaderboard_sync")
|
|
else:
|
|
# Server can update directly
|
|
_update_leaderboard_display()
|
|
|
|
@rpc("any_peer")
|
|
func request_leaderboard_sync():
|
|
"""Client requests leaderboard data from server."""
|
|
if multiplayer.is_server():
|
|
var sender_id = multiplayer.get_remote_sender_id()
|
|
# Build player list with peer_ids and names
|
|
var player_data = []
|
|
for p in get_tree().get_nodes_in_group("Players"):
|
|
player_data.append({
|
|
# Use name.to_int() for consistent ID
|
|
"peer_id": p.name.to_int(),
|
|
"name": p.display_name if not p.display_name.is_empty() else str(p.name),
|
|
"score": goals_cycle_manager.get_player_score(p.name.to_int()) if goals_cycle_manager else 0
|
|
})
|
|
rpc_id(sender_id, "sync_leaderboard_data", player_data)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_leaderboard_data(player_data: Array):
|
|
"""Receive leaderboard data from server and update UI."""
|
|
# Update local player node scores first so live UI tick stays synced
|
|
for data in player_data:
|
|
var p_node = get_node_or_null(str(data.peer_id))
|
|
if p_node:
|
|
p_node.score = data.score
|
|
|
|
var leaderboard_panel = get_node_or_null("LeaderboardPanel")
|
|
if not leaderboard_panel:
|
|
return
|
|
|
|
var vbox = leaderboard_panel.get_node_or_null("MarginContainer/VBox")
|
|
if not vbox:
|
|
return
|
|
|
|
# Sort by score descending (with Stop n Go winner priority)
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1:
|
|
player_data.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:
|
|
player_data.sort_custom(func(a, b): return a.score > b.score)
|
|
|
|
# Update entries
|
|
_render_leaderboard_entries(player_data)
|
|
|
|
func _update_leaderboard_display():
|
|
var leaderboard_panel = get_node_or_null("LeaderboardPanel")
|
|
if not leaderboard_panel:
|
|
return
|
|
|
|
# Try both possible paths for vbox
|
|
var vbox = leaderboard_panel.get_node_or_null("MarginContainer/VBox")
|
|
if not vbox:
|
|
vbox = leaderboard_panel.get_node_or_null("VBox")
|
|
if not vbox:
|
|
return
|
|
|
|
# Get all players in game
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
|
|
# Build scores array with all players
|
|
var player_data = []
|
|
for p in all_players:
|
|
var peer_id = p.name.to_int()
|
|
var score = goals_cycle_manager.get_player_score(peer_id) if goals_cycle_manager else 0
|
|
p.score = score # Assign locally so ui_manager.gd reads correct score
|
|
player_data.append({"peer_id": peer_id, "name": p.display_name if not p.display_name.is_empty() else str(p.name), "score": score})
|
|
|
|
# Sort by score descending (with Stop n Go winner priority)
|
|
if LobbyManager.game_mode == "Stop n Go" and stop_n_go_winner_id != -1:
|
|
player_data.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:
|
|
player_data.sort_custom(func(a, b): return a.score > b.score)
|
|
|
|
_render_leaderboard_entries(player_data)
|
|
|
|
func _render_leaderboard_entries(sorted_player_data: Array):
|
|
var leaderboard_panel = get_node_or_null("LeaderboardPanel")
|
|
if not leaderboard_panel: return
|
|
|
|
var vbox = leaderboard_panel.get_node_or_null("MarginContainer/VBox")
|
|
if not vbox: vbox = leaderboard_panel.get_node_or_null("VBox")
|
|
if not vbox: return
|
|
|
|
var my_id = multiplayer.get_unique_id()
|
|
var my_index = -1
|
|
for i in range(sorted_player_data.size()):
|
|
if sorted_player_data[i].peer_id == my_id:
|
|
my_index = i
|
|
break
|
|
|
|
# Determine items to display (Max 4 slots for HUD)
|
|
var items_to_display = []
|
|
|
|
# add top 3
|
|
for i in range(min(3, sorted_player_data.size())):
|
|
items_to_display.append({"data": sorted_player_data[i], "rank": i + 1})
|
|
|
|
# add 4th slot (Smart Slot)
|
|
if sorted_player_data.size() >= 4:
|
|
# If local player is outside top 3 (index > 2), show them in 4th slot
|
|
# But if they are exactly 4th (index 3), it's the same as showing 4th place.
|
|
# If they are 5th (index 4) or worse, we replace 4th place with them.
|
|
if my_index > 3:
|
|
# Show local player
|
|
items_to_display.append({"data": sorted_player_data[my_index], "rank": my_index + 1})
|
|
else:
|
|
# Show standard 4th place
|
|
items_to_display.append({"data": sorted_player_data[3], "rank": 4})
|
|
|
|
# Render
|
|
# We assume the HUD has 4 slots max, but code iterates up to 8 just in case UI has more and we want to hide them.
|
|
for i in range(8):
|
|
var entry = vbox.get_node_or_null("Entry" + str(i + 1))
|
|
if not entry: continue
|
|
|
|
# Only show up to 4 entries in this new "Smart" mode
|
|
if i < items_to_display.size() and i < 4:
|
|
var item = items_to_display[i]
|
|
var data = item.data
|
|
var rank = item.rank
|
|
|
|
var rank_label = entry.get_node_or_null("RankLabel")
|
|
var name_label = entry.get_node_or_null("NameLabel")
|
|
var score_label = entry.get_node_or_null("ScoreLabel")
|
|
|
|
if rank_label: rank_label.text = _get_ordinal(rank)
|
|
if name_label: name_label.text = str(data.name)
|
|
if score_label: score_label.text = str(data.score)
|
|
|
|
entry.visible = true
|
|
|
|
if data.peer_id == my_id:
|
|
entry.modulate = Color(1.0, 1.0, 0.0) # Yellow
|
|
else:
|
|
entry.modulate = Color.WHITE
|
|
else:
|
|
entry.visible = false
|
|
|
|
func _get_ordinal(n: int) -> String:
|
|
match n:
|
|
1: return "1st"
|
|
2: return "2nd"
|
|
3: return "3rd"
|
|
_: return str(n) + "th"
|
|
|
|
# =============================================================================
|
|
# Pause Menu & Settings
|
|
# =============================================================================
|
|
|
|
func _input(event):
|
|
if event.is_action_pressed("ui_cancel"):
|
|
_toggle_pause_menu()
|
|
|
|
# DEBUG: check all floors
|
|
if event is InputEventKey and event.pressed and event.keycode == KEY_F9:
|
|
check_all_floors()
|
|
|
|
func check_all_floors():
|
|
print("--- CHECKING ALL FLOORS (Debug F9) ---")
|
|
var enhanced_gridmap = get_node_or_null("EnhancedGridMap")
|
|
if not enhanced_gridmap:
|
|
print("Error: EnhancedGridMap not found.")
|
|
return
|
|
|
|
var missing_count = 0
|
|
var total_checked = 0
|
|
|
|
# Assuming standard 14x14 board (0-13)
|
|
for x in range(14):
|
|
for y in range(14):
|
|
total_checked += 1
|
|
var cell_3d = Vector3i(x, 0, y)
|
|
var item = enhanced_gridmap.get_cell_item(cell_3d)
|
|
|
|
if item == -1:
|
|
print("MISSING FLOOR at [%d, %d]! (Item: -1)" % [x, y])
|
|
missing_count += 1
|
|
# Optional: Auto-fix?
|
|
# enhanced_gridmap.set_cell_item(cell_3d, 1)
|
|
elif item == 6:
|
|
print("ICE/CRACK FLOOR at [%d, %d] (Item: 6)" % [x, y])
|
|
|
|
print("--- CHECK COMPLETE: Found %d missing floors out of %d checked. ---" % [missing_count, total_checked])
|
|
var msg_type = NotificationManager.MessageType.WARNING if missing_count > 0 else NotificationManager.MessageType.NORMAL
|
|
NotificationManager.send_message(GameStateManager.local_player_character, "Checked Floors: %d Missing" % missing_count, msg_type)
|
|
|
|
func _toggle_pause_menu():
|
|
var pause_menu = get_node_or_null("PauseMenu")
|
|
if pause_menu:
|
|
pause_menu.visible = not pause_menu.visible
|
|
|
|
# Hide NetworkInfo in pause menu as requested (too technical/unnecessary for local pause)
|
|
var network_info = pause_menu.get_node_or_null("Panel/NetworkPanel/NetworkInfo")
|
|
if network_info:
|
|
network_info.visible = false
|
|
|
|
func _on_resume_pressed():
|
|
var pause_menu = get_node_or_null("PauseMenu")
|
|
if pause_menu:
|
|
pause_menu.visible = false
|
|
# get_tree().paused = false # Removed for multiplayer consistency
|
|
|
|
func _on_how_to_play_pressed():
|
|
"""Open How To Play panel directly, regardless of where it was called from."""
|
|
var help_panel = get_node_or_null("HowToPlayPanel")
|
|
if help_panel:
|
|
help_panel.visible = true
|
|
|
|
func _on_how_to_play_back_pressed():
|
|
"""Just close the How To Play panel."""
|
|
var help_panel = get_node_or_null("HowToPlayPanel")
|
|
if help_panel:
|
|
help_panel.visible = false
|
|
|
|
func _on_settings_pressed():
|
|
var pause_menu = get_node_or_null("PauseMenu")
|
|
if pause_menu:
|
|
pause_menu.visible = false
|
|
|
|
var settings_menu = get_node_or_null("SettingsMenu")
|
|
if not settings_menu:
|
|
var scene = load("res://scenes/ui/settings_menu.tscn")
|
|
if scene:
|
|
settings_menu = scene.instantiate()
|
|
settings_menu.name = "SettingsMenu"
|
|
add_child(settings_menu)
|
|
|
|
# Connect close button
|
|
var close_btn = settings_menu.get_node_or_null("PanelContainer/VBoxContainer/Header/CloseButton")
|
|
if close_btn:
|
|
if close_btn.pressed.is_connected(_on_settings_back_pressed):
|
|
close_btn.pressed.disconnect(_on_settings_back_pressed)
|
|
close_btn.pressed.connect(_on_settings_back_pressed)
|
|
|
|
if settings_menu:
|
|
settings_menu.open()
|
|
|
|
func _on_quit_match_pressed():
|
|
get_tree().paused = false # Ensure unpaused when returning to menu
|
|
# Properly disconnect from Nakama and clear lobby state to prevent desync
|
|
LobbyManager.leave_room()
|
|
|
|
# Return to lobby via loading screen
|
|
var loading_screen_scene = load("res://scenes/loading_screen/loading_screen.tscn")
|
|
if loading_screen_scene:
|
|
var loading_screen = loading_screen_scene.instantiate()
|
|
get_tree().root.add_child(loading_screen)
|
|
loading_screen.load_level("res://scenes/lobby.tscn")
|
|
else:
|
|
# Fallback
|
|
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
|
|
|
func _on_settings_back_pressed():
|
|
var pause_menu = get_node_or_null("PauseMenu")
|
|
var settings_menu = get_node_or_null("SettingsMenu")
|
|
if settings_menu:
|
|
settings_menu.visible = false
|
|
if pause_menu:
|
|
pause_menu.visible = true
|
|
|
|
func _on_unstuck_pressed():
|
|
"""Teleport the local player to a safe spawn position when stuck."""
|
|
if _unstuck_cooldown_remaining > 0.0:
|
|
print("[Unstuck] On cooldown: %.0fs remaining" % _unstuck_cooldown_remaining)
|
|
return
|
|
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap:
|
|
print("[Unstuck] No gridmap found")
|
|
return
|
|
|
|
# Find the local player
|
|
var local_player = null
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
for player in all_players:
|
|
if player.is_multiplayer_authority():
|
|
local_player = player
|
|
break
|
|
|
|
if not local_player:
|
|
print("[Unstuck] No local player found")
|
|
return
|
|
|
|
# Find a safe spawn position using the spawn zone system
|
|
var safe_pos = _find_safe_spawn_position(enhanced_gridmap, local_player)
|
|
if safe_pos == Vector2i(-1, -1):
|
|
print("[Unstuck] Failed to find safe position")
|
|
return
|
|
|
|
# Teleport the player to the safe position
|
|
local_player.current_position = safe_pos
|
|
local_player.position = local_player.grid_to_world(safe_pos)
|
|
local_player.is_player_moving = false
|
|
|
|
# Sync the new position to all clients
|
|
if local_player.has_method("rpc"):
|
|
local_player.rpc("sync_position", safe_pos)
|
|
|
|
print("[Unstuck] Teleported player to safe position: %s" % safe_pos)
|
|
|
|
# Start cooldown
|
|
_unstuck_cooldown_remaining = UNSTUCK_COOLDOWN
|
|
var unstuck_btn = get_node_or_null("PauseMenu/Panel/VBox/UnstuckBtn")
|
|
if unstuck_btn:
|
|
unstuck_btn.disabled = true
|
|
|
|
# Close the pause menu
|
|
var pause_menu = get_node_or_null("PauseMenu")
|
|
if pause_menu:
|
|
pause_menu.visible = false
|
|
|
|
func _find_safe_spawn_position(gridmap: Node, player: Node) -> Vector2i:
|
|
"""Find a safe spawn position using the existing spawn zone system.
|
|
Prioritizes player zones but will fall back to any walkable position."""
|
|
|
|
# Get spawn zones with perimeter buffer
|
|
var spawn_zones = _get_spawn_zones(gridmap)
|
|
var player_zones = _get_player_spawn_zones(spawn_zones)
|
|
|
|
# Collect valid positions from player zones
|
|
var valid_positions = []
|
|
for zone in player_zones:
|
|
for x in range(zone.position.x, zone.position.x + zone.size.x):
|
|
for z in range(zone.position.y, zone.position.y + zone.size.y):
|
|
var cell = Vector3i(x, 0, z)
|
|
if gridmap.get_cell_item(cell) == 0: # Walkable floor
|
|
# Check for obstacles on layer 1
|
|
var layer1_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
|
if layer1_item == -1 or layer1_item in [7, 8, 9, 10, 11, 12, 13, 14]: # Empty or pickable tiles
|
|
# Check if position is not occupied by another player
|
|
var occupied = false
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
for p in all_players:
|
|
if p != player and p.current_position == Vector2i(x, z):
|
|
occupied = true
|
|
break
|
|
|
|
if not occupied:
|
|
valid_positions.append(Vector2i(x, z))
|
|
|
|
# If we found valid positions, pick a random one
|
|
if valid_positions.size() > 0:
|
|
valid_positions.shuffle()
|
|
return valid_positions[0]
|
|
|
|
# Fallback: Search the entire grid with buffer for ANY walkable position
|
|
for x in range(PERIMETER_BUFFER, gridmap.columns - PERIMETER_BUFFER):
|
|
for z in range(PERIMETER_BUFFER, gridmap.rows - PERIMETER_BUFFER):
|
|
var cell = Vector3i(x, 0, z)
|
|
if gridmap.get_cell_item(cell) == 0: # Walkable
|
|
var layer1_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
|
if layer1_item == -1 or layer1_item in [7, 8, 9, 10, 11, 12, 13, 14]:
|
|
var occupied = false
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
for p in all_players:
|
|
if p != player and p.current_position == Vector2i(x, z):
|
|
occupied = true
|
|
break
|
|
if not occupied:
|
|
return Vector2i(x, z)
|
|
|
|
# Ultimate fallback: center of the map
|
|
return Vector2i(gridmap.columns / 2, gridmap.rows / 2)
|
|
|
|
func _on_button_size_changed(value: float):
|
|
if touch_controls:
|
|
touch_controls.button_size = value
|
|
touch_controls._save_settings()
|
|
|
|
func _on_opacity_changed(value: float):
|
|
if touch_controls:
|
|
touch_controls.button_opacity = value
|
|
touch_controls._save_settings()
|
|
|
|
func _on_joystick_toggled(enabled: bool):
|
|
if touch_controls:
|
|
touch_controls.set_joystick_enabled(enabled)
|
|
touch_controls._save_settings()
|
|
|
|
func can_rpc() -> bool:
|
|
if not check_multiplayer(): return false
|
|
|
|
if LobbyManager.is_lan_mode:
|
|
return true
|
|
|
|
var nakama = get_node_or_null("/root/NakamaManager")
|
|
if nakama and nakama.has_method("is_connected_to_nakama"):
|
|
return nakama.is_connected_to_nakama()
|
|
|
|
return true
|
|
|
|
func check_multiplayer() -> bool:
|
|
"""Safety check for multiplayer peer access."""
|
|
if not is_inside_tree(): return false
|
|
# Accessing multiplayer here is safe because we checked is_inside_tree
|
|
var peer = multiplayer.multiplayer_peer
|
|
if not peer: return false
|
|
if peer.get_connection_status() == MultiplayerPeer.CONNECTION_DISCONNECTED: return false
|
|
return true
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func display_message(message: String, type: int = 0):
|
|
"""Broadcasts a message to the local player's UI. This is called via main.rpc from various managers."""
|
|
# Find local player
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
for player in all_players:
|
|
# Check if this player is controlled by THIS client
|
|
if player.is_multiplayer_authority():
|
|
if player.has_method("display_message"):
|
|
player.display_message(message, type)
|
|
break
|