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:
+42
-12
@@ -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_gr2(PART_WEAPON)/ model.shield_gr2(PART_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 EWeaponSubTypes(CItemData::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 seam:40250 实体表暂无变身字段(m2_client.cpp entity dict 无 poly_race /
|
||||
# polymorph)。一旦 GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 带上变身 race,这里改成读
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# HitReaction —— CLIENT-GAP §3.7 受击方视图(PlayerView / MobView)共用的受击状态机。
|
||||
# __HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)的分支判定、
|
||||
# __Shake + ShakeProcess(世界矩阵平移 ±rand()%10 cm)、CGraphicThingInstance::InsertDelay、
|
||||
# InterceptOnceMotion + PushOnceMotion + PushLoopMotion 的动作链。
|
||||
# 视图只提供「按状态名绑定一段一次性动作」的 Callable(bind(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 # ShakeProcess:rand() % 10
|
||||
const LOOP_STATES := ["wait", "walk", "run"]
|
||||
const DAMAGE_STATES := ["damage", "damage_back"] # IsDamage 类
|
||||
const KNOCKDOWN_STATES := ["knockdown", "knockdown_back"] # IsKnockDown:DAMAGE_FLYING(_BACK)
|
||||
const STANDUP_STATES := ["standup", "standup_back"] # __IsStandUpMotion:STAND_UP(_BACK)
|
||||
# CActorInstance::isLock:NORMAL_ATTACK / COMBO_ATTACK_* / SPECIAL_* / 钓鱼 / 表情跳舞等
|
||||
const LOCK_STATES := ["attack", "combo", "skill", "__motion", "emotion", "dig",
|
||||
"fishing", "fishing_react", "fishing_catch", "fishing_fail"]
|
||||
const SKILL_STATES := ["skill"] # IsUsingSkill:SKILL 段 / SPECIAL_1..6
|
||||
|
||||
var delay_left := 0.0
|
||||
var speed := 1.0 # 当前动作的 fSpeedRatio(InsertDelay 结束后恢复)
|
||||
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_scale:InsertDelay 期间保持冻结。
|
||||
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
@@ -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 / __HitStone(ActorInstanceBattle.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
@@ -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_bone;PART_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_ATTACK;COMBO_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).msa,COMBO_ATTACK_N -> combo_0N.msa),
|
||||
# 模式目录缺该段时退回 general 的 attack(_1).msa(GENERAL 模式的 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 / __HitStone(ActorInstanceBattle.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")
|
||||
|
||||
@@ -162,6 +162,60 @@ func load_file(path: String) -> bool:
|
||||
count = _by_id.size()
|
||||
return count > 0
|
||||
|
||||
# RegisterSkillTable(PythonSkill.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)))
|
||||
|
||||
# __GetSkillTargetRange(PythonPlayerSkill.cpp:391)= rkSkillData.GetTargetRange()
|
||||
# + GetStatus(POINT_BOW_DISTANCE)*100。这里只给 .msk 的基础射程(cm);主角弓距加成
|
||||
# 由 net_play(有玩家状态)叠加。
|
||||
# + GetStatus(POINT_BOW_DISTANCE)*100。这里是 GetTargetRange(PythonSkill.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.FlyShape(SINGLE/FAN/CIRCLE)。
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user