fix: 装备属性面板避让逻辑 + 多项功能更新

- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
This commit is contained in:
shen
2026-09-21 16:38:59 -07:00
parent 1522a8a1a1
commit c93894313a
5498 changed files with 4558004 additions and 1442 deletions
+216 -30
View File
@@ -10,16 +10,18 @@
# GENERAL WAIT 00.msa 65 (组 动作名 文件 权重)
extends Node3D
# net_world 的状态名 -> motlist.txt 的动作名(按优先级取第一个存在的)
# net_world 的状态名 -> 40250 CRaceMotionData::EName。
# motlist.txt 中的多个别名会先归并到同一个动作索引,再按权重选 vector 中的
# 一个文件;这里保留名称表只为兼容没有 motlist 的测试替身。
const STATE_MOTIONS := {
"wait": ["WAIT", "WAIT1"],
"walk": ["WALK", "RUN"],
"run": ["RUN", "WALK"],
"attack": ["NORMAL_ATTACK", "NORMAL_ATTACK1", "SPECIAL_1"],
"combo": ["NORMAL_ATTACK1", "NORMAL_ATTACK"],
"skill": ["SPECIAL_1", "SKILL", "NORMAL_ATTACK"],
"damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"],
"dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"],
"wait": ["WAIT"],
"walk": ["WALK"],
"run": ["RUN"],
"attack": ["NORMAL_ATTACK"],
"combo": ["COMBO_ATTACK"],
"skill": ["SPECIAL", "SKILL1", "SKILL2", "SKILL3", "SKILL4", "SKILL5"],
"damage": ["FRONT_DAMAGE"],
"dead": ["FRONT_DEAD"],
"emotion": ["WAIT"],
# §3.7 受击动作步(motlist 名 -> NAME_DAMAGE_BACK / DAMAGE_FLYING(_BACK) / STAND_UP(_BACK))。
# 不加别名:缺哪段就让 InterceptOnceMotion / PushOnceMotion 失败,与参考端一致。
@@ -29,6 +31,36 @@ const STATE_MOTIONS := {
"standup": ["FRONT_STANDUP"],
"standup_back": ["BACK_STANDUP"],
}
# CRaceManager::__LoadRaceMotionList 的完整名称 -> EName 映射。
# 后缀回退(WAIT3 -> WAIT 等)在 _npc_motion_index 中单独实现,不能把
# FRONT_DAMAGE / BACK_DAMAGE 等不同方向错误地合并。
const NPC_MOTION_INDEX := {
"SPAWN": 25,
"WAIT": 1, "WAIT1": 1, "WAIT2": 1,
"WALK": 2, "WALK1": 2, "WALK2": 2,
"RUN": 3, "RUN1": 3, "RUN2": 3,
"STOP": 32,
"DEAD": 11,
"COMBO_ATTACK": 14, "COMBO_ATTACK1": 15, "COMBO_ATTACK2": 16,
"NORMAL_ATTACK": 13, "NORMAL_ATTACK1": 13, "NORMAL_ATTACK2": 13,
"FRONT_DAMAGE": 5, "FRONT_DAMAGE1": 5, "FRONT_DAMAGE2": 5, "FRONT_DAMAGE3": 5,
"FRONT_DEAD": 11, "FRONT_DEAD1": 11, "FRONT_DEAD2": 11,
"FRONT_KNOCKDOWN": 6, "FRONT_KNOCKDOWN1": 6,
"FRONT_STANDUP": 7, "FRONT_STANDUP1": 7,
"BACK_DAMAGE": 8, "BACK_DAMAGE1": 8,
"BACK_DEAD": 12, "BACK_DEAD1": 12, "BACK_DEAD2": 12,
"BACK_KNOCKDOWN": 9, "BACK_KNOCKDOWN1": 9,
"BACK_STANDUP": 10, "BACK_STANDUP1": 10,
"SPECIAL": 33, "SPECIAL1": 34, "SPECIAL2": 35,
"SPECIAL3": 36, "SPECIAL4": 37, "SPECIAL5": 38,
"SKILL1": 171, "SKILL2": 172, "SKILL3": 173, "SKILL4": 174, "SKILL5": 175,
}
const STATE_MOTION_INDEXES := {
"wait": [1], "walk": [2], "run": [3], "attack": [13], "combo": [14],
"skill": [33, 171, 172, 173, 174, 175], "damage": [5], "dead": [11],
"emotion": [1], "damage_back": [8], "knockdown": [6],
"knockdown_back": [9], "standup": [7], "standup_back": [10],
}
const HitReaction = preload("res://ui/hit_reaction.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
const MOVE_STATES := ["walk", "run"]
@@ -43,8 +75,11 @@ var _dir := ""
var _mesh_stem := ""
var _hit := HitReaction.new()
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _motion_variants := {} # EName(int) -> [{motion, path, weight}, ...]
var _motlist_loaded := false
var _state := ""
var _move_speed_ratio := 1.0
var _motion_hidden := false
var _last_resolution := {}
var _audio: Node
var _sound_instances: Array = []
@@ -56,7 +91,8 @@ static var _npclist: Dictionary = {}
static var _race_src_name: Dictionary = {}
static var _npclist_loaded := false
# race -> { "dir": String, "mesh_stem": String, "gr2": String, "motions": Dictionary }
# race -> { "dir": String, "mesh_stem": String, "gr2": String,
# "motions": Dictionary, "motion_variants": Dictionary, "motlist_loaded": bool }
# 同一种怪/NPC 只解析一次目录、主网格与 motlist.txt,后续实例直接复用,避免数万次同步磁盘 IO。
static var _race_spec_cache: Dictionary = {}
@@ -101,6 +137,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
_mesh_stem = String(cached.get("mesh_stem", ""))
gr2 = String(cached.get("gr2", ""))
_motions = (cached.get("motions", {}) as Dictionary).duplicate()
_motion_variants = (cached.get("motion_variants", {}) as Dictionary).duplicate(true)
_motlist_loaded = bool(cached.get("motlist_loaded", false))
else:
_load_npclist(assets_root)
var spec := _find_model_spec(assets_root, proto, race)
@@ -109,12 +147,14 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
_dir = String(spec.get("dir", ""))
_mesh_stem = String(spec.get("mesh_stem", ""))
gr2 = String(spec.get("gr2", ""))
_load_motlist()
_load_motlist(String(spec.get("motion_list", "")))
_race_spec_cache[race] = {
"dir": _dir,
"mesh_stem": _mesh_stem,
"gr2": gr2,
"motions": _motions.duplicate()
"motions": _motions.duplicate(),
"motion_variants": _motion_variants.duplicate(true),
"motlist_loaded": _motlist_loaded,
}
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
@@ -143,8 +183,21 @@ func set_audio(audio_node: Node) -> void:
_audio = audio_node
_refresh_sound_script(String(anim.get("anim_path")) if anim else "")
# 40250 CActorInstance::__HideEvent / __ShowEvent. Mob/NPC views expose the
# same actor-level seam as PlayerView even before net_world gets unified event
# routing for every remote actor.
func set_motion_hidden(hidden: bool) -> void:
_motion_hidden = hidden
if is_instance_valid(model) and model is Node3D:
(model as Node3D).visible = not hidden
func is_motion_hidden() -> bool:
return _motion_hidden
func set_anim_state(s: String) -> void:
if s == _state or anim == null:
# 40250 SetLoopMotion reaches __SetMotion even for the current motion, which
# also restores visibility after a CHARACTER_HIDE motion event.
if (s == _state and not _motion_hidden) or anim == null:
return
if HitReaction.is_reaction(s):
_hit.start([[s]], _state, _bind_reaction)
@@ -153,13 +206,16 @@ func set_anim_state(s: String) -> void:
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
_hit.cancel()
_state = s
_last_resolution = resolve_motion(s)
# 40250 SetLoopMotion first resolves GetMotionKey. A missing key returns
# without cancelling the current motion or changing the logical state.
var resolution := resolve_motion(s)
_last_resolution = resolution
var msa := String(_last_resolution.path)
if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return
_hit.cancel()
_state = s
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeed:m_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
@@ -179,10 +235,6 @@ func _loop_speed(state: String) -> float:
func get_move_motion_speeds() -> Vector2:
var w := MsaMotion.move_speed(String(resolve_motion("walk").path))
var r := MsaMotion.move_speed(String(resolve_motion("run").path))
if w <= 0.0 and r > 0.0:
w = r
elif r <= 0.0 and w > 0.0:
r = w
return Vector2(w, r)
# CGraphicThingInstance::InsertDelay(fStiffenTime)
@@ -191,14 +243,25 @@ func insert_delay(d: float) -> void:
# CActorInstance::__HitGood / __HitGreate / __HitStone(ActorInstanceBattle.cpp)
func hit_good(scalar: float, stunned: bool) -> void:
if _hit.current_step in HitReaction.KNOCKDOWN_STATES or _hit.current_step in HitReaction.STANDUP_STATES:
return
_hit.start(_hit.good(_state, scalar, stunned), _state, _bind_reaction)
func hit_greate(scalar: float, stunned: bool) -> void:
if _hit.current_step in HitReaction.KNOCKDOWN_STATES or _hit.current_step in HitReaction.STANDUP_STATES:
return
_hit.start(_hit.greate(_state, scalar, stunned), _state, _bind_reaction, stunned)
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# CActorInstance::TEMP_Push large-correction tail:
# InterceptOnceMotion(DAMAGE_FLYING) + PushOnceMotion(STAND_UP).
func sync_push_motion() -> bool:
if anim == null:
return false
return _hit.start([["knockdown", "standup"], ["standup"]], _state, _bind_reaction)
# 40250 CActorInstance::__Shake(100)
func shake() -> void:
_hit.shake()
@@ -246,7 +309,11 @@ func _bind_reaction(step: String) -> bool:
func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not HitReaction.is_reaction(state):
_hit.cancel()
_state = state
_state = state
# CActorInstance::__SetMotion calls __ShowEvent() once a valid motion key is
# selected, so a subsequent action cannot remain hidden accidentally.
if _motion_hidden:
set_motion_hidden(false)
anim.set("loop", loop)
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
@@ -270,6 +337,21 @@ func get_motion_path(state: String) -> String:
func resolve_motion(state: String) -> Dictionary:
var names: Array = STATE_MOTIONS.get(state, [])
var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""}
# Once a real motlist was loaded, use the same EName vector as CRaceData.
# Do not fall through to a different state (RUN -> WALK, DAMAGE -> WAIT)
# when the registered key is absent: SetLoopMotion would return FALSE.
if _motlist_loaded:
for motion_index: int in STATE_MOTION_INDEXES.get(state, []):
if not _motion_variants.has(motion_index):
continue
var selected: Dictionary = _pick_motion_variant(motion_index)
if selected.is_empty():
continue
out.motion = String(selected.get("motion", ""))
out.path = String(selected.get("path", ""))
return out
out.fallback_reason = "reference_keep_current_motion"
return out
for mo: String in names:
if _motions.has(mo):
out.motion = mo
@@ -287,6 +369,22 @@ func resolve_motion(state: String) -> Dictionary:
out.fallback_reason = "reference_keep_current_motion"
return out
func _pick_motion_variant(motion_index: int) -> Dictionary:
var variants: Array = _motion_variants.get(motion_index, [])
if variants.is_empty():
return {}
if variants.size() == 1:
return variants[0]
# CActorInstance::GetRandomMotionKey: random() % 100, subtract each
# byPercentage in registration order, and leave sub-index 0 when the
# configured weights do not cover the whole [0, 100) interval.
var remaining := randi() % 100
for variant: Dictionary in variants:
remaining -= int(variant.get("weight", 0))
if remaining < 0:
return variant
return variants[0]
func last_motion_resolution() -> Dictionary:
return _last_resolution.duplicate()
@@ -347,19 +445,69 @@ func _folder_candidates(proto: Node, race: int) -> Array:
out.append(c)
return out
func _load_motlist() -> void:
var ml := _dir.path_join("motlist.txt")
func _load_motlist(motion_list_path: String = "") -> void:
_motions.clear()
_motion_variants.clear()
_motlist_loaded = false
var ml := motion_list_path if motion_list_path != "" else _dir.path_join("motlist.txt")
if not FileAccess.file_exists(ml):
return
_motlist_loaded = true
var f := FileAccess.open(ml, FileAccess.READ)
if f == null:
_motlist_loaded = false
return
var last_percent := 0
var last_damage_path := ""
var has_spawn := false
while f and not f.eof_reached():
var parts := f.get_line().strip_edges().split(" ", false)
# CMemoryTextFileLoader + sscanf treat tabs and repeated spaces as
# whitespace; normalize both before splitting.
var parts := f.get_line().strip_edges().replace("\t", " ").split(" ", false)
if parts.size() >= 3:
var motion := String(parts[1])
var motion := String(parts[1]).to_upper()
var file := String(parts[2])
if parts.size() >= 4:
last_percent = int(parts[3])
var motion_index := _npc_motion_index(motion)
if motion_index < 0:
continue
var abs := _dir.path_join(file)
if FileAccess.file_exists(abs):
if not FileAccess.file_exists(abs):
continue
var entry := {"motion": motion, "path": abs, "weight": last_percent}
if not _motion_variants.has(motion_index):
_motion_variants[motion_index] = []
(_motion_variants[motion_index] as Array).append(entry)
# Keep the first direct alias for compatibility/debug output. The
# actual loaded path is always selected from _motion_variants.
if not _motions.has(motion):
_motions[motion] = abs
if motion_index == 25:
has_spawn = true
elif motion_index == 5:
last_damage_path = abs
if not has_spawn and last_damage_path != "":
# Preserve the reference loader's exact fallback: reuse the final
# parsed nPercent value, not the damage row's own weight.
var spawn_entry := {"motion": "SPAWN", "path": last_damage_path, "weight": last_percent}
_motion_variants[25] = [spawn_entry]
if not _motions.has("SPAWN"):
_motions["SPAWN"] = last_damage_path
func _npc_motion_index(raw_motion: String) -> int:
var motion := raw_motion.to_upper()
if NPC_MOTION_INDEX.has(motion):
return int(NPC_MOTION_INDEX[motion])
# CRaceManager accepts at most two trailing characters as an alias
# (WAIT3 / WAIT20 -> WAIT), but rejects unrelated names such as DAMAGE.
for trim_count in [1, 2]:
if motion.length() <= trim_count:
break
var base := motion.substr(0, motion.length() - trim_count)
if NPC_MOTION_INDEX.has(base):
return int(NPC_MOTION_INDEX[base])
return -1
func _pick_base_gr2() -> String:
return _pick_base_gr2_in(_dir)
@@ -423,6 +571,40 @@ func _resolve_msm_base_model(assets_root: String, msm_path: String) -> String:
return c
return ""
func _resolve_msm_motion_list(msm_path: String) -> String:
if not FileAccess.file_exists(msm_path):
return ""
var f := FileAccess.open(msm_path, FileAccess.READ)
if f == null:
return ""
var regex := RegEx.create_from_string("(?i)MotionListFileName\\s+\\\"([^\\\"]+)\\\"")
if regex == null:
return ""
var match := regex.search(f.get_as_text())
if match == null:
return ""
var raw := match.get_string(1).replace("\\", "/").strip_edges()
var path := msm_path.get_base_dir().path_join(raw)
return path if FileAccess.file_exists(path) else ""
func _motion_list_for_dir(d: String, mesh_stem: String) -> String:
# RaceData::LoadRaceData gets the motion list name from the selected MSM.
# The plain motlist.txt fallback covers older NPC assets whose MSM has no
# MotionListFileName field and is not used when an explicit field exists.
var msm_candidates := [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]
for msm_name: String in msm_candidates:
var msm_path := d.path_join(msm_name)
if not FileAccess.file_exists(msm_path):
continue
var explicit := _resolve_msm_motion_list(msm_path)
if explicit != "":
return explicit
var msm_text := FileAccess.get_file_as_string(msm_path)
if msm_text.findn("MotionListFileName") >= 0:
return ""
var fallback := d.path_join("motlist.txt")
return fallback if FileAccess.file_exists(fallback) else ""
func _find_model_spec(assets_root: String, proto: Node, race: int) -> Dictionary:
var codes: Array[String] = []
var primary: String = _npclist.get(race, "")
@@ -511,22 +693,26 @@ func _check_dir_for_model(assets_root: String, d: String, mesh_stem: String) ->
# 1. 对应代号的网格
var gr2 := d.path_join(mesh_stem + ".gr2")
if FileAccess.file_exists(gr2):
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2,
"motion_list": _motion_list_for_dir(d, mesh_stem)}
# 2. 目录同名主网格
gr2 = d.path_join(d.get_file() + ".gr2")
if FileAccess.file_exists(gr2):
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2,
"motion_list": _motion_list_for_dir(d, mesh_stem)}
# 3. 目录里第一个非动作 .gr2
gr2 = _pick_base_gr2_in(d)
if gr2 != "":
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2,
"motion_list": _motion_list_for_dir(d, mesh_stem)}
# 4. 检查 .msm 的 BaseModelFileName(如 campfire.msm, flag_red.msm)
for msm_name in [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]:
var msm_p := d.path_join(msm_name)
if FileAccess.file_exists(msm_p):
var base_gr2 := _resolve_msm_base_model(assets_root, msm_p)
if base_gr2 != "":
return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2}
return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2,
"motion_list": _resolve_msm_motion_list(msm_p)}
return {}
func _find_dir(assets_root: String, folder: String) -> String: