Release 2.4.4: Candy Survival Knock and backfire fixes
Release / Build & Release (push) Successful in 7m54s

This commit is contained in:
2026-07-09 15:05:15 +08:00
parent 85cdd5e9d4
commit 6506e9f416
9 changed files with 116 additions and 35 deletions
+5
View File
@@ -1,4 +1,9 @@
## [NEXT] ## [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 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 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. - Fixed currency split between Gacha and Shop — wallet balance now updates immediately after purchase without needing to reopen the panel.
+1 -1
View File
@@ -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. - Fires `try_knock(attacker, target)` when adjacent to an opponent.
- Consumes 1 of 5 starting charges (`player_knocks`). - Consumes 1 of 5 starting charges (`player_knocks`).
- *Steal*: If target has candies, attacker adopts target's full stack and color; target is zeroed. - *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)**: - **Ghost (Phase-Through)**:
- Input bound natively to `action_grab_tekton` inside `player_input_manager.gd` (bypasses default grab behavior in this mode). - 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`). - Consumes 1 of 5 starting charges (`player_ghosts`).
+21 -1
View File
@@ -1,7 +1,27 @@
{ {
"latest_version": "2.4.3", "latest_version": "2.4.4",
"minimum_app_version": "2.1.0", "minimum_app_version": "2.1.0",
"releases": [ "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", "version": "2.4.3",
"date": "2026-07-04", "date": "2026-07-04",
+4 -4
View File
@@ -14,7 +14,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_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() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
patch_delta_compression_level_zstd=19 patch_delta_compression_level_zstd=19
@@ -85,7 +85,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_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() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
patch_delta_compression_level_zstd=19 patch_delta_compression_level_zstd=19
@@ -314,7 +314,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter=""
export_path="build/tekton_armageddon_v2.4.3.zip" export_path="build/tekton_armageddon_v2.4.4.zip"
patches=PackedStringArray() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
patch_delta_compression_level_zstd=19 patch_delta_compression_level_zstd=19
@@ -589,7 +589,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_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() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
patch_delta_compression_level_zstd=19 patch_delta_compression_level_zstd=19
+1 -1
View File
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="Tekton Dash Armageddon" config/name="Tekton Dash Armageddon"
config/version="2.4.3" config/version="2.4.4"
run/main_scene="res://scenes/ui/boot_screen.tscn" run/main_scene="res://scenes/ui/boot_screen.tscn"
config/features=PackedStringArray("4.6", "Forward Plus") config/features=PackedStringArray("4.6", "Forward Plus")
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1) boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
+16
View File
@@ -1536,6 +1536,13 @@ func sync_game_start(player_list: Array, is_turn_based: bool):
stop_n_go_manager.name = "StopNGoManager" stop_n_go_manager.name = "StopNGoManager"
add_child(stop_n_go_manager) add_child(stop_n_go_manager)
stop_n_go_manager.activate_client_side() 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) # Initialize leaderboard for all peers (after a delay to ensure players loaded)
call_deferred("_deferred_init_leaderboard") call_deferred("_deferred_init_leaderboard")
@@ -2791,3 +2798,12 @@ func display_message(message: String, type: int = 0):
if player.has_method("display_message"): if player.has_method("display_message"):
player.display_message(message, type) player.display_message(message, type)
break 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
+1
View File
@@ -611,6 +611,7 @@ var _candy_scene: PackedScene = preload("res://scenes/candy.tscn")
func sync_candy_stack(colors: Array) -> void: func sync_candy_stack(colors: Array) -> void:
var old_stack = get_node_or_null("CandyStack") var old_stack = get_node_or_null("CandyStack")
if old_stack: if old_stack:
old_stack.name = "CandyStack_Old_" + str(randi())
old_stack.queue_free() old_stack.queue_free()
if colors.is_empty() or not _candy_scene: return if colors.is_empty() or not _candy_scene: return
+57 -22
View File
@@ -148,8 +148,6 @@ func _define_sticky_walls() -> void:
sticky_cells[Vector2i(NPC_CENTER.x + dx, NPC_CENTER.y + dy)] = true sticky_cells[Vector2i(NPC_CENTER.x + dx, NPC_CENTER.y + dy)] = true
func start_game_mode() -> void: func start_game_mode() -> void:
if multiplayer.is_server():
activate_client_side()
active = true active = true
current_face = CandyColor.HEART current_face = CandyColor.HEART
face_timer = 0.0 face_timer = 0.0
@@ -183,6 +181,9 @@ func start_game_mode() -> void:
rpc("sync_knock_charge_count", pid, START_KNOCK) rpc("sync_knock_charge_count", pid, START_KNOCK)
rpc("sync_ghost_charge_count", pid, START_GHOST) rpc("sync_ghost_charge_count", pid, START_GHOST)
if multiplayer.is_server():
activate_client_side()
print("[CandySurvival] Started with %d players" % pids.size()) print("[CandySurvival] Started with %d players" % pids.size())
func _enter_tree() -> void: func _enter_tree() -> void:
@@ -331,15 +332,6 @@ func _update_candy_badge(pid: int) -> void:
var score = player_score.get(pid, 0) var score = player_score.get(pid, 0)
rpc("sync_candy_badge", pid, count, mult, face_match, score, colors) 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: func get_multiplier(pid: int) -> float:
var count = player_candies.get(pid, 0) var count = player_candies.get(pid, 0)
return 1.0 + count * MULTI_STEP return 1.0 + count * MULTI_STEP
@@ -395,31 +387,49 @@ func try_knock(attacker: int, target: int) -> bool:
return false return false
if player_ghost_active.get(target, false): if player_ghost_active.get(target, false):
return 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 return false
var target_colors = player_candy_colors.get(target, []) var target_colors = player_candy_colors.get(target, [])
var target_candies = target_colors.size() var target_candies = target_colors.size()
var attacker_node = main_scene.get_node_or_null(str(attacker)) var attacker_node = _find_player_node(attacker)
var target_node = main_scene.get_node_or_null(str(target)) var target_node = _find_player_node(target)
if attacker_node and target_node: if attacker_node and target_node:
attacker_node.rpc("sync_bump", target_node.current_position) attacker_node.rpc("sync_bump", target_node.current_position)
if target_candies == 0: if target_candies == 0:
# Backfire: both lose a charge # Backfire: attacker loses a charge & gets staggered
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]) 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: if attacker_node:
attacker_node.rpc("apply_stagger", 2.0) 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) rpc("sync_knock_result", attacker, target, 0)
return false return false
# Steal all candies # Steal all candies (Successful knock)
if not player_candy_colors.has(attacker): if not player_candy_colors.has(attacker):
player_candy_colors[attacker] = [] player_candy_colors[attacker] = []
player_candy_colors[attacker].append_array(target_colors) 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[attacker] = player_candy_colors[attacker].size()
player_candies[target] = 0 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]) rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
player_last_knocked_by[target] = attacker player_last_knocked_by[target] = attacker
@@ -567,7 +577,24 @@ func _add_score(pid: int, points: int) -> void:
func _get_player_ids() -> Array: func _get_player_ids() -> Array:
if main_scene and main_scene.has_method("get_player_ids"): if main_scene and main_scene.has_method("get_player_ids"):
return main_scene.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: func candy_survival_round_duration() -> int:
return 180 return 180
@@ -577,10 +604,18 @@ func get_score(pid: int) -> int:
# ── RPCs (stubs — HUD client handles visuals) ── # ── 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: func sync_candy_badge(pid: int, count: int, mult: float, face_match: bool, score: int, colors: Array) -> void:
if not active: if not active:
activate_client_side() 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(): if pid == multiplayer.get_unique_id():
_last_badge_count = count _last_badge_count = count
_last_badge_mult = mult _last_badge_mult = mult
@@ -644,7 +679,7 @@ func _restore_delivery_indicator() -> void:
else: else:
hud_delivery_indicator.text = "" 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: func sync_knock_result(attacker: int, target: int, candies: int) -> void:
if not active: if not active:
activate_client_side() activate_client_side()
+9 -5
View File
@@ -111,7 +111,7 @@ func handle_unhandled_input(event):
# 2. Action Buttons (Remappable via InputMap / KEY_Q override for Candy Survival) # 2. Action Buttons (Remappable via InputMap / KEY_Q override for Candy Survival)
var is_knock = false var is_knock = false
if LobbyManager.game_mode == "Candy Survival": 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: else:
is_knock = event.is_action_pressed("action_knock_tekton") is_knock = event.is_action_pressed("action_knock_tekton")
@@ -120,20 +120,24 @@ func handle_unhandled_input(event):
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager") var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
if gm and gm.active: if gm and gm.active:
var att_id = player.get("peer_id") if "peer_id" in player else player.name.to_int() 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) NotificationManager.send_message(player, "No Knock charges left!", NotificationManager.MessageType.WARNING)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return 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 p_pos = player.current_position
var all_players = get_tree().get_nodes_in_group("Players") var all_players = get_tree().get_nodes_in_group("Players")
var target_found = null var target_found = null
var min_dist = 999
for other in all_players: 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 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 target_found = other
min_dist = dist
if dist == 1:
break break
if target_found: if target_found: