客户端问题修改
This commit is contained in:
@@ -41,6 +41,12 @@ var _reconnecting_game := false
|
||||
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
|
||||
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
|
||||
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
|
||||
var _auto_char_slot := -1 # MT_CHAR_SLOT:自动登录时优先选择的角色槽位。
|
||||
|
||||
func _init() -> void:
|
||||
var raw_slot := OS.get_environment("MT_CHAR_SLOT").strip_edges()
|
||||
if raw_slot.is_valid_int():
|
||||
_auto_char_slot = int(raw_slot)
|
||||
|
||||
func start(assets_root: String = "", injected_client: Node = null) -> void:
|
||||
_assets = assets_root
|
||||
@@ -110,6 +116,11 @@ func _on_char_list(list: Array) -> void:
|
||||
_goto_select()
|
||||
if auto_login and not _chars.is_empty():
|
||||
var idx := int(_chars[0].get("index", 0))
|
||||
if _auto_char_slot >= 0:
|
||||
for character in _chars:
|
||||
if int(character.get("index", -1)) == _auto_char_slot:
|
||||
idx = _auto_char_slot
|
||||
break
|
||||
if client.has_method("enter_game"):
|
||||
client.enter_game(idx)
|
||||
elif client.has_method("select_character"):
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Render the selection screen with deterministic reference data; no login needed.
|
||||
extends SceneTree
|
||||
|
||||
const Screen = preload("res://ui/char_select_screen.gd")
|
||||
|
||||
class Client extends Node:
|
||||
func get_empire() -> int: return 3
|
||||
func get_slot_count() -> int: return 4
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1472, 854)
|
||||
var client := Client.new()
|
||||
root.add_child(client)
|
||||
var screen := Screen.new()
|
||||
root.add_child(screen)
|
||||
screen.setup(client, AssetRoot.path(), [{
|
||||
"index": 0, "name": "[SA]Admin", "job": 0, "level": 105,
|
||||
"guild_name": "[GM-TEAM]", "play_minutes": 2180,
|
||||
"ht": 90, "iq": 15, "st": 90, "dx": 90,
|
||||
"main_part": 20009, "hair_part": 0,
|
||||
}])
|
||||
for i in 40:
|
||||
await process_frame
|
||||
if screen._pv == null:
|
||||
printerr("FAIL: selection model did not load")
|
||||
quit(1)
|
||||
return
|
||||
print("Selected armor: ", screen._pv.model.get("gr2_path"))
|
||||
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
|
||||
for surface in mi.mesh.get_surface_count():
|
||||
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
|
||||
if not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"):
|
||||
printerr("FAIL: server armor was not applied")
|
||||
quit(1)
|
||||
return
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
root.get_texture().get_image().save_png("/tmp/metin2-char-select.png")
|
||||
print("PASS: selection model, server armor and UI assembly")
|
||||
quit()
|
||||
@@ -6,6 +6,7 @@
|
||||
extends Node
|
||||
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
const LiveSmokeTest = preload("res://live_smoke_test.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
||||
@@ -21,3 +22,8 @@ func _ready() -> void:
|
||||
af.name = "AppFlow"
|
||||
add_child(af)
|
||||
af.start(root)
|
||||
if OS.get_environment("MT_TEST_MODE") == "smoke":
|
||||
var smoke := LiveSmokeTest.new()
|
||||
smoke.name = "LiveSmokeTest"
|
||||
add_child(smoke)
|
||||
smoke.setup(af, af.client)
|
||||
|
||||
@@ -15,8 +15,8 @@ enum CameraMode {
|
||||
# ClientVS22 stores camera settings in centimetres/degrees. The Godot scene
|
||||
# is metres/radians, so keep the conversion at this boundary instead of
|
||||
# leaking mixed units into the orbit controls.
|
||||
const DEFAULT_DISTANCE_CM := 1550.0
|
||||
const DEFAULT_PITCH_DEG := 27.0
|
||||
const DEFAULT_DISTANCE_CM := 2100.0
|
||||
const DEFAULT_PITCH_DEG := 34.0
|
||||
const DEFAULT_ROTATION_DEG := 0.0
|
||||
const DEFAULT_HEIGHT_CM := 100.0
|
||||
|
||||
@@ -74,7 +74,8 @@ const FADE_ALPHA := 0.72 # 挡住玩家时的透明度
|
||||
var _faded: Array[Node] = [] # 上一帧被淡出的 GeometryInstance3D
|
||||
|
||||
func _ready() -> void:
|
||||
fov = 55.0
|
||||
# Match the original third-person framing: farther camera and narrower lens.
|
||||
fov = 45.0
|
||||
far = 4000.0
|
||||
_apply_setting(_normal_setting())
|
||||
if target:
|
||||
|
||||
+20
-2
@@ -183,6 +183,11 @@ func setup(m2client: Node, assets_root: String,
|
||||
net_world.set_camera(cam)
|
||||
net_world.set_local_node(player)
|
||||
net_world.entity_added.connect(func(node: Node3D, _vid: int):
|
||||
# Model-factory upgrades replace an earlier placeholder node in place.
|
||||
# Drop queued-for-free entries before registering the rebuilt pick target.
|
||||
for old in pc.pickables.duplicate():
|
||||
if old == null or not is_instance_valid(old):
|
||||
pc.pickables.erase(old)
|
||||
if node not in pc.pickables:
|
||||
pc.pickables.append(node))
|
||||
net_world.entity_removed.connect(func(vid: int):
|
||||
@@ -272,7 +277,7 @@ func setup(m2client: Node, assets_root: String,
|
||||
add_child(quickbar)
|
||||
quickbar.item_mouse = mouse_controller
|
||||
quickbar.net_play = net_play # §3.8 修改 1:技能三层校验的运行期上下文
|
||||
quickbar.setup(client, skill_table, ui, func() -> Node: return player)
|
||||
quickbar.setup(client, skill_table, ui, func() -> Node: return player, assets_root)
|
||||
# §3.4 MODE_USE_SKILL:预约技能进射程后由 quickbar 执行实际施法。
|
||||
if net_play:
|
||||
net_play.use_skill_hook = func(slot: int) -> bool: return quickbar.activate_reserved(slot)
|
||||
@@ -1333,12 +1338,25 @@ func _build_lighting() -> void:
|
||||
e.background_color = Color(0.55, 0.62, 0.72)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(0.5, 0.55, 0.62)
|
||||
e.ambient_light_energy = 0.6
|
||||
e.ambient_light_energy = 0.42
|
||||
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||||
e.tonemap_exposure = 0.82
|
||||
e.adjustment_enabled = true
|
||||
e.adjustment_saturation = 1.05
|
||||
e.adjustment_contrast = 1.08
|
||||
e.adjustment_brightness = 0.88
|
||||
we.environment = e
|
||||
add_child(we)
|
||||
_env = e
|
||||
|
||||
func _on_main_set(vid: int) -> void:
|
||||
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
|
||||
# as local before building its view so NetWorld removes (and never recreates)
|
||||
# the generic remote-player capsule for our own character.
|
||||
if net_world and net_world.has_method("set_local_vid"):
|
||||
net_world.set_local_vid(vid)
|
||||
if net_world and net_world.has_method("set_local_node") and player:
|
||||
net_world.set_local_node(player)
|
||||
var e: Dictionary = client.get_entity(vid)
|
||||
if e.is_empty() or player == null:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# live_smoke_test —— 真实服务器端到端冒烟测试。
|
||||
#
|
||||
# 由 script/live_smoke_test.sh 以 MT_TEST_MODE=smoke 启动。测试流程:
|
||||
# 自动登录 → 角色列表 → 进入游戏 → 世界/属性读取 → 移动/停止 → 聊天
|
||||
# → 可选重连 → 输出 JSON 报告并以 PASS/FAIL 退出。
|
||||
#
|
||||
# 只使用可逆的常规操作,不执行删角、丢弃、强化、交易等破坏性动作。
|
||||
extends Node
|
||||
|
||||
const FUNC_WAIT := 0
|
||||
const FUNC_MOVE := 1
|
||||
const CHAT_TALKING := 0
|
||||
const SETTLE_SECONDS := 2.5
|
||||
const STEP_TIMEOUT_SECONDS := 25.0
|
||||
const TOTAL_TIMEOUT_SECONDS := 120.0
|
||||
|
||||
var _flow: Node
|
||||
var _client: Node
|
||||
var _state := "boot"
|
||||
var _state_since_ms := 0
|
||||
var _started_ms := 0
|
||||
var _entered_count := 0
|
||||
var _phase_history: Array[String] = []
|
||||
var _checks: Array[Dictionary] = []
|
||||
var _failures: Array[String] = []
|
||||
var _events := {
|
||||
"char_list": 0,
|
||||
"entered_game": 0,
|
||||
"entity_moved": 0,
|
||||
"chat": 0,
|
||||
"disconnected": 0,
|
||||
}
|
||||
var _origin := Vector3.ZERO
|
||||
var _origin_valid := false
|
||||
var _mutations := true
|
||||
var _reconnect := true
|
||||
var _finished := false
|
||||
var _finish_queued := false
|
||||
var _report_path := ""
|
||||
|
||||
func setup(flow: Node, m2client: Node) -> void:
|
||||
_flow = flow
|
||||
_client = m2client
|
||||
_started_ms = Time.get_ticks_msec()
|
||||
_state_since_ms = _started_ms
|
||||
_mutations = OS.get_environment("MT_SMOKE_MUTATIONS") != "0"
|
||||
_reconnect = OS.get_environment("MT_SMOKE_RECONNECT") != "0"
|
||||
_report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
|
||||
if _report_path.is_empty():
|
||||
_report_path = "user://live_smoke_report.json"
|
||||
|
||||
if _client == null:
|
||||
_fail("M2Client 未创建")
|
||||
_finish_later()
|
||||
return
|
||||
if _client.has_signal("stage_changed"):
|
||||
_client.stage_changed.connect(_on_stage_changed)
|
||||
if _client.has_signal("phase_changed"):
|
||||
_client.phase_changed.connect(_on_phase_changed)
|
||||
if _client.has_signal("char_list"):
|
||||
_client.char_list.connect(_on_char_list)
|
||||
if _client.has_signal("entered_game"):
|
||||
_client.entered_game.connect(_on_entered_game)
|
||||
if _client.has_signal("login_failed"):
|
||||
_client.login_failed.connect(_on_login_failed)
|
||||
if _client.has_signal("disconnected"):
|
||||
_client.disconnected.connect(_on_disconnected)
|
||||
if _client.has_signal("entity_moved"):
|
||||
_client.entity_moved.connect(_on_entity_moved)
|
||||
if _client.has_signal("chat"):
|
||||
_client.chat.connect(_on_chat)
|
||||
|
||||
_state = "waiting_login"
|
||||
_log("START mutations=%s reconnect=%s report=%s" % [_mutations, _reconnect, _report_path])
|
||||
_check(OS.get_environment("MT_PROTOCOL") == "classic", "classic 协议已启用")
|
||||
_check(OS.get_environment("MT_ACCOUNT") != "", "账号环境变量已提供")
|
||||
_check(OS.get_environment("MT_PASSWORD") != "", "密码环境变量已提供")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _finished:
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
var total_s := float(now - _started_ms) / 1000.0
|
||||
if total_s > TOTAL_TIMEOUT_SECONDS:
|
||||
_fail("总超时,当前状态=%s" % _state)
|
||||
_finish_later()
|
||||
return
|
||||
|
||||
match _state:
|
||||
"settle_first":
|
||||
if _seconds_in_state(now) >= SETTLE_SECONDS:
|
||||
_run_world_checks()
|
||||
"wait_move":
|
||||
if _seconds_in_state(now) >= 1.5:
|
||||
_send_stop()
|
||||
"wait_chat":
|
||||
if _seconds_in_state(now) >= 0.5:
|
||||
_send_chat()
|
||||
"wait_reconnect":
|
||||
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
|
||||
_fail("重连后未重新进入游戏")
|
||||
_finish_later()
|
||||
"settle_second":
|
||||
if _seconds_in_state(now) >= SETTLE_SECONDS:
|
||||
_check(_client != null and _client.is_in_game(), "重连后客户端仍处于游戏状态")
|
||||
_finish_later()
|
||||
"waiting_login", "waiting_first_game":
|
||||
if _seconds_in_state(now) >= STEP_TIMEOUT_SECONDS:
|
||||
_fail("阶段超时:%s" % _state)
|
||||
_finish_later()
|
||||
|
||||
func _seconds_in_state(now: int) -> float:
|
||||
return float(now - _state_since_ms) / 1000.0
|
||||
|
||||
func _set_state(next_state: String) -> void:
|
||||
_state = next_state
|
||||
_state_since_ms = Time.get_ticks_msec()
|
||||
_log("STATE %s" % _state)
|
||||
|
||||
func _on_stage_changed(stage: String) -> void:
|
||||
_log("stage=%s" % stage)
|
||||
|
||||
func _on_phase_changed(phase: String) -> void:
|
||||
_phase_history.append(phase)
|
||||
_log("phase=%s" % phase)
|
||||
|
||||
func _on_char_list(characters: Array) -> void:
|
||||
_events["char_list"] += 1
|
||||
_check(not characters.is_empty(), "角色列表非空")
|
||||
if characters.is_empty():
|
||||
_fail("服务器返回空角色列表")
|
||||
_finish_later()
|
||||
else:
|
||||
_log("char_list count=%d" % characters.size())
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_entered_count += 1
|
||||
_events["entered_game"] = _entered_count
|
||||
_check(true, "第 %d 次进入游戏" % _entered_count)
|
||||
if _entered_count == 1:
|
||||
_set_state("settle_first")
|
||||
elif _state == "wait_reconnect":
|
||||
_set_state("settle_second")
|
||||
|
||||
func _on_login_failed(reason: String) -> void:
|
||||
_fail("登录失败:%s" % reason)
|
||||
_finish_later()
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_events["disconnected"] += 1
|
||||
_log("disconnected=%s" % reason)
|
||||
if _state != "wait_reconnect" and _state != "settle_second":
|
||||
_fail("测试期间意外断线:%s" % reason)
|
||||
_finish_later()
|
||||
|
||||
func _on_entity_moved(_vid: int) -> void:
|
||||
_events["entity_moved"] += 1
|
||||
|
||||
func _on_chat(_type: int, _vid: int, _text: String) -> void:
|
||||
_events["chat"] += 1
|
||||
|
||||
func _run_world_checks() -> void:
|
||||
var in_game: bool = _client != null and _client.has_method("is_in_game") and _client.is_in_game()
|
||||
_check(in_game, "客户端处于 in_game")
|
||||
var main_vid := int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0
|
||||
_check(main_vid != 0, "主角色 VID 有效")
|
||||
if main_vid == 0:
|
||||
_fail("主角色不存在,无法继续常规测试")
|
||||
_finish_later()
|
||||
return
|
||||
|
||||
var main_entity: Dictionary = _client.get_entity(main_vid)
|
||||
_check(not main_entity.is_empty(), "主角色实体可读取")
|
||||
var entities: Array = _client.get_entities()
|
||||
_check(not entities.is_empty(), "世界实体列表可读取")
|
||||
var points: Dictionary = _client.get_points()
|
||||
_check(not points.is_empty(), "属性数据可读取")
|
||||
_check(_has_method_and_array("get_inventory"), "背包接口可读取")
|
||||
_check(_has_method_and_array("get_skills"), "技能接口可读取")
|
||||
_check(_has_method_and_array("get_quickslots"), "快捷栏接口可读取")
|
||||
_check(_has_method_and_array("get_affects"), "状态接口可读取")
|
||||
|
||||
var p: Variant = main_entity.get("pos_cm", null)
|
||||
if p is Vector3:
|
||||
_origin = p
|
||||
_origin_valid = true
|
||||
_log("main_vid=%d pos_cm=%s entities=%d" % [main_vid, p, entities.size()])
|
||||
else:
|
||||
_fail("主角色缺少 pos_cm")
|
||||
|
||||
if not _mutations:
|
||||
_finish_later()
|
||||
return
|
||||
_send_move()
|
||||
|
||||
func _has_method_and_array(method_name: String) -> bool:
|
||||
if _client == null or not _client.has_method(method_name):
|
||||
return false
|
||||
var value: Variant = _client.call(method_name)
|
||||
return value is Array
|
||||
|
||||
func _send_move() -> void:
|
||||
if not _origin_valid:
|
||||
_finish_later()
|
||||
return
|
||||
var ok := bool(_client.move(FUNC_MOVE, 0, 0.0, int(_origin.x) + 100, int(_origin.y)))
|
||||
_check(ok, "发送 1 米移动包")
|
||||
_set_state("wait_move")
|
||||
|
||||
func _send_stop() -> void:
|
||||
var ok := bool(_client.move(FUNC_WAIT, 0, 0.0, int(_origin.x), int(_origin.y)))
|
||||
_check(ok, "发送停止移动包")
|
||||
_set_state("wait_chat")
|
||||
|
||||
func _send_chat() -> void:
|
||||
var ok := bool(_client.say(CHAT_TALKING, "[smoke] client live smoke test"))
|
||||
_check(ok, "发送普通聊天包")
|
||||
if not _reconnect:
|
||||
_finish_later()
|
||||
return
|
||||
var reconnect_ok := bool(_client.reconnect())
|
||||
_check(reconnect_ok, "调用客户端重连")
|
||||
if reconnect_ok:
|
||||
_set_state("wait_reconnect")
|
||||
else:
|
||||
_finish_later()
|
||||
|
||||
func _check(ok: bool, name: String) -> void:
|
||||
_checks.append({"name": name, "ok": ok})
|
||||
if ok:
|
||||
print("LIVE_SMOKE PASS: " + name)
|
||||
else:
|
||||
_fail(name)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
if not _failures.has(message):
|
||||
_failures.append(message)
|
||||
printerr("LIVE_SMOKE FAIL: " + message)
|
||||
|
||||
func _log(message: String) -> void:
|
||||
print("LIVE_SMOKE: " + message)
|
||||
|
||||
func _finish_later() -> void:
|
||||
if _finish_queued or _finished:
|
||||
return
|
||||
_finish_queued = true
|
||||
call_deferred("_finish")
|
||||
|
||||
func _finish() -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
var passed := _failures.is_empty()
|
||||
var report := {
|
||||
"status": "PASS" if passed else "FAIL",
|
||||
"started_at_ms": _started_ms,
|
||||
"duration_seconds": float(Time.get_ticks_msec() - _started_ms) / 1000.0,
|
||||
"entered_game_count": _entered_count,
|
||||
"phase_history": _phase_history,
|
||||
"events": _events,
|
||||
"checks": _checks,
|
||||
"failures": _failures,
|
||||
"mutations": _mutations,
|
||||
"reconnect": _reconnect,
|
||||
}
|
||||
var file := FileAccess.open(_report_path, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, " "))
|
||||
file.close()
|
||||
else:
|
||||
printerr("LIVE_SMOKE: 无法写入报告:" + _report_path)
|
||||
if passed:
|
||||
print("LIVE_SMOKE RESULT: PASS")
|
||||
else:
|
||||
printerr("LIVE_SMOKE RESULT: FAIL (%d failures)" % _failures.size())
|
||||
get_tree().quit(0 if passed else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4kxv31wxrhj5
|
||||
@@ -213,6 +213,23 @@ func catch_up() -> void:
|
||||
|
||||
func set_model_factory(f: Callable) -> void:
|
||||
_model_factory = f
|
||||
# The game server can send the initial actor burst before GameScene has
|
||||
# finished loading mob/item proto tables. Those actors were necessarily made
|
||||
# as capsules; once the real factory is available, replace them from the
|
||||
# authoritative EntityStore rows instead of leaving mixed placeholder/GR2
|
||||
# models on screen.
|
||||
_refresh_existing_model_views()
|
||||
|
||||
func _refresh_existing_model_views() -> void:
|
||||
if not _model_factory.is_valid() or client == null or not client.has_method("get_entity"):
|
||||
return
|
||||
for vid in _by_vid.keys().duplicate():
|
||||
var id := int(vid)
|
||||
if id == _local_vid:
|
||||
continue
|
||||
var d: Dictionary = client.get_entity(id)
|
||||
if not d.is_empty():
|
||||
_on_spawn(d)
|
||||
|
||||
# 本地玩家用 player_controller 的节点代表,这里就不要再画一个。
|
||||
func set_local_vid(vid: int) -> void:
|
||||
|
||||
@@ -5,6 +5,7 @@ extends SceneTree
|
||||
const SkillTable = preload("res://ui/skill_table.gd")
|
||||
const SkillUI = preload("res://ui/skill_ui.gd")
|
||||
const Quickbar = preload("res://ui/quickbar.gd")
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const UiManager = preload("res://ui/ui_manager.gd")
|
||||
|
||||
class FakeClient extends Node:
|
||||
@@ -74,6 +75,11 @@ func _run() -> void:
|
||||
# 列偏移修正后:skill 1 'Three-Way Cut' motion_name=samyeon motion_idx=1, ATTACK_SKILL
|
||||
_ck(st.motion_idx_of(1) == 1, "skill 1 motion_idx == 1 (列偏移已修)")
|
||||
_ck(String(s1.get("motion", "")) == "samyeon", "skill 1 motion_name == samyeon")
|
||||
var skill_icon := UiAssets.load_tex(assets, "ETC/ymir work/ui/skill/warrior/samyeon_01.sub")
|
||||
_ck(skill_icon != null, "skill 1 icon loads from .sub + shared SkillWarrior.dds")
|
||||
if skill_icon:
|
||||
_ck(skill_icon.get_width() == 32 and skill_icon.get_height() == 32,
|
||||
"skill 1 icon region is 32x32")
|
||||
_ck(st.is_attack(1), "skill 1 是 ATTACK_SKILL")
|
||||
_ck(st.is_ranged(46), "skill 46 bow skill is ranged")
|
||||
_ck(not st.is_ranged(1), "skill 1 melee skill is not ranged")
|
||||
@@ -261,6 +267,8 @@ func _run() -> void:
|
||||
get_root().add_child(qb)
|
||||
qb.setup(fc, st, ui, func() -> Node: return pl)
|
||||
qb.assign(0, "skill", 1)
|
||||
_ck(qb._slots[0].icon.texture != null, "quickbar skill slot shows skill icon")
|
||||
_ck(String(qb._slots[0].lbl.text) == "", "quickbar hides text when skill icon is available")
|
||||
_ck(fc.quickslot_ops == [["add", 0, 2, 1]], "assign -> CG_QUICKSLOT_ADD slot 0")
|
||||
qb.activate(0)
|
||||
_ck(fc.skill_uses.size() == 1 and fc.skill_uses[0] == [1, 2000],
|
||||
|
||||
@@ -15,6 +15,8 @@ extends Control
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
const UiKit = preload("res://ui_kit.gd")
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const EquipModel = preload("res://ui/equip_model.gd")
|
||||
const DESIGN_SIZE := Vector2(1472, 854)
|
||||
|
||||
signal select_requested(index: int)
|
||||
signal delete_requested(index: int, private_code: String)
|
||||
@@ -61,9 +63,11 @@ var _class_img: TextureRect
|
||||
var _class_lbl: Label
|
||||
var _status: Label
|
||||
var _slot_lbl: Label
|
||||
var _spin := 0.0
|
||||
var _last_empire := -1
|
||||
var _rename_pending := false
|
||||
var _layout: Control
|
||||
var _board: Control
|
||||
var _proto: Node
|
||||
|
||||
func setup(client: Node, assets_root: String, chars: Array) -> void:
|
||||
_client = client
|
||||
@@ -72,9 +76,15 @@ func setup(client: Node, assets_root: String, chars: Array) -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_background()
|
||||
_build_stage()
|
||||
_layout = Control.new()
|
||||
_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_layout)
|
||||
_build_class_name()
|
||||
_build_panel()
|
||||
_build_navigation()
|
||||
_build_status()
|
||||
resized.connect(_resize_layout)
|
||||
_resize_layout()
|
||||
_last_empire = _empire_id()
|
||||
set_process(true)
|
||||
_select(_first_occupied())
|
||||
@@ -181,7 +191,8 @@ func _build_background() -> void:
|
||||
tr.texture = tex
|
||||
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
# The original stretches the 1024x768 atlas region to the window.
|
||||
tr.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(tr)
|
||||
|
||||
@@ -206,12 +217,12 @@ func _build_stage() -> void:
|
||||
|
||||
var key := DirectionalLight3D.new()
|
||||
key.rotation_degrees = Vector3(-32, 28, 0)
|
||||
key.light_energy = 1.7
|
||||
key.light_energy = 0.9
|
||||
key.light_color = Color(1.0, 0.96, 0.90)
|
||||
root.add_child(key)
|
||||
var rim := DirectionalLight3D.new()
|
||||
rim.rotation_degrees = Vector3(-6, 200, 0)
|
||||
rim.light_energy = 0.7
|
||||
rim.light_energy = 0.25
|
||||
rim.light_color = Color(0.75, 0.83, 1.0)
|
||||
root.add_child(rim)
|
||||
|
||||
@@ -220,17 +231,18 @@ func _build_stage() -> void:
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0, 0, 0, 0)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(0.42, 0.45, 0.55)
|
||||
e.ambient_light_energy = 0.9
|
||||
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
e.ambient_light_color = Color(0.8, 0.8, 0.8)
|
||||
e.ambient_light_energy = 0.65
|
||||
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||||
we.environment = e
|
||||
root.add_child(we)
|
||||
|
||||
_pivot = Node3D.new()
|
||||
_pivot.rotation.y = -PI * 0.5
|
||||
root.add_child(_pivot)
|
||||
|
||||
_cam = Camera3D.new()
|
||||
_cam.fov = 30.0
|
||||
_cam.fov = 10.0
|
||||
_cam.position = Vector3(0.0, 1.0, 4.0)
|
||||
root.add_child(_cam)
|
||||
_cam.make_current()
|
||||
@@ -251,29 +263,56 @@ func _frame_model() -> void:
|
||||
first = false
|
||||
if first:
|
||||
return
|
||||
var c := aabb.get_center()
|
||||
var r: float = maxf(aabb.size.length() * 0.5, 0.9)
|
||||
var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量
|
||||
var off := Vector3(-r * 0.5, 0.0, 0.0)
|
||||
_cam.position = c + off + Vector3(0.0, r * 0.04, dist)
|
||||
_cam.look_at(c + off, Vector3.UP)
|
||||
var height := maxf(aabb.size.y, 1.0)
|
||||
# Reference: head at 14%, feet at 85%, center at 59% of the window.
|
||||
var view_height := height / 0.71
|
||||
var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5)))
|
||||
var aspect := size.x / maxf(size.y, 1.0)
|
||||
var target := aabb.get_center()
|
||||
target.x -= view_height * aspect * 0.09
|
||||
target.y -= height * 0.007
|
||||
_cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0)))
|
||||
_cam.look_at(target, Vector3.UP)
|
||||
|
||||
func _rebuild_model() -> void:
|
||||
if _pivot == null:
|
||||
return
|
||||
if _pv != null and is_instance_valid(_pv):
|
||||
_pv.hide()
|
||||
_pv.queue_free()
|
||||
_pv = null
|
||||
var c := _cur()
|
||||
if c.is_empty() or String(c.get("name", "")) == "":
|
||||
return
|
||||
var job := clampi(int(c.get("job", 0)), 0, 3)
|
||||
var job := clampi(int(c.get("job", 0)), 0, 7)
|
||||
var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
|
||||
var pv := PlayerView.new()
|
||||
_pivot.add_child(pv)
|
||||
if pv.build(_assets, job, pump):
|
||||
_pv = pv
|
||||
pv.set_anim_state("wait")
|
||||
if _proto == null and ClassDB.class_exists("Metin2Proto"):
|
||||
_proto = ClassDB.instantiate("Metin2Proto")
|
||||
add_child(_proto)
|
||||
for lang in ["en", "common"]:
|
||||
var ip := _assets.path_join("locale/locale/%s/item_proto" % lang)
|
||||
if FileAccess.file_exists(ip):
|
||||
_proto.call("load_item_proto", ip)
|
||||
break
|
||||
var equip := EquipModel.new()
|
||||
pv.add_child(equip)
|
||||
equip.setup_remote(null, func() -> Node: return pv, _assets, job,
|
||||
[int(c.get("main_part", 0)), 0, 0, int(c.get("hair_part", 0))], _proto)
|
||||
# Expansion armor textures live beside their GR2, outside the base PC pack.
|
||||
var body_path := String(pv.model.get("gr2_path"))
|
||||
pv.model.set("texture_dir", body_path.get_base_dir())
|
||||
# Shape zero uses the head/hair already included in the armor mesh.
|
||||
if int(c.get("hair_part", 0)) == 0:
|
||||
pv.model.set("hair_gr2", "")
|
||||
pv._ground_model()
|
||||
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
|
||||
if pv.anim and FileAccess.file_exists(intro):
|
||||
pv.anim.set("anim_path", intro)
|
||||
pv.anim.set("loop", true)
|
||||
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
|
||||
for i in 4:
|
||||
await get_tree().process_frame
|
||||
@@ -287,22 +326,22 @@ func _rebuild_model() -> void:
|
||||
|
||||
func _build_class_name() -> void:
|
||||
_class_img = TextureRect.new()
|
||||
_class_img.position = Vector2(48, 60)
|
||||
_class_img.position = Vector2(92, 164)
|
||||
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_class_img)
|
||||
_layout.add_child(_class_img)
|
||||
|
||||
_class_lbl = Label.new()
|
||||
_class_lbl.position = Vector2(50, 54)
|
||||
_class_lbl.position = Vector2(120, 180)
|
||||
_class_lbl.add_theme_font_size_override("font_size", 84)
|
||||
_class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09))
|
||||
_class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
|
||||
_class_lbl.add_theme_constant_override("outline_size", 10)
|
||||
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_class_lbl)
|
||||
_layout.add_child(_class_lbl)
|
||||
|
||||
func _refresh_class_name() -> void:
|
||||
var job := clampi(int(_cur().get("job", 0)), 0, 3)
|
||||
var job := int(_cur().get("job", 0)) & 3
|
||||
var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
|
||||
var tex: Texture2D = null
|
||||
if has_char:
|
||||
@@ -319,27 +358,29 @@ func _refresh_class_name() -> void:
|
||||
# --- 左侧信息板 --------------------------------------------------------------
|
||||
|
||||
func _build_panel() -> void:
|
||||
var board := UiKit.board(_assets, "board", 24, 96)
|
||||
board.position = Vector2(56, 168)
|
||||
board.size = Vector2(300, 396)
|
||||
var board := UiKit.board(_assets, "thinboard", 16, 16)
|
||||
_board = board
|
||||
board.position = Vector2(119, 313)
|
||||
board.size = Vector2(208, 323)
|
||||
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(board)
|
||||
_layout.add_child(board)
|
||||
# 板底纹太透就垫一层
|
||||
var tint := ColorRect.new()
|
||||
tint.color = Color(0.03, 0.05, 0.06, 0.62)
|
||||
tint.position = board.position + Vector2(10, 10)
|
||||
tint.size = board.size - Vector2(20, 20)
|
||||
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(tint)
|
||||
_layout.add_child(tint)
|
||||
|
||||
_panel_box = VBoxContainer.new()
|
||||
_panel_box.position = board.position + Vector2(22, 22)
|
||||
_panel_box.custom_minimum_size = Vector2(256, 0)
|
||||
_panel_box.add_theme_constant_override("separation", 9)
|
||||
add_child(_panel_box)
|
||||
_panel_box.position = board.position + Vector2(14, 12)
|
||||
_panel_box.custom_minimum_size = Vector2(180, 0)
|
||||
_panel_box.add_theme_constant_override("separation", 6)
|
||||
_layout.add_child(_panel_box)
|
||||
|
||||
func _refresh_panel() -> void:
|
||||
for c in _panel_box.get_children():
|
||||
_panel_box.remove_child(c)
|
||||
c.queue_free()
|
||||
var d := _cur()
|
||||
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
|
||||
@@ -349,7 +390,8 @@ func _refresh_panel() -> void:
|
||||
head.add_theme_constant_override("separation", 10)
|
||||
_panel_box.add_child(head)
|
||||
var crest := TextureRect.new()
|
||||
crest.custom_minimum_size = Vector2(56, 56)
|
||||
crest.custom_minimum_size = Vector2(70, 40)
|
||||
crest.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
var gid := int(d.get("guild_id", 0))
|
||||
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
|
||||
@@ -365,9 +407,9 @@ func _refresh_panel() -> void:
|
||||
head.add_child(crest)
|
||||
var gbox := VBoxContainer.new()
|
||||
head.add_child(gbox)
|
||||
gbox.add_child(_plain(_empire_name() if has_char else "—", 14, Color(0.95, 0.9, 0.7)))
|
||||
gbox.add_child(_plain(_empire_name() if has_char else "—", 10, Color(0.95, 0.9, 0.7)))
|
||||
var gname := String(d.get("guild_name", "")).strip_edges()
|
||||
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 13, Color(0.8, 0.82, 0.86)))
|
||||
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 10, Color(0.8, 0.82, 0.86)))
|
||||
|
||||
_panel_box.add_child(_sep())
|
||||
|
||||
@@ -413,21 +455,6 @@ func _refresh_panel() -> void:
|
||||
)
|
||||
erow.add_child(eb)
|
||||
|
||||
# 槽位切换
|
||||
if _chars.size() > 1:
|
||||
var nav := HBoxContainer.new()
|
||||
nav.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
nav.add_theme_constant_override("separation", 12)
|
||||
_panel_box.add_child(nav)
|
||||
var prev := Button.new(); prev.text = "◀"; prev.custom_minimum_size = Vector2(38, 28)
|
||||
prev.pressed.connect(func(): _select(wrapi(_sel - 1, 0, _chars.size())))
|
||||
nav.add_child(prev)
|
||||
_slot_lbl = _plain("%d / %d" % [_sel + 1, _chars.size()], 14, Color(1, 1, 1))
|
||||
nav.add_child(_slot_lbl)
|
||||
var nxt := Button.new(); nxt.text = "▶"; nxt.custom_minimum_size = Vector2(38, 28)
|
||||
nxt.pressed.connect(func(): _select(wrapi(_sel + 1, 0, _chars.size())))
|
||||
nav.add_child(nxt)
|
||||
|
||||
# 按钮
|
||||
var brow := GridContainer.new()
|
||||
brow.columns = 2
|
||||
@@ -436,28 +463,78 @@ func _refresh_panel() -> void:
|
||||
_panel_box.add_child(brow)
|
||||
var start := Button.new()
|
||||
start.text = "开始"
|
||||
start.custom_minimum_size = Vector2(122, 34)
|
||||
start.custom_minimum_size = Vector2(180, 22)
|
||||
_style_button(start)
|
||||
start.disabled = not has_char or empire_required
|
||||
start.pressed.connect(_do_start)
|
||||
brow.add_child(start)
|
||||
_panel_box.add_child(start)
|
||||
_panel_box.move_child(start, _panel_box.get_child_count() - 2)
|
||||
start.visible = has_char
|
||||
var create := Button.new()
|
||||
create.text = "创建"
|
||||
create.custom_minimum_size = Vector2(122, 34)
|
||||
create.custom_minimum_size = Vector2(180, 22)
|
||||
_style_button(create)
|
||||
create.disabled = has_char or empire_required
|
||||
create.pressed.connect(_open_create_dialog)
|
||||
brow.add_child(create)
|
||||
_panel_box.add_child(create)
|
||||
_panel_box.move_child(create, _panel_box.get_child_count() - 2)
|
||||
create.visible = not has_char
|
||||
var del := Button.new()
|
||||
del.text = "删除"
|
||||
del.custom_minimum_size = Vector2(122, 34)
|
||||
del.custom_minimum_size = Vector2(86, 20)
|
||||
_style_button(del)
|
||||
del.disabled = not has_char or empire_required
|
||||
del.pressed.connect(_open_delete_dialog)
|
||||
brow.add_child(del)
|
||||
var quit := Button.new()
|
||||
quit.text = "退出"
|
||||
quit.custom_minimum_size = Vector2(122, 34)
|
||||
quit.custom_minimum_size = Vector2(86, 20)
|
||||
_style_button(quit)
|
||||
quit.pressed.connect(func(): back_requested.emit())
|
||||
brow.add_child(quit)
|
||||
|
||||
func _style_button(button: Button) -> void:
|
||||
button.add_theme_font_size_override("font_size", 10)
|
||||
for state in ["normal", "hover", "pressed", "disabled"]:
|
||||
var suffix := "02" if state == "hover" else "03" if state == "pressed" else "01"
|
||||
var prefix := "XLarge" if button.custom_minimum_size.x > 100 else "Large"
|
||||
var texture := UiAssets.load_tex(_assets,
|
||||
"d:/ymir work/ui/public/%s_Button_%s.sub" % [prefix, suffix])
|
||||
if texture:
|
||||
var textured := StyleBoxTexture.new()
|
||||
textured.texture = texture
|
||||
textured.set_texture_margin_all(3)
|
||||
button.add_theme_stylebox_override(state, textured)
|
||||
continue
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.22, 0.22, 0.20, 0.95)
|
||||
if state == "hover":
|
||||
style.bg_color = Color(0.32, 0.31, 0.27)
|
||||
style.border_color = Color(0.48, 0.46, 0.39)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(3)
|
||||
button.add_theme_stylebox_override(state, style)
|
||||
|
||||
func _build_navigation() -> void:
|
||||
for direction in [-1, 1]:
|
||||
var button := TextureButton.new()
|
||||
var side := "left" if direction < 0 else "right"
|
||||
var base := "d:/ymir work/ui/intro/select/dragon_%s_button_" % side
|
||||
button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub")
|
||||
button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub")
|
||||
button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub")
|
||||
button.position = Vector2(714 if direction < 0 else 1024, 712)
|
||||
button.custom_minimum_size = Vector2(160, 44)
|
||||
button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色"
|
||||
button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size())))
|
||||
_layout.add_child(button)
|
||||
|
||||
func _resize_layout() -> void:
|
||||
if _layout:
|
||||
_layout.size = DESIGN_SIZE
|
||||
_layout.scale = size / DESIGN_SIZE
|
||||
_frame_model()
|
||||
|
||||
# --- 底部状态 --------------------------------------------------------------
|
||||
|
||||
func _build_status() -> void:
|
||||
@@ -746,15 +823,11 @@ func _empire_flag_tex() -> Texture2D:
|
||||
return null
|
||||
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
|
||||
|
||||
func _process(dt: float) -> void:
|
||||
_spin += dt
|
||||
func _process(_dt: float) -> void:
|
||||
var empire := _empire_id()
|
||||
if empire != _last_empire:
|
||||
_last_empire = empire
|
||||
_refresh_panel()
|
||||
if _pivot:
|
||||
# 正面朝相机 + 轻微来回摆
|
||||
_pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35
|
||||
|
||||
# --- 小工具 --------------------------------------------------------------
|
||||
|
||||
@@ -780,21 +853,32 @@ func _sep() -> Control:
|
||||
func _kv(k: String, v: String) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
var kl := _plain(k, 13, Color(0.72, 0.78, 0.85))
|
||||
kl.custom_minimum_size = Vector2(64, 0)
|
||||
row.custom_minimum_size.y = 20
|
||||
var kl := _plain(k, 10, Color(0.82, 0.82, 0.80))
|
||||
kl.custom_minimum_size = Vector2(42, 0)
|
||||
row.add_child(kl)
|
||||
row.add_child(_plain(v, 14, Color(1, 1, 1)))
|
||||
var value := _plain(v, 10, Color(1, 1, 1))
|
||||
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0, 0, 0, 0.85)
|
||||
style.border_color = Color(0.4, 0.4, 0.37)
|
||||
style.set_border_width_all(1)
|
||||
value.add_theme_stylebox_override("normal", style)
|
||||
row.add_child(value)
|
||||
return row
|
||||
|
||||
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
var kl := _plain(label, 13, Color(0.72, 0.78, 0.85))
|
||||
kl.custom_minimum_size = Vector2(44, 0)
|
||||
row.custom_minimum_size.y = 20
|
||||
var kl := _plain(label, 10, Color(0.82, 0.82, 0.80))
|
||||
kl.custom_minimum_size = Vector2(26, 0)
|
||||
row.add_child(kl)
|
||||
var track := ColorRect.new()
|
||||
track.color = Color(0, 0, 0, 0.5)
|
||||
track.custom_minimum_size = Vector2(150, 14)
|
||||
track.custom_minimum_size = Vector2(98, 8)
|
||||
track.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
row.add_child(track)
|
||||
var fill := ColorRect.new()
|
||||
fill.color = col
|
||||
@@ -804,7 +888,7 @@ func _stat(label: String, value: int, col: Color) -> HBoxContainer:
|
||||
fill.offset_left = 1
|
||||
fill.offset_top = 1
|
||||
fill.offset_bottom = -1
|
||||
fill.offset_right = 1 + int(clampf(value / 25.0, 0.0, 1.0) * 148.0)
|
||||
fill.offset_right = 1 + int(clampf(value / 90.0, 0.0, 1.0) * 96.0)
|
||||
track.add_child(fill)
|
||||
row.add_child(_plain(str(value), 13, Color(1, 1, 1)))
|
||||
row.add_child(_plain(str(value), 10, Color(1, 1, 1)))
|
||||
return row
|
||||
|
||||
@@ -102,6 +102,8 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
|
||||
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
|
||||
if pump.is_valid(): pump.call()
|
||||
add_child(model)
|
||||
model.ready.connect(_ground_model)
|
||||
_ground_model()
|
||||
_load_motlist()
|
||||
if ClassDB.class_exists("Metin2AnimPlayer"):
|
||||
anim = ClassDB.instantiate("Metin2AnimPlayer")
|
||||
@@ -160,6 +162,10 @@ func set_display_name(n: String) -> void:
|
||||
add_child(tag)
|
||||
tag.text = n
|
||||
|
||||
func _ground_model() -> void:
|
||||
if model and model.has_method("get_ground_offset"):
|
||||
model.position.y = float(model.call("get_ground_offset"))
|
||||
|
||||
# --- 内部 -------------------------------------------------------------------
|
||||
|
||||
func _folder_candidates(proto: Node, race: int) -> Array:
|
||||
|
||||
@@ -54,6 +54,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||
model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格
|
||||
if pump.is_valid(): pump.call()
|
||||
add_child(model)
|
||||
model.ready.connect(_ground_model)
|
||||
_ground_model()
|
||||
|
||||
if ClassDB.class_exists("Metin2AnimPlayer"):
|
||||
anim = ClassDB.instantiate("Metin2AnimPlayer")
|
||||
@@ -196,9 +198,15 @@ func _refresh_sound_script(motion_path: String) -> void:
|
||||
func _set(prop: StringName, val: Variant) -> bool:
|
||||
if String(prop) in _forward and model:
|
||||
model.set(prop, val)
|
||||
if String(prop) == "gr2_path":
|
||||
call_deferred("_ground_model")
|
||||
return true
|
||||
return false
|
||||
|
||||
func _ground_model() -> void:
|
||||
if model and model.has_method("get_ground_offset"):
|
||||
model.position.y = float(model.call("get_ground_offset"))
|
||||
|
||||
# --- 路径解析 -------------------------------------------------------------
|
||||
|
||||
func _resolve_dir(assets_root: String, rel: String) -> String:
|
||||
|
||||
+103
-4
@@ -15,6 +15,14 @@
|
||||
extends Node
|
||||
|
||||
const PlayerSkill := preload("res://player_skill.gd")
|
||||
const UiAssets := preload("res://ui/ui_assets.gd")
|
||||
|
||||
const JOB_DIR := {
|
||||
"WARRIOR": "warrior",
|
||||
"ASSASSIN": "assassin",
|
||||
"SURA": "sura",
|
||||
"SHAMAN": "shaman",
|
||||
}
|
||||
|
||||
signal skill_activated(skill_id: int) # game_scene 接它播技能特效
|
||||
# §3.8 修改 1:三层校验挡下(code = OnCannotUseSkill 字符串码),game_scene 弹文案
|
||||
@@ -33,16 +41,19 @@ var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1)
|
||||
var _player_getter: Callable
|
||||
var _root: Control
|
||||
var item_mouse: Node
|
||||
var _slots := [] # 当前页 UI:[{btn, cd, lbl}]
|
||||
var _assets_root := ""
|
||||
var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}]
|
||||
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
|
||||
var _page := 0
|
||||
var _move_from := -1
|
||||
var _mobile_mode := false
|
||||
|
||||
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void:
|
||||
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable,
|
||||
assets_root_override := "") -> void:
|
||||
client = m2client
|
||||
table = skill_table
|
||||
_player_getter = player_getter
|
||||
_assets_root = assets_root_override if assets_root_override != "" else AssetRoot.path()
|
||||
_skill_gate = PlayerSkill.new()
|
||||
_skill_gate.setup(client, table)
|
||||
for _i in SLOT_COUNT:
|
||||
@@ -52,6 +63,8 @@ func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter:
|
||||
client.skill_cooldown_end.connect(_on_cd_end)
|
||||
if client.has_signal("quickslots_changed"):
|
||||
client.quickslots_changed.connect(restore_from_server)
|
||||
if client.has_signal("skills_changed"):
|
||||
client.skills_changed.connect(_refresh_page)
|
||||
restore_from_server()
|
||||
|
||||
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
|
||||
@@ -314,17 +327,40 @@ func _build(parent: Node) -> void:
|
||||
num.text = str(i + 1)
|
||||
num.position = Vector2(3, 1)
|
||||
num.add_theme_font_size_override("font_size", 10)
|
||||
num.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
slot.add_child(num)
|
||||
var icon := TextureRect.new()
|
||||
icon.name = "icon"
|
||||
icon.position = Vector2(4, 4)
|
||||
icon.size = Vector2(32, 32)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
slot.add_child(icon)
|
||||
var lbl := Label.new()
|
||||
lbl.name = "lbl"
|
||||
lbl.position = Vector2(3, 15)
|
||||
lbl.add_theme_font_size_override("font_size", 9)
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
slot.add_child(lbl)
|
||||
var grade := Label.new()
|
||||
grade.name = "grade"
|
||||
grade.position = Vector2(25, 25)
|
||||
grade.size = Vector2(13, 13)
|
||||
grade.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
grade.add_theme_font_size_override("font_size", 9)
|
||||
grade.add_theme_color_override("font_color", Color(1.0, 0.9, 0.45))
|
||||
grade.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
|
||||
grade.add_theme_constant_override("shadow_offset_x", 1)
|
||||
grade.add_theme_constant_override("shadow_offset_y", 1)
|
||||
grade.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
slot.add_child(grade)
|
||||
var cd := ColorRect.new()
|
||||
cd.name = "cd"
|
||||
cd.color = Color(0, 0, 0, 0.55)
|
||||
cd.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
cd.visible = false
|
||||
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
slot.add_child(cd)
|
||||
var idx := i
|
||||
slot.gui_input.connect(func(e: InputEvent):
|
||||
@@ -334,7 +370,7 @@ func _build(parent: Node) -> void:
|
||||
elif e.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_clear(idx))
|
||||
row.add_child(slot)
|
||||
_slots.append({"btn": slot, "cd": cd, "lbl": lbl})
|
||||
_slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade})
|
||||
if item_mouse and item_mouse.has_method("register_target"):
|
||||
var local_idx: int = i
|
||||
item_mouse.register_target(slot,
|
||||
@@ -378,11 +414,74 @@ func _refresh_slot(slot: int) -> void:
|
||||
return
|
||||
var s: Dictionary = _state[_global_slot(slot)]
|
||||
var txt := ""
|
||||
var tex: Texture2D = null
|
||||
var grade_text := ""
|
||||
if s.kind == "skill" and table:
|
||||
txt = table.name_of(s.id).substr(0, 5)
|
||||
tex = _skill_icon(int(s.id))
|
||||
grade_text = _skill_grade_text(int(s.id))
|
||||
elif s.kind == "item":
|
||||
txt = "#%d" % s.id
|
||||
_slots[slot].lbl.text = txt
|
||||
var icon: TextureRect = _slots[slot].icon
|
||||
icon.texture = tex
|
||||
icon.visible = tex != null
|
||||
_slots[slot].lbl.text = txt if tex == null else ""
|
||||
_slots[slot].grade.text = grade_text
|
||||
_slots[slot].grade.visible = grade_text != "" and tex != null
|
||||
|
||||
func _skill_grade(skill_id: int) -> int:
|
||||
if client and client.has_method("get_skills"):
|
||||
for skill in client.get_skills():
|
||||
if int(skill.get("id", 0)) == skill_id:
|
||||
return clampi(int(skill.get("master", 0)), 0, 3)
|
||||
return 0
|
||||
|
||||
func _skill_grade_text(skill_id: int) -> String:
|
||||
match _skill_grade(skill_id):
|
||||
1: return "M"
|
||||
2: return "G"
|
||||
3: return "P"
|
||||
_: return ""
|
||||
|
||||
func _skill_icon(skill_id: int) -> Texture2D:
|
||||
if _assets_root == "" or table == null or not table.has_method("entry"):
|
||||
return null
|
||||
var data: Dictionary = table.entry(skill_id)
|
||||
var job := String(data.get("job", "")).to_upper()
|
||||
var motion := String(data.get("motion", "")).strip_edges()
|
||||
if motion == "":
|
||||
return null
|
||||
var candidates: Array[String] = []
|
||||
if JOB_DIR.has(job):
|
||||
var dir := String(JOB_DIR[job])
|
||||
for suffix in _skill_icon_suffixes(skill_id):
|
||||
candidates.append("ETC/ymir work/ui/skill/%s/%s%s.sub" % [dir, motion, suffix])
|
||||
elif job == "SUPPORT":
|
||||
for suffix in _skill_icon_suffixes(skill_id):
|
||||
candidates.append("ETC/ymir work/ui/skill/common/support/%s%s.sub" % [motion, suffix])
|
||||
candidates.append("ETC/ymir work/ui/skill/common/support/%s.sub" % motion)
|
||||
elif job == "HORSE":
|
||||
candidates.append("ETC/ymir work/ui/skill/common/horse/%s.sub" % motion)
|
||||
elif job == "GUILD":
|
||||
candidates.append("ETC/ymir work/ui/skill/common/guild/%s.sub" % motion)
|
||||
for rel in candidates:
|
||||
var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
|
||||
if tex != null:
|
||||
return tex
|
||||
return null
|
||||
|
||||
func _skill_icon_suffixes(skill_id: int) -> Array[String]:
|
||||
var grade := _skill_grade(skill_id)
|
||||
var first := "_01"
|
||||
if grade == 1:
|
||||
first = "_02"
|
||||
elif grade >= 2:
|
||||
first = "_03"
|
||||
var out: Array[String] = [first]
|
||||
for suffix in ["_03", "_02", "_01"]:
|
||||
if not out.has(suffix):
|
||||
out.append(suffix)
|
||||
return out
|
||||
|
||||
func _refresh_page() -> void:
|
||||
if _root and _root.has_node("page"):
|
||||
|
||||
+17
-3
@@ -100,9 +100,23 @@ static func _load_sub(path: String) -> Texture2D:
|
||||
"bottom": b = int(parts[1])
|
||||
if image_name == "":
|
||||
return null
|
||||
var img_path := path.get_base_dir().path_join(image_name)
|
||||
if not FileAccess.file_exists(img_path):
|
||||
img_path = path.get_base_dir().path_join(image_name.get_basename() + ".tga")
|
||||
# Skill .sub files live below ui/skill/<job>, while their shared DDS
|
||||
# lives in ui/. Search the containing directory and its parents.
|
||||
var img_path := ""
|
||||
var search_dir := path.get_base_dir()
|
||||
for _i in 6:
|
||||
for candidate in [image_name, image_name.get_basename() + ".tga",
|
||||
image_name.get_basename() + ".png", image_name.get_basename() + ".dds"]:
|
||||
var candidate_path := search_dir.path_join(candidate)
|
||||
if FileAccess.file_exists(candidate_path):
|
||||
img_path = candidate_path
|
||||
break
|
||||
if img_path != "":
|
||||
break
|
||||
var parent_dir := search_dir.get_base_dir()
|
||||
if parent_dir == search_dir:
|
||||
break
|
||||
search_dir = parent_dir
|
||||
if not FileAccess.file_exists(img_path):
|
||||
return null
|
||||
var base := _load_image_file(img_path)
|
||||
|
||||
Reference in New Issue
Block a user