diff --git a/CHANGELOG_DRAFT.md b/CHANGELOG_DRAFT.md index ac6ec8e..8b4d1cf 100644 --- a/CHANGELOG_DRAFT.md +++ b/CHANGELOG_DRAFT.md @@ -1,4 +1,9 @@ ## [NEXT] + +## [2.4.4] — 2026-07-09 +- Fixed Candy Survival Knock mechanic: pressing Q now properly knocks adjacent players diagonally and cardinally. +- Implemented Candy Survival Knock backfire mechanic: knocking a player with 0 candies transfers your candy stack to them and staggers you. +- Fixed Candy Survival candy stack 3D mesh visual desync and client-side stack synchronization. - Fixed playerboard desync where host-side board changes weren't reflected on remote clients in multiplayer sessions. - Fixed punch/smack SFX playing repeatedly when quickly attacking another player. - Fixed currency split between Gacha and Shop — wallet balance now updates immediately after purchase without needing to reopen the panel. diff --git a/TODO.md b/TODO.md index 54252a2..5610e9a 100644 --- a/TODO.md +++ b/TODO.md @@ -78,7 +78,7 @@ This document serves as an exhaustive, technical record of all modifications, re - Fires `try_knock(attacker, target)` when adjacent to an opponent. - Consumes 1 of 5 starting charges (`player_knocks`). - *Steal*: If target has candies, attacker adopts target's full stack and color; target is zeroed. - - *Backfire*: If target has 0 candies, attacker loses a charge and both suffer the knock animation state (handled by caller). + - *Backfire*: If target has 0 candies, attacker loses a charge and gets staggered, while any candy stack held by the attacker is transferred to the target. - **Ghost (Phase-Through)**: - Input bound natively to `action_grab_tekton` inside `player_input_manager.gd` (bypasses default grab behavior in this mode). - Consumes 1 of 5 starting charges (`player_ghosts`). diff --git a/assets/data/version.json b/assets/data/version.json index 26d6bf2..8954a30 100644 --- a/assets/data/version.json +++ b/assets/data/version.json @@ -1,7 +1,27 @@ { - "latest_version": "2.4.3", + "latest_version": "2.4.4", "minimum_app_version": "2.1.0", "releases": [ + { + "version": "2.4.4", + "date": "2026-07-09", + "pck_url": "https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck", + "pck_size": 0, + "changelog": [ + "Fixed Candy Survival Knock mechanic: pressing Q now properly knocks adjacent players diagonally and cardinally.", + "Implemented Candy Survival Knock backfire mechanic: knocking a player with 0 candies transfers your candy stack to them and staggers you.", + "Fixed Candy Survival candy stack 3D mesh visual desync and client-side stack synchronization.", + "Fixed playerboard desync where host-side board changes weren't reflected on remote clients in multiplayer sessions.", + "Fixed punch/smack SFX playing repeatedly when quickly attacking another player.", + "Fixed currency split between Gacha and Shop \u2014 wallet balance now updates immediately after purchase without needing to reopen the panel.", + "Fixed fatal crash in Multiplayer Gauntlet caused by missing `has_method` check on smack cooldown timers on remote peers.", + "Upgraded engine from Godot 4.6 to 4.7 stable for better performance and stability.", + "Migrated patch delivery from GitHub Pages to Gitea native raw endpoint \u2014 no more external dependencies for game updates.", + "CI: optimized build cache for Godot binary, eliminating repeated 140MB downloads on every workflow run.", + "CI: shallow repository checkout (--depth 1) for faster clone times.", + "CI: removed unnecessary export template download from patch deployment workflow (--export-pack doesn't need them)." + ] + }, { "version": "2.4.3", "date": "2026-07-04", diff --git a/export_presets.cfg b/export_presets.cfg index b92480c..2ade278 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -14,7 +14,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/windows/tekton_armageddon_v2.4.3.exe" +export_path="build/windows/tekton_armageddon_v2.4.4.exe" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -85,7 +85,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/tekton-dash-armageddon-v.2.4.3.apk" +export_path="build/tekton-dash-armageddon-v.2.4.4.apk" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -314,7 +314,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/tekton_armageddon_v2.4.3.zip" +export_path="build/tekton_armageddon_v2.4.4.zip" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -589,7 +589,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="build/linux/tekton_armageddon_v2.4.3.x86_64" +export_path="build/linux/tekton_armageddon_v2.4.4.x86_64" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 diff --git a/project.godot b/project.godot index 19b44df..c234eed 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="Tekton Dash Armageddon" -config/version="2.4.3" +config/version="2.4.4" run/main_scene="res://scenes/ui/boot_screen.tscn" config/features=PackedStringArray("4.6", "Forward Plus") boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1) diff --git a/scenes/main.gd b/scenes/main.gd index 83c77a5..1f1ed1b 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -1536,6 +1536,13 @@ func sync_game_start(player_list: Array, is_turn_based: bool): stop_n_go_manager.name = "StopNGoManager" add_child(stop_n_go_manager) stop_n_go_manager.activate_client_side() + elif LobbyManager.game_mode == "Candy Survival": + if not candy_survival_manager: + candy_survival_manager = load("res://scripts/managers/candy_survival_manager.gd").new() + candy_survival_manager.name = "CandySurvivalManager" + add_child(candy_survival_manager) + candy_survival_manager.initialize(self, $EnhancedGridMap) + candy_survival_manager.activate_client_side() # Initialize leaderboard for all peers (after a delay to ensure players loaded) call_deferred("_deferred_init_leaderboard") @@ -2791,3 +2798,12 @@ func display_message(message: String, type: int = 0): if player.has_method("display_message"): player.display_message(message, type) break + +func get_player_ids() -> Array: + 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 diff --git a/scenes/player.gd b/scenes/player.gd index 0913675..129b1c0 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -611,6 +611,7 @@ var _candy_scene: PackedScene = preload("res://scenes/candy.tscn") func sync_candy_stack(colors: Array) -> void: var old_stack = get_node_or_null("CandyStack") if old_stack: + old_stack.name = "CandyStack_Old_" + str(randi()) old_stack.queue_free() if colors.is_empty() or not _candy_scene: return diff --git a/scripts/managers/candy_survival_manager.gd b/scripts/managers/candy_survival_manager.gd index 24ec35d..2132ea4 100644 --- a/scripts/managers/candy_survival_manager.gd +++ b/scripts/managers/candy_survival_manager.gd @@ -148,8 +148,6 @@ func _define_sticky_walls() -> void: sticky_cells[Vector2i(NPC_CENTER.x + dx, NPC_CENTER.y + dy)] = true func start_game_mode() -> void: - if multiplayer.is_server(): - activate_client_side() active = true current_face = CandyColor.HEART face_timer = 0.0 @@ -183,6 +181,9 @@ func start_game_mode() -> void: 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: @@ -330,15 +331,6 @@ func _update_candy_badge(pid: int) -> void: var face_match = (color != -1) var score = player_score.get(pid, 0) rpc("sync_candy_badge", pid, count, mult, face_match, score, colors) - - # 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", colors) - break func get_multiplier(pid: int) -> float: var count = player_candies.get(pid, 0) @@ -395,31 +387,49 @@ func try_knock(attacker: int, target: int) -> bool: return false if player_ghost_active.get(target, false): return false - if player_knocks.get(attacker, 0) <= 0: + 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 = main_scene.get_node_or_null(str(attacker)) - var target_node = main_scene.get_node_or_null(str(target)) + var attacker_node = _find_player_node(attacker) + var target_node = _find_player_node(target) if attacker_node and target_node: attacker_node.rpc("sync_bump", target_node.current_position) if target_candies == 0: - # Backfire: both lose a charge - player_knocks[attacker] = player_knocks.get(attacker, 0) - 1 + # 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 also knocked down briefly (backfire) + # Attacker is knocked down briefly (backfire) if attacker_node: attacker_node.rpc("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() + 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 + # Steal all candies (Successful knock) if not player_candy_colors.has(attacker): player_candy_colors[attacker] = [] player_candy_colors[attacker].append_array(target_colors) @@ -428,7 +438,7 @@ func try_knock(attacker: int, target: int) -> bool: player_candies[attacker] = player_candy_colors[attacker].size() player_candies[target] = 0 - player_knocks[attacker] = player_knocks.get(attacker, 0) - 1 + 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 @@ -567,7 +577,24 @@ func _add_score(pid: int, points: int) -> void: func _get_player_ids() -> Array: if main_scene and main_scene.has_method("get_player_ids"): return main_scene.get_player_ids() - return [] + 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 @@ -577,10 +604,18 @@ func get_score(pid: int) -> int: # ── RPCs (stubs — HUD client handles visuals) ── -@rpc("authority", "call_remote", "unreliable") +@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 @@ -644,7 +679,7 @@ func _restore_delivery_indicator() -> void: else: hud_delivery_indicator.text = "" -@rpc("authority", "call_remote", "unreliable") +@rpc("authority", "call_local", "reliable") func sync_knock_result(attacker: int, target: int, candies: int) -> void: if not active: activate_client_side() diff --git a/scripts/managers/player_input_manager.gd b/scripts/managers/player_input_manager.gd index cdd8b6f..95bf25d 100644 --- a/scripts/managers/player_input_manager.gd +++ b/scripts/managers/player_input_manager.gd @@ -111,7 +111,7 @@ func handle_unhandled_input(event): # 2. Action Buttons (Remappable via InputMap / KEY_Q override for Candy Survival) var is_knock = false if LobbyManager.game_mode == "Candy Survival": - is_knock = (event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_Q) or event.is_action_pressed("action_knock_tekton") + is_knock = (event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_Q or event.physical_keycode == KEY_Q)) or event.is_action_pressed("action_knock_tekton") else: is_knock = event.is_action_pressed("action_knock_tekton") @@ -120,21 +120,25 @@ 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() - if gm.player_knocks.get(att_id, 0) <= 0: + 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 (first adjacent player) + # 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: continue + if other == player or not is_instance_valid(other): continue var other_pos = other.current_position - if abs(p_pos.x - other_pos.x) + abs(p_pos.y - other_pos.y) == 1: + 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 - break + 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()