- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级) - 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植) - _register_hit 改为 map::insert 语义,修正命中上限 - 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段 - 去掉本地连击超时,motion_bound 清命中表 / 连击段号 - __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、 命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd) - ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING - mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上 - 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试; gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体 - 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
255 lines
12 KiB
GDScript
255 lines
12 KiB
GDScript
# hit_view_test —— CLIENT-GAP §3.7 受击方 / 攻击方视图(PlayerView / MobView)headless 自检:
|
||
# __HitGood / __HitGreate / __HitStone 动作链(ActorInstanceBattle.cpp:769-890)、__Shake、
|
||
# InsertDelay(动作冻结)、攻击动作按 (motion mode, motion index, fSpeedRatio) 绑定、
|
||
# .msm 防御球、motion_bound(__SetMotion 尾给 net_play 的钩子)。
|
||
# godot --headless --path project --script hit_view_test.gd
|
||
extends SceneTree
|
||
|
||
const PlayerView = preload("res://ui/player_view.gd")
|
||
const MobView = preload("res://ui/mob_view.gd")
|
||
|
||
# Metin2AnimPlayer 桩:只记属性(anim_path 每次赋值都记,验证「同路径先清再设」)
|
||
class FakeAnim extends Node:
|
||
signal playback_finished
|
||
var sets := []
|
||
var anim_path := "":
|
||
set(v):
|
||
anim_path = v
|
||
sets.append(v)
|
||
var loop := true
|
||
var time_scale := 1.0
|
||
var playing := true
|
||
var blend_time := 0.15
|
||
func get_motion_data() -> Dictionary: return {}
|
||
|
||
const MSM := "ScriptType CharacterModelScript\nGroup AttachingData\n{\n\tGroup AttachingData00\n\t{\n\t\tAttachingDataType 0\n\t\tIsAttaching 0\n\t\tGroup CollisionData\n\t\t{\n\t\t\tCollisionType 3\n\t\t\tGroup SphereData\n\t\t\t{\n\t\t\t\tSphereDataCount 1\n\t\t\t\tGroup SphereData00\n\t\t\t\t{\n\t\t\t\t\tRadius %.1f\n\t\t\t\t\tPosition 0.0 0.0 %.1f\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
|
||
|
||
var _fail := 0
|
||
func _ck(c: bool, m: String) -> void:
|
||
if not c:
|
||
_fail += 1
|
||
printerr("FAIL: " + m)
|
||
|
||
func _touch(p: String, body := "") -> void:
|
||
DirAccess.make_dir_recursive_absolute(p.get_base_dir())
|
||
var f := FileAccess.open(p, FileAccess.WRITE)
|
||
f.store_string(body)
|
||
f.close()
|
||
|
||
var _done := false
|
||
|
||
func _init() -> void:
|
||
await _run()
|
||
_ck(_done, "test body ran to completion (a SCRIPT ERROR aborted _run)")
|
||
if _fail == 0:
|
||
print("PASS: hit_view_test (hit reactions / shake / delay / attack motion / defending spheres)")
|
||
quit(0)
|
||
else:
|
||
printerr("%d check(s) failed" % _fail)
|
||
quit(1)
|
||
|
||
func _run() -> void:
|
||
var root := ProjectSettings.globalize_path("user://hit_view_test")
|
||
var gen := root.path_join("general")
|
||
for n in ["wait", "run", "attack", "attack_1", "damage", "damage_1", "damage_2", "damage_3",
|
||
"damage_flying", "falling_stand", "back_damage_flying", "back_falling_stand"]:
|
||
_touch(gen.path_join(n + ".msa"))
|
||
for n in ["combo_01", "combo_02", "attack"]:
|
||
_touch(root.path_join("onehand_sword/%s.msa" % n))
|
||
_touch(root.path_join("root/msm/warrior_m.msm"), MSM % [50.0, 90.0])
|
||
_touch(root.path_join("root/msm/warrior_w.msm"), MSM % [40.0, 80.0])
|
||
var gen_attack := [gen.path_join("attack.msa"), gen.path_join("attack_1.msa")]
|
||
|
||
# --- PlayerView ---------------------------------------------------------
|
||
var pv: Node3D = PlayerView.new()
|
||
get_root().add_child(pv)
|
||
var model := Node3D.new()
|
||
pv.add_child(model)
|
||
pv.model = model
|
||
var anim := FakeAnim.new()
|
||
pv.add_child(anim)
|
||
pv.anim = anim
|
||
anim.playback_finished.connect(pv._on_playback_finished)
|
||
pv.motion_dir = gen
|
||
pv._assets_root = root
|
||
pv._race = 0
|
||
var bound := []
|
||
pv.motion_bound.connect(func(s: String): bound.append(s))
|
||
await process_frame
|
||
|
||
pv.set_anim_state("wait")
|
||
_ck(anim.anim_path == gen.path_join("wait.msa") and bound == ["wait"],
|
||
"set_anim_state binds wait.msa -> motion_bound(wait), got %s" % [bound])
|
||
|
||
# __SetMotion(MAKE_MOTION_KEY(mode, index), fSpeedRatio = m_fAtkSpd)
|
||
var c1 := root.path_join("onehand_sword/combo_01.msa")
|
||
pv.play_attack_motion(2, 14, 1.5)
|
||
_ck(anim.anim_path == c1 and not anim.loop and is_equal_approx(anim.time_scale, 1.5) \
|
||
and bound.back() == "attack",
|
||
"ONEHAND_SWORD COMBO_ATTACK_1 -> combo_01.msa one-shot at speed 1.5, got %s" % anim.anim_path)
|
||
anim.sets.clear()
|
||
pv.play_attack_motion(2, 14, 1.5)
|
||
_ck(anim.sets == ["", c1], "same seg again -> re-bound from frame 0, got %s" % [anim.sets])
|
||
pv.play_attack_motion(2, 15, 1.0)
|
||
_ck(anim.anim_path == root.path_join("onehand_sword/combo_02.msa"), "index 15 -> combo_02.msa")
|
||
pv.play_attack_motion(1, 13, 1.0)
|
||
_ck(anim.anim_path in gen_attack, "GENERAL NORMAL_ATTACK -> attack / attack_1, got %s" % anim.anim_path)
|
||
pv.play_attack_motion(1, 14, 1.0)
|
||
_ck(anim.anim_path in gen_attack, "GENERAL COMBO_ATTACK_1 -> attack / attack_1, got %s" % anim.anim_path)
|
||
pv.play_attack_motion(6, 14, 1.0)
|
||
_ck(anim.anim_path in gen_attack, "missing mode folder -> general attack fallback, got %s" % anim.anim_path)
|
||
_ck(not pv.is_in_hit_reaction(), "attack motion is not a hit reaction")
|
||
|
||
# CGraphicThingInstance::InsertDelay:动作冻结 fDelay 秒后恢复 fSpeedRatio
|
||
pv.play_attack_motion(2, 14, 1.5)
|
||
pv.insert_delay(0.1)
|
||
_ck(anim.time_scale == 0.0, "InsertDelay -> motion frozen")
|
||
pv._process(0.05)
|
||
_ck(anim.time_scale == 0.0, "inside the delay -> still frozen")
|
||
pv._process(0.06)
|
||
_ck(is_equal_approx(anim.time_scale, 1.5), "delay over -> fSpeedRatio restored, got %s" % anim.time_scale)
|
||
|
||
# isLock(攻击动作中):__HitGood 只 __Shake(100)
|
||
var atk_path: String = anim.anim_path
|
||
pv.hit_good(-1.0, false)
|
||
_ck(anim.anim_path == atk_path and pv._hit.shake_left > 0.0, "isLock (attack) -> shake only, no damage motion")
|
||
pv._process(0.05)
|
||
var off: Vector3 = model.position
|
||
_ck(absf(off.x) <= 0.09 and absf(off.y) <= 0.09 and absf(off.z) <= 0.09,
|
||
"ShakeProcess offset within rand()%%10 cm, got %s" % off)
|
||
pv._process(0.2)
|
||
_ck(model.position == Vector3.ZERO, "shake over -> model back on its ground offset, got %s" % model.position)
|
||
|
||
# __HitGood:fScalar < 0 -> NAME_DAMAGE;>= 0 -> NAME_DAMAGE_BACK;之后 PushLoopMotion(WAIT)
|
||
pv.set_anim_state("wait")
|
||
pv._hit.shake_left = 0.0
|
||
pv.hit_good(-1.0, false)
|
||
_ck(anim.anim_path in [gen.path_join("damage.msa"), gen.path_join("damage_1.msa")] and not anim.loop \
|
||
and pv.is_in_hit_reaction() and bound.back() == "damage",
|
||
"fScalar < 0 -> NAME_DAMAGE (damage / damage_1), got %s" % anim.anim_path)
|
||
anim.playback_finished.emit()
|
||
_ck(anim.anim_path == gen.path_join("wait.msa") and not pv.is_in_hit_reaction(), "damage done -> WAIT")
|
||
pv.hit_good(1.0, false)
|
||
_ck(anim.anim_path in [gen.path_join("damage_2.msa"), gen.path_join("damage_3.msa")],
|
||
"fScalar >= 0 -> NAME_DAMAGE_BACK (damage_2 / damage_3), got %s" % anim.anim_path)
|
||
anim.playback_finished.emit()
|
||
pv._process(0.2)
|
||
pv._hit.shake_left = 0.0
|
||
var before: String = anim.anim_path
|
||
pv.hit_good(-1.0, true)
|
||
_ck(anim.anim_path == before and pv._hit.shake_left == 0.0, "stunned -> Die() branch: no shake / damage motion")
|
||
|
||
# __HitGreate:DAMAGE_FLYING -> STAND_UP -> 回循环动作;击倒 / 起身中不再受 Good / Greate
|
||
pv.set_anim_state("run")
|
||
pv.hit_greate(-1.0, false)
|
||
_ck(anim.anim_path == gen.path_join("damage_flying.msa") and pv.is_in_hit_reaction(),
|
||
"GREAT fScalar < 0 -> NAME_DAMAGE_FLYING, got %s" % anim.anim_path)
|
||
before = anim.anim_path
|
||
pv.hit_good(-1.0, false)
|
||
_ck(anim.anim_path == before, "IsKnockDown -> __HitGood returns")
|
||
pv.hit_greate(1.0, false)
|
||
_ck(anim.anim_path == before, "IsKnockDown -> __HitGreate returns")
|
||
anim.playback_finished.emit()
|
||
_ck(anim.anim_path == gen.path_join("falling_stand.msa"), "knockdown done -> PushOnceMotion(NAME_STAND_UP)")
|
||
pv.hit_greate(-1.0, false)
|
||
_ck(anim.anim_path == gen.path_join("falling_stand.msa"), "__IsStandUpMotion -> __HitGreate returns")
|
||
anim.playback_finished.emit()
|
||
_ck(anim.anim_path == gen.path_join("run.msa") and not pv.is_in_hit_reaction(),
|
||
"stand up done -> loop motion resumes (run), got %s" % anim.anim_path)
|
||
pv.hit_greate(1.0, false)
|
||
_ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "GREAT fScalar >= 0 -> NAME_DAMAGE_FLYING_BACK")
|
||
anim.playback_finished.emit()
|
||
_ck(anim.anim_path == gen.path_join("back_falling_stand.msa"), "-> NAME_STAND_UP_BACK")
|
||
anim.playback_finished.emit()
|
||
pv.hit_greate(1.0, true)
|
||
_ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned GREAT -> DAMAGE_FLYING_BACK")
|
||
anim.playback_finished.emit()
|
||
_ck(anim.anim_path == gen.path_join("back_damage_flying.msa"), "stunned knockdown -> m_isRealDead: no stand up")
|
||
# IsUsingSkill -> 只 __Shake
|
||
pv.set_anim_state("skill")
|
||
pv._hit.shake_left = 0.0
|
||
before = anim.anim_path
|
||
pv.hit_greate(-1.0, false)
|
||
_ck(anim.anim_path == before and pv._hit.shake_left > 0.0, "IsUsingSkill -> __HitGreate shakes only")
|
||
pv.set_anim_state("wait")
|
||
pv._hit.shake_left = 0.0
|
||
pv.hit_stone(false)
|
||
_ck(pv._hit.shake_left > 0.0 and anim.anim_path == gen.path_join("wait.msa"), "__HitStone -> shake only")
|
||
pv._hit.shake_left = 0.0
|
||
pv.hit_stone(true)
|
||
_ck(pv._hit.shake_left == 0.0, "stunned stone -> Die() branch, no shake")
|
||
|
||
# .msm 防御球:root/msm/<class>_<m|w>.msm
|
||
var sp: Array = pv.get_defending_spheres()
|
||
_ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 50.0) \
|
||
and Vector3(sp[0]["pos"]).is_equal_approx(Vector3(0, 0, 90)),
|
||
"race 0 -> warrior_m.msm defending sphere, got %s" % [sp])
|
||
pv._race = 4
|
||
sp = pv.get_defending_spheres()
|
||
_ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 40.0), "race 4 -> warrior_w.msm, got %s" % [sp])
|
||
|
||
# --- MobView ------------------------------------------------------------
|
||
var mdir := root.path_join("mob")
|
||
var files := {"WAIT": "00.msa", "RUN": "03.msa", "NORMAL_ATTACK": "20.msa", "FRONT_DAMAGE": "30.msa",
|
||
"FRONT_KNOCKDOWN": "32.msa", "FRONT_STANDUP": "33.msa", "BACK_DAMAGE": "34.msa", "BACK_KNOCKDOWN": "35.msa"}
|
||
var mv: Node3D = MobView.new()
|
||
get_root().add_child(mv)
|
||
var mmodel := Node3D.new()
|
||
mv.add_child(mmodel)
|
||
mv.model = mmodel
|
||
var manim := FakeAnim.new()
|
||
mv.add_child(manim)
|
||
mv.anim = manim
|
||
manim.playback_finished.connect(mv._on_playback_finished)
|
||
mv._dir = mdir
|
||
mv._mesh_stem = "mobx"
|
||
for k in files:
|
||
_touch(mdir.path_join(files[k]))
|
||
mv._motions[k] = mdir.path_join(files[k])
|
||
_touch(mdir.path_join("mobx.msm"), MSM % [60.0, 120.0])
|
||
var mbound := []
|
||
mv.motion_bound.connect(func(s: String): mbound.append(s))
|
||
var M := func(n: String) -> String: return mdir.path_join(files[n])
|
||
|
||
mv.set_anim_state("wait")
|
||
_ck(manim.anim_path == M.call("WAIT") and mbound == ["wait"], "mob set_anim_state -> motion_bound(wait)")
|
||
mv.hit_good(1.0, false)
|
||
_ck(manim.anim_path == M.call("BACK_DAMAGE") and mv.is_in_hit_reaction(), "mob fScalar >= 0 -> BACK_DAMAGE")
|
||
manim.playback_finished.emit()
|
||
_ck(manim.anim_path == M.call("WAIT") and not mv.is_in_hit_reaction(), "mob damage done -> WAIT")
|
||
mv.hit_good(-1.0, false)
|
||
_ck(manim.anim_path == M.call("FRONT_DAMAGE"), "mob fScalar < 0 -> FRONT_DAMAGE")
|
||
manim.playback_finished.emit()
|
||
mv._motions.erase("BACK_DAMAGE")
|
||
mv.hit_good(1.0, false)
|
||
_ck(manim.anim_path == M.call("FRONT_DAMAGE"), "no DAMAGE_BACK motion -> InterceptOnceMotion(NAME_DAMAGE)")
|
||
manim.playback_finished.emit()
|
||
mv.hit_greate(1.0, false)
|
||
_ck(manim.anim_path == M.call("BACK_KNOCKDOWN"), "mob GREAT fScalar >= 0 -> BACK_KNOCKDOWN")
|
||
manim.playback_finished.emit()
|
||
_ck(manim.anim_path == M.call("WAIT"), "no BACK_STANDUP -> PushOnceMotion fails -> WAIT, got %s" % manim.anim_path)
|
||
mv.hit_greate(-1.0, false)
|
||
_ck(manim.anim_path == M.call("FRONT_KNOCKDOWN"), "mob GREAT fScalar < 0 -> FRONT_KNOCKDOWN")
|
||
manim.playback_finished.emit()
|
||
_ck(manim.anim_path == M.call("FRONT_STANDUP"), "-> FRONT_STANDUP")
|
||
manim.playback_finished.emit()
|
||
_ck(manim.anim_path == M.call("WAIT"), "-> WAIT")
|
||
mv._motions.erase("FRONT_KNOCKDOWN")
|
||
mv._motions.erase("BACK_KNOCKDOWN")
|
||
before = manim.anim_path
|
||
mv.hit_greate(1.0, false)
|
||
_ck(manim.anim_path == before, "no knockdown motions -> no motion change")
|
||
mv.set_anim_state("attack")
|
||
mv.hit_good(-1.0, false)
|
||
_ck(manim.anim_path == M.call("NORMAL_ATTACK"), "mob attacking -> isLock, no damage motion")
|
||
mv.insert_delay(0.05)
|
||
_ck(manim.time_scale == 0.0, "mob InsertDelay -> frozen")
|
||
mv._process(0.06)
|
||
_ck(manim.time_scale == 1.0, "mob delay over -> speed restored")
|
||
sp = mv.get_defending_spheres()
|
||
_ck(sp.size() == 1 and is_equal_approx(float(sp[0]["radius"]), 60.0), "mob -> <dir>/<stem>.msm, got %s" % [sp])
|
||
|
||
pv.queue_free()
|
||
mv.queue_free()
|
||
_done = true
|