792 lines
27 KiB
GDScript
792 lines
27 KiB
GDScript
extends Node
|
|
class_name StopNGoManager
|
|
|
|
# StopNGoManager - Handles phase transitions, missions, and movement penalties
|
|
|
|
signal phase_changed(new_phase: String, remaining_time: float)
|
|
signal mission_status_updated(player_id: int, completed: bool)
|
|
signal player_penalized(player_id: int)
|
|
|
|
enum Phase {GO, STOP}
|
|
|
|
const GO_DURATION: float = 15.0
|
|
const STOP_DURATION: float = 4.0
|
|
const REQUIRED_GOALS: int = 8
|
|
|
|
# Dynamic Safe Zone
|
|
const SAFE_ZONE_PRE_TIME: float = 5.0 # Seconds before STOP to spawn safe zone
|
|
const SAFE_ZONE_RADIUS: int = 2 # 5x5 area (radius 2 from center)
|
|
var safe_zone_centers: Array[Vector2i] = []
|
|
var safe_zone_spawned: bool = false
|
|
|
|
# Power-Up Tile Spawning
|
|
const POWERUP_TILES = [11, 14] # Speed (11) and Ghost (14)
|
|
const POWERUP_SPAWN_COUNT: int = 5 # Number of power-up tiles to spawn
|
|
var powerups_spawned: bool = false
|
|
var stop_phase_occurred: bool = false
|
|
|
|
const PERMANENT_POWERUP_LOCATIONS: Array[Vector2i] = [
|
|
Vector2i(4, 3), # Area 1
|
|
Vector2i(8, 7), # Area 2
|
|
Vector2i(11, 4), # Area 3
|
|
Vector2i(15, 8), # Area 4
|
|
Vector2i(18, 5) # Area 5
|
|
]
|
|
|
|
var current_phase: Phase = Phase.GO
|
|
var phase_timer: float = GO_DURATION
|
|
var is_active: bool = false
|
|
|
|
var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int}
|
|
var finish_line_x: int = 21 # Right side of the map for win condition
|
|
|
|
# Tile IDs
|
|
const TILE_WALKABLE = 0
|
|
const TILE_START = 3 # Start Line
|
|
const TILE_FINISH = 3 # Finish Line
|
|
const TILE_SAFE = 2 # Green Safe Zone
|
|
const TILE_OBSTACLE = 4 # Wall
|
|
const TILE_LIGHTNING_STONE = 15 # Ancient Rock with Lightning Symbol
|
|
|
|
var hud_layer: CanvasLayer
|
|
var mission_label: Label
|
|
var red_tint_overlay: ColorRect
|
|
|
|
# Traffic Light / StopTimer Visuals
|
|
var stop_timer_node: PanelContainer
|
|
var stop_segments: Array[Panel] = []
|
|
var lit_style: StyleBoxFlat
|
|
var dim_style: StyleBoxFlat
|
|
var red_style: StyleBoxFlat
|
|
|
|
func _ready():
|
|
set_process(false)
|
|
_setup_hud()
|
|
|
|
func _setup_hud():
|
|
hud_layer = CanvasLayer.new()
|
|
hud_layer.layer = 5 # Ensure it's above normal UI but below Pause Menu (10)
|
|
hud_layer.visible = false
|
|
add_child(hud_layer)
|
|
|
|
# Full-screen red tint overlay (below everything else in this layer, but above game)
|
|
red_tint_overlay = ColorRect.new()
|
|
red_tint_overlay.color = Color(1.0, 0.0, 0.0, 0.25) # Transparent red
|
|
red_tint_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) # Cover whole screen
|
|
red_tint_overlay.visible = false # Hidden initially
|
|
hud_layer.add_child(red_tint_overlay)
|
|
|
|
# New container for bottom-mid label
|
|
var bottom_container = CenterContainer.new()
|
|
bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
|
bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
|
bottom_container.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
|
bottom_container.offset_bottom = -50
|
|
hud_layer.add_child(bottom_container)
|
|
|
|
var custom_font = load("res://assets/fonts/Nougat-ExtraBlack.ttf")
|
|
|
|
mission_label = Label.new()
|
|
mission_label.text = "MISSION: Collect Goals"
|
|
mission_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
if custom_font: mission_label.add_theme_font_override("font", custom_font)
|
|
mission_label.add_theme_font_size_override("font_size", 28) # Slightly larger since it's centered
|
|
mission_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
|
mission_label.add_theme_constant_override("outline_size", 8)
|
|
bottom_container.add_child(mission_label)
|
|
|
|
func _process(delta):
|
|
if not is_active:
|
|
return
|
|
|
|
# Decrement timer locally for all peers (smoother HUD than waiting for RPC)
|
|
phase_timer -= delta
|
|
|
|
if multiplayer.is_server():
|
|
# Spawn safe zone 5 seconds before STOP phase begins
|
|
if current_phase == Phase.GO and not safe_zone_spawned and phase_timer <= SAFE_ZONE_PRE_TIME:
|
|
_spawn_safe_zone()
|
|
|
|
if phase_timer <= 0:
|
|
if current_phase == Phase.GO:
|
|
_start_phase(Phase.STOP)
|
|
else:
|
|
_start_phase(Phase.GO)
|
|
|
|
# Update HUD locally
|
|
_update_hud_visuals()
|
|
|
|
func _on_goal_count_updated(_peer_id: int, _count: int):
|
|
# Refresh visuals whenever points change
|
|
_update_hud_visuals()
|
|
|
|
var _has_notified_mission_complete: bool = false
|
|
|
|
func _update_hud_visuals():
|
|
# Toggle Red Screen Tint
|
|
if red_tint_overlay:
|
|
red_tint_overlay.visible = (current_phase == Phase.STOP)
|
|
|
|
var my_id = multiplayer.get_unique_id()
|
|
if mission_label:
|
|
var main = get_node_or_null("/root/Main")
|
|
var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager") if main else null
|
|
|
|
# Get count from GoalsCycleManager (Source of truth for PlayerBoardLabel)
|
|
var completed_count = goals_cycle_manager.player_goal_counts.get(my_id, 0) if goals_cycle_manager else 0
|
|
|
|
mission_label.text = "GOALS (%d/%d)" % [completed_count, REQUIRED_GOALS]
|
|
|
|
if completed_count >= REQUIRED_GOALS:
|
|
mission_label.text = "ALL GOALS COMPLETE!\nREACH THE FINISH!"
|
|
mission_label.add_theme_color_override("font_color", Color.GOLD)
|
|
|
|
# Notify player once
|
|
if my_id == multiplayer.get_unique_id() and not _has_notified_mission_complete:
|
|
_has_notified_mission_complete = true
|
|
var player_node = main.get_node_or_null(str(my_id))
|
|
if player_node:
|
|
NotificationManager.send_message(player_node, "ALL GOALS COMPLETE!", NotificationManager.MessageType.GOAL)
|
|
else:
|
|
mission_label.add_theme_color_override("font_color", Color.WHITE)
|
|
_has_notified_mission_complete = false
|
|
|
|
# Update StopTimer (Traffic Light)
|
|
_update_stop_timer_visuals()
|
|
|
|
func _update_stop_timer_visuals():
|
|
if not stop_timer_node:
|
|
# Try to find it once
|
|
var main = get_node_or_null("/root/Main")
|
|
if main:
|
|
stop_timer_node = main.get_node_or_null("StopTimer")
|
|
if stop_timer_node:
|
|
var hbox = stop_timer_node.get_node_or_null("HBox")
|
|
if hbox:
|
|
stop_segments.clear()
|
|
for i in range(3):
|
|
var seg = hbox.get_node_or_null("Segment%d" % i)
|
|
if seg: stop_segments.append(seg)
|
|
|
|
# Prepare styles
|
|
lit_style = StyleBoxFlat.new()
|
|
lit_style.bg_color = Color.YELLOW
|
|
lit_style.border_width_left = 2
|
|
lit_style.border_width_top = 2
|
|
lit_style.border_width_right = 2
|
|
lit_style.border_width_bottom = 2
|
|
lit_style.border_color = Color(1.0, 1.0, 1.0, 0.5)
|
|
|
|
dim_style = StyleBoxFlat.new()
|
|
dim_style.bg_color = Color(0.1, 0.1, 0.1, 0.8) # Dark dim
|
|
|
|
red_style = StyleBoxFlat.new()
|
|
red_style.bg_color = Color.RED
|
|
red_style.border_width_left = 2
|
|
red_style.border_width_top = 2
|
|
red_style.border_width_right = 2
|
|
red_style.border_width_bottom = 2
|
|
red_style.border_color = Color(1.0, 0.5, 0.5, 0.5)
|
|
|
|
if not stop_timer_node: return
|
|
|
|
# ALWAYS VISIBLE in Stop n Go mode
|
|
stop_timer_node.visible = true
|
|
|
|
if current_phase == Phase.GO:
|
|
# GO Phase: All dim unless in last 3 seconds
|
|
for i in range(stop_segments.size()):
|
|
var threshold = 3.0 - i
|
|
if phase_timer <= threshold:
|
|
stop_segments[i].add_theme_stylebox_override("panel", lit_style)
|
|
else:
|
|
stop_segments[i].add_theme_stylebox_override("panel", dim_style)
|
|
else:
|
|
# STOP Phase: All Red
|
|
for seg in stop_segments:
|
|
seg.add_theme_stylebox_override("panel", red_style)
|
|
|
|
func activate_client_side():
|
|
is_active = true
|
|
if hud_layer:
|
|
hud_layer.visible = true
|
|
|
|
# Connect to GoalsCycleManager for immediate HUD updates
|
|
var main = get_node_or_null("/root/Main")
|
|
if main:
|
|
var gcm = main.get_node_or_null("GoalsCycleManager")
|
|
if gcm and not gcm.goal_count_updated.is_connected(_on_goal_count_updated):
|
|
gcm.goal_count_updated.connect(_on_goal_count_updated)
|
|
|
|
set_process(true)
|
|
|
|
func start_game_mode():
|
|
# This should primarily be called by the Server
|
|
# Clients get activated via RPCs (sync_phase, etc)
|
|
if multiplayer.is_server():
|
|
activate_client_side() # Server also needs local processing
|
|
# _setup_arena() # REMOVED: Already explicitly called in main.gd _setup_host_game to prepare floor before spawns!
|
|
_assign_missions()
|
|
_start_phase(Phase.GO)
|
|
else:
|
|
# Clients just wait for updates, but can enable HUD if needed
|
|
# activate_client_side() can be called when first sync arrives
|
|
pass
|
|
|
|
func _start_phase(phase: Phase):
|
|
current_phase = phase
|
|
phase_timer = GO_DURATION if phase == Phase.GO else STOP_DURATION
|
|
|
|
var phase_name = "GO" if phase == Phase.GO else "STOP"
|
|
if can_rpc():
|
|
rpc("sync_phase", phase_name, phase_timer)
|
|
|
|
if phase == Phase.STOP:
|
|
stop_phase_occurred = true
|
|
# --- DYNAMIC SAFE ZONE: Penalize players outside the zone ---
|
|
if safe_zone_spawned:
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
for p in all_players:
|
|
if not _is_in_safe_zone(p.current_position):
|
|
_scatter_player_tiles(p)
|
|
|
|
# Refresh power-ups every STOP phase
|
|
_spawn_powerup_tiles()
|
|
|
|
# If GO phase starts, clear all STOP phase freezes and safe zone
|
|
if phase == Phase.GO:
|
|
_clear_safe_zone()
|
|
var all_players = get_tree().get_nodes_in_group("Players")
|
|
for p in all_players:
|
|
if p.has_method("sync_stop_freeze"):
|
|
p.rpc("sync_stop_freeze", false)
|
|
|
|
emit_signal("phase_changed", phase_name, phase_timer)
|
|
|
|
func can_rpc() -> bool:
|
|
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
|
return false
|
|
return true
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_phase(phase_name: String, duration: float):
|
|
if not is_active:
|
|
activate_client_side()
|
|
current_phase = Phase.GO if phase_name == "GO" else Phase.STOP
|
|
phase_timer = duration
|
|
|
|
func _setup_arena():
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
print("[StopNGo] Setting up 22x10 Arena with Randomized Obstacles...")
|
|
|
|
# Explicitly sync dimensions and clear grid on all clients
|
|
if can_rpc():
|
|
rpc("sync_arena_setup")
|
|
|
|
# Apply locally for Server (RPC is call_remote)
|
|
_apply_arena_setup()
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func sync_arena_setup():
|
|
print("[StopNGo] Client: Syncing Arena Setup (22x10)...")
|
|
_apply_arena_setup()
|
|
|
|
func _apply_arena_setup():
|
|
# Shared logic for resizing and clearing
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
# Fallback just in case
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
# Set Size for Stop n Go explicitly, bypassing setters that wipe the map
|
|
gridmap.set("columns", 22)
|
|
gridmap.set("rows", 10)
|
|
|
|
# Clear existing items on all layers
|
|
gridmap.clear()
|
|
|
|
# Dynamic Safe Zone: No static safe columns anymore
|
|
# Safe zone spawns randomly during gameplay
|
|
|
|
# Create bands based on X (Horizontal Progress)
|
|
for x in range(gridmap.columns):
|
|
var tile_id = TILE_WALKABLE
|
|
if x == 0:
|
|
tile_id = TILE_START
|
|
elif x == gridmap.columns - 1:
|
|
tile_id = TILE_FINISH
|
|
|
|
for z in range(gridmap.rows):
|
|
gridmap.set_cell_item(Vector3i(x, 0, z), tile_id)
|
|
|
|
# Note: Specific obstacles removed as per user request to replace with random ones.
|
|
# MISSION TILES: Moved to start_game_mode() to ensure they spawn AFTER walls.
|
|
|
|
gridmap.update_grid_data()
|
|
gridmap.initialize_astar()
|
|
|
|
func setup_mission_tiles():
|
|
"""Public wrapper to trigger mission tile spawning before game start."""
|
|
if multiplayer.is_server():
|
|
_spawn_mission_tiles()
|
|
|
|
func spawn_initial_powerups():
|
|
"""Public wrapper to spawn powerups before game start."""
|
|
if multiplayer.is_server():
|
|
_spawn_powerup_tiles()
|
|
|
|
func _spawn_mission_tiles():
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
# Forbidden Zones (Start, Finish) - No items here
|
|
var forbidden_x = [0, 21]
|
|
|
|
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
|
|
var goal_items = [7, 8, 9, 10]
|
|
|
|
for x in range(gridmap.columns):
|
|
if x in forbidden_x:
|
|
continue # Clear zone
|
|
|
|
for z in range(gridmap.rows):
|
|
# Ensure we don't spawn on obstacles
|
|
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
|
|
|
# PROTECTED FLOOR CHECK: Don't spawn on walls or void
|
|
if base_tile in [TILE_OBSTACLE, -1] or current_item == TILE_OBSTACLE or current_item == 13:
|
|
continue
|
|
|
|
# Spawn tiles with 60% density
|
|
if randf() > 0.6:
|
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
|
continue
|
|
|
|
var tile_type = goal_items[randi() % goal_items.size()]
|
|
gridmap.set_cell_item(Vector3i(x, 1, z), tile_type)
|
|
|
|
# Sync to clients
|
|
var main = get_node("/root/Main")
|
|
if main:
|
|
main.rpc("sync_grid_item", x, 1, z, tile_type)
|
|
|
|
func _assign_missions():
|
|
# NO-OP: Missions are now achievement-based (Complete 3 Goals)
|
|
# which is tracked natively by GoalsCycleManager.
|
|
pass
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_missions(missions: Dictionary):
|
|
player_missions = missions
|
|
|
|
func check_movement_violation(player_id: int, from: Vector2i, to: Vector2i) -> bool:
|
|
"""Check if movement is illegal (during STOP phase and not in safe zone)."""
|
|
if current_phase == Phase.STOP:
|
|
# Use dynamic safe zone position check instead of static tile check
|
|
if not _is_in_safe_zone(from):
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if gridmap:
|
|
var tile_from = gridmap.get_cell_item(Vector3i(from.x, 0, from.y))
|
|
if tile_from != TILE_START and tile_from != TILE_FINISH:
|
|
_penalize_player(player_id)
|
|
return true
|
|
return false
|
|
|
|
func _penalize_player(player_id: int):
|
|
if not multiplayer.is_server(): return
|
|
|
|
var main = get_node("/root/Main")
|
|
if not main: return
|
|
|
|
var player_node = main.get_node_or_null(str(player_id))
|
|
if player_node:
|
|
# We now use the 'on_stop_phase_violation' RPC which scatters tiles around the player
|
|
# like they got attacked, instead of just dropping them in one pile.
|
|
if can_rpc():
|
|
player_node.rpc("on_stop_phase_violation")
|
|
|
|
# Notification is also handled inside on_stop_phase_violation on the player node
|
|
emit_signal("player_penalized", player_id)
|
|
|
|
func update_mission_progress(_player_id: int, _tile_id: int):
|
|
# Redundant in Board-based mode, but kept for compatibility.
|
|
# The board is synced separately via sync_playerboard in playerboard_manager.gd.
|
|
pass
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func sync_mission_progress(_player_id: int, _mission_index: int, _current: int):
|
|
# Deprecated
|
|
pass
|
|
|
|
func is_mission_complete(player_id: int) -> bool:
|
|
var main = get_node_or_null("/root/Main")
|
|
if not main: return false
|
|
|
|
var goals_cycle_manager = main.get_node_or_null("GoalsCycleManager")
|
|
if not goals_cycle_manager: return false
|
|
|
|
var completed_count = goals_cycle_manager.player_goal_counts.get(player_id, 0)
|
|
return completed_count >= REQUIRED_GOALS
|
|
|
|
func check_win_condition(player_id: int, position: Vector2i) -> bool:
|
|
# 1. Must reach the finish line (Column 21)
|
|
if position.x < finish_line_x:
|
|
return false
|
|
|
|
# 2. Must have enough Goal Completions
|
|
if is_mission_complete(player_id):
|
|
print("[StopNGo] Player %d REACHED FINISH with goals complete!" % player_id)
|
|
return true
|
|
else:
|
|
# Inform the player locally if they reach the end without goals
|
|
var main = get_node_or_null("/root/Main")
|
|
var player_node = main.get_node_or_null(str(player_id)) if main else null
|
|
if player_node:
|
|
NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % REQUIRED_GOALS, NotificationManager.MessageType.WARNING)
|
|
|
|
print("[StopNGo] Player %d reached finish but goals incomplete." % player_id)
|
|
return false
|
|
|
|
# =============================================================================
|
|
# Dynamic Safe Zone
|
|
# =============================================================================
|
|
|
|
func _spawn_safe_zone():
|
|
"""Server: Pick a random walkable position and spawn the safe zone."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
# Collect valid center positions (account for wall footprint: radius + 1)
|
|
var spawn_buffer = SAFE_ZONE_RADIUS + 1
|
|
var valid_positions: Array[Vector2i] = []
|
|
for x in range(spawn_buffer, gridmap.columns - spawn_buffer):
|
|
for z in range(spawn_buffer, gridmap.rows - spawn_buffer):
|
|
var tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
# Only walkable tiles (not start/finish)
|
|
if tile == TILE_WALKABLE:
|
|
valid_positions.append(Vector2i(x, z))
|
|
|
|
if valid_positions.is_empty():
|
|
print("[StopNGo] WARNING: No valid position for safe zone!")
|
|
return
|
|
|
|
# Rank positions by how "open" they are (more walkable tiles in 5x5 area)
|
|
valid_positions.sort_custom(func(a, b):
|
|
return _count_walkable_neighbors(gridmap, a) > _count_walkable_neighbors(gridmap, b)
|
|
)
|
|
|
|
# Take the top 20 most open positions to pick from (adds randomness while ensuring good UX)
|
|
var top_picks = valid_positions.slice(0, min(20, valid_positions.size()))
|
|
top_picks.shuffle()
|
|
|
|
safe_zone_centers = []
|
|
|
|
# Pick 1st one
|
|
var first_center = top_picks.pop_front()
|
|
safe_zone_centers.append(first_center)
|
|
|
|
# Pick 2nd one (Must not overlap 5x5 area)
|
|
for pos in top_picks:
|
|
var dx = abs(pos.x - first_center.x)
|
|
var dz = abs(pos.y - first_center.y)
|
|
if dx > 5 or dz > 5:
|
|
safe_zone_centers.append(pos)
|
|
break
|
|
|
|
if safe_zone_centers.size() < 2:
|
|
# Fallback to shuffled valid_positions if top_picks was too restrictive
|
|
valid_positions.shuffle()
|
|
for pos in valid_positions:
|
|
if safe_zone_centers.size() >= 2: break
|
|
var dx = abs(pos.x - first_center.x)
|
|
var dz = abs(pos.y - first_center.y)
|
|
if dx > 5 or dz > 5:
|
|
safe_zone_centers.append(pos)
|
|
|
|
safe_zone_spawned = true
|
|
print("[StopNGo] Safe Zones spawned at %s (radius %d)" % [safe_zone_centers, SAFE_ZONE_RADIUS])
|
|
|
|
# Sync to all peers
|
|
if can_rpc():
|
|
rpc("sync_safe_zone", safe_zone_centers, SAFE_ZONE_RADIUS)
|
|
|
|
func _count_walkable_neighbors(gridmap: Node, center: Vector2i) -> int:
|
|
var count = 0
|
|
for dx in range(-SAFE_ZONE_RADIUS, SAFE_ZONE_RADIUS + 1):
|
|
for dz in range(-SAFE_ZONE_RADIUS, SAFE_ZONE_RADIUS + 1):
|
|
var tile = gridmap.get_cell_item(Vector3i(center.x + dx, 0, center.y + dz))
|
|
if tile == TILE_WALKABLE:
|
|
count += 1
|
|
return count
|
|
|
|
func _clear_safe_zone():
|
|
"""Server: Clear the safe zone overlay and reset state."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
if safe_zone_spawned:
|
|
# Copy the centers before we reset them so the RPC knows what to clear
|
|
var centers_to_clear = safe_zone_centers.duplicate()
|
|
|
|
# Reset internal state
|
|
safe_zone_spawned = false
|
|
safe_zone_centers = []
|
|
|
|
if can_rpc():
|
|
rpc("sync_clear_safe_zone", centers_to_clear)
|
|
else:
|
|
sync_clear_safe_zone(centers_to_clear)
|
|
|
|
func _is_in_safe_zone(pos: Vector2i) -> bool:
|
|
"""Check if a position is within ANY of the dynamic safe zones."""
|
|
if not safe_zone_spawned or safe_zone_centers.is_empty():
|
|
return false
|
|
|
|
for center in safe_zone_centers:
|
|
var dx = abs(pos.x - center.x)
|
|
var dz = abs(pos.y - center.y)
|
|
if dx <= SAFE_ZONE_RADIUS and dz <= SAFE_ZONE_RADIUS:
|
|
return true
|
|
return false
|
|
|
|
func _scatter_player_tiles(player_node: Node):
|
|
"""Server: Take all tiles from player's playerboard and scatter them onto nearby grid cells."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
var main = get_node_or_null("/root/Main")
|
|
if not main: return
|
|
|
|
var gridmap = main.get_node_or_null("EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
var peer_id = player_node.name.to_int()
|
|
var playerboard = player_node.playerboard
|
|
var tiles_to_scatter: Array[int] = []
|
|
|
|
# Collect all non-empty tiles from playerboard
|
|
for i in range(playerboard.size()):
|
|
if playerboard[i] != -1:
|
|
tiles_to_scatter.append(playerboard[i])
|
|
playerboard[i] = -1
|
|
|
|
if tiles_to_scatter.is_empty():
|
|
return # Nothing to scatter
|
|
|
|
# Find valid nearby positions to drop tiles (within radius 3 of player)
|
|
var center = player_node.current_position
|
|
var valid_drop_positions: Array[Vector2i] = []
|
|
for dx in range(-3, 4):
|
|
for dz in range(-3, 4):
|
|
var pos = Vector2i(center.x + dx, center.y + dz)
|
|
# Bounds check
|
|
if pos.x < 0 or pos.x >= gridmap.columns or pos.y < 0 or pos.y >= gridmap.rows:
|
|
continue
|
|
# Check floor is walkable (not void, not obstacle)
|
|
var floor_tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
|
if floor_tile == -1 or floor_tile == TILE_OBSTACLE:
|
|
continue
|
|
# Check floor 1 is empty (no existing item)
|
|
var existing_item = gridmap.get_cell_item(Vector3i(pos.x, 1, pos.y))
|
|
if existing_item != -1:
|
|
continue
|
|
valid_drop_positions.append(pos)
|
|
|
|
# Scatter tiles onto valid positions
|
|
var rng = RandomNumberGenerator.new()
|
|
rng.randomize()
|
|
|
|
for tile in tiles_to_scatter:
|
|
if valid_drop_positions.is_empty():
|
|
break # No more space
|
|
|
|
var idx = rng.randi() % valid_drop_positions.size()
|
|
var drop_pos = valid_drop_positions[idx]
|
|
valid_drop_positions.remove_at(idx)
|
|
|
|
# Place tile on grid Floor 1
|
|
gridmap.set_cell_item(Vector3i(drop_pos.x, 1, drop_pos.y), tile)
|
|
# Sync to all clients
|
|
main.rpc("sync_grid_item", drop_pos.x, 1, drop_pos.y, tile)
|
|
|
|
# Sync cleared playerboard to all clients
|
|
main.rpc("sync_playerboard", peer_id, playerboard)
|
|
|
|
# Notify the player
|
|
NotificationManager.send_message(player_node, "Not in Safe Zone! Tiles scattered!", NotificationManager.MessageType.WARNING)
|
|
|
|
# Screen shake
|
|
if player_node.has_method("trigger_screen_shake") and can_rpc():
|
|
player_node.rpc("trigger_screen_shake", "heavy")
|
|
|
|
print("[StopNGo] Scattered %d tiles from Player %d" % [tiles_to_scatter.size(), peer_id])
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_safe_zone(centers: Array, radius: int):
|
|
"""Client: Show the safe zone overlay on the grid."""
|
|
# GDScript 2.0 type conversion check for RPC arrays
|
|
safe_zone_centers = []
|
|
for c in centers:
|
|
safe_zone_centers.append(Vector2i(c.x, c.y))
|
|
|
|
safe_zone_spawned = true
|
|
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
# Paint safe zones on Floor 0 (Floor layer)
|
|
# Also paint walls (ID 16) around the zone with 1 door on each side
|
|
for center in safe_zone_centers:
|
|
# 1. Paint the safe floor
|
|
for dx in range(-radius, radius + 1):
|
|
for dz in range(-radius, radius + 1):
|
|
var x = center.x + dx
|
|
var z = center.y + dz
|
|
if x >= 0 and x < gridmap.columns and z >= 0 and z < gridmap.rows:
|
|
if gridmap.get_cell_item(Vector3i(x, 0, z)) == TILE_WALKABLE:
|
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_SAFE)
|
|
|
|
# 2. Paint the walls (item ID 16) ON LAYER 1 to block movement
|
|
# We scan from -3 to +3 to handle symmetric wall indices around the 5x5 zone
|
|
for dx in range(-radius - 1, radius + 2):
|
|
for dz in range(-radius - 1, radius + 2):
|
|
var x = center.x + dx
|
|
var z = center.y + dz
|
|
|
|
if x >= 0 and x < gridmap.columns and z >= 0 and z < gridmap.rows:
|
|
# big room logic: 5x5 span (Indices -2 to 2)
|
|
# North/West use -radius/-radius-1, South/East use radius/radius+1
|
|
var is_north = dz == -radius - 1 and dx >= -radius and dx <= radius
|
|
var is_south = dz == radius and dx >= -radius and dx <= radius
|
|
var is_west = dx == -radius and dz >= -radius and dz <= radius
|
|
var is_east = dx == radius + 1 and dz >= -radius and dz <= radius
|
|
|
|
var orientation = 0
|
|
var is_wall = false
|
|
|
|
if is_north:
|
|
orientation = 0
|
|
is_wall = true
|
|
elif is_south:
|
|
orientation = 0
|
|
is_wall = true
|
|
elif is_west:
|
|
orientation = 22
|
|
is_wall = true
|
|
elif is_east:
|
|
orientation = 22
|
|
is_wall = true
|
|
|
|
if is_wall:
|
|
# Door logic: Skip center (0) on the respective border line
|
|
var is_door = (is_north and dx == 0) or (is_south and dx == 0) or \
|
|
(is_west and dz == 0) or (is_east and dz == 0)
|
|
if is_door:
|
|
continue
|
|
|
|
gridmap.set_cell_item(Vector3i(x, 1, z), 16, orientation)
|
|
|
|
# Update pathfinding for bots and movement checks
|
|
gridmap.initialize_astar()
|
|
|
|
# Notify local player
|
|
var my_id = multiplayer.get_unique_id()
|
|
var main = get_node_or_null("/root/Main")
|
|
var player_node = main.get_node_or_null(str(my_id)) if main else null
|
|
if player_node:
|
|
NotificationManager.send_message(player_node, "⚠ Safe Zone spawned! Get inside!", NotificationManager.MessageType.WARNING)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_clear_safe_zone(centers_to_clear: Array):
|
|
"""Client: Clear the safe zone overlay."""
|
|
var gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
|
if not gridmap:
|
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
if not centers_to_clear.is_empty():
|
|
for center in centers_to_clear:
|
|
# Radius 2 (5x5)
|
|
for dx in range(-SAFE_ZONE_RADIUS, SAFE_ZONE_RADIUS + 1):
|
|
for dz in range(-SAFE_ZONE_RADIUS, SAFE_ZONE_RADIUS + 1):
|
|
var x = center.x + dx
|
|
var z = center.y + dz
|
|
if x >= 0 and x < gridmap.columns and z >= 0 and z < gridmap.rows:
|
|
# Restore Floor 0 back to standard walkable floor
|
|
var current = gridmap.get_cell_item(Vector3i(x, 0, z))
|
|
if current == TILE_SAFE:
|
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
|
|
|
# Also clean up walls ON LAYER 1
|
|
# Scan -3 to +3 range to ensure all shifted walls are cleared
|
|
for dx in range(-SAFE_ZONE_RADIUS - 1, SAFE_ZONE_RADIUS + 2):
|
|
for dz in range(-SAFE_ZONE_RADIUS - 1, SAFE_ZONE_RADIUS + 2):
|
|
var x = center.x + dx
|
|
var z = center.y + dz
|
|
if x >= 0 and x < gridmap.columns and z >= 0 and z < gridmap.rows:
|
|
# Clear any wall items on Floor 1
|
|
if gridmap.get_cell_item(Vector3i(x, 1, z)) != -1:
|
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
|
|
|
# Clear local state
|
|
safe_zone_centers = []
|
|
safe_zone_spawned = false
|
|
|
|
# Restore navigation
|
|
gridmap.initialize_astar()
|
|
|
|
print("[StopNGo] Safe Zones cleared.")
|
|
# Ensure local state is also updated in case this was just an RPC call
|
|
safe_zone_centers = []
|
|
safe_zone_spawned = false
|
|
|
|
# =============================================================================
|
|
# Power-Up Tile Spawning (Speed & Ghost)
|
|
# =============================================================================
|
|
|
|
func _spawn_powerup_tiles():
|
|
"""Server: Spawn 5 permanent power-up tiles at fixed positions."""
|
|
if not multiplayer.is_server(): return
|
|
|
|
var main = get_node_or_null("/root/Main")
|
|
if not main: return
|
|
|
|
var gridmap = main.get_node_or_null("EnhancedGridMap")
|
|
if not gridmap: return
|
|
|
|
print("[StopNGo] Spawning/Refreshing 5 static power-up tiles...")
|
|
|
|
for i in range(PERMANENT_POWERUP_LOCATIONS.size()):
|
|
var pos = PERMANENT_POWERUP_LOCATIONS[i]
|
|
|
|
# Set Floor 0 beneath power-up to ID 15 (Ancient Lightning Stone)
|
|
gridmap.set_cell_item(Vector3i(pos.x, 0, pos.y), TILE_LIGHTNING_STONE)
|
|
|
|
# Cycle through the available power-up types
|
|
var tile_id = POWERUP_TILES[i % POWERUP_TILES.size()]
|
|
|
|
# Place on Floor 1
|
|
gridmap.set_cell_item(Vector3i(pos.x, 1, pos.y), tile_id)
|
|
|
|
# Sync both floor and tile to all clients and host
|
|
if can_rpc():
|
|
main.rpc("sync_grid_item", pos.x, 0, pos.y, TILE_LIGHTNING_STONE) # Sync floor change (Stone on)
|
|
main.rpc("sync_grid_item", pos.x, 1, pos.y, tile_id) # Sync power-up
|
|
|
|
powerups_spawned = true
|
|
print("[StopNGo] Static power-up refresh completed.")
|