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
This commit is contained in:
shenlei
2026-09-12 13:15:28 +09:00
co-authored by Claude Opus 5
parent 96c29d2bb7
commit 1933a5ceda
30 changed files with 2353 additions and 209 deletions
+42 -12
View File
@@ -5,8 +5,10 @@
# em.setup(m2client, item_list, model_getter, assets_root, race)
#
# 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment()
# WEAR_WEAPON(4) -> model.weapon_gr2 = item_list.model(vnum) 解析后的真实路径
# WEAR_SHIELD(10)-> model.shield_gr2左手刚体挂点,同 weapon
# WEAR_WEAPON(4) -> item_list.model(vnum) 解析后的真实路径,按左右手分给
# model.weapon_gr2PART_WEAPON/ model.shield_gr2PART_WEAPON_LEFT
# ActorInstanceAttach.cpp AttachWeapon:匕首双手、弓左手、骑马的扇双手)
# WEAR_SHIELD(10)-> 不渲染(参考端没有盾牌挂件,item_list 的盾也没有模型)
# WEAR_HEAD(1) -> 有模型的头盔 = 覆盖发型槽(Metin2 约定:头防替换头发)
# WEAR_BODY(0) -> race_spec.shape(armor_shape_of(vnum)) -> model.gr2_path (+ 换肤)
# armor_model_map[vnum] 若给了则优先
@@ -34,6 +36,12 @@ const PART_WEAPON := 1
const PART_HEAD := 2
const PART_HAIR := 3
const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
# item_length.h EWeaponSubTypesCItemData::GetWeaponType = bSubType
const WEAPON_SUB_DAGGER := 1
const WEAPON_SUB_BOW := 2
const WEAPON_SUB_FAN := 5
const HAND_RIGHT := 1
const HAND_LEFT := 2
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
var client: Node
@@ -47,7 +55,7 @@ var _spec_tried := false
var _last_weapon_vnum := -1
var _last_body_vnum := -1
var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵
var _last_shield_vnum := -1
var _last_weapon_hands := -1
var _last_hair_part := -1
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
var _remote_mode := false
@@ -77,6 +85,11 @@ func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
client.entity_info.connect(func(v, _d):
if main_getter.is_valid() and int(main_getter.call()) == v:
refresh())
# 上 / 下马改变扇子的左右手(__IsLeftHandWeapon: FAN && IsMountingHorse
if client and client.has_signal("mount_changed"):
client.mount_changed.connect(func(v):
if main_getter.is_valid() and int(main_getter.call()) == v:
refresh())
# 远端 PC 不读取本地 get_equipment();参考端直接消费
# GC_CHAR_ADDITIONAL_INFO / GC_CHARACTER_UPDATE 的 awPart[4]。
@@ -139,17 +152,34 @@ func refresh() -> void:
# --- 武器:parts[WEAPON] 优先,为 0 回退 WEAR_WEAPON;过 PartHiding 遮挡 ---------
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
if wpn_eff != _last_weapon_vnum:
var hands := _weapon_hands(wpn_eff)
if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands:
_last_weapon_vnum = wpn_eff
model.set("weapon_gr2", _resolve_weapon(wpn_eff))
_last_weapon_hands = hands
# 左手用 GetSubModelThing()item .msm 加载在参考端被注释掉,恒为同一个 gr2
var wpath := _resolve_weapon(wpn_eff)
model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "")
model.set("shield_gr2", wpath if hands & HAND_LEFT else "")
# --- 盾(左手挂点,同武器遮挡规则)--------------------------------------------
if eq.size() > WEAR_SHIELD:
var sh_vnum := int(eq[WEAR_SHIELD].get("vnum", 0))
var sh_eff := 0 if PartHiding.weapon_hidden(raw_shape, is_poly, sh_vnum) else sh_vnum
if sh_eff != _last_shield_vnum:
_last_shield_vnum = sh_eff
model.set("shield_gr2", _resolve_weapon(sh_eff))
# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码
func _weapon_hands(vnum: int) -> int:
if vnum == 0:
return 0
var sub := -1
if proto and proto.has_method("item"):
sub = int(proto.item(vnum).get("sub_type", -1))
if sub == WEAPON_SUB_DAGGER or (sub == WEAPON_SUB_FAN and _is_mounting()):
return HAND_RIGHT | HAND_LEFT
if sub == WEAPON_SUB_BOW:
return HAND_LEFT
return HAND_RIGHT
# IsMountingHorse:主角实体 mount_vnum 非 0。远端 PC 暂无坐骑状态输入(seam)。
func _is_mounting() -> bool:
if _remote_mode or not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
return false
var vid: int = main_getter.call()
return vid != 0 and int(client.get_entity(vid).get("mount_vnum", 0)) != 0
# §4.1 修改 4 seam40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race /
# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读
+127
View File
@@ -0,0 +1,127 @@
# HitReaction —— CLIENT-GAP §3.7 受击方视图(PlayerView / MobView)共用的受击状态机。
# __HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp)的分支判定、
# __Shake + ShakeProcess(世界矩阵平移 ±rand()%10 cm)、CGraphicThingInstance::InsertDelay、
# InterceptOnceMotion + PushOnceMotion + PushLoopMotion 的动作链。
# 视图只提供「按状态名绑定一段一次性动作」的 Callablebind(step) -> bool,资源缺失 = false
# 等同 GetMotionKey 失败时 InterceptOnceMotion / PushOnceMotion 返回 FALSE)。
extends RefCounted
const SHAKE_TIME := 0.1 # __Shake(100)m_dwShakeTime = now + 100 ms
const SHAKE_RAND_CM := 10 # ShakeProcessrand() % 10
const LOOP_STATES := ["wait", "walk", "run"]
const DAMAGE_STATES := ["damage", "damage_back"] # IsDamage 类
const KNOCKDOWN_STATES := ["knockdown", "knockdown_back"] # IsKnockDownDAMAGE_FLYING(_BACK)
const STANDUP_STATES := ["standup", "standup_back"] # __IsStandUpMotionSTAND_UP(_BACK)
# CActorInstance::isLockNORMAL_ATTACK / COMBO_ATTACK_* / SPECIAL_* / 钓鱼 / 表情跳舞等
const LOCK_STATES := ["attack", "combo", "skill", "__motion", "emotion", "dig",
"fishing", "fishing_react", "fishing_catch", "fishing_fail"]
const SKILL_STATES := ["skill"] # IsUsingSkillSKILL 段 / SPECIAL_1..6
var delay_left := 0.0
var speed := 1.0 # 当前动作的 fSpeedRatioInsertDelay 结束后恢复)
var shake_left := 0.0
var queue: Array = [] # 剩余的 PushOnceMotion 步
var resume := "wait" # 链尾 PushLoopMotion 的循环动作
var active := false # 受击动作链正在播
var real_dead := false # m_isRealDead:晕眩击倒后不起身
var _shaking := false
var _shake_base := Vector3.ZERO
static func is_reaction(state: String) -> bool:
return state in DAMAGE_STATES or state in KNOCKDOWN_STATES or state in STANDUP_STATES
# __HitGood:返回候选动作链(按顺序尝试,首步绑定成功的那条生效)。
func good(state: String, scalar: float, stunned: bool) -> Array:
if state in KNOCKDOWN_STATES:
return []
if stunned:
return [] # IsStun() -> Die():死亡动作由服务器驱动(seam)
shake()
if state in LOCK_STATES:
return []
if scalar < 0.0:
return [["damage"]]
return [["damage_back"], ["damage"]]
# __HitGreate
func greate(state: String, scalar: float, stunned: bool) -> Array:
if state in KNOCKDOWN_STATES or state in STANDUP_STATES:
return []
shake()
if state in SKILL_STATES:
return []
if stunned:
if scalar < 0.0:
return [["knockdown"]]
return [["knockdown_back"], ["knockdown"]]
if scalar < 0.0:
return [["knockdown", "standup"]]
return [["knockdown_back", "standup_back"], ["knockdown", "standup"]]
# __HitStone
func stone(stunned: bool) -> void:
if not stunned:
shake()
func shake() -> void:
shake_left = SHAKE_TIME
func insert_delay(d: float, anim: Object) -> void:
if d <= 0.0:
return
delay_left = d
if anim:
anim.set("time_scale", 0.0)
# 绑定新动作时的 time_scaleInsertDelay 期间保持冻结。
func scale_for(ratio: float) -> float:
speed = ratio
return 0.0 if delay_left > 0.0 else ratio
# 首步绑定成功 -> 记下链尾要回的循环动作,剩余步入队。
func start(chains: Array, current: String, bind: Callable, dead_after := false) -> bool:
for chain: Array in chains:
if chain.is_empty() or not bool(bind.call(String(chain[0]))):
continue
if not is_reaction(current):
resume = current if current in LOOP_STATES else "wait"
queue = chain.slice(1)
active = true
real_dead = dead_after
return true
return false
# 一段受击动作播完:绑下一步(缺资源的步跳过);返回 false = 链结束。
func advance(bind: Callable) -> bool:
while not queue.is_empty():
if bool(bind.call(String(queue.pop_front()))):
return true
active = false
return false
func cancel() -> void:
active = false
queue.clear()
real_dead = false
func tick(dt: float, anim: Object, model: Node3D) -> void:
if delay_left > 0.0:
delay_left -= dt
if delay_left <= 0.0 and anim:
anim.set("time_scale", speed)
if shake_left > 0.0:
shake_left -= dt
if model == null:
return
if shake_left > 0.0:
if not _shaking:
_shaking = true
_shake_base = model.position
model.position = _shake_base + Vector3(_rand_cm(), _rand_cm(), _rand_cm()) / 100.0
elif _shaking:
_shaking = false
model.position = _shake_base
static func _rand_cm() -> float:
var v := float(randi() % SHAKE_RAND_CM)
return v if randi() % 2 == 0 else -v
+94 -4
View File
@@ -21,11 +21,25 @@ const STATE_MOTIONS := {
"damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"],
"dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"],
"emotion": ["WAIT"],
# §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。
# 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。
"damage_back": ["BACK_DAMAGE"],
"knockdown": ["FRONT_KNOCKDOWN"],
"knockdown_back": ["BACK_KNOCKDOWN"],
"standup": ["FRONT_STANDUP"],
"standup_back": ["BACK_STANDUP"],
}
const HitReaction = preload("res://ui/hit_reaction.gd")
const HitCollision = preload("res://hit_collision.gd")
# CActorInstance::__SetMotion 尾:动作绑定完成。
signal motion_bound(state: String)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var _dir := ""
var _mesh_stem := ""
var _hit := HitReaction.new()
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := ""
var _last_resolution := {}
@@ -89,6 +103,7 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
break
if _dir == "":
return false
_mesh_stem = mesh_stem
var gr2 := _dir.path_join(mesh_stem + ".gr2")
if not FileAccess.file_exists(gr2):
gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格
@@ -114,6 +129,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("blend_time", 0.15)
add_child(anim)
if anim.has_signal("playback_finished"):
anim.playback_finished.connect(_on_playback_finished)
set_anim_state("wait")
CharShadow.attach(self, 1.6) # 怪脚印大一点
return true
@@ -125,15 +142,87 @@ func set_audio(audio_node: Node) -> void:
func set_anim_state(s: String) -> void:
if s == _state or anim == null:
return
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
return
# 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列)
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
_hit.cancel()
_state = s
_last_resolution = resolve_motion(s)
var msa := String(_last_resolution.path)
if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
_refresh_sound_script(msa)
_bind(s, msa, s in HitReaction.LOOP_STATES, 1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp
func hit_good(scalar: float, stunned: bool) -> void:
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
func is_in_hit_reaction() -> bool:
return _hit.active
# 受击方防御球:怪目录的 .msm<网格代号>.msm / <目录名>.msm / 目录里第一个 .msm)。
func get_defending_spheres() -> Array:
if _dir == "":
return []
var cands: Array = []
if _mesh_stem != "":
cands.append(_dir.path_join(_mesh_stem + ".msm"))
cands.append(_dir.path_join(_dir.get_file() + ".msm"))
for p: String in cands:
if FileAccess.file_exists(p):
return HitCollision.parse_msm_defending(p)
var da := DirAccess.open(_dir)
if da:
for fn in da.get_files():
if fn.to_lower().ends_with(".msm"):
return HitCollision.parse_msm_defending(_dir.path_join(fn))
return []
func _on_playback_finished() -> void:
if not _hit.active:
return
if _hit.advance(_bind_reaction) or _hit.real_dead:
return
_state = ""
set_anim_state(_hit.resume)
func _bind_reaction(step: String) -> bool:
if anim == null:
return false
var r := resolve_motion(step)
_last_resolution = r
var msa := String(r.path)
if msa == "":
return false
_bind(step, msa, false, 1.0)
return true
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
anim.set("loop", loop)
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
anim.set("anim_path", path)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
# Read-only motion resolver used by the forest acceptance harness. Returning
# the selected .msa before playback lets the test distinguish a real action
@@ -169,7 +258,8 @@ func resolve_motion(state: String) -> Dictionary:
func last_motion_resolution() -> Dictionary:
return _last_resolution.duplicate()
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
return
+129 -18
View File
@@ -13,6 +13,30 @@ extends Node3D
# 4=WAR_W 5=ASN_M 6=SURA_W 7=SHA_M
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
const FEMALE_RACES := [1, 3, 4, 6]
# playersettingmodule.py RegisterAttachingBoneName(按职业,男女同名):
# PART_WEAPON -> model.weapon_bonePART_WEAPON_LEFT -> model.shield_bone"" = 未登记,不挂)
const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"]
const HitReaction = preload("res://ui/hit_reaction.gd")
const HitCollision = preload("res://hit_collision.gd")
# CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级)
const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword",
5: "bow", 6: "fan", 7: "bell", 8: "fishing", 9: "horse", 10: "horse_onehand_sword",
11: "horse_twohand_sword", 12: "horse_dualhand_sword", 13: "horse_bow", 14: "horse_fan",
15: "horse_bell", 16: "wedding"}
const MOTION_NORMAL_ATTACK := 13 # CRaceMotionData::NAME_NORMAL_ATTACKCOMBO_ATTACK_1 = 14
# 受击动作步 -> playersettingmodule RegisterMotionData 的文件(同名多份按权重随机,这里等权)
const REACTION_FILES := {
"damage": ["damage", "damage_1"], # NAME_DAMAGE
"damage_back": ["damage_2", "damage_3"], # NAME_DAMAGE_BACK
"knockdown": ["damage_flying"], # NAME_DAMAGE_FLYING
"knockdown_back": ["back_damage_flying"], # NAME_DAMAGE_FLYING_BACK
"standup": ["falling_stand"], # NAME_STAND_UP
"standup_back": ["back_falling_stand"], # NAME_STAND_UP_BACK
}
# CActorInstance::__SetMotion 尾:动作绑定完成(net_play 据此清 m_HitDataMap / 连击段号)。
signal motion_bound(state: String)
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
@@ -24,11 +48,15 @@ var _audio: Node
var _sound_instances: Array = []
var _sound_frame := -1
var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin", "specular_power"]
var _left_bone := ""
var _race := 0
var _hit := HitReaction.new()
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
func build(assets_root: String, race: int, pump := Callable()) -> bool:
_assets_root = assets_root
_race = race
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
return false
var cls: String = CLASS_OF[race & 3]
@@ -45,6 +73,12 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
# PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses.
model.set("flip_winding", true)
model.set("texture_dir", base)
# 挂点骨骼名要在 gr2_path 之前设:Metin2Model 默认 equip_right_hand 只有战士骨架有,
# 其余职业找不到骨骼就不建 Weapon 节点(武器不在手上)。
model.set("weapon_bone", WEAPON_BONE[race & 3])
_left_bone = WEAPON_LEFT_BONE[race & 3]
if _left_bone != "":
model.set("shield_bone", _left_bone)
if pump.is_valid(): pump.call()
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
if body == "":
@@ -88,14 +122,20 @@ func set_audio(audio_node: Node) -> void:
func set_anim_state(s: String) -> void:
if s == _state or anim == null or motion_dir == "":
return
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
return
# 受击动作链播放中:循环动作请求留到链尾 PushLoopMotion 再回(seam:参考端动作队列)
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
_hit.cancel()
_state = s
var msa := motion_dir.path_join(s + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(s + ".gr2")
if FileAccess.file_exists(msa):
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
_refresh_sound_script(msa)
_bind(s, msa, s in HitReaction.LOOP_STATES, 1.0)
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
# the numeric motion id; paired emotions use the other entity's race to select
@@ -163,29 +203,97 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool:
msa = motion_dir.path_join(name + ".msa")
if not FileAccess.file_exists(msa):
return false
_state = "__motion"
anim.set("loop", false)
# A repeated command can name the same clip; clear first because the native
# player treats assigning the current anim_path as a no-op.
if String(anim.get("anim_path")) == msa:
anim.set("anim_path", "")
anim.set("anim_path", msa)
_refresh_sound_script(msa)
_bind("__motion", msa, false, 1.0)
return true
# §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio })
# 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msaCOMBO_ATTACK_N -> combo_0N.msa),
# 模式目录缺该段时退回 general 的 attack(_1).msaGENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。
func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
if anim == null or motion_dir == "":
return
var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general")))
var names: Array = ["attack", "attack_1"]
if index > MOTION_NORMAL_ATTACK:
names = ["combo_%02d" % (index - MOTION_NORMAL_ATTACK)]
var msa := _pick_msa(mode_dir, names)
if msa == "":
msa = _pick_msa(motion_dir, ["attack", "attack_1"])
if msa == "":
return
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
# CActorInstance::__HitGood / __HitGreate / __HitStoneActorInstanceBattle.cpp)。
# scalar = dot(攻击方朝向, 受击方朝向)< 0 面对面 -> 正面受击动作。
func hit_good(scalar: float, stunned: bool) -> void:
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
func get_defending_spheres() -> Array:
var sex := "w" if _race in FEMALE_RACES else "m"
return HitCollision.parse_msm_defending(
_assets_root.path_join("root/msm/%s_%s.msm" % [CLASS_OF[_race & 3], sex]))
func _on_playback_finished() -> void:
# CLIENT-GAP §3.7: the hit reaction ("damage") is a one-shot knockback clip.
# When it ends the actor leaves the pushed state (CActorInstance::IsPushing),
# so fall back to idle — net_play polls is_in_hit_reaction() to drop its gate.
if _state == "__motion" or _state == "damage":
# §3.7 受击动作链:InterceptOnceMotion -> PushOnceMotion(STAND_UP…) -> PushLoopMotion。
# 链尾回循环动作 —— net_play polls is_in_hit_reaction() to drop its knockback gate.
if _hit.active:
if _hit.advance(_bind_reaction) or _hit.real_dead:
return
_state = ""
set_anim_state(_hit.resume)
return
if _state == "__motion":
_state = ""
set_anim_state("wait")
# True while the one-shot hit/knockback clip is still playing.
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
return _state == "damage"
return _hit.active
func _process(_delta: float) -> void:
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
if msa == "":
return false
_bind(step, msa, false, 1.0)
return true
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
anim.set("loop", loop)
# A repeated one-shot can name the same clip; clear first because the native
# player treats assigning the current anim_path as a no-op.
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "")
anim.set("anim_path", path)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
func _pick_msa(dir: String, names: Array) -> String:
var found: Array = []
for n in names:
var p := dir.path_join(String(n) + ".msa")
if FileAccess.file_exists(p):
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
return
@@ -207,6 +315,9 @@ func _refresh_sound_script(motion_path: String) -> void:
func _set(prop: StringName, val: Variant) -> bool:
if String(prop) in _forward and model:
# ActorInstanceAttach.cpp AttachWeapon:该职业没登记 PART_WEAPON_LEFT 骨骼就不挂左手
if String(prop) == "shield_gr2" and _left_bone == "":
val = ""
model.set(prop, val)
if String(prop) == "gr2_path":
call_deferred("_ground_model")
+61 -2
View File
@@ -162,6 +162,60 @@ func load_file(path: String) -> bool:
count = _by_id.size()
return count > 0
# RegisterSkillTablePythonSkill.cpp:90):SkillTable.txt 按 TAB 切分,列数不等于
# TABLE_TOKEN_TYPE_MAX_NUM 的行、skilldesc 里没有的 vnum 都跳过。参考端 RegisterSkill(.msk)
# 没有调用点,TSkillData 构造时 dwTargetRange = 0,只由这里非空的 TARGET_RANGE 列覆盖;
# 所以表生效后不再沿用 .msk 的 Range。第 2 列是 CP949 技能名,按字节切分避免解码告警。
const TABLE_TOKEN_TYPE_VNUM := 0
const TABLE_TOKEN_TYPE_TARGET_RANGE := 25
const TABLE_TOKEN_TYPE_MAX_NUM := 27
func load_table(path: String) -> bool:
if _by_id.is_empty() or not FileAccess.file_exists(path):
return false
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return false
var bytes := file.get_buffer(file.get_length())
var ranges := {}
var start := 0
while start < bytes.size():
var stop := bytes.find(10, start)
if stop < 0:
stop = bytes.size()
var fields := _tab_fields(bytes.slice(start, stop))
start = stop + 1
if fields.size() != TABLE_TOKEN_TYPE_MAX_NUM:
continue
var vnum := int(_ascii_token(fields[TABLE_TOKEN_TYPE_VNUM]))
if not _by_id.has(vnum):
continue
ranges[vnum] = _ascii_token(fields[TABLE_TOKEN_TYPE_TARGET_RANGE])
if ranges.is_empty():
return false
for id in _by_id:
_by_id[id]["target_range"] = 0
for vnum in ranges:
if String(ranges[vnum]) != "":
_by_id[vnum]["target_range"] = int(ranges[vnum])
return true
static func _tab_fields(line: PackedByteArray) -> Array:
var fields := []
var begin := 0
for i in line.size() + 1:
if i == line.size() or line[i] == 9:
fields.append(line.slice(begin, i))
begin = i + 1
return fields
static func _ascii_token(field: PackedByteArray) -> String:
var out := PackedByteArray()
for b in field:
if b >= 32 and b <= 126:
out.append(b)
return out.get_string_from_ascii().strip_edges()
func has(id: int) -> bool:
return _by_id.has(id)
@@ -270,9 +324,14 @@ func target_count(id: int, level: int) -> int:
return int(floor(float(value)))
# __GetSkillTargetRangePythonPlayerSkill.cpp:391= rkSkillData.GetTargetRange()
# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成
# 由 net_play(有玩家状态)叠加。
# + GetStatus(POINT_BOW_DISTANCE)*100。这里是 GetTargetRangePythonSkill.cpp:1086):
# MELEE_ATTACK / CHARGE_ATTACK 固定 MELEE_SKILL_TARGET_RANGE,否则 dwTargetRange
# load_table 之后取 SkillTable.txt TARGET_RANGE);主角弓距加成由 player_skill 叠加。
const MELEE_SKILL_TARGET_RANGE := 170
func target_range(id: int) -> int:
if is_melee(id) or is_charge_skill(id):
return MELEE_SKILL_TARGET_RANGE
return int(_by_id.get(id, {}).get("target_range", 0))
# skilldesc / .msk 的 FAN_RANGE / CIRCLE_RANGE → NetPlay.FlyShapeSINGLE/FAN/CIRCLE)。
+5 -7
View File
@@ -177,9 +177,8 @@ func close_top() -> bool:
func top() -> Control:
return _stack[-1] if not _stack.is_empty() else null
# wndMgr 的输入边界:打开的窗口先拿到键盘 / 鼠标语义,世界控制不能从窗口
# 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗;
# 模态确认框只允许 ESC 回到窗口栈。
# wndMgr 的输入边界:鼠标不能从打开的窗口下方穿透到世界;模态确认框(LockWindow)
# 只允许 ESC 回到窗口栈。键盘按 CWindowManager::RunKeyDown:非模态窗口不截键。
func blocks_game_input(event: InputEvent) -> bool:
var popup := _mobile_modal_popup()
if popup != null:
@@ -197,10 +196,9 @@ func blocks_game_input(event: InputEvent) -> bool:
return true
if event is InputEventMouse:
return top_window.get_global_rect().has_point(event.position)
if event is InputEventKey:
if event.keycode == KEY_ESCAPE:
return false
return event.keycode not in [KEY_I, KEY_K, KEY_V, KEY_N, KEY_B, KEY_L, KEY_M, KEY_O]
# 键盘:没有 LockWindow 时键先给 ActiveWindow(输入框——Godot 里由获得焦点的控件在
# gui_input 中自行吃掉),其余落到 game.py OnKeyDown,所以技能窗 / 背包开着时
# 快捷栏 1-4 / F1-F4、Space 普攻和开关窗键都照常生效。
return false
func _input(event: InputEvent) -> void: