fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧 - inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域 - 包含其他累积的功能开发和测试文件
This commit is contained in:
+216
-30
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user