Files
mtgodot-poc/project/hit_view_test.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

260 lines
12 KiB
GDScript
Raw 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
# PlayerView uses these timing accessors to decide whether an explicit loop
# request may interrupt a one-shot. A zero-duration stub means the clip is
# already at its boundary, matching the test's manual playback_finished.emit().
func get_duration() -> float: return 0.0
func get_time() -> float: return 0.0
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", "skill", "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.091 and absf(off.y) <= 0.091 and absf(off.z) <= 0.091,
"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