extends Node class_name GauntletManager # GauntletManager - Handles Candy Cannon Survival (Gauntlet) game mode # Pattern: StopNGoManager + PortalModeManager signal phase_changed(phase_index: int, phase_name: String) signal cannon_fired(targets: Array) signal player_trapped(player_id: int) signal cleanser_granted(player_id: int) # ============================================================================= # Constants # ============================================================================= const ARENA_COLUMNS: int = 20 const ARENA_ROWS: int = 20 const NPC_SIZE: int = 3 const NPC_CENTER: Vector2i = Vector2i(9, 9) # Center of 20x20 (0-indexed, center of 3x3 block) # Tile IDs (matching MeshLibrary) const TILE_WALKABLE: int = 0 const TILE_OBSTACLE: int = 4 const TILE_STICKY: int = 17 # New candy-pink overlay (Layer 2) const TILE_TELEGRAPH: int = 18 # Warning glow (Layer 2, temporary) # Phase timing thresholds (seconds elapsed) const PHASE_1_START: float = 0.0 # Open Arena const PHASE_2_START: float = 60.0 # Route Pressure const PHASE_3_START: float = 120.0 # Survival Endgame # ============================================================================= # Phase System # ============================================================================= enum Phase { OPEN_ARENA, ROUTE_PRESSURE, SURVIVAL_ENDGAME } var current_phase: Phase = Phase.OPEN_ARENA var elapsed_time: float = 0.0 var is_active: bool = false # ============================================================================= # Cannon State # ============================================================================= var cannon_timer: float = 0.0 var cannon_interval: float = 5.0 # seconds between volleys var volley_size: int = 5 var sticky_cells: Dictionary = {} # Vector2i → true var last_targeted_player_id: int = -1 # Phase-specific cannon parameters var phase_configs: Array = [ # Phase 0 (Open Arena): slow, small volleys {"interval": 5.0, "volley": 5, "telegraph_time": 1.2}, # Phase 1 (Route Pressure): faster, bigger volleys {"interval": 4.0, "volley": 8, "telegraph_time": 1.0}, # Phase 2 (Survival Endgame): rapid fire, huge volleys {"interval": 3.0, "volley": 12, "telegraph_time": 0.8}, ] # ============================================================================= # Smack State (per-player) # ============================================================================= func has_smack_charged(pid: int) -> bool: if smack_charged.has(pid) and smack_charged[pid] > 0: return true return false @rpc("any_peer", "call_local", "reliable") func consume_smack(pid: int) -> void: # Local state reset smack_charged[pid] = 0.0 smack_cooldowns[pid] = SMACK_COOLDOWN # Play smack sound if SfxManager: SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode") var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() if curr_pid == pid: if player.has_method("sync_modulate"): if _can_rpc(): player.rpc("sync_modulate", Color.WHITE) else: player.sync_modulate(Color.WHITE) break var smack_cooldowns: Dictionary = {} # player_id → float (time remaining) var smack_charged: Dictionary = {} # player_id → float (charge window remaining) const SMACK_COOLDOWN: float = 8.0 const SMACK_CHARGE_WINDOW: float = 3.0 # ============================================================================= # Cleanser Tracking # ============================================================================= var player_mission_completions: Dictionary = {} # player_id → int var player_cleansers: Dictionary = {} # player_id → int (0 or 1) var cleanser_active: Dictionary = {} # player_id → true when immunity active var cleanser_cells_left: Dictionary = {} # player_id → int (cells remaining) const CLEANSER_MAX_CELLS: int = 5 const CLEANSER_ACTIVATION_DELAY: float = 0.3 # ============================================================================= # Trapped Players # ============================================================================= var trapped_players: Dictionary = {} # player_id → true # ============================================================================= # Slow-Mo Effect # ============================================================================= var slowmo_active: bool = false var slowmo_timer: float = 0.0 var slowmo_duration: float = 4.0 const SLOWMO_SCALE: float = 0.25 # 1/4 speed var slowmo_overlay: ColorRect = null # ============================================================================= # References # ============================================================================= var main_scene: Node = null var gridmap: Node = null var candy_cannon_scene: PackedScene = preload("res://scenes/candy_cannon.tscn") var cannon_instance: Node3D = null # HUD var hud_layer: CanvasLayer var phase_label: Label var cleanser_label: Label var cleanser_icon: TextureRect var cleanser_count: int = 0 var slowmo_label: Label var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn") # ============================================================================= # Lifecycle # ============================================================================= func _ready(): set_process(false) _setup_hud() func _exit_tree(): # Ensure time_scale is always restored when leaving Gauntlet mode Engine.time_scale = 1.0 func initialize(main: Node, grid: Node) -> void: main_scene = main gridmap = grid print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null") # Connect to GoalsCycleManager for scoring and mission tracking if main_scene: var gcm = main_scene.get_node_or_null("GoalsCycleManager") if gcm: gcm.goal_count_updated.connect(_on_goal_count_updated) gcm.score_updated.connect(_on_score_updated) print("[Gauntlet] Connected to GoalsCycleManager") func _process(delta: float) -> void: if not is_active: return elapsed_time += delta # Phase escalation _check_phase_transition() # Server only logic if multiplayer.is_server(): # Cannon timer cannon_timer -= delta if cannon_timer <= 0.0: _fire_volley() cannon_timer = cannon_interval # Smack mechanic update (ALL PEERS) var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() # Allow local peer to predict setup if not smack_cooldowns.has(pid) and not smack_charged.has(pid): smack_cooldowns[pid] = SMACK_COOLDOWN smack_charged[pid] = 0.0 if smack_cooldowns[pid] > 0: smack_cooldowns[pid] -= delta if smack_cooldowns[pid] <= 0: smack_cooldowns[pid] = 0.0 smack_charged[pid] = SMACK_CHARGE_WINDOW if player.has_method("sync_modulate"): if multiplayer.is_server() and _can_rpc(): player.rpc("sync_modulate", Color.PINK) elif not multiplayer.is_server(): player.sync_modulate(Color.PINK) elif smack_charged[pid] > 0: smack_charged[pid] -= delta if smack_charged[pid] <= 0: smack_charged[pid] = 0.0 smack_cooldowns[pid] = SMACK_COOLDOWN if player.has_method("sync_modulate"): if multiplayer.is_server() and _can_rpc(): player.rpc("sync_modulate", Color.WHITE) elif not multiplayer.is_server(): player.sync_modulate(Color.WHITE) # Cleanser input (local player only) if Input.is_action_just_pressed("use_cleanser"): _try_use_cleanser() # Slow-mo timer (all peers for visual consistency) if slowmo_active: slowmo_timer -= delta if slowmo_timer <= 0: _end_slowmo() # ============================================================================= # Game Mode Start # ============================================================================= func start_game_mode() -> void: if multiplayer.is_server(): activate_client_side() _start_phase(Phase.OPEN_ARENA) func activate_client_side() -> void: is_active = true if hud_layer: hud_layer.visible = true set_process(true) # ============================================================================= # Phase Management # ============================================================================= func _check_phase_transition() -> void: var new_phase = current_phase if elapsed_time >= PHASE_3_START: new_phase = Phase.SURVIVAL_ENDGAME elif elapsed_time >= PHASE_2_START: new_phase = Phase.ROUTE_PRESSURE if new_phase != current_phase: _start_phase(new_phase) func _start_phase(phase: Phase) -> void: current_phase = phase var config = phase_configs[int(phase)] cannon_interval = config["interval"] volley_size = config["volley"] cannon_timer = cannon_interval var phase_name = _phase_to_string(phase) print("[Gauntlet] Phase changed to: ", phase_name) if _can_rpc(): rpc("sync_phase", int(phase), phase_name) emit_signal("phase_changed", int(phase), phase_name) func _phase_to_string(phase: Phase) -> String: match phase: Phase.OPEN_ARENA: return "Open Arena" Phase.ROUTE_PRESSURE: return "Route Pressure" Phase.SURVIVAL_ENDGAME: return "Survival!" _: return "Unknown" @rpc("authority", "call_local", "reliable") func sync_phase(phase_index: int, phase_name: String) -> void: if not is_active: activate_client_side() current_phase = phase_index as Phase var config = phase_configs[phase_index] cannon_interval = config["interval"] volley_size = config["volley"] _update_hud_phase(phase_name) # ============================================================================= # Arena Setup # ============================================================================= func _setup_arena() -> void: """Called by host in main._setup_host_game()""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: push_error("[Gauntlet] No EnhancedGridMap found!") return print("[Gauntlet] Setting up %dx%d Arena..." % [ARENA_COLUMNS, ARENA_ROWS]) # Sync to clients if _can_rpc(): rpc("sync_arena_setup") # Apply locally for server _apply_arena_setup() @rpc("authority", "call_remote", "reliable") func sync_arena_setup() -> void: print("[Gauntlet] Client: Syncing Arena Setup (%dx%d)..." % [ARENA_COLUMNS, ARENA_ROWS]) _apply_arena_setup() func _apply_arena_setup() -> void: """Shared arena layout logic for host + clients.""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Resize grid (bypass setters that wipe the map) gridmap.set("columns", ARENA_COLUMNS) gridmap.set("rows", ARENA_ROWS) # Clear all gridmap.clear() # Build the 20x20 arena for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) # Center 3x3 block: NPC obstacle (Candy Cannon) if _is_npc_zone(pos): gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue # Boundary walls: perimeter (row 0, row 19, col 0, col 19) if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) continue # Interior: walkable floor gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) gridmap.diagonal_movement = true gridmap.update_grid_data() gridmap.initialize_astar() if not cannon_instance and main_scene: cannon_instance = candy_cannon_scene.instantiate() cannon_instance.name = "CandyCannon" var cx = NPC_CENTER.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0 var cz = NPC_CENTER.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 cannon_instance.position = Vector3(cx, 0, cz) main_scene.add_child(cannon_instance) print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [ NPC_CENTER.x, NPC_CENTER.y ]) func _is_npc_zone(pos: Vector2i) -> bool: """Check if a position is within the center 3x3 NPC zone.""" var half = NPC_SIZE / 2 # integer division = 1 var min_coord = NPC_CENTER - Vector2i(half, half) # (8, 8) var max_coord = NPC_CENTER + Vector2i(half, half) # (10, 10) return pos.x >= min_coord.x and pos.x <= max_coord.x and pos.y >= min_coord.y and pos.y <= max_coord.y func get_spawn_points(player_count: int) -> Array[Vector2i]: """Return spawn positions based on player count. Inside boundary walls.""" # 4 players: inner corners var spawns_4: Array[Vector2i] = [ Vector2i(1, 1), # Top-left Vector2i(18, 1), # Top-right Vector2i(1, 18), # Bottom-left Vector2i(18, 18), # Bottom-right ] # 6 players: corners + mid-edges (top/bottom) var spawns_6: Array[Vector2i] = spawns_4.duplicate() spawns_6.append(Vector2i(10, 1)) # Top-mid spawns_6.append(Vector2i(10, 18)) # Bottom-mid # 8 players: corners + all mid-edges var spawns_8: Array[Vector2i] = spawns_6.duplicate() spawns_8.append(Vector2i(1, 10)) # Left-mid spawns_8.append(Vector2i(18, 10)) # Right-mid match player_count: 4: return spawns_4 5, 6: return spawns_6 _, 7, 8: return spawns_8 _: return spawns_4 # ============================================================================= # Tile Spawning & Mission System (Task #3) # ============================================================================= func setup_mission_tiles() -> void: """Public wrapper called from main.gd before countdown. Server-only.""" if multiplayer.is_server(): _spawn_mission_tiles() func _spawn_mission_tiles() -> void: """Distribute colored goal tiles across the 20x20 arena. Follows StopNGoManager._spawn_mission_tiles() pattern. Excludes center 3x3 NPC zone.""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Goal items: Heart(7), Diamond(8), Star(9), Coin(10) var goal_items = [7, 8, 9, 10] var tiles_spawned: int = 0 for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) # Skip NPC cannon zone (center 3x3) if _is_npc_zone(pos): continue # Check base floor — don't spawn on obstacles or void var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z)) if base_tile == TILE_OBSTACLE or base_tile == -1: continue # Skip if something already exists on Layer 1 var current_item = gridmap.get_cell_item(Vector3i(x, 1, z)) if current_item != -1: continue # Spawn tiles with 60% density (40% chance to skip) if randf() > 0.6: continue var tile_type = goal_items[randi() % goal_items.size()] gridmap.set_cell_item(Vector3i(x, 1, z), tile_type) tiles_spawned += 1 # Sync to clients var main = get_node("/root/Main") if main: main.rpc("sync_grid_item", x, 1, z, tile_type) print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS]) # ============================================================================= # Cannon Logic (Server Only) # ============================================================================= func _fire_volley() -> void: """Select target cells, highlight, telegraph, then apply sticky after delay.""" if not multiplayer.is_server(): return var targets = _select_targets() if targets.is_empty(): return var config = phase_configs[int(current_phase)] var telegraph_time = config["telegraph_time"] var highlight_time: float = 0.8 # Floor highlight duration before telegraph # Highlight phase — show pulsing floor warning BEFORE telegraph if _can_rpc(): rpc("sync_telegraph_highlight", targets) await get_tree().create_timer(highlight_time).timeout # Telegraph phase — show warning overlay if _can_rpc(): rpc("sync_telegraph", targets) # Shoot projectiles visually with 0.1s offset between shots if cannon_instance and cannon_instance.has_method("spawn_projectile_rpc") and cannon_instance.can_rpc(): var cs = gridmap.cell_size for i in range(targets.size()): var target = targets[i] var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0) # Stagger shots: 0.1s offset per projectile await get_tree().create_timer(i * 0.1).timeout cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time) # Wait remaining telegraph duration, then apply impact var remaining_time = telegraph_time - (targets.size() - 1) * 0.1 if remaining_time > 0: await get_tree().create_timer(remaining_time).timeout if _can_rpc(): rpc("sync_impact", targets) emit_signal("cannon_fired", targets) func _select_targets() -> Array: """Pick target cells for this volley based on current phase weights.""" var targets: Array = [] var all_players = get_tree().get_nodes_in_group("Players") # Collect all valid walkable positions (excluding NPC zone and existing sticky) var valid_positions: Array = [] for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) if _is_npc_zone(pos): continue if sticky_cells.has(pos): continue valid_positions.append(pos) if valid_positions.is_empty(): return targets # Simple targeting: mix of random + player-adjacent var remaining = volley_size # 40% of volley near players var player_targets = int(remaining * 0.4) for i in range(player_targets): if all_players.is_empty(): break # Pick a random player var player = all_players[randi() % all_players.size()] var player_pos = player.current_position if player.get("current_position") else Vector2i(10, 10) # Pick a cell near them (within 3 tiles) var nearby = _get_nearby_valid_cells(player_pos, 3, valid_positions) if not nearby.is_empty(): var target = nearby[randi() % nearby.size()] if target not in targets: targets.append(target) remaining -= 1 # Remaining: random scatter valid_positions.shuffle() for pos in valid_positions: if remaining <= 0: break if pos not in targets: targets.append(pos) remaining -= 1 return targets func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Array: var result: Array = [] for pos in valid: if abs(pos.x - center.x) <= radius and abs(pos.y - center.y) <= radius: result.append(pos) return result # ============================================================================= # Telegraph & Impact (RPCs) # ============================================================================= @rpc("authority", "call_local", "reliable") func sync_telegraph_highlight(targets: Array) -> void: """Show pulsing floor highlight on target cells BEFORE the telegraph drop.""" if not gridmap: return # Create programmatic highlight overlays (pulsing circles on floor) for target in targets: var pos = target as Vector2i var cs = gridmap.cell_size var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0) # Create a flat pulsing indicator mesh var mesh_inst = MeshInstance3D.new() var box = BoxMesh.new() box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8) mesh_inst.mesh = box mesh_inst.position = world_pos var mat = StandardMaterial3D.new() mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.albedo_color = Color(1.0, 0.3, 0.5, 0.4) # Pink warning glow mat.emission_enabled = true mat.emission = Color(1.0, 0.3, 0.5) mat.emission_energy_multiplier = 2.0 mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mesh_inst.material_override = mat # Add to scene tree under main var main = get_node_or_null("/root/Main") if main: main.add_child(mesh_inst) # Pulse animation var tween = create_tween().set_loops() tween.tween_method(func(a): mat.albedo_color.a = a, 0.4, 0.1, 0.2) tween.tween_method(func(a): mat.albedo_color.a = a, 0.1, 0.4, 0.2) # Auto-remove after highlight duration var remove_timer = get_tree().create_timer(0.8) remove_timer.timeout.connect(func(): if is_instance_valid(mesh_inst): mesh_inst.queue_free() ) # Play warning sound if SfxManager: SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") @rpc("authority", "call_local", "reliable") func sync_telegraph(targets: Array) -> void: """Show warning overlay on target cells with multi-stage animation.""" if not gridmap: return # Place telegraph tiles for target in targets: var pos = target as Vector2i gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) # Animate telegraph with Tween (build-up phase) _animate_telegraph(targets) func _animate_telegraph(targets: Array) -> void: """Tween animation for telegraph: fade in, flash, then transition to sticky.""" var config = phase_configs[int(current_phase)] var telegraph_time = config["telegraph_time"] var build_up_time = telegraph_time * 0.8 # 80% for build-up var flash_time = telegraph_time * 0.2 # 20% for flash # Create tween for visual feedback var tween = create_tween() tween.set_parallel(true) # Phase 1: Fade in (alpha 0 -> 1) during build-up # Note: GridMap tiles don't support alpha directly, so we use modulation # We'll animate the gridmap overlay opacity conceptually for target in targets: var pos = target as Vector2i # Tween the cell brightness by swapping between telegraph variants tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.0) tween.tween_callback(_flash_telegraph.bind(targets, 1)).set_delay(0.4) tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.8) # Audio: rising pitch during build-up if SfxManager: SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") await get_tree().create_timer(1.0).timeout func _flash_telegraph(targets: Array, brightness: int) -> void: """Flicker telegraph tiles between normal and bright.""" if not gridmap: return # Toggle visual feedback - in full implementation would modify material/overlay # For now, this provides the timing structure for the animation pass @rpc("authority", "call_local", "reliable") func sync_impact(targets: Array) -> void: """Apply sticky cells at target positions.""" if not gridmap: return for target in targets: var pos = target as Vector2i # Replace telegraph with sticky on Layer 2 gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) sticky_cells[pos] = true # Screen shake for impact if main_scene and main_scene.get("screen_shake_manager"): main_scene.screen_shake_manager.shake(0.15, 0.4) # Audio: impact splat sound if SfxManager: SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter") # Spawn candy splash particles at impact locations _spawn_impact_particles(targets) # Check if any player is now trapped _check_all_players_trapped() func _spawn_impact_particles(targets: Array) -> void: """Spawn candy splash particles at impact locations.""" if not main_scene: return for target in targets: var pos = target as Vector2i var world_pos = Vector3( pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, 0.5, # Slightly above floor pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 ) # Create a simple particle effect (GPUParticles3D) var particles = GPUParticles3D.new() particles.emitting = true particles.one_shot = true particles.amount = 8 particles.lifetime = 0.5 particles.explosiveness = 1.0 # Candy pink color var material = ParticleProcessMaterial.new() material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE material.emission_sphere_radius = 0.2 material.direction = Vector3(0, 1, 0) material.spread = 45.0 material.initial_velocity_min = 2.0 material.initial_velocity_max = 4.0 material.gravity = Vector3(0, -9.8, 0) material.scale_min = 0.1 material.scale_max = 0.3 particles.process_material = material particles.position = world_pos main_scene.add_child(particles) # Auto-remove after particles finish await get_tree().create_timer(1.0).timeout if particles and is_instance_valid(particles): particles.queue_free() # ============================================================================= # Sticky / Trap System # ============================================================================= func is_sticky_cell(pos: Vector2i) -> bool: return sticky_cells.has(pos) func _check_all_players_trapped() -> void: if not multiplayer.is_server(): return var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1) if is_sticky_cell(pos) and not trapped_players.has(player.get("peer_id") if "peer_id" in player else -1): _trap_player(player) func _trap_player(player: Node) -> void: var pid = player.get("peer_id") if "peer_id" in player else -1 if pid == -1: return trapped_players[pid] = true print("[Gauntlet] Player %d TRAPPED at %s" % [pid, str(player.current_position)]) emit_signal("player_trapped", pid) # Apply visual feedback and notify if player.has_method("apply_stagger"): if _can_rpc(): player.rpc("apply_stagger", 999.0) # Basically infinite until cleansed else: player.apply_stagger(999.0) NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING) func clear_sticky_cell(pos: Vector2i) -> void: """Used by Cleanser power-up to remove a sticky cell.""" sticky_cells.erase(pos) if gridmap: gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) # Sync removal to clients if main_scene and _can_rpc(): main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1) func _try_use_cleanser() -> void: """Local player attempts to activate Cleanser for 5-cell sticky immunity.""" var local_pid = multiplayer.get_unique_id() var count = player_cleansers.get(local_pid, 0) if count <= 0: return # Block activation during stun var all_players = get_tree().get_nodes_in_group("Players") var local_player = null for p in all_players: var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int() if pid == local_pid: local_player = p break if not local_player: return if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): return # Already active if cleanser_active.has(local_pid): return # 0.3s activation delay await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout # Re-validate after delay if not is_instance_valid(local_player): return if local_player.get("is_frozen") or local_player.get("is_stop_frozen"): return # Activate cleanser immunity cleanser_active[local_pid] = true cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS # Consume cleanser from inventory player_cleansers[local_pid] = 0 update_cleanser_ui(0) # Sync to server/clients if not multiplayer.is_server() and _can_rpc(): rpc("rpc_activate_cleanser", local_pid) elif multiplayer.is_server(): if _can_rpc(): rpc("sync_cleanser_count", local_pid, 0) # Trigger slow-mo for dramatic effect if multiplayer.is_server(): trigger_slowmo() else: rpc("rpc_trigger_slowmo") NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP) func deactivate_cleanser(player_id: int) -> void: """Deactivate cleanser immunity for a player.""" cleanser_active.erase(player_id) cleanser_cells_left.erase(player_id) func is_cleanser_active(player_id: int) -> bool: """Check if a player has active cleanser immunity.""" return cleanser_active.has(player_id) func use_cleanser_cell(player_id: int) -> bool: """Use one cleanser cell. Returns true if still active, false if exhausted.""" if not cleanser_active.has(player_id): return false cleanser_cells_left[player_id] -= 1 if cleanser_cells_left[player_id] <= 0: deactivate_cleanser(player_id) return false return true @rpc("any_peer", "call_local", "reliable") func rpc_activate_cleanser(pid: int) -> void: """RPC for clients to activate cleanser on server.""" if multiplayer.is_server(): if not cleanser_active.has(pid): cleanser_active[pid] = true cleanser_cells_left[pid] = CLEANSER_MAX_CELLS player_cleansers[pid] = 0 if _can_rpc(): rpc("sync_cleanser_count", pid, 0) @rpc("any_peer", "call_local", "reliable") func rpc_use_cleanser(pos: Vector2i) -> void: """RPC for clients to clear a sticky cell via Cleanser.""" if multiplayer.is_server(): clear_sticky_cell(pos) @rpc("any_peer", "call_local", "reliable") func rpc_consume_cleanser(pid: int) -> void: """RPC for clients to report Cleanser consumption to server.""" if multiplayer.is_server(): player_cleansers[pid] = 0 if _can_rpc(): rpc("sync_cleanser_count", pid, 0) @rpc("any_peer", "reliable") func rpc_trigger_slowmo() -> void: """RPC for clients to request slow-mo from server.""" if multiplayer.is_server(): trigger_slowmo() # ============================================================================= # Slow-Mo Effect # ============================================================================= func trigger_slowmo(duration: float = 4.0) -> void: """Trigger slow-motion effect at 1/4 speed. Server-authoritative.""" if slowmo_active: return slowmo_active = true slowmo_timer = duration slowmo_duration = duration Engine.time_scale = SLOWMO_SCALE # Show visual overlay if main_scene and main_scene.has_node("Camera3D200"): _show_slowmo_overlay() # Show slow-mo HUD label if slowmo_label: slowmo_label.visible = true if _can_rpc(): rpc("sync_slowmo_start", duration) func _end_slowmo() -> void: slowmo_active = false Engine.time_scale = 1.0 _hide_slowmo_overlay() # Hide slow-mo HUD label if slowmo_label: slowmo_label.visible = false if _can_rpc(): rpc("sync_slowmo_end") func _show_slowmo_overlay() -> void: if slowmo_overlay: return slowmo_overlay = ColorRect.new() slowmo_overlay.color = Color(0.3, 0.5, 1.0, 0.1) # Subtle blue tint slowmo_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) slowmo_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE var cam = main_scene.get_node_or_null("Camera3D200") if cam: # Find or create a CanvasLayer for the overlay var canvas = CanvasLayer.new() canvas.layer = 4 main_scene.add_child(canvas) canvas.add_child(slowmo_overlay) # Fade in slowmo_overlay.color.a = 0.0 var tween = create_tween() tween.tween_property(slowmo_overlay, "color:a", 0.1, 0.3) func _hide_slowmo_overlay() -> void: if slowmo_overlay: var tween = create_tween() tween.tween_property(slowmo_overlay, "color:a", 0.0, 0.3) tween.tween_callback(slowmo_overlay.get_parent().queue_free) slowmo_overlay = null @rpc("authority", "call_local", "reliable") func sync_slowmo_start(duration: float) -> void: slowmo_active = true slowmo_timer = duration Engine.time_scale = SLOWMO_SCALE _show_slowmo_overlay() if slowmo_label: slowmo_label.visible = true @rpc("authority", "call_local", "reliable") func sync_slowmo_end() -> void: _end_slowmo() # ============================================================================= # HUD # ============================================================================= func _setup_hud() -> void: var hud_instance = _gauntlet_hud_scene.instantiate() hud_layer = hud_instance hud_layer.visible = false add_child(hud_layer) phase_label = hud_layer.get_node("TopContainer/PhaseLabel") cleanser_icon = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserIcon") cleanser_label = hud_layer.get_node("BottomContainer/CleanserHBox/CleanserLabel") slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel") _generate_cleanser_icon() func _generate_cleanser_icon() -> void: var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8) icon_img.fill(Color(0.4, 0.9, 1.0)) icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1)) for x in range(16): icon_img.set_pixel(x, 0, Color(0.2, 0.6, 0.7)) icon_img.set_pixel(x, 15, Color(0.2, 0.6, 0.7)) for y in range(16): icon_img.set_pixel(0, y, Color(0.2, 0.6, 0.7)) icon_img.set_pixel(15, y, Color(0.2, 0.6, 0.7)) for i in range(4, 12): icon_img.set_pixel(i, 7, Color(1.0, 1.0, 1.0, 0.8)) icon_img.set_pixel(7, i, Color(1.0, 1.0, 1.0, 0.8)) cleanser_icon.texture = ImageTexture.create_from_image(icon_img) func _update_hud_phase(phase_name: String) -> void: if phase_label: var icon = "🍬" match phase_name: "Route Pressure": icon = "⚠️" phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold "Survival!": icon = "💀" phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red _: phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink phase_label.text = "%s %s" % [icon, phase_name.to_upper()] # Animate phase label with bounce effect _animate_phase_label() func update_cleanser_ui(count: int) -> void: cleanser_count = count if cleanser_label: cleanser_label.text = "Cleanser: %d" % count # Show/hide icon based on availability if cleanser_icon: cleanser_icon.visible = count > 0 if count > 0: # Pulse animation when cleanser is available var tween = create_tween() tween.set_loops(2) tween.tween_property(cleanser_icon, "modulate", Color(1.5, 1.5, 1.5, 1), 0.3) tween.tween_property(cleanser_icon, "modulate", Color.WHITE, 0.3) func _animate_phase_label() -> void: """Animate phase label with bounce effect.""" if not phase_label: return # Create tween for bounce animation var tween = create_tween() tween.set_ease(Tween.EASE_OUT) tween.set_trans(Tween.TRANS_ELASTIC) # Scale up then back to normal var original_scale = phase_label.scale tween.tween_property(phase_label, "scale", original_scale * 1.2, 0.1) tween.tween_property(phase_label, "scale", original_scale, 0.2) # Flash effect tween.tween_property(phase_label, "modulate", Color(2, 2, 2, 1), 0.1) tween.tween_property(phase_label, "modulate", Color.WHITE, 0.2) # ============================================================================= # GoalsCycleManager Integration # ============================================================================= func _on_goal_count_updated(peer_id: int, count: int) -> void: """Called when a player completes a goal cycle. Grant cleanser every 2 missions.""" if not multiplayer.is_server(): return # Track mission completions per player if not player_mission_completions.has(peer_id): player_mission_completions[peer_id] = 0 player_mission_completions[peer_id] += 1 # Grant cleanser every 2 missions (max 1) var completions = player_mission_completions[peer_id] if completions % 2 == 0: if not player_cleansers.has(peer_id): player_cleansers[peer_id] = 0 if player_cleansers[peer_id] < 1: player_cleansers[peer_id] = 1 emit_signal("cleanser_granted", peer_id) print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions]) # Respawn mission tiles in non-sticky locations _respawn_mission_tiles() # Sync cleanser count to HUD rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0)) func _on_score_updated(peer_id: int, new_score: int) -> void: """Called when a player's score is updated.""" pass # Score sync handled by GoalsCycleManager func _respawn_mission_tiles() -> void: """Respawn mission tiles in non-sticky locations after mission completion.""" if not gridmap: gridmap = get_parent().get_node_or_null("EnhancedGridMap") if not gridmap: gridmap = get_node_or_null("/root/Main/EnhancedGridMap") if not gridmap: return # Goal items: Heart(7), Diamond(8), Star(9), Coin(10) var goal_items = [7, 8, 9, 10] var tiles_spawned: int = 0 # Find empty non-sticky cells to place new tiles var empty_cells: Array = [] for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): var pos = Vector2i(x, z) if _is_npc_zone(pos): continue # Skip boundary walls if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1: continue # Skip sticky cells if sticky_cells.has(pos): continue # Check if cell is empty on Layer 1 var current_item = gridmap.get_cell_item(Vector3i(x, 1, z)) if current_item == -1: empty_cells.append(pos) # Shuffle and place tiles empty_cells.shuffle() var tiles_to_place = min(empty_cells.size(), 20) # Limit respawn count for i in range(tiles_to_place): var pos = empty_cells[i] var tile_type = goal_items[randi() % goal_items.size()] gridmap.set_cell_item(Vector3i(pos.x, 1, pos.y), tile_type) tiles_spawned += 1 # Sync to clients if main_scene: main_scene.rpc("sync_grid_item", pos.x, 1, pos.y, tile_type) print("[Gauntlet] Respawned %d mission tiles" % tiles_spawned) @rpc("authority", "call_local", "reliable") func sync_cleanser_count(peer_id: int, count: int) -> void: """Sync cleanser count to HUD for specific player.""" # Update local player's cleanser UI var local_pid = multiplayer.get_unique_id() if peer_id == local_pid: update_cleanser_ui(count) # ============================================================================= # Utility # ============================================================================= func _can_rpc() -> bool: if not multiplayer.has_multiplayer_peer(): return false if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: return false return true