extends Node class_name CandySurvivalManager # Candy Survival — Sugar Rush Chaos # Speed – Building – Sabotage (blueprints + power-ups) 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 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 # ── HUD ── var hud_layer: CanvasLayer var hud_knock_label: Label var hud_ghost_label: Label var hud_rush_bar: ProgressBar var hud_rush_label: Label var hud_stack_badge: Label var hud_points_label: Label var _candy_survival_hud_scene: PackedScene = preload("res://scenes/candy_survival_hud.tscn") func _ready(): set_process(false) _setup_hud() func _setup_hud() -> void: var hud_instance = _candy_survival_hud_scene.instantiate() hud_layer = hud_instance hud_layer.visible = false add_child(hud_layer) var bot_box = hud_layer.get_node_or_null("BottomContainer/HBoxContainer") if bot_box: hud_knock_label = bot_box.get_node_or_null("SabotagePanel/KnockChargesLabel") hud_ghost_label = bot_box.get_node_or_null("SabotagePanel/GhostChargesLabel") hud_stack_badge = bot_box.get_node_or_null("ScorePanel/CandyStackBadge") hud_points_label = bot_box.get_node_or_null("ScorePanel/PointsLabel") var top_box = hud_layer.get_node_or_null("TopContainer") if top_box: hud_rush_bar = top_box.get_node_or_null("SugarRushBar") if hud_rush_bar: hud_rush_label = hud_rush_bar.get_node_or_null("SugarRushLabel") func start_game_mode() -> void: if multiplayer.is_server(): activate_client_side() func activate_client_side() -> void: active = true if hud_layer: hud_layer.visible = true set_process(true) # 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_sugar_rush: Dictionary = {} # pid -> float (remaining) var player_last_knocked_by: Dictionary = {} # pid -> int # 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") func _define_sticky_walls() -> void: # 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_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()] # Sync color to the Mekton NPC if mekton_node and mekton_node.has_method("set_face_color_rpc"): mekton_node.rpc("set_face_color_rpc", current_face) # 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 rpc("sync_ghost_active", pid, false) # Turn off ghost visual/state 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: player.set("is_invisible", false) if player.has_method("sync_modulate"): if multiplayer.is_server() and player.has_method("can_rpc") and player.can_rpc(): player.rpc("sync_modulate", Color.WHITE) else: player.sync_modulate(Color.WHITE) break # 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: 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: return not _grid_has_color_tiles(primary_color) func _grid_has_color_tiles(color: int) -> bool: 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) func _update_candy_badge(pid: int) -> void: var count = player_candies.get(pid, 0) var mult = 1.0 + count * MULTI_STEP var color = player_candy_color.get(pid, -1) rpc("sync_candy_badge", pid, count, mult) # Update the 3D meshes on the player's head across all clients 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_candy_stack"): player.rpc("sync_candy_stack", count, color) break func get_multiplier(pid: int) -> float: var count = player_candies.get(pid, 0) return 1.0 + count * MULTI_STEP # ── Mekton Delivery ── @rpc("any_peer", "call_local", "reliable") func try_deliver(pid: int) -> bool: if not multiplayer.is_server(): return false 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) func _restore_time_scale() -> void: 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 ── @rpc("any_peer", "call_local", "reliable") func try_knock(attacker: int, target: int) -> bool: if not multiplayer.is_server(): return false 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 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) # Core powerup integration: set is_invisible on the player node 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: player.set("is_invisible", true) if player.has_method("sync_modulate"): if multiplayer.is_server() and player.has_method("can_rpc") and player.can_rpc(): player.rpc("sync_modulate", Color(1.0, 1.0, 1.0, 0.4)) else: player.sync_modulate(Color(1.0, 1.0, 1.0, 0.4)) break 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 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) if npc.has_method("set_face_color_rpc"): npc.set_face_color_rpc(current_face) return npc return null func _apply_arena_setup() -> void: pass func setup_mission_tiles() -> void: 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: var points = [] var corners = [ Vector2i(2, 2), Vector2i(ARENA_COLS - 2, 2), Vector2i(2, ARENA_ROWS - 2), Vector2i(ARENA_COLS - 2, ARENA_ROWS - 2) ] 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} 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 (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 # ── Scoring ── 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 (stubs — HUD client handles visuals) ── @rpc("authority", "call_remote", "unreliable") func sync_candy_badge(pid: int, count: int, mult: float) -> void: if pid == multiplayer.get_unique_id(): if hud_stack_badge: hud_stack_badge.text = "Stack: %d (x%.1f)" % [count, mult] @rpc("authority", "call_remote", "unreliable") func sync_knock_result(attacker: int, target: int, candies: int) -> void: var local = multiplayer.get_unique_id() if attacker == local or target == local: if hud_knock_label and player_knocks.has(local): hud_knock_label.text = "🥊 Knocks: %d" % player_knocks[local] @rpc("authority", "call_remote", "unreliable") func sync_ghost_active(pid: int, active_on: bool) -> void: if pid == multiplayer.get_unique_id(): if hud_ghost_label and player_ghosts.has(pid): hud_ghost_label.text = "👻 Ghosts: %d" % player_ghosts[pid]