Files
tekton/scripts/managers/stop_n_go_manager.gd
T

643 lines
23 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}
# 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, Ghost (Freeze and Wall excluded in this mode)
const POWERUP_SPAWN_COUNT: int = 5 # Number of power-up tiles to spawn
var powerups_spawned: bool = false
var stop_phase_occurred: bool = false
var safe_zone_wall_scene = preload("res://scenes/safe_zone_wall.tscn")
const PERMANENT_POWERUP_LOCATIONS: Array[Vector2i] = [
Vector2i(4, 1), # Power up 1
Vector2i(3, 9), # Power up 2
Vector2i(9, 6), # Power up 3
Vector2i(16, 2), # Power up 4
Vector2i(18, 8) # Power up 5
]
var current_phase: Phase = Phase.GO
var phase_timer: float = 15.0 # Initialized dynamically later
var is_active: bool = false
var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int}
var finish_line_x: int = 22 # 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():
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
var required_goals = LobbyManager.sng_required_goals
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 = float(LobbyManager.sng_go_duration) if phase == Phase.GO else float(LobbyManager.sng_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
# --- STATIC SAFE ZONE: Penalize players outside the zone ---
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
if phase == Phase.GO:
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 (23x12)...")
_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", 23)
gridmap.set("rows", 12)
# Clear existing items on all layers
gridmap.clear()
# Dynamic Safe Zone: No static safe columns anymore
# Safe zone spawns randomly during gameplay
var non_walkable_coords = [
Vector2i(0,0), Vector2i(1,0), Vector2i(2,0), Vector2i(3,0), Vector2i(4,0), Vector2i(5,0), Vector2i(6,0), Vector2i(7,0), Vector2i(8,0), Vector2i(9,0), Vector2i(10,0), Vector2i(13,0), Vector2i(19,0), Vector2i(20,0), Vector2i(21,0), Vector2i(22,0),
Vector2i(0,1), Vector2i(1,1), Vector2i(2,1), Vector2i(3,1), Vector2i(6,1),
Vector2i(0,2), Vector2i(1,2), Vector2i(2,2), Vector2i(3,2),
Vector2i(17,9), Vector2i(18,9), Vector2i(19,9), Vector2i(20,9), Vector2i(21,9), Vector2i(22,9),
Vector2i(11,10), Vector2i(12,10), Vector2i(13,10), Vector2i(15,10), Vector2i(16,10), Vector2i(17,10), Vector2i(18,10), Vector2i(19,10), Vector2i(20,10), Vector2i(21,10), Vector2i(22,10),
Vector2i(0,11), Vector2i(4,11), Vector2i(5,11), Vector2i(6,11), Vector2i(9,11), Vector2i(10,11), Vector2i(11,11), Vector2i(12,11), Vector2i(13,11), Vector2i(14,11), Vector2i(15,11), Vector2i(16,11), Vector2i(17,11), Vector2i(18,11), Vector2i(19,11), Vector2i(20,11), Vector2i(21,11), Vector2i(22,11),
# Fixed User Obstacles Below:
Vector2i(2,8), # Cactus 1
Vector2i(4,4), # Cactus 2
Vector2i(12,6), # Cactus 3
Vector2i(12,9), # Cactus 4
Vector2i(5,9), Vector2i(5,10), # Wall rock 1
Vector2i(10,1), Vector2i(10,2), Vector2i(11,1), # Wall rock 2
Vector2i(17,6), # Wall rock 3
Vector2i(7,4), Vector2i(7,5), # Tree 1
Vector2i(10,4), # Tree 2
Vector2i(13,3), Vector2i(14,3), Vector2i(14,4), # Tree 3
Vector2i(20,4), Vector2i(20,5), # Tree 4
Vector2i(9,8), # Statue 1
Vector2i(17,3) # Statue 2
]
# Create bands based on X (Horizontal Progress)
for x in range(gridmap.columns):
for z in range(gridmap.rows):
var current_pos = Vector2i(x, z)
if current_pos in non_walkable_coords:
gridmap.set_cell_item(Vector3i(x, 0, z), -1) # empty space / void
gridmap.set_cell_item(Vector3i(x, 1, z), TILE_OBSTACLE) # wall block
continue
var tile_id = TILE_WALKABLE
if x == 0:
tile_id = TILE_START
elif x == gridmap.columns - 1:
tile_id = TILE_FINISH
gridmap.set_cell_item(Vector3i(x, 0, z), tile_id)
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
# Paint Static Safe Zones
_paint_static_safe_zone(gridmap, 7, 11, 6, 9, -1, -1, 8, 8)
_paint_static_safe_zone(gridmap, 15, 19, 1, 5, -1, 18, 2, 2)
# 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, gridmap.columns - 1]
# 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, void, or static powerup pods
if base_tile in [TILE_OBSTACLE, -1, TILE_LIGHTNING_STONE] or current_item == TILE_OBSTACLE:
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 >= LobbyManager.sng_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!" % LobbyManager.sng_required_goals, NotificationManager.MessageType.WARNING)
print("[StopNGo] Player %d reached finish but goals incomplete." % player_id)
return false
# =============================================================================
# Static Safe Zone
# =============================================================================
func _paint_static_safe_zone(gridmap: Node, min_x: int, max_x: int, min_z: int, max_z: int, north_door_x: int = -1, south_door_x: int = -1, west_door_z: int = -1, east_door_z: int = -1):
# Paint safe floor
for x in range(min_x, max_x + 1):
for z in range(min_z, max_z + 1):
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_SAFE)
# Get center opening for horizontal walls
var center_n_x = north_door_x if north_door_x != -1 else int(float(min_x + max_x) / 2.0)
var center_s_x = south_door_x if south_door_x != -1 else int(float(min_x + max_x) / 2.0)
# Instantiate Top and Bottom horizontal walls
for x in range(min_x, max_x + 1):
if x != center_n_x:
_instantiate_safe_zone_wall(Vector3(x + 0.5, 0.0, min_z), 0) # Bottom/North
if x != center_s_x:
_instantiate_safe_zone_wall(Vector3(x + 0.5, 0.0, max_z + 1), 0) # Top/South
# Get center opening for vertical walls
var center_w_z = west_door_z if west_door_z != -1 else int(float(min_z + max_z) / 2.0)
var center_e_z = east_door_z if east_door_z != -1 else int(float(min_z + max_z) / 2.0)
for z in range(min_z, max_z + 1):
if z != center_w_z:
_instantiate_safe_zone_wall(Vector3(min_x, 0.0, z + 0.5), 90) # Left/West
if z != center_e_z:
_instantiate_safe_zone_wall(Vector3(max_x + 1, 0.0, z + 0.5), 90) # Right/East
func _is_in_safe_zone(pos: Vector2i) -> bool:
"""Check if a position is within ANY of the static safe zones."""
# Safe zone 1: [7,6] - [11,9]
if pos.x >= 7 and pos.x <= 11 and pos.y >= 6 and pos.y <= 9: return true
# Safe zone 2: [15,1] - [19,5]
if pos.x >= 15 and pos.x <= 19 and pos.y >= 1 and pos.y <= 5: 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, not static powerup spawn)
var floor_tile = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
if floor_tile == -1 or floor_tile == TILE_OBSTACLE or floor_tile == TILE_LIGHTNING_STONE:
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])
# Removed dynamic sync methods.
func _instantiate_safe_zone_wall(pos: Vector3, rotation_deg: float):
if not safe_zone_wall_scene: return
var wall = safe_zone_wall_scene.instantiate()
add_child(wall)
wall.add_to_group("SafeZoneWalls")
wall.position = pos
wall.rotation_degrees.y = rotation_deg
# =============================================================================
# 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)
# Select a random power-up type (User Request: ensure all types can spawn)
var tile_id = POWERUP_TILES.pick_random()
# 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.")