feat(candy-survival): toggle-and-walk knock mode, unified animations, score rewards, RPC fix

- 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
This commit is contained in:
2026-07-09 18:03:26 +08:00
parent 00c9e85527
commit 2b4c9d9dcb
8 changed files with 116 additions and 68 deletions
+6
View File
@@ -1,4 +1,10 @@
## [NEXT]
- Changed Candy Survival knock from instant-ambush to **toggle-and-walk mode** like other game modes: press Q to toggle Knock Mode ON (red glow), then walk into a target to execute the knock. Press Q again to cancel. Consumed on use. Bots auto-toggle when they have charges.
- Fixed `Attempt to call RPC with unknown peer ID: 5` crash in Candy Survival when updating ghost inventory or candy stacks for AI bots or unverified peers.
- Added +100 score rewards in Candy Survival when completing goals by collecting tiles, successfully landing a KNOCK, or tactically activating GHOST.
- Updated Candy Survival knocking animation to match other game modes (`attack_mode` SFX, bump animation, 3-tile knockback along path, and stun stagger) while keeping the Candy Survival ruleset: successful knock steals all target's candies and knocks target back; knocking a target with 0 candies backfires, knocking the attacker back 3 tiles and transferring attacker's candies to target.
- Fixed profile panel (`SaveNameBtn`) failing when updating username on connected peers by passing `null` for login username so Nakama only updates `display_name` without unique username collisions.
- Preserved detailed profile update failure messages in ProfilePanel UI status label and persisted display name changes to storage.
## [2.4.6] — 2026-07-09
- Fixed NakamaMultiplayerBridge dictionary lookup crash when receiving match state before user presence is populated by creating a `SafeNakamaMultiplayerBridge` subclass.
+11
View File
@@ -210,6 +210,17 @@ func _run_ai_tick():
print("[BotController] Action Taken: Attack Pursuit")
return
# Priority 0.3: Candy Survival — auto-toggle knock mode when bot has charges
if strategic_planner and strategic_planner.is_candy_survival_mode():
var gm = strategic_planner._get_candy_survival_manager()
if gm and gm.active:
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
var charges = gm.player_knocks.get(pid, 0)
if charges > 0:
actor.is_charged_strike = true
else:
actor.is_charged_strike = false
# Priority 0.5: Candy Survival (#075) — use Ghost powerup if boxed in
if await _try_activate_ghost():
print("[BotController] Action Taken: Ghost (trapped)")
+73 -23
View File
@@ -283,6 +283,7 @@ func on_blueprint_completed(pid: int, primary_tile_id: int, off_color: bool) ->
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:
@@ -403,23 +404,12 @@ func try_knock(attacker: int, target: int) -> bool:
var attacker_node = _find_player_node(attacker)
var target_node = _find_player_node(target)
if attacker_node and target_node:
if attacker_node.has_method("can_rpc") and attacker_node.can_rpc():
attacker_node.rpc("sync_bump", target_node.current_position)
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])
# Attacker is knocked down briefly (backfire)
if attacker_node:
if attacker_node.has_method("can_rpc") and attacker_node.can_rpc():
attacker_node.rpc("apply_stagger", 2.0)
elif attacker_node.has_method("apply_stagger"):
attacker_node.apply_stagger(2.0)
# Backfire penalty: Attacker's candy stack transfers to Target!
var attacker_colors = player_candy_colors.get(attacker, [])
var attacker_candies = attacker_colors.size()
@@ -452,14 +442,7 @@ func try_knock(attacker: int, target: int) -> bool:
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
player_last_knocked_by[target] = attacker
# Target is knocked down / staggered on successful knock
if target_node:
if target_node.has_method("can_rpc") and target_node.can_rpc():
target_node.rpc("apply_stagger", 2.0)
elif target_node.has_method("apply_stagger"):
target_node.apply_stagger(2.0)
_add_score(attacker, target_candies * 100)
_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)
@@ -493,6 +476,7 @@ func try_activate_ghost(pid: int) -> bool:
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:
@@ -716,6 +700,63 @@ func sync_knock_result(attacker: int, target: int, candies: int) -> void:
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:
@@ -759,7 +800,10 @@ func sync_state_to_player(peer_id: int) -> void:
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_id(peer_id, "sync_candy_stack", colors)
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:
@@ -771,10 +815,16 @@ func _update_special_inventory_for_ghosts(pid: int) -> void:
var st_manager = player_node.get_node_or_null("SpecialTilesManager")
if st_manager:
var has_charges = (player_ghosts.get(pid, 0) > 0)
if has_charges:
st_manager.rpc_id(pid, "sync_inventory_add", st_manager.SpecialEffect.INVISIBLE_MODE, 8)
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:
st_manager.rpc_id(pid, "sync_inventory_remove", st_manager.SpecialEffect.INVISIBLE_MODE)
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:
+16 -27
View File
@@ -120,39 +120,28 @@ func handle_unhandled_input(event):
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
if gm and gm.active:
var att_id = player.get("peer_id") if "peer_id" in player else player.name.to_int()
# Toggle mode: if already in knock mode, turn it off
if player.get("is_charged_strike"):
player.is_charged_strike = false
NotificationManager.send_message(player, "Knock Mode OFF", NotificationManager.MessageType.WARNING)
get_viewport().set_input_as_handled()
return
# Check charges before toggling on
if gm.player_knocks.get(att_id, 5) <= 0:
NotificationManager.send_message(player, "No Knock charges left!", NotificationManager.MessageType.WARNING)
get_viewport().set_input_as_handled()
return
# Find a target to knock (adjacent player: cardinal or diagonal distance <= 1)
var p_pos = player.current_position
var all_players = get_tree().get_nodes_in_group("Players")
var target_found = null
var min_dist = 999
for other in all_players:
if other == player or not is_instance_valid(other): continue
var other_pos = other.current_position
var dist = max(abs(p_pos.x - other_pos.x), abs(p_pos.y - other_pos.y))
if dist <= 1 and dist < min_dist:
target_found = other
min_dist = dist
if dist == 1:
break
if target_found:
var tgt_id = target_found.get("peer_id") if "peer_id" in target_found else target_found.name.to_int()
if multiplayer.is_server():
gm.try_knock(att_id, tgt_id)
else:
gm.rpc_id(1, "try_knock", att_id, tgt_id)
else:
# No target, but play the bump animation anyway in place
player.rpc("sync_bump", p_pos)
# Toggle knock mode ON (reuses charged strike visuals)
player.is_charged_strike = true
NotificationManager.send_message(player, "🥊 Knock Mode ON — Walk into a target!", NotificationManager.MessageType.POWERUP)
player.update_active_player_indicator()
get_viewport().set_input_as_handled()
return
if player.powerup_manager:
player.powerup_manager.use_special_effect()
if player.get("is_attack_mode") and player.has_method("enter_attack_mode"):
+6 -15
View File
@@ -215,16 +215,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
NotificationManager.send_message(player, "Target is Immune!", NotificationManager.MessageType.WARNING)
return false
# Candy Survival: check if attacker has knock charges
var has_knock = false
var main_for_knock = player.get_tree().root.get_node_or_null("Main")
var gm_for_knock = main_for_knock.get("candy_survival_manager") if main_for_knock else null
if gm_for_knock and gm_for_knock.active:
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
var charges = gm_for_knock.player_knocks.get(att_pid, 0) if gm_for_knock.player_knocks else 0
has_knock = charges > 0
if (not player.get("is_charged_strike") and not has_knock) or player.get("is_invisible"):
if not player.get("is_charged_strike") or player.get("is_invisible"):
# Standard bumping effect (Visual only)
print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
if _can_rpc():
@@ -258,12 +249,12 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
if gm and gm.active and gm.has_method("try_knock"):
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int()
if gm.try_knock(pid, other_pid):
print("[CandySurvival] Player %d knocked %d, stole candies!" % [pid, other_pid])
if multiplayer.is_server():
gm.try_knock(pid, other_pid)
else:
print("[CandySurvival] Player %d tried to knock %d (backfire or no charges)" % [pid, other_pid])
if _can_rpc():
player.rpc("sync_bump", target_pos, false)
gm.rpc_id(1, "try_knock", pid, other_pid)
# Consume knock mode after use (toggle off)
player.is_charged_strike = false
return false
# === SUPER PUSH (Attack Mode) ===
+2 -2
View File
@@ -216,10 +216,9 @@ func update_display_name(new_name: String) -> bool:
emit_signal("profile_updated")
return true
var formatted_username = new_name.replace(" ", "_").to_lower()
var result: NakamaAsyncResult = await NakamaManager.client.update_account_async(
NakamaManager.session,
formatted_username, # username (sync to display name)
null, # username (keep existing unique username unchanged)
new_name # display_name
)
@@ -228,6 +227,7 @@ func update_display_name(new_name: String) -> bool:
return false
profile["display_name"] = new_name
await _save_profile_data()
emit_signal("profile_updated")
return true
@@ -0,0 +1 @@
uid://dfyyc34ekv3yw
+1 -1
View File
@@ -607,7 +607,7 @@ func _on_save_name_pressed() -> void:
emit_signal("profile_updated")
await get_tree().create_timer(3.0).timeout
status_label.text = ""
else:
elif status_label.text == "Saving...":
_set_status("Failed to update name.", Color.RED)
# ─────────────────────────────────────────────────────────────