2b4c9d9dcb
- Changed knock from instant-ambush to toggle-and-walk: press Q to toggle Knock Mode ON, walk into target to execute, consumed on use - Unified knock animation with other game modes (attack_mode SFX, bump, 3-tile knockback, stagger) - Added +100 score rewards for completing goals, KNOCK, and GHOST - Fixed RPC unknown peer ID crash for AI bots in ghost/candy stack sync - Bots auto-toggle is_charged_strike when they have knock charges - Fixed profile panel username update on connected peers - Added SafeNakamaMultiplayerBridge for match state crash prevention
856 lines
28 KiB
GDScript
856 lines
28 KiB
GDScript
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 hud_delivery_indicator: Label
|
||
var _candy_survival_hud_scene: PackedScene = preload("res://scenes/candy_survival_hud.tscn")
|
||
|
||
# Cache for local client HUD resync (avoids local dictionary/timer desyncs)
|
||
var _last_badge_count: int = 0
|
||
var _last_badge_mult: float = 1.0
|
||
var _last_badge_face_match: bool = false
|
||
|
||
func _ready():
|
||
set_process(false)
|
||
_setup_hud()
|
||
|
||
func _exit_tree() -> void:
|
||
Engine.time_scale = 1.0
|
||
|
||
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")
|
||
hud_delivery_indicator = bot_box.get_node_or_null("ScorePanel/DeliveryIndicator")
|
||
|
||
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 activate_client_side() -> void:
|
||
active = true
|
||
if hud_layer:
|
||
hud_layer.visible = true
|
||
set_process(true)
|
||
|
||
var local = multiplayer.get_unique_id()
|
||
player_knocks[local] = START_KNOCK
|
||
player_ghosts[local] = START_GHOST
|
||
|
||
if hud_stack_badge:
|
||
hud_stack_badge.text = "Stack: 0 (x1.0)\nRed: 0x Blue: 0x Green: 0x Yellow: 0x"
|
||
if hud_knock_label:
|
||
hud_knock_label.text = "🥊 Knocks: 5"
|
||
if hud_ghost_label:
|
||
hud_ghost_label.text = "👻 Ghosts: 5"
|
||
|
||
# Per-player state
|
||
var player_candies: Dictionary = {} # pid -> int (stack count)
|
||
var player_candy_colors: Dictionary = {} # pid -> Array of CandyColor
|
||
var player_candy_color: Dictionary:
|
||
get:
|
||
var d = {}
|
||
for pid in player_candy_colors:
|
||
var colors = player_candy_colors[pid]
|
||
d[pid] = colors.back() if colors.size() > 0 else -1
|
||
return d
|
||
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_colors.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_colors[pid] = []
|
||
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
|
||
_update_candy_badge(pid)
|
||
_update_special_inventory_for_ghosts(pid)
|
||
rpc("sync_knock_charge_count", pid, START_KNOCK)
|
||
rpc("sync_ghost_charge_count", pid, START_GHOST)
|
||
|
||
if multiplayer.is_server():
|
||
activate_client_side()
|
||
|
||
print("[CandySurvival] Started with %d players" % pids.size())
|
||
|
||
func _enter_tree() -> void:
|
||
set_process(true)
|
||
|
||
func _process(delta: float) -> void:
|
||
if not active:
|
||
return
|
||
if not multiplayer.is_server():
|
||
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 is_instance_valid(mekton_node) and mekton_node.has_method("set_face_color_rpc"):
|
||
if multiplayer.is_server() and can_rpc():
|
||
mekton_node.rpc("set_face_color_rpc", current_face)
|
||
else:
|
||
mekton_node.set_face_color_rpc(current_face)
|
||
|
||
# Update delivery indicators for all players based on new face
|
||
for pid in player_candies:
|
||
_update_candy_badge(pid)
|
||
|
||
|
||
# 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_tile_id: int, off_color: bool) -> void:
|
||
player_blueprints[pid] = player_blueprints.get(pid, 0) + 1
|
||
|
||
if player_blueprints[pid] % 2 == 0:
|
||
if multiplayer.is_server():
|
||
add_ghost_charge(pid)
|
||
|
||
var target_tile_id = primary_tile_id
|
||
var player_node: Node = null
|
||
if main_scene:
|
||
player_node = main_scene.get_node_or_null(str(pid))
|
||
if player_node and player_node.goals.size() > 0:
|
||
target_tile_id = player_node.goals[0]
|
||
|
||
var candy_color = CandyColor.HEART
|
||
for key in TILE_IDS:
|
||
if TILE_IDS[key] == target_tile_id:
|
||
candy_color = key
|
||
break
|
||
|
||
_give_candy(pid, candy_color)
|
||
_add_score(pid, 100) # Award +100 points when completing goals by collecting tiles
|
||
|
||
# Clear the board now that it's matched and converted to a candy
|
||
if main_scene and player_node:
|
||
if player_node.playerboard_manager:
|
||
# Visual wipe
|
||
player_node.playerboard.fill(-1)
|
||
main_scene.rpc("sync_playerboard", pid, player_node.playerboard)
|
||
|
||
# Record goal completion stats in GoalsCycleManager
|
||
var goals_cycle = main_scene.get_node_or_null("GoalsCycleManager")
|
||
if goals_cycle:
|
||
# Increment goal count (but DO NOT trigger the general score points
|
||
# because in Candy Survival, points are awarded during Mekton deposit)
|
||
if not goals_cycle.player_goal_counts.has(pid):
|
||
goals_cycle.player_goal_counts[pid] = 0
|
||
goals_cycle.player_goal_counts[pid] += 1
|
||
goals_cycle.emit_signal("goal_count_updated", pid, goals_cycle.player_goal_counts[pid])
|
||
goals_cycle.rpc("sync_goal_count", pid, goals_cycle.player_goal_counts[pid])
|
||
|
||
# Generate new goals immediately to keep player active
|
||
goals_cycle.regenerate_goals_for_player(player_node)
|
||
# Need to randomize tiles around player to refresh pickups for the new goal
|
||
goals_cycle._randomize_tiles_around_player(player_node)
|
||
|
||
func can_finish_with_off_color(pid: int, primary_tile_id: int) -> bool:
|
||
return not _grid_has_color_tiles(primary_tile_id)
|
||
|
||
func _grid_has_color_tiles(target_tile_id: int) -> bool:
|
||
if not gridmap:
|
||
return true
|
||
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_tile_id:
|
||
return true
|
||
return false
|
||
|
||
# ── Candy Stack ──
|
||
|
||
func _give_candy(pid: int, color: int) -> void:
|
||
player_candies[pid] = player_candies.get(pid, 0) + 1
|
||
if not player_candy_colors.has(pid):
|
||
player_candy_colors[pid] = []
|
||
player_candy_colors[pid].append(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 colors = player_candy_colors.get(pid, [])
|
||
var color = colors.back() if colors.size() > 0 else -1
|
||
var face_match = (color != -1)
|
||
var score = player_score.get(pid, 0)
|
||
rpc("sync_candy_badge", pid, count, mult, face_match, score, colors)
|
||
|
||
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 colors = player_candy_colors.get(pid, [])
|
||
var color = colors.back() if colors.size() > 0 else -1
|
||
if color == -1 or colors.size() == 0:
|
||
rpc("sync_delivery_result", pid, false, "No candy")
|
||
return false
|
||
|
||
var count = colors.size()
|
||
var mult = get_multiplier(pid)
|
||
|
||
var base_points = 1000 * count
|
||
_add_score(pid, int(base_points * mult))
|
||
|
||
player_candies[pid] = 0
|
||
player_candy_colors[pid] = []
|
||
_update_candy_badge(pid)
|
||
|
||
_trigger_sugar_rush(pid, count, mult)
|
||
|
||
rpc("sync_delivery_result", pid, true, "Delivered!")
|
||
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 not player_knocks.has(attacker):
|
||
player_knocks[attacker] = START_KNOCK
|
||
if player_knocks[attacker] <= 0:
|
||
return false
|
||
|
||
var target_colors = player_candy_colors.get(target, [])
|
||
var target_candies = target_colors.size()
|
||
|
||
var attacker_node = _find_player_node(attacker)
|
||
var target_node = _find_player_node(target)
|
||
|
||
if target_candies == 0:
|
||
# Backfire: attacker loses a charge & gets staggered
|
||
player_knocks[attacker] = player_knocks.get(attacker, START_KNOCK) - 1
|
||
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
|
||
|
||
# Backfire penalty: Attacker's candy stack transfers to Target!
|
||
var attacker_colors = player_candy_colors.get(attacker, [])
|
||
var attacker_candies = attacker_colors.size()
|
||
if attacker_candies > 0:
|
||
if not player_candy_colors.has(target):
|
||
player_candy_colors[target] = []
|
||
player_candy_colors[target].append_array(attacker_colors)
|
||
player_candy_colors[attacker] = []
|
||
|
||
player_candies[target] = player_candy_colors[target].size()
|
||
player_candies[attacker] = 0
|
||
|
||
_add_score(target, attacker_candies * 100)
|
||
_update_candy_badge(attacker)
|
||
_update_candy_badge(target)
|
||
|
||
rpc("sync_knock_result", attacker, target, 0)
|
||
return false
|
||
|
||
# Steal all candies (Successful knock)
|
||
if not player_candy_colors.has(attacker):
|
||
player_candy_colors[attacker] = []
|
||
player_candy_colors[attacker].append_array(target_colors)
|
||
player_candy_colors[target] = []
|
||
|
||
player_candies[attacker] = player_candy_colors[attacker].size()
|
||
player_candies[target] = 0
|
||
|
||
player_knocks[attacker] = player_knocks.get(attacker, START_KNOCK) - 1
|
||
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
|
||
player_last_knocked_by[target] = attacker
|
||
|
||
_add_score(attacker, target_candies * 100 + 100) # Award +100 points for using KNOCK + 100 per candy stolen
|
||
_update_candy_badge(attacker)
|
||
_update_candy_badge(target)
|
||
|
||
rpc("sync_knock_result", attacker, target, target_candies)
|
||
return true
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
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)
|
||
rpc("sync_ghost_charge_count", pid, player_ghosts[pid])
|
||
_update_special_inventory_for_ghosts(pid)
|
||
|
||
# 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
|
||
|
||
_add_score(pid, 100) # Award +100 points for tactically using GHOST
|
||
return true
|
||
|
||
func is_ghost_active(pid: int) -> bool:
|
||
return player_ghost_active.get(pid, false)
|
||
|
||
func can_rpc() -> bool:
|
||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||
return false
|
||
return true
|
||
|
||
# ── Arena ──
|
||
|
||
func _setup_arena() -> void:
|
||
if not gridmap:
|
||
return
|
||
if can_rpc():
|
||
rpc("sync_arena_setup")
|
||
_apply_arena_setup()
|
||
|
||
@rpc("authority", "call_remote", "reliable")
|
||
func sync_arena_setup() -> void:
|
||
print("[CandySurvival] Client: Syncing Arena Setup (18x18)...")
|
||
_apply_arena_setup()
|
||
|
||
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.name = "CandySurvivalNpc"
|
||
npc.position = Vector3(center.x + 1, 0, center.y + 1)
|
||
add_child(npc, true)
|
||
if npc.has_method("set_face_color_rpc"):
|
||
npc.set_face_color_rpc(current_face)
|
||
return npc
|
||
return null
|
||
|
||
func _apply_arena_setup() -> void:
|
||
if not gridmap:
|
||
return
|
||
gridmap.set("columns", ARENA_COLS)
|
||
gridmap.set("rows", ARENA_ROWS)
|
||
gridmap.clear()
|
||
|
||
# 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 and is_instance_valid(mekton_node):
|
||
mekton_node.queue_free()
|
||
mekton_node = _spawn_mekton_npc(NPC_CENTER)
|
||
print("[CandySurvival] Arena setup applied 18x18")
|
||
|
||
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()
|
||
var pids: Array = []
|
||
for p in get_tree().get_nodes_in_group("Players"):
|
||
if not is_instance_valid(p): continue
|
||
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||
if pid != 0 and not pids.has(pid):
|
||
pids.append(pid)
|
||
return pids
|
||
|
||
func _find_player_node(pid: int) -> Node:
|
||
if main_scene:
|
||
var n = main_scene.get_node_or_null(str(pid))
|
||
if n: return n
|
||
for p in get_tree().get_nodes_in_group("Players"):
|
||
if not is_instance_valid(p): continue
|
||
var p_id = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||
if p_id == pid:
|
||
return p
|
||
return null
|
||
|
||
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_local", "reliable")
|
||
func sync_candy_badge(pid: int, count: int, mult: float, face_match: bool, score: int, colors: Array) -> void:
|
||
if not active:
|
||
activate_client_side()
|
||
player_candies[pid] = count
|
||
player_candy_colors[pid] = colors
|
||
player_score[pid] = score
|
||
|
||
var p_node = _find_player_node(pid)
|
||
if p_node and p_node.has_method("sync_candy_stack"):
|
||
p_node.sync_candy_stack(colors)
|
||
|
||
if pid == multiplayer.get_unique_id():
|
||
_last_badge_count = count
|
||
_last_badge_mult = mult
|
||
_last_badge_face_match = face_match
|
||
|
||
if hud_stack_badge:
|
||
var red = 0
|
||
var blue = 0
|
||
var yellow = 0
|
||
var green = 0
|
||
for col in colors:
|
||
match int(col):
|
||
0: red += 1
|
||
1: blue += 1
|
||
2: green += 1
|
||
3: yellow += 1
|
||
hud_stack_badge.text = "Stack: %d (x%.1f)\nRed: %dx Blue: %dx Green: %dx Yellow: %dx" % [count, mult, red, blue, green, yellow]
|
||
if hud_points_label:
|
||
hud_points_label.text = "Score: %d" % score
|
||
if hud_delivery_indicator:
|
||
if count > 0:
|
||
if face_match:
|
||
hud_delivery_indicator.text = "READY TO DELIVER!"
|
||
hud_delivery_indicator.add_theme_color_override("font_color", Color(0.4, 1.0, 0.4))
|
||
else:
|
||
hud_delivery_indicator.text = "Waiting for match..."
|
||
hud_delivery_indicator.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||
else:
|
||
hud_delivery_indicator.text = ""
|
||
|
||
@rpc("authority", "call_remote", "unreliable")
|
||
func sync_delivery_result(pid: int, success: bool, msg: String) -> void:
|
||
if not active:
|
||
activate_client_side()
|
||
if pid != multiplayer.get_unique_id():
|
||
return
|
||
if hud_delivery_indicator:
|
||
if success:
|
||
hud_delivery_indicator.text = "✓ Delivered! +Sugar Rush!"
|
||
hud_delivery_indicator.add_theme_color_override("font_color", Color(0.3, 1.0, 0.3))
|
||
else:
|
||
hud_delivery_indicator.text = "✗ " + msg
|
||
hud_delivery_indicator.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
|
||
# Flash and auto-clear after 1.5s
|
||
var tween = create_tween()
|
||
tween.tween_interval(1.5)
|
||
tween.tween_callback(_restore_delivery_indicator)
|
||
|
||
func _restore_delivery_indicator() -> void:
|
||
if not active or not hud_delivery_indicator:
|
||
return
|
||
var count = _last_badge_count
|
||
var face_match = _last_badge_face_match
|
||
if count > 0:
|
||
if face_match:
|
||
hud_delivery_indicator.text = "READY TO DELIVER!"
|
||
hud_delivery_indicator.add_theme_color_override("font_color", Color(0.4, 1.0, 0.4))
|
||
else:
|
||
hud_delivery_indicator.text = "Waiting for match..."
|
||
hud_delivery_indicator.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||
else:
|
||
hud_delivery_indicator.text = ""
|
||
|
||
@rpc("authority", "call_local", "reliable")
|
||
func sync_knock_result(attacker: int, target: int, candies: int) -> void:
|
||
if not active:
|
||
activate_client_side()
|
||
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]
|
||
|
||
var attacker_node = _find_player_node(attacker)
|
||
var target_node = _find_player_node(target)
|
||
if not attacker_node or not target_node:
|
||
return
|
||
|
||
# SFX: Play attack_mode sound like other game modes
|
||
var sfx = Engine.get_main_loop().root.get_node_or_null("SfxManager")
|
||
if sfx and sfx.has_method("play"):
|
||
sfx.play("attack_mode")
|
||
|
||
# Attacker bump animation toward target
|
||
if attacker_node.has_method("sync_bump"):
|
||
attacker_node.sync_bump(target_node.current_position, false)
|
||
|
||
# Calculate push direction from attacker to target
|
||
var diff: Vector2i = target_node.current_position - attacker_node.current_position
|
||
var push_dir = Vector2i(clamp(diff.x, -1, 1), clamp(diff.y, -1, 1))
|
||
if push_dir == Vector2i.ZERO:
|
||
push_dir = Vector2i(1, 0)
|
||
|
||
if candies > 0:
|
||
# SUCCESSFUL STEAL: target has candies
|
||
# Target is knocked back up to 3 tiles & stunned
|
||
_apply_knock_animation_to_victim(target_node, push_dir, 2.0)
|
||
else:
|
||
# BACKFIRE: target had 0 candies
|
||
# Target is NOT stunned ("whoever doesn't hold candy, will not stunned")
|
||
# Attacker backfires: knocked back up to 3 tiles away from target & stunned
|
||
_apply_knock_animation_to_victim(attacker_node, -push_dir, 2.0)
|
||
|
||
func _apply_knock_animation_to_victim(victim_node: Node, push_dir: Vector2i, stagger_duration: float) -> void:
|
||
if not is_instance_valid(victim_node):
|
||
return
|
||
|
||
var pushed_to_pos: Vector2i = victim_node.current_position
|
||
var push_path: Array = []
|
||
for i in range(3):
|
||
var next_back: Vector2i = pushed_to_pos + push_dir
|
||
var can_push: bool = false
|
||
if victim_node.get("movement_manager") and victim_node.movement_manager.has_method("_can_push_to"):
|
||
can_push = victim_node.movement_manager._can_push_to(next_back)
|
||
if can_push:
|
||
pushed_to_pos = next_back
|
||
push_path.append(Vector2(pushed_to_pos.x, pushed_to_pos.y))
|
||
if is_sticky_cell(pushed_to_pos):
|
||
break
|
||
else:
|
||
break
|
||
|
||
if push_path.size() > 0:
|
||
if victim_node.has_method("start_movement_along_path"):
|
||
victim_node.start_movement_along_path(push_path, false, true)
|
||
victim_node.target_position = pushed_to_pos
|
||
|
||
if victim_node.has_method("apply_stagger"):
|
||
victim_node.apply_stagger(stagger_duration)
|
||
|
||
@rpc("authority", "call_remote", "unreliable")
|
||
func sync_ghost_active(pid: int, active_on: bool) -> void:
|
||
if not active:
|
||
activate_client_side()
|
||
if pid == multiplayer.get_unique_id():
|
||
if hud_ghost_label and player_ghosts.has(pid):
|
||
hud_ghost_label.text = "👻 Ghosts: %d" % player_ghosts[pid]
|
||
|
||
func sync_state_to_player(peer_id: int) -> void:
|
||
# Ensure the client gets the arena setup before state
|
||
if can_rpc():
|
||
rpc_id(peer_id, "sync_arena_setup")
|
||
|
||
# Sync Mekton's current face color
|
||
if mekton_node and is_instance_valid(mekton_node) and mekton_node.has_method("set_face_color_rpc"):
|
||
if can_rpc():
|
||
mekton_node.rpc_id(peer_id, "set_face_color_rpc", current_face)
|
||
|
||
# Sync Ghost inventory to make the HUD button visible on player load
|
||
_update_special_inventory_for_ghosts(peer_id)
|
||
|
||
# Sync Knock and Ghost charges
|
||
rpc_id(peer_id, "sync_knock_charge_count", peer_id, player_knocks.get(peer_id, START_KNOCK))
|
||
rpc_id(peer_id, "sync_ghost_charge_count", peer_id, player_ghosts.get(peer_id, START_GHOST))
|
||
|
||
# Sync all players' candy stack heights and colors
|
||
for pid in player_candy_colors.keys():
|
||
var count = player_candies.get(pid, 0)
|
||
var mult = get_multiplier(pid)
|
||
var colors = player_candy_colors.get(pid, [])
|
||
var color = colors.back() if colors.size() > 0 else -1
|
||
var face_match = (color != -1)
|
||
var score = player_score.get(pid, 0)
|
||
|
||
# Sync badge
|
||
rpc_id(peer_id, "sync_candy_badge", pid, count, mult, face_match, score, colors)
|
||
|
||
# Sync 3D stack meshes
|
||
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"):
|
||
if peer_id in multiplayer.get_peers():
|
||
player.rpc_id(peer_id, "sync_candy_stack", colors)
|
||
else:
|
||
player.sync_candy_stack(colors)
|
||
break
|
||
|
||
func _update_special_inventory_for_ghosts(pid: int) -> void:
|
||
if not multiplayer.is_server():
|
||
return
|
||
if main_scene:
|
||
var player_node = main_scene.get_node_or_null(str(pid))
|
||
if player_node:
|
||
var st_manager = player_node.get_node_or_null("SpecialTilesManager")
|
||
if st_manager:
|
||
var has_charges = (player_ghosts.get(pid, 0) > 0)
|
||
if pid in multiplayer.get_peers():
|
||
if has_charges:
|
||
st_manager.rpc_id(pid, "sync_inventory_add", st_manager.SpecialEffect.INVISIBLE_MODE, 8)
|
||
else:
|
||
st_manager.rpc_id(pid, "sync_inventory_remove", st_manager.SpecialEffect.INVISIBLE_MODE)
|
||
else:
|
||
if has_charges:
|
||
st_manager.sync_inventory_add(st_manager.SpecialEffect.INVISIBLE_MODE, 8)
|
||
else:
|
||
st_manager.sync_inventory_remove(st_manager.SpecialEffect.INVISIBLE_MODE)
|
||
|
||
@rpc("any_peer", "call_local", "reliable")
|
||
func add_ghost_charge(pid: int) -> void:
|
||
if not multiplayer.is_server():
|
||
return
|
||
player_ghosts[pid] = player_ghosts.get(pid, 0) + 1
|
||
rpc("sync_ghost_charge_count", pid, player_ghosts[pid])
|
||
_update_special_inventory_for_ghosts(pid)
|
||
|
||
@rpc("authority", "call_local", "unreliable")
|
||
func sync_ghost_charge_count(pid: int, count: int) -> void:
|
||
if not active:
|
||
activate_client_side()
|
||
if player_ghosts.has(pid) or pid == multiplayer.get_unique_id():
|
||
player_ghosts[pid] = count
|
||
if multiplayer.get_unique_id() == pid:
|
||
if hud_ghost_label:
|
||
hud_ghost_label.text = "👻 Ghosts: %d" % count
|
||
|
||
@rpc("authority", "call_local", "unreliable")
|
||
func sync_knock_charge_count(pid: int, count: int) -> void:
|
||
if not active:
|
||
activate_client_side()
|
||
if player_knocks.has(pid) or pid == multiplayer.get_unique_id():
|
||
player_knocks[pid] = count
|
||
if multiplayer.get_unique_id() == pid:
|
||
if hud_knock_label:
|
||
hud_knock_label.text = "🥊 Knocks: %d" % count
|