feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+210
View File
@@ -0,0 +1,210 @@
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)