diff --git a/scripts/managers/candy_survival_manager.gd b/scripts/managers/candy_survival_manager.gd index b9a2b00..36497b5 100644 --- a/scripts/managers/candy_survival_manager.gd +++ b/scripts/managers/candy_survival_manager.gd @@ -2,10 +2,6 @@ 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) @@ -22,12 +18,12 @@ 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 +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_PER_CANDY: float = 0.4 # extra seconds per delivered candy const SR_SPEED: float = 2.0 # ── Knock / Ghost ── @@ -36,13 +32,11 @@ const START_GHOST: int = 5 const GHOST_DURATION: float = 4.0 # ── Mekton ── -const COLOR_CYCLE_TIME: float = 5.0 # seconds between face color changes +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 @@ -51,351 +45,346 @@ 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_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_ghost_timer: Dictionary = {} # pid -> float +var player_score: Dictionary = {} # pid -> int +var player_blueprints: Dictionary = {} # pid -> int (completed count) +var player_sugar_rush: Dictionary = {} # pid -> float (remaining) 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 = [] +# Sticky wall cells (pre-placed collision only) +var sticky_cells: Dictionary = {} # Vector2i -> bool + +# Tile ID mapping +const TILE_IDS: Dictionary = {CandyColor.HEART: 7, CandyColor.DIAMOND: 8, CandyColor.STAR: 9, CandyColor.COIN: 10} + +# ── Initialization ── func initialize(main, grid) -> void: - main_scene = main - gridmap = grid - _define_sticky_walls() - print("[CandySurvival] Initialized") + 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)) + # Arena boundary walls (rows/cols 0 and 17) + for x in range(ARENA_COLS): + sticky_cells[Vector2i(x, 0)] = true + sticky_cells[Vector2i(x, ARENA_ROWS - 1)] = true + for y in range(ARENA_ROWS): + sticky_cells[Vector2i(0, y)] = true + sticky_cells[Vector2i(ARENA_COLS - 1, y)] = true + # Mekton NPC zone (3x3 center) + for dx in range(NPC_CELLS): + for dy in range(NPC_CELLS): + sticky_cells[Vector2i(NPC_CENTER.x + dx, NPC_CENTER.y + dy)] = true 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()) + 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_sugar_rush.clear() + player_last_knocked_by.clear() + + # 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 _enter_tree() -> void: + set_process(true) 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)) + 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()] + + # Sugar Rush timers + var any_rush = false + 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) + if player_sugar_rush[pid] > 0: + any_rush = true + if any_rush: + Engine.time_scale = SR_SPEED + + # 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) + 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 + _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) + 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 + if not gridmap: + return true + 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) + 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) + var count = player_candies.get(pid, 0) + var mult = 1.0 + count * MULTI_STEP + 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 + 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 + var color = player_candy_color.get(pid, -1) + if color == -1 or player_candies.get(pid, 0) == 0: + return false + if color != current_face: + return false + + var count = player_candies[pid] + var mult = get_multiplier(pid) + _add_score(pid, count * 500) + + player_candies[pid] = 0 + player_candy_color[pid] = -1 + _update_candy_badge(pid) + + _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) + 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 + 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 + if player_ghost_active.get(target, false): + return false + if player_knocks.get(attacker, 0) <= 0: + return false + + var target_candies = player_candies.get(target, 0) + + if target_candies == 0: + # Backfire: both lose a charge + player_knocks[attacker] = player_knocks.get(attacker, 0) - 1 + # Attacker is also knocked down briefly (visual handled by caller) + rpc("sync_knock_result", attacker, target, 0) + 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 + if player_ghost_active.get(pid, false): + return false + if player_ghosts.get(pid, 0) <= 0: + return false + + 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) + 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") + 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 walls on layer 2 + for pos in sticky_cells.keys(): + gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), 14) + + # Spawn Mekton NPC at center + if mekton_node: + mekton_node.queue_free() + mekton_node = _spawn_mekton_npc(NPC_CENTER) + print("[CandySurvival] Arena setup 18x18") + +func _spawn_mekton_npc(center: Vector2i) -> Node: + var path = "res://scenes/candy_survival_npc.tscn" + if ResourceLoader.exists(path): + var npc = load(path).instantiate() + npc.position = Vector3(center.x + 1, 0, center.y + 1) + add_child(npc) + return npc + return null func _apply_arena_setup() -> void: - # Client-side visual sync - pass + 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) + if not gridmap: + return + randomize() + var tile_ids = [7, 8, 9, 10] + 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 + var points = [] + var corners = [ + Vector2i(2, 2), Vector2i(ARENA_COLS - 3, 2), + Vector2i(2, ARENA_ROWS - 3), Vector2i(ARENA_COLS - 3, ARENA_ROWS - 3) + ] + for i in range(count): + points.append(corners[i % corners.size()]) + return points func get_arena_positions() -> Dictionary: - return {"columns": ARENA_COLS, "rows": ARENA_ROWS} + 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)} + return {"min_x": -1.0, "max_x": float(ARENA_COLS), "min_z": -1.0, "max_z": float(ARENA_ROWS)} + +# ── Sticky (pure wall collision) ── -# ── Sticky (now pure wall collision) ── func is_sticky_cell(pos: Vector2i) -> bool: - return sticky_cells.has(pos) + 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 + 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 + +# ── Scoring ── -# ── 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) + 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 [] + 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 + return 180 func get_score(pid: int) -> int: - return player_score.get(pid, 0) + return player_score.get(pid, 0) + +# ── RPCs (stubs — HUD client handles visuals) ── -# ── RPCs ── func sync_candy_badge(pid: int, count: int, mult: float) -> void: - pass # Client handles HUD update + pass func sync_knock_result(attacker: int, target: int, candies: int) -> void: - pass # Client handles animation + pass func sync_ghost_active(pid: int, active_on: bool) -> void: - pass # Client handles visual + pass