Implement playable Mac client and rendering validation

This commit is contained in:
shen
2026-09-11 14:58:22 +08:00
parent 1ab68bd06e
commit 374d4165d8
233 changed files with 6546 additions and 284 deletions
+3 -1
View File
@@ -238,7 +238,9 @@ func _build_stage() -> void:
root.add_child(we)
_pivot = Node3D.new()
_pivot.rotation.y = PI * 0.5
# Animation sampling no longer reapplies the exported InitialPlacement;
# the intro pose already faces the camera without a quarter-turn correction.
_pivot.rotation.y = 0.0
root.add_child(_pivot)
_cam = Camera3D.new()
+9
View File
@@ -98,6 +98,9 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
# NPC/monster GR2 uses the same source winding as PCs. Without conversion,
# back-face culling exposes interiors and hides the outward-facing surfaces.
model.set("flip_winding", true)
model.set("texture_dir", _dir)
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
if pump.is_valid(): pump.call()
@@ -129,6 +132,12 @@ func set_anim_state(s: String) -> void:
anim.set("anim_path", msa)
_refresh_sound_script(msa)
# Read-only motion resolver used by the forest acceptance harness. Returning
# the selected .msa before playback lets the test distinguish a real action
# from a silent "keep the previous animation" fallback.
func get_motion_path(state: String) -> String:
return _motion_for(state)
func _process(_delta: float) -> void:
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
+2 -3
View File
@@ -32,10 +32,9 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
return false
var cls: String = CLASS_OF[race & 3]
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
# pc contains races 0..3 (including female assassin/shaman); pc2 is 4..7.
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race >= 4 else "PC/ymir work/pc/%s" % cls
var base := _resolve_dir(assets_root, pc_dir)
if base == "":
base = _resolve_dir(assets_root, "PC/ymir work/pc/%s" % cls) # 兜底用男模
if base == "":
return false
motion_dir = base.path_join("general")
+12 -2
View File
@@ -25,6 +25,7 @@ const JOB_DIR := {
}
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
signal skill_cast_started(skill_id: int, target_vid: int) # resolved target, not later selection
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
signal skill_rejected(skill_id: int, code: String)
@@ -44,6 +45,7 @@ var item_mouse: Node
var _assets_root := ""
var _slots := [] # 当前页 UI[{btn, cd, lbl, icon, grade}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session
var _page := 0
var _move_from := -1
var _mobile_mode := false
@@ -93,7 +95,9 @@ func restore_from_server() -> void:
continue
match int(qs.get("type", 0)):
1: restored[pos] = {"kind": "item", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
2: restored[pos] = {"kind": "skill", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
2:
var skill_id := int(qs.get("ref", 0))
restored[pos] = {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
3: restored[pos] = {"kind": "emote", "id": int(qs.get("ref", 0)), "cd_end": 0.0}
_state = restored
_refresh_page()
@@ -108,7 +112,7 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool:
var type := 2 if kind == "skill" else 1 if kind == "item" else 3 if kind == "emote" else 0
if type == 0 or not client.quickslot_add(global, type, id):
return false
_state[global] = {"kind": kind, "id": id, "cd_end": 0.0}
_state[global] = {"kind": kind, "id": id, "cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
_refresh_slot(slot)
return true
@@ -207,8 +211,13 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void:
if client.has_method("use_skill"):
intent_sent = client.use_skill(int(s.id), target_vid)
if intent_sent and client.cast_skill(mi, yaw, int(xy.x), int(xy.y)):
skill_cast_started.emit(int(s.id), target_vid)
s.cd_end = _now() + _skill_cooldown(int(s.id))
_state[global] = s
_skill_cooldowns[int(s.id)] = s.cd_end
for peer in _state:
if peer.kind == "skill" and peer.id == s.id:
peer.cd_end = s.cd_end
# §3.4:扇形 / 圆形技能的附加 fly-targeting(主目标已在 use_skill 里发过;
# PythonPlayerSkill.cpp:684 的 `if (dwTargetMaxCount>0 …)` 补目标块)。
if target_vid != 0 and net_play and table \
@@ -550,6 +559,7 @@ func _refresh_page() -> void:
_refresh_slot(slot)
func _on_cd_end(skill_id: int) -> void:
_skill_cooldowns.erase(skill_id)
for i in _state.size():
var s: Dictionary = _state[i]
if s.kind == "skill" and s.id == skill_id:
+6 -4
View File
@@ -8,7 +8,6 @@
extends RefCounted
static var _cache := {}
static var _dds_helper: Object = null
# DDS 运行时解码:走 Metin2World.load_ddsC++ 里的 dxt.cppDXT1/3/5 + BGRA8)。
# Godot 无原生运行时 DDS 解码,UI 里的 select.dds / 职业名图都得走这条路。
@@ -17,9 +16,12 @@ static func load_dds_image(path: String) -> Image:
return null
if not ClassDB.class_exists("Metin2World"):
return null
if _dds_helper == null or not is_instance_valid(_dds_helper):
_dds_helper = ClassDB.instantiate("Metin2World")
var img = _dds_helper.call("load_dds", path)
# Metin2World is a Node, not RefCounted; a static reference never frees it.
# The decoder returns an independently owned Image, so release the helper
# after each decode (textures themselves remain cached by load_tex).
var helper: Object = ClassDB.instantiate("Metin2World")
var img = helper.call("load_dds", path)
helper.free()
return img if img is Image else null
static func load_tex(assets_root: String, vpath: String) -> Texture2D: