- 桥梁与静态物体高度采样修复: - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程 - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题 - 新增 test_bridge_height_parity.gd 自动化对拍测试 - 40250 怪物击杀经验动效: - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附 - 40250 客户端全系统功能对齐(Batches 1-31): - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试 - 文档沉淀: - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
211 lines
7.2 KiB
GDScript
211 lines
7.2 KiB
GDScript
extends SceneTree
|
||
const NetPlay = preload("res://net_play.gd")
|
||
|
||
# test_combo_continuous_flow.gd
|
||
# 验证 40250 连击状态机:长按空格、连点空格与目标追击时的连击链(1->2->3->4)顺畅推进,
|
||
# 绝无因外层 _attack_cd 吞键或卡死在第 1 段的现象。
|
||
|
||
class FakeClient extends Node:
|
||
var ents := {}
|
||
var attacks := []
|
||
var moves := []
|
||
var _in_game := true
|
||
signal damage(vid: int, amount: int, flag: int)
|
||
signal target_info(vid: int, hp_pct: int)
|
||
signal entity_despawned(vid: int)
|
||
signal entity_dead(vid: int)
|
||
signal combo_changed(enabled: bool)
|
||
signal observer_mode_changed(enabled: bool)
|
||
signal entity_main_set(vid: int)
|
||
signal entity_moved(vid: int, x: int, y: int, rot: float, dur: int)
|
||
signal points_changed()
|
||
signal vitals_changed()
|
||
|
||
func is_in_game() -> bool: return _in_game
|
||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||
func get_entities() -> Array: return ents.values()
|
||
func attack(skill: int, vid: int) -> bool:
|
||
attacks.append([skill, vid])
|
||
return true
|
||
func set_target(_vid: int) -> void: pass
|
||
func move(func_type: int, arg: int, rot: float, x: int, y: int) -> void:
|
||
moves.append([func_type, arg, rot, x, y])
|
||
|
||
class FakePlayerController extends Node:
|
||
var player: Node3D = null
|
||
var active := true
|
||
var frozen := false
|
||
var locked := false
|
||
var moving_skill := false
|
||
var pickables: Array[Node3D] = []
|
||
signal target_selected(node: Node3D)
|
||
signal moved(is_moving: bool)
|
||
signal anim_state(s: String)
|
||
func can_move() -> bool: return active and not frozen and not locked
|
||
func is_going() -> bool: return false
|
||
func stop() -> void: pass
|
||
func walk_to(_pos: Vector3) -> void: pass
|
||
|
||
class FakePlayerView extends Node3D:
|
||
var states: Array[String] = []
|
||
var attack_motions: Array = []
|
||
var anim: FakeAnim = FakeAnim.new()
|
||
var attack_md := {}
|
||
signal motion_bound(state: String)
|
||
|
||
func play_attack_motion(mode: int, index: int, speed: float) -> void:
|
||
attack_motions.append([mode, index, speed])
|
||
states.append("attack")
|
||
if not attack_md.is_empty():
|
||
anim.md = attack_md
|
||
|
||
func set_anim_state(s: String) -> void:
|
||
states.append(s)
|
||
|
||
func is_in_hit_reaction() -> bool:
|
||
return false
|
||
|
||
class FakeAnim:
|
||
var md := {}
|
||
func get_motion_data() -> Dictionary: return md
|
||
|
||
var _fail := 0
|
||
|
||
func _ck(cond: bool, msg: String) -> void:
|
||
if cond:
|
||
print(" [PASS] " + msg)
|
||
else:
|
||
_fail += 1
|
||
printerr(" [FAIL] " + msg)
|
||
|
||
func _init() -> void:
|
||
print("=== Running test_combo_continuous_flow ===")
|
||
await _run_tests()
|
||
if _fail == 0:
|
||
print("\n=== ALL COMBO TESTS PASSED (0 failures) ===\n")
|
||
quit(0)
|
||
else:
|
||
printerr("\n=== %d COMBO TEST(S) FAILED ===\n" % _fail)
|
||
quit(1)
|
||
|
||
func _run_tests() -> void:
|
||
var fc := FakeClient.new()
|
||
fc.ents[1000] = {"vid": 1000, "kind": 0, "name": "Hero", "dead": false, "in_safe": false}
|
||
fc.ents[2000] = {"vid": 2000, "kind": 2, "name": "WildDog", "dead": false, "in_safe": false}
|
||
|
||
var pcn := Node3D.new()
|
||
get_root().add_child(pcn)
|
||
pcn.position = Vector3.ZERO
|
||
|
||
var pc := FakePlayerController.new()
|
||
pc.player = pcn
|
||
|
||
var pv := FakePlayerView.new()
|
||
get_root().add_child(pv)
|
||
|
||
var np: Node = NetPlay.new()
|
||
get_root().add_child(np)
|
||
np.setup(fc, pc, null, null)
|
||
np.player_view = pv
|
||
np._main_vid = 1000
|
||
|
||
# 注册 4 段单手剑 Combo 段表 (motions: 14, 15, 16, 17)
|
||
var K: int = (np.MOTION_MODE_ONEHAND_SWORD << 16) | 0
|
||
np._combo_tables = {0: {K: PackedInt32Array([14, 15, 16, 17])}}
|
||
np.combo_class = 0
|
||
np.combo_motion_mode = np.MOTION_MODE_ONEHAND_SWORD
|
||
|
||
# 配置带有 ComboInputData 的 motion 属性 (典型单手剑连击:长 0.8s, 输入窗 0.15~0.5s, 0.25s 切下一段)
|
||
pv.attack_md = {
|
||
"has_combo_input": true,
|
||
"duration": 0.8,
|
||
"pre_input_time": 0.15,
|
||
"direct_input_time": 0.25,
|
||
"input_limit_time": 0.50,
|
||
"next_combo": 0.25,
|
||
}
|
||
pv.anim.md = pv.attack_md
|
||
var _unused_md := {
|
||
"has_combo_input": true,
|
||
"duration": 0.8,
|
||
"pre_input_time": 0.15,
|
||
"direct_input_time": 0.25,
|
||
"input_limit_time": 0.50,
|
||
"next_combo": 0.25,
|
||
}
|
||
|
||
await process_frame
|
||
|
||
print("\n--- Test 1: Space Key Hold Down Continuous 4-Hit Combo Chain ---")
|
||
# 玩家长按空格键
|
||
np.set_attack_key(true)
|
||
|
||
# 帧 1:发起第 1 段 (段号 14)
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 1, "Frame 1: Space hold triggers Combo 1 (got index %d)" % np._combo_index)
|
||
_ck(pv.attack_motions.size() >= 1 and pv.attack_motions[-1][1] == 14, "Combo 1 plays motion 14")
|
||
|
||
# 时间推进到 0.10s (在 pre_input_time 0.15 之前):保持在段 1,且未挂起预输入
|
||
np._local_time = np._motion_start_t + 0.10
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 1 and not np._is_pre_input, "t=0.10s (<0.15s): still in combo 1, no pre-input")
|
||
|
||
# 时间推进到 0.20s (在 [0.15s, 0.25s] 预输入窗内):因为空格仍按着,成功挂起预输入
|
||
np._local_time = np._motion_start_t + 0.20
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 1 and np._is_pre_input, "t=0.20s in [0.15, 0.25]: pre-input successfully latched")
|
||
|
||
# 时间推进到 0.26s (>0.25s NextComboTime):ComboProcess 到点,自动推进到第 2 段 (段号 15)!
|
||
np._local_time = np._motion_start_t + 0.26
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 2 and not np._is_pre_input, "t=0.26s (>0.25s): ComboProcess advances to Combo 2 (index %d)" % np._combo_index)
|
||
_ck(pv.attack_motions.size() >= 2 and pv.attack_motions[-1][1] == 15, "Combo 2 plays motion 15")
|
||
|
||
# 时间推进到段 2 的 0.20s 预输入窗
|
||
np._local_time = np._motion_start_t + 0.20
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 2 and np._is_pre_input, "Combo 2 t=0.20s: pre-input latched for Combo 3")
|
||
|
||
# 时间推进到段 2 的 0.26s -> 自动切入第 3 段 (段号 16)!
|
||
np._local_time = np._motion_start_t + 0.26
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 3 and not np._is_pre_input, "Combo 3 triggered (index %d, motion 16)" % np._combo_index)
|
||
|
||
# 时间推进到段 3 的 0.20s -> 预输入
|
||
np._local_time = np._motion_start_t + 0.20
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 3 and np._is_pre_input, "Combo 3 t=0.20s: pre-input latched for Combo 4")
|
||
|
||
# 时间推进到段 3 的 0.26s -> 自动切入最后一段 (段 4,段号 17)!
|
||
np._local_time = np._motion_start_t + 0.26
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 4 and not np._is_pre_input, "Combo 4 triggered (index %d, motion 17, last segment)" % np._combo_index)
|
||
|
||
# 段 4 播放完毕 (0.8s),回到 wait,重置 combo 索引
|
||
np._local_time = np._motion_start_t + 0.85
|
||
pv.anim.md = {"has_combo_input": false, "duration": 2.0}
|
||
pv.motion_bound.emit("wait")
|
||
_ck(np._combo_index == 0, "Motion finished back to wait -> combo_index resets to 0")
|
||
|
||
# 下一帧因为空格一直按住,立即无缝开启下一轮 Combo 1
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 1, "Next frame: Space still held down seamlessly restarts Combo 1")
|
||
|
||
# 松开空格
|
||
np.set_attack_key(false)
|
||
|
||
print("\n--- Test 2: Rapid Space Key Tapping ---")
|
||
# 模拟连续按空格(每次按下抬起)
|
||
np._clear_combo()
|
||
np.set_attack_key(true)
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 1, "Tap 1: starts Combo 1")
|
||
np.set_attack_key(false)
|
||
|
||
# 在直接输入窗内 (t=0.30s > 0.25s) 再次按空格
|
||
np._local_time = np._motion_start_t + 0.30
|
||
np.set_attack_key(true)
|
||
np._process(0.016)
|
||
_ck(np._combo_index == 2, "Tap 2 at t=0.30s (>NextComboTime) immediately advances to Combo 2")
|
||
np.set_attack_key(false)
|