Files
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 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
2026-09-12 13:15:28 +09:00

255 lines
12 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# hit_view_test —— CLIENT-GAP §3.7 受击方 / 攻击方视图(PlayerView / MobViewheadless 自检:
# __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)
# __HitGoodfScalar < 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")
# __HitGreateDAMAGE_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