Files
mtgodot-poc/project/ui/equip_model.gd
T
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

424 lines
16 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.
# EquipModel (P2) —— 装备变化 → Metin2Model 部件切换。
#
# var em := preload("res://ui/equip_model.gd").new()
# add_child(em)
# em.setup(m2client, item_list, model_getter, assets_root, race)
#
# 监听 M2Client.inventory_changed(window == EQUIPMENT),读 get_equipment()
# 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] 若给了则优先
#
# 时装(costume):Metin2 不发独立的 costume 槽包,服务器把 costume 的 vnum 直接写进
# 主角实体的 parts[] 数组(ARMOR/HAIR)。所以每个渲染槽优先取 parts[],为 0 才回退
# get_equipment()。这样 costume「自动」生效,也是渲染别的玩家用的同一条路。
#
# §4.1 修改 4(部位遮挡):解出身体 shape 后过 PartHiding —— 动物 / 怪物时装
# shape 100-103)抑制武器 + 头发;婚纱(shape 201)非捧花时抑制武器;变身
# (seam,暂无实体字段)会把身体 shape 归 0 且清武器 / 头发。刷新顺序对齐
# InstanceBase.cpp ChangeArmor:身体 → 头发 → 武器(→ 盾)。
extends Node
const RaceSpec = preload("res://ui/race_spec.gd")
const PartHiding = preload("res://part_hiding.gd")
const WEAR_BODY := 0
const WEAR_HEAD := 1
const WEAR_WEAPON := 4
const WEAR_SHIELD := 10
# CHR_EQUIPPART_*parts[] 下标)
const PART_ARMOR := 0
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
var item_list: RefCounted # ItemList
var proto: Node # Metin2Proto(有的话 armor shape 走 item_proto values[3]
var assets_root := ""
var race := -1
var _model_getter: Callable
var _spec: RefCounted # RaceSpec(惰性)
var _spec_tried := false
var _last_weapon_vnum := -1
var _last_body_vnum := -1
var _last_head_vnum := -2 # -1 是「发型被遮挡」的有效态,用 -2 当「未应用」哨兵
var _last_weapon_hands := -1
var _last_hair_part := -1
var main_getter: Callable = Callable() # func() -> int:主角 vid(取 parts 用)
var _remote_mode := false
var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR]
# armor vnum -> race .msm 的 shape index。默认:有 proto 用 item_proto values[3]
# (对齐客户端 __ArmorVnumToShapeSHAPE_VALUE_SLOT_INDEX=3),否则回退 =vnum。应用方可覆写。
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec
var armor_model_map := {}
func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
race_index := -1) -> void:
client = m2client
item_list = il
_model_getter = model_getter
assets_root = assets
race = race_index
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
if client and client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(v):
if race < 0 and client.has_method("get_entity"):
race = int(client.get_entity(v).get("race", -1))
refresh())
# GC_CHARACTER_UPDATE / GC_CHAR_ADD_INFO 改了主角 parts(换装 / 时装)-> 全刷
if client and client.has_signal("entity_info"):
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]。
func setup_remote(il: RefCounted, model_getter: Callable, assets := "",
race_index := -1, parts := [], proto_node: Node = null) -> void:
client = null
item_list = il
_model_getter = model_getter
assets_root = assets
race = race_index
proto = proto_node
_remote_mode = true
_remote_parts = parts.duplicate() if parts is Array else []
while _remote_parts.size() < 4:
_remote_parts.append(0)
if _remote_parts.size() > 4:
_remote_parts = _remote_parts.slice(0, 4)
refresh()
func set_race(r: int) -> void:
race = r
_spec = null
_spec_tried = false
func _on_inv_changed(window: int, _cell: int) -> void:
if window == 2: # mtnet::WINDOW_EQUIPMENT
refresh()
func refresh() -> void:
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
if model == null or (client == null and not _remote_mode):
return
var eq: Array = client.get_equipment() if client != null and client.has_method("get_equipment") else []
if not _remote_mode and eq.size() <= WEAR_WEAPON:
return
var parts := _parts() # 主角 parts[](时装 / 别的玩家用);空 -> 全回退 eq
# --- 身体:parts[ARMOR] 优先(普通盔甲或时装),为 0 回退 WEAR_BODY -------------
var body_vnum := _eff(parts, PART_ARMOR, eq, WEAR_BODY)
# 遮挡判定用的 shape(对齐 __ArmorVnumToShapeproto values[3] 非 0,否则 =vnum
var raw_shape := int(armor_shape_of.call(body_vnum)) if armor_shape_of.is_valid() else body_vnum
var is_poly := _is_poly()
if body_vnum != _last_body_vnum:
_last_body_vnum = body_vnum
_apply_body(model, body_vnum)
# --- 头 / 发:动物·怪物时装(shape 100-103)或变身 -> 隐藏;否则头盔覆盖 / 发型 ----
var hair_off := PartHiding.hair_hidden(raw_shape, is_poly)
var head_vnum := _eff(parts, PART_HEAD, eq, WEAR_HEAD)
var head_key := -1 if hair_off else head_vnum
if head_key != _last_head_vnum:
_last_head_vnum = head_key
if hair_off:
model.set("hair_gr2", "")
model.set("hair_skin", "")
_last_hair_part = -1 # 恢复时强制重挂发型
else:
_apply_head_or_hair(model, head_vnum)
# --- 武器: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)
var hands := _weapon_hands(wpn_eff)
if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands:
_last_weapon_vnum = 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 "")
# 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,这里改成读
# client.get_entity(main_getter.call()).get("poly_race", 0) != 0 即可。
func _is_poly() -> bool:
return false
# 有效 vnum:主角 parts[part_idx] 非 0 就用它(时装 / 远端玩家),否则用装备槽
func _eff(parts: Array, part_idx: int, eq: Array, wear_slot: int) -> int:
if parts.size() > part_idx and int(parts[part_idx]) != 0:
return int(parts[part_idx])
return int(eq[wear_slot].get("vnum", 0)) if eq.size() > wear_slot else 0
func _parts() -> Array:
if _remote_mode:
return _remote_parts
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
return []
var vid: int = main_getter.call()
if vid == 0:
return []
var p = client.get_entity(vid).get("parts", [])
return p if p is Array else []
func _apply_head_or_hair(model: Node, head_vnum: int) -> void:
# 头盔有模型 -> 用它替换发型(Metin2:头防走 hair 槽)
if head_vnum != 0:
var hm := _resolve_weapon(head_vnum) # item_list.model 解析,复用武器解析器
if hm != "":
model.set("hair_gr2", hm)
model.set("hair_skin", "")
return
# 没头盔(或头盔无模型)-> 回到角色发型
_apply_hair_from_parts(model, true)
func _apply_hair_from_parts(model: Node, force := false) -> void:
if model == null:
return
var hp := _hair_part()
if hp < 0 or (hp == _last_hair_part and not force):
return
_last_hair_part = hp
# 时装假发 / 大编号 -> parts[HAIR] 是 item vnum,走 item_list.model(同头盔)
if hp >= 100 and item_list:
var wig := _resolve_weapon(hp)
if wig != "":
model.set("hair_gr2", wig)
model.set("hair_skin", "")
return
# 小编号 -> race .msm 的 hair shape index
var e := _spec_hair(hp)
if e.is_empty():
return
var m := _resolve_spec_asset(e, String(e.get("model", "")))
var ts := _resolve_spec_asset(e, String(e.get("target_skin", "")))
if m != "":
model.set("hair_gr2", m)
if ts != "":
model.set("hair_skin", ts)
func _hair_part() -> int:
if _remote_mode:
return int(_remote_parts[PART_HAIR]) if _remote_parts.size() > PART_HAIR else -1
if not main_getter.is_valid() or client == null or not client.has_method("get_entity"):
return -1
var vid: int = main_getter.call()
if vid == 0:
return -1
var e: Dictionary = client.get_entity(vid)
var parts = e.get("parts", [])
if parts is Array and parts.size() > CHR_EQUIPPART_HAIR:
return int(parts[CHR_EQUIPPART_HAIR])
return -1
func set_hair(index: int) -> void:
if _remote_mode:
_set_remote_part(PART_HAIR, index)
refresh()
return
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
if model == null:
return
var e := _spec_hair(index)
if e.is_empty():
return
var m := _resolve_spec_asset(e, String(e.get("model", "")))
var ts := _resolve_spec_asset(e, String(e.get("target_skin", "")))
if m != "":
model.set("hair_gr2", m)
if ts != "":
model.set("hair_skin", ts)
func set_armor(index: int) -> void:
if not _remote_mode:
return
_set_remote_part(PART_ARMOR, index)
refresh()
func set_weapon(index: int) -> void:
if not _remote_mode:
return
_set_remote_part(PART_WEAPON, index)
refresh()
func set_head(index: int) -> void:
if not _remote_mode:
return
_set_remote_part(PART_HEAD, index)
refresh()
func _set_remote_part(part: int, value: int) -> void:
while _remote_parts.size() <= part:
_remote_parts.append(0)
_remote_parts[part] = value
# --- body ------------------------------------------------------------------
# armor vnum -> item_proto values[3](非 0);否则 =vnum(对齐 __ArmorVnumToShape
func _armor_shape_default(vnum: int) -> int:
if vnum > 1 and proto and proto.has_method("item"):
var it: Dictionary = proto.item(vnum)
var v = it.get("values", [])
if v is Array and v.size() > 3 and int(v[3]) != 0:
return int(v[3])
return vnum
func _armor_specular(vnum: int) -> int:
if proto and proto.has_method("item"):
return int(proto.item(vnum).get("specular", 0))
return 0
func _apply_body(model: Node, vnum: int) -> void:
# 强化等级越高越亮(PARITY §2.7fSpecular = bSpecular / 100
var sp := _armor_specular(vnum)
if sp > 0:
model.set("specular_power", sp / 100.0)
if armor_model_map.has(vnum):
model.set("gr2_path", String(armor_model_map[vnum]))
return
var shape_idx: int = armor_shape_of.call(vnum) if armor_shape_of.is_valid() else vnum
var e := _spec_shape(shape_idx)
if e.is_empty():
return # 没有对应 shape:保持当前身体
var body := _resolve_spec_asset(e, String(e.get("model", "")))
if body != "":
model.set("gr2_path", body)
# 换肤(同模型不同 TargetSkin):覆盖 surface 0
var src: String = e.get("source_skin", "")
var tgt: String = e.get("target_skin", "")
if tgt != "" and tgt != src and model.has_method("set_surface_texture"):
var t := _resolve_spec_asset(e, tgt)
if t != "":
model.call("set_surface_texture", 0, t)
func _spec_shape(idx: int) -> Dictionary:
_ensure_spec()
return _spec.shape(idx) if _spec else {}
func _spec_hair(idx: int) -> Dictionary:
_ensure_spec()
return _spec.hair(idx) if _spec else {}
func _ensure_spec() -> void:
if _spec_tried:
return
_spec_tried = true
if race < 0 or assets_root == "":
return
var cls: String = CLASS_OF[race & 3]
var suffix := "w" if race in [1, 3, 4, 6] else "m"
for rel in ["root/msm/%s_%s.msm" % [cls, suffix],
"PC/ymir work/pc/%s/%s.msm" % [cls, cls],
"pc2/ymir work/pc2/%s/%s.msm" % [cls, cls],
"season1/season1/pc/%s.msm" % cls]:
var p := _resolve_asset(rel)
if p != "":
var rs := RaceSpec.new()
if rs.load_file(p):
_spec = rs
return
# --- 路径解析 ------------------------------------------------------------
# 用 race_spec 的一条 shape/hair 记录 + 一个相对文件(model / *_skin)解析绝对路径。
# 依次试: 规格文件目录/<rel> · PathName(去盘符)+扫散包 · 按 basename 扫 pc/<cls>/ 与 pc2/<cls>/
func _resolve_spec_asset(e: Dictionary, rel: String) -> String:
if rel == "":
return ""
var sdir := String(e.get("spec_dir", ""))
if sdir != "":
var p := sdir.path_join(rel)
if FileAccess.file_exists(p):
return p
var byp := _resolve_asset(String(e.get("path", "")) + rel)
if byp != "":
return byp
# 规格覆盖层(例如 root/msm/warrior_m.msm)可能只给 Model 名而没有
# PathName。按 basename 回退时除职业根目录外,还必须查 hair/;否则
# HairIndex 1001..1012 能解析却加载失败,并静默保留 PlayerView 的
# hair_1_1 默认模型,造成选角发型遮脸。
var bn := rel.get_file()
if race >= 0:
var cls: String = CLASS_OF[race & 3]
for sub in [
"PC/ymir work/pc/%s/%s" % [cls, bn],
"PC/ymir work/pc/%s/hair/%s" % [cls, bn],
"pc2/ymir work/pc2/%s/%s" % [cls, bn],
"pc2/ymir work/pc2/%s/hair/%s" % [cls, bn],
]:
var q := _resolve_asset(sub)
if q != "":
return q
return ""
func _resolve_weapon(vnum: int) -> String:
if vnum == 0 or item_list == null:
return ""
var vpath: String = item_list.model(vnum)
if vpath == "":
var base := (vnum / 10) * 10
vpath = "d:/ymir work/item/weapon/%05d.gr2" % base
return _resolve_asset(vpath)
# "d:/ymir work/..." 或散包相对路径 -> <assets>/(<pack>/)...
func _resolve_asset(vpath: String) -> String:
if vpath == "" or assets_root == "":
return ""
var rel := vpath.replace("\\", "/")
if rel.length() >= 2 and rel[1] == ":":
rel = rel.substr(2)
rel = rel.lstrip("/")
var direct := assets_root.path_join(rel)
if FileAccess.file_exists(direct):
return direct
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
var cand := assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(cand):
return cand
return ""