extends Node class_name CandySurvivalManager # Candy Survival — Sugar Rush Chaos # Speed – Building – Sabotage (blueprints + power-ups) # ponytail: This replaces the old gauntlet ground-growth/sticky/smack system entirely. # Legacy smack/cleanser/sticky_growth methods deleted — all movement blocking # is now pure-collision sticky walls. Add back growth if boss requests a # "rising sticky tide" mechanic later. signal sugar_rush_started(pid) signal sugar_rush_ended(pid) signal mekton_face_changed(color_index) # ── Arena ── const ARENA_COLS: int = 18 const ARENA_ROWS: int = 18 const NPC_CENTER: Vector2i = Vector2i(8, 8) const NPC_CELLS: int = 3 # 3x3 Mekton zone # ── Blueprint ── const BLUEPRINT_SIZE: int = 3 const OFF_COLOR_PENALTY: float = 0.5 # ── Candy ── const CANDY_PPS: float = 1.0 # points per second per candy const MULTI_STEP: float = 0.1 # extra multiplier per candy # ── Sugar Rush ── const SR_BASE_TIME: float = 2.0 const SR_PER_CANDY: float = 0.4 # extra seconds per delivered candy const SR_SPEED: float = 2.0 # ── Knock / Ghost ── const START_KNOCK: int = 5 const START_GHOST: int = 5 const GHOST_DURATION: float = 4.0 # ── Mekton ── const COLOR_CYCLE_TIME: float = 5.0 # seconds between face color changes enum CandyColor { HEART, DIAMOND, STAR, COIN } # ── State ── var active: bool = false var arena_width: int = ARENA_COLS var arena_height: int = ARENA_ROWS var main_scene: Node var gridmap: Node var mekton_node: Node var current_face: CandyColor = CandyColor.HEART var face_timer: float = 0.0 var game_elapsed: float = 0.0 # Per-player state var player_candies: Dictionary = {} # pid -> int (stack count) var player_candy_color: Dictionary = {} # pid -> CandyColor (-1 if none) var player_knocks: Dictionary = {} # pid -> int (charges left) var player_ghosts: Dictionary = {} # pid -> int (charges left) var player_ghost_active: Dictionary = {} # pid -> bool var player_ghost_timer: Dictionary = {} # pid -> float var player_score: Dictionary = {} # pid -> int var player_blueprints: Dictionary = {} # pid -> int (completed count) var player_position: Dictionary = {} # pid -> Vector2i var player_sugar_rush: Dictionary = {} # pid -> float (remaining, 0=none) var player_last_knocked_by: Dictionary = {} # pid -> int # Sticky wall cells (pre-placed collision) var sticky_cells: Dictionary = {} # Vector2i -> bool # Pre-placed sticky positions for 18x18 arena var preplaced_sticky: Array = [] func initialize(main, grid) -> void: main_scene = main gridmap = grid _define_sticky_walls() print("[CandySurvival] Initialized") func _define_sticky_walls() -> void: # Block Mekton NPC zone (3x3 center) var cx = NPC_CENTER.x var cy = NPC_CENTER.y for dx in range(NPC_CELLS): for dy in range(NPC_CELLS): preplaced_sticky.append(Vector2i(cx + dx, cy + dy)) # Block arena outer boundary walls for x in range(ARENA_COLS): preplaced_sticky.append(Vector2i(x, 0)) preplaced_sticky.append(Vector2i(x, ARENA_ROWS - 1)) for y in range(ARENA_ROWS): preplaced_sticky.append(Vector2i(0, y)) preplaced_sticky.append(Vector2i(ARENA_COLS - 1, y)) # Lobby-zone rows (rows 0-1 and rows 16-17 outside play field) for x in range(2, ARENA_COLS - 2): for y in [1, ARENA_ROWS - 2]: preplaced_sticky.append(Vector2i(x, y)) func start_game_mode() -> void: active = true current_face = CandyColor.HEART face_timer = 0.0 game_elapsed = 0.0 player_candies.clear() player_candy_color.clear() player_knocks.clear() player_ghosts.clear() player_ghost_active.clear() player_ghost_timer.clear() player_score.clear() player_blueprints.clear() player_position.clear() player_sugar_rush.clear() player_last_knocked_by.clear() sticky_cells.clear() # Mark pre-placed sticky as active for pos in preplaced_sticky: sticky_cells[pos] = true # Init players from lobby var pids = _get_player_ids() for pid in pids: player_candies[pid] = 0 player_candy_color[pid] = -1 player_knocks[pid] = START_KNOCK player_ghosts[pid] = START_GHOST player_ghost_active[pid] = false player_ghost_timer[pid] = 0.0 player_score[pid] = 0 player_blueprints[pid] = 0 player_sugar_rush[pid] = 0.0 player_last_knocked_by[pid] = 0 print("[CandySurvival] Started with %d players" % pids.size()) func _process(delta: float) -> void: if not active: return game_elapsed += delta # Mekton face color cycle face_timer += delta if face_timer >= COLOR_CYCLE_TIME: face_timer = 0.0 current_face = CandyColor.values()[(current_face + 1) % CandyColor.size()] mekton_face_changed.emit(current_face) # Sugar Rush timers for pid in player_sugar_rush.keys(): if player_sugar_rush[pid] > 0: player_sugar_rush[pid] -= delta if player_sugar_rush[pid] <= 0: player_sugar_rush[pid] = 0.0 _restore_time_scale() sugar_rush_ended.emit(pid) # Ghost timers for pid in player_ghost_active.keys(): if player_ghost_active[pid]: player_ghost_timer[pid] -= delta if player_ghost_timer[pid] <= 0: player_ghost_active[pid] = false player_ghost_timer[pid] = 0.0 # Candy stack: points per second per candy for pid in player_candies.keys(): var count = player_candies.get(pid, 0) if count > 0: var points = count * CANDY_PPS * delta _add_score(pid, int(points)) # ── Blueprint ── func on_blueprint_completed(pid: int, primary_color: int, off_color: bool) -> void: # Called when a 3x3 pattern is detected as complete var base_points = 1000 var points = base_points if not off_color else int(base_points * OFF_COLOR_PENALTY) _add_score(pid, points) player_blueprints[pid] = player_blueprints.get(pid, 0) + 1 # Award candy on head (matching blueprint primary color) _give_candy(pid, primary_color) func can_finish_with_off_color(pid: int, primary_color: int) -> bool: # Returns true if primary color tiles are exhausted on the grid return not _grid_has_color_tiles(primary_color) func _grid_has_color_tiles(color: int) -> bool: # Check if any tile of given color exists on gridmap layer 1 if not gridmap: return true var tile_ids = {0: 7, 1: 8, 2: 9, 3: 10} # Heart/Diamond/Star/Coin var target = tile_ids.get(color, 7) for x in range(1, ARENA_COLS - 1): for y in range(1, ARENA_ROWS - 1): var v = Vector3i(x, 1, y) if gridmap.get_cell_item(v) == target: return true return false # ── Candy Stack ── func _give_candy(pid: int, color: int) -> void: player_candies[pid] = player_candies.get(pid, 0) + 1 player_candy_color[pid] = color _update_candy_badge(pid) func _update_candy_badge(pid: int) -> void: var count = player_candies.get(pid, 0) var mult = 1.0 + count * MULTI_STEP # RPC to update HUD rpc("sync_candy_badge", pid, count, mult) func get_multiplier(pid: int) -> float: var count = player_candies.get(pid, 0) return 1.0 + count * MULTI_STEP # ── Mekton Delivery ── func try_deliver(pid: int) -> bool: # Player at Mekton zone tries to deliver matching-color candies var color = player_candy_color.get(pid, -1) if color == -1 or player_candies.get(pid, 0) == 0: return false # No candies to deliver if color != current_face: return false # Color mismatch # Successful delivery: consume all candies of held color var count = player_candies[pid] var mult = get_multiplier(pid) var points = count * 500 # base delivery points _add_score(pid, points) # Clear candy stack player_candies[pid] = 0 player_candy_color[pid] = -1 _update_candy_badge(pid) # Trigger Sugar Rush _trigger_sugar_rush(pid, count, mult) return true func _trigger_sugar_rush(pid: int, candies: int, mult: float) -> void: var duration = SR_BASE_TIME + candies * SR_PER_CANDY * mult player_sugar_rush[pid] = duration Engine.time_scale = SR_SPEED sugar_rush_started.emit(pid) func _restore_time_scale() -> void: # Only restore if no other player is in rush var any_rush = false for pid in player_sugar_rush.keys(): if player_sugar_rush[pid] > 0: any_rush = true break if not any_rush: Engine.time_scale = 1.0 # ── Knock / Ghost ── func try_knock(attacker: int, target: int) -> bool: if player_ghost_active.get(target, false): return false # Ghost immunity if player_knocks.get(attacker, 0) <= 0: return false # No charges var target_candies = player_candies.get(target, 0) if target_candies == 0: # Backfire: attacker also knocked player_knocks[attacker] = player_knocks.get(attacker, 0) - 1 # Both knocked (stunned briefly) return false # Steal all candies player_candies[attacker] = player_candies.get(attacker, 0) + target_candies player_candies[target] = 0 player_candy_color[target] = -1 player_candy_color[attacker] = player_candy_color.get(target, -1) player_knocks[attacker] = player_knocks.get(attacker, 0) - 1 player_last_knocked_by[target] = attacker _add_score(attacker, target_candies * 100) _update_candy_badge(attacker) _update_candy_badge(target) rpc("sync_knock_result", attacker, target, target_candies) return true func try_activate_ghost(pid: int) -> bool: if player_ghost_active.get(pid, false): return false # Already ghosted if player_ghosts.get(pid, 0) <= 0: return false # No charges left player_ghosts[pid] = player_ghosts.get(pid, 0) - 1 player_ghost_active[pid] = true player_ghost_timer[pid] = GHOST_DURATION rpc("sync_ghost_active", pid, true) return true func is_ghost_active(pid: int) -> bool: return player_ghost_active.get(pid, false) # ── Arena ── func _setup_arena() -> void: if not gridmap: return gridmap.clear_layer(0) gridmap.clear_layer(1) gridmap.clear_layer(2) # Build floor for x in range(ARENA_COLS): for y in range(ARENA_ROWS): gridmap.set_cell_item(Vector3i(x, 0, y), 0) # Apply sticky cells on layer 2 for pos in sticky_cells.keys(): gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), 14) # sticky overlay id # Spawn Mekton NPC at center if mekton_node: mekton_node.queue_free() if main_scene and main_scene.has_method("_spawn_mekton_npc"): mekton_node = main_scene._spawn_mekton_npc(NPC_CENTER) print("[CandySurvival] Arena 18x18 setup with Mekton at center") func _apply_arena_setup() -> void: # Client-side visual sync pass func setup_mission_tiles() -> void: # Spawn tiles on layer 1 at 60% density, avoiding NPC zone + sticky if not gridmap: return randomize() var tile_ids = [7, 8, 9, 10] # Heart, Diamond, Star, Coin var count = 0 for x in range(1, ARENA_COLS - 1): for y in range(1, ARENA_ROWS - 1): if sticky_cells.has(Vector2i(x, y)): continue if randf() < 0.6: var tile = tile_ids[randi() % tile_ids.size()] gridmap.set_cell_item(Vector3i(x, 1, y), tile) count += 1 print("[CandySurvival] Spawned %d mission tiles" % count) func get_spawn_points(count: int) -> Array: # Return spawn points in quadrants avoiding NPC zone var points = [] var quadrants = [ [Vector2i(2, 2), Vector2i(4, 2), Vector2i(2, 4)], [Vector2i(ARENA_COLS - 3, 2), Vector2i(ARENA_COLS - 5, 2), Vector2i(ARENA_COLS - 3, 4)], [Vector2i(2, ARENA_ROWS - 3), Vector2i(4, ARENA_ROWS - 3), Vector2i(2, ARENA_ROWS - 5)], [Vector2i(ARENA_COLS - 3, ARENA_ROWS - 3), Vector2i(ARENA_COLS - 5, ARENA_ROWS - 3), Vector2i(ARENA_COLS - 3, ARENA_ROWS - 5)] ] for i in range(min(count, quadrants.size())): points.append(quadrants[i][0]) return points func get_arena_positions() -> Dictionary: return {"columns": ARENA_COLS, "rows": ARENA_ROWS} func get_arena_bounds() -> Dictionary: return {"min_x": -1.0, "max_x": float(ARENA_COLS), "min_z": -1.0, "max_z": float(ARENA_ROWS)} # ── Sticky (now pure wall collision) ── func is_sticky_cell(pos: Vector2i) -> bool: return sticky_cells.has(pos) func _is_npc_zone(pos: Vector2i) -> bool: var cx = NPC_CENTER.x var cy = NPC_CENTER.y return pos.x >= cx and pos.x < cx + NPC_CELLS and pos.y >= cy and pos.y < cy + NPC_CELLS # ── Scorring ── func _add_score(pid: int, points: int) -> void: player_score[pid] = player_score.get(pid, 0) + points if main_scene and main_scene.has_method("add_points"): main_scene.add_points(pid, points) # ── Helpers ── func _get_player_ids() -> Array: if main_scene and main_scene.has_method("get_player_ids"): return main_scene.get_player_ids() return [] func candy_survival_round_duration() -> int: return 180 func get_score(pid: int) -> int: return player_score.get(pid, 0) # ── RPCs ── func sync_candy_badge(pid: int, count: int, mult: float) -> void: pass # Client handles HUD update func sync_knock_result(attacker: int, target: int, candies: int) -> void: pass # Client handles animation func sync_ghost_active(pid: int, active_on: bool) -> void: pass # Client handles visual