2568 lines
92 KiB
GDScript
2568 lines
92 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 vfx_manager
|
|
|
|
# Minimal local state
|
|
var _connection_check_timer: float = 0.0
|
|
var reserved_static_positions: Array[Vector2i] = []
|
|
|
|
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"
|
|
"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)
|
|
|
|
# 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
|
|
|
|
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()
|
|
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()
|
|
|
|
# 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
|
|
|
|
# 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":
|
|
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 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()
|
|
|
|
|
|
func _assign_random_spawn_positions():
|
|
"""Assign spawn positions distributed to 4 corners (2 per corner for 8 players)."""
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap:
|
|
return
|
|
|
|
# Lists for each quadrant
|
|
var spawns_TL = [] # Top-Left
|
|
var spawns_TR = [] # Top-Right
|
|
var spawns_BL = [] # Bottom-Left
|
|
var spawns_BR = [] # Bottom-Right
|
|
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
|
|
|
|
var mid_x = enhanced_gridmap.columns / 2
|
|
var mid_z = enhanced_gridmap.rows / 2
|
|
|
|
# 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)
|
|
|
|
for x in range(enhanced_gridmap.columns):
|
|
for z in range(enhanced_gridmap.rows):
|
|
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?
|
|
# Stand clears exactly 3x3 area
|
|
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
|
|
|
|
all_spawns.append(pos)
|
|
|
|
if x < mid_x and z < mid_z:
|
|
spawns_TL.append(pos)
|
|
elif x >= mid_x and z < mid_z:
|
|
spawns_TR.append(pos)
|
|
elif x < mid_x and z >= mid_z:
|
|
spawns_BL.append(pos)
|
|
else:
|
|
spawns_BR.append(pos)
|
|
|
|
# Sort lists by distance to corners (closest to corner should be last, to be popped first)
|
|
# TL: Close to (0,0) -> Sort descending distance (so closest is at end)
|
|
spawns_TL.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
|
|
|
|
# TR: Close to (13, 0)
|
|
var tr_corner = Vector2i(enhanced_gridmap.columns - 1, 0)
|
|
spawns_TR.sort_custom(func(a, b): return a.distance_squared_to(tr_corner) > b.distance_squared_to(tr_corner))
|
|
|
|
# BL: Close to (0, 13)
|
|
var bl_corner = Vector2i(0, enhanced_gridmap.rows - 1)
|
|
spawns_BL.sort_custom(func(a, b): return a.distance_squared_to(bl_corner) > b.distance_squared_to(bl_corner))
|
|
|
|
# BR: Close to (13, 13)
|
|
var br_corner = Vector2i(enhanced_gridmap.columns - 1, enhanced_gridmap.rows - 1)
|
|
spawns_BR.sort_custom(func(a, b): return a.distance_squared_to(br_corner) > b.distance_squared_to(br_corner))
|
|
|
|
# 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 to corners: TL, TR, BR, BL, TL, TR, BR, BL...
|
|
# Order: TL -> TR -> BR -> BL (Clockwise-ish)
|
|
var quadrants = [spawns_TL, spawns_TR, spawns_BR, spawns_BL]
|
|
|
|
for player in all_players:
|
|
var assigned_pos = Vector2i(-1, -1)
|
|
|
|
# Try to get from the current quadrant
|
|
var quadrant_idx = spawn_index % 4
|
|
var quadrant = quadrants[quadrant_idx]
|
|
|
|
if quadrant.size() > 0:
|
|
assigned_pos = quadrant.pop_back()
|
|
else:
|
|
# Fallback: Try other quadrants if preferred one is empty
|
|
for q in quadrants:
|
|
if q.size() > 0:
|
|
assigned_pos = q.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 a Tekton NPC at a random location."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
# Find random valid position
|
|
var enhanced_gridmap = $EnhancedGridMap
|
|
if not enhanced_gridmap: return
|
|
|
|
# Spawn 3 Roaming Tektons
|
|
var spawned_count = 0
|
|
var attempts = 0
|
|
|
|
while spawned_count < 3 and attempts < 50:
|
|
attempts += 1
|
|
|
|
# Find random valid position
|
|
var valid_pos = Vector2i(-1, -1)
|
|
var x = randi() % enhanced_gridmap.columns
|
|
var y = randi() % enhanced_gridmap.rows
|
|
var cell = Vector3i(x, 0, y)
|
|
|
|
# Check if walkable and no existing Tekton nearby?
|
|
if enhanced_gridmap.get_cell_item(cell) == 0: # Walkable floor
|
|
# Ensure not occupied by static tekton stand (Item 4)
|
|
var item_id = enhanced_gridmap.get_cell_item(Vector3i(x, 1, y))
|
|
if item_id == 4: continue # Wall/Stand
|
|
|
|
# Also check RESERVED positions (if they haven't spawned yet or for safety)
|
|
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_pos = Vector2i(x, y)
|
|
|
|
# Generate a consistent ID/Name for sync (add index to ensure uniqueness)
|
|
var tekton_id = Time.get_ticks_msec() + spawned_count
|
|
_create_tekton(valid_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", valid_pos, tekton_id)
|
|
|
|
spawned_count += 1
|
|
print("[Main] Spawned Tekton %d at %s" % [spawned_count, valid_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 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.action_points = 2
|
|
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()
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# =============================================================================
|
|
# 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...")
|
|
|
|
# Disable player controls
|
|
var local_player = GameStateManager.local_player_character
|
|
if local_player:
|
|
local_player.action_points = 0
|
|
|
|
# Signal Global Game End (Stops Bot ticks and logic)
|
|
GameStateManager.end_game()
|
|
|
|
# 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_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
|