Files
tekton/scripts/managers/candy_survival_manager.gd
T
god 528e22875d rename gauntlet→candy_survival + rewrite Candy Survival per boss design
- Rename enum GAUNTLET→CANDY_SURVIVAL, all gauntlet_*→candy_survival_*
- Rename files: gauntlet_manager→candy_survival_manager, candy_cannon→candy_survival_npc, gauntlet.tscn→candy_survival.tscn
- Rewrite candy_survival_manager.gd: blueprints, candy stack, Mekton delivery, Sugar Rush, knock/ghost charges, sticky-as-wall
- Update player_movement_manager.gd: smack→knock system, ghost integration
- All Candy Survival issues (#54-57, #65-70) retitled per boss design
- Shared managers (goals_cycle, goal, player_race, playerboard, turn) untouched
2026-07-06 01:28:39 +08:00

402 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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