diff --git a/CHANGELOG_DRAFT.md b/CHANGELOG_DRAFT.md index c3e0027..7e6771a 100644 --- a/CHANGELOG_DRAFT.md +++ b/CHANGELOG_DRAFT.md @@ -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. diff --git a/scripts/bot_controller.gd b/scripts/bot_controller.gd index e0e1a42..69640dc 100644 --- a/scripts/bot_controller.gd +++ b/scripts/bot_controller.gd @@ -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)") diff --git a/scripts/managers/candy_survival_manager.gd b/scripts/managers/candy_survival_manager.gd index ed524b6..a580a1e 100644 --- a/scripts/managers/candy_survival_manager.gd +++ b/scripts/managers/candy_survival_manager.gd @@ -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: diff --git a/scripts/managers/player_input_manager.gd b/scripts/managers/player_input_manager.gd index 95bf25d..af2cbbe 100644 --- a/scripts/managers/player_input_manager.gd +++ b/scripts/managers/player_input_manager.gd @@ -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"): diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 825bbb8..b6f7e7a 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -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) === diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd index d5a8fea..bddded6 100644 --- a/scripts/managers/user_profile_manager.gd +++ b/scripts/managers/user_profile_manager.gd @@ -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 diff --git a/scripts/services/safe_nakama_multiplayer_bridge.gd.uid b/scripts/services/safe_nakama_multiplayer_bridge.gd.uid new file mode 100644 index 0000000..00488bf --- /dev/null +++ b/scripts/services/safe_nakama_multiplayer_bridge.gd.uid @@ -0,0 +1 @@ +uid://dfyyc34ekv3yw diff --git a/scripts/ui/profile_panel.gd b/scripts/ui/profile_panel.gd index 30b9f47..45fc8d0 100644 --- a/scripts/ui/profile_panel.gd +++ b/scripts/ui/profile_panel.gd @@ -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) # ─────────────────────────────────────────────────────────────