Files
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

609 lines
24 KiB
GDScript
Raw Permalink 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.
# Quickbar (P6) —— 服务端 36 槽;参考客户端本地显示 4 页 × 8 格(32 格)。
#
# var qb := preload("res://ui/quickbar.gd").new()
# add_child(qb)
# qb.setup(m2client, skill_table, canvas_parent, player_getter)
# qb.assign(0, "skill", 1) # 当前页 0 号格放技能 id 1
# qb.assign(1, "item", 5) # 当前页 1 号格放背包 5 格的药水
# # 输入:数字 1..4 -> 本页 0..3F1..F4 -> 本页 4..7。
#
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(默认朝向 = 玩家 yaw)。
# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的
# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
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 接它播技能特效
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)
const CM := 100.0
const DEFAULT_CD := 2.0
const SLOTS_PER_PAGE := 8
const PAGE_COUNT := 4
const SLOT_COUNT := 36 # QUICKSLOT_MAX_NUM;最后 4 格没有本地显示页
var client: Node
var table: RefCounted # SkillTable
var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源
var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1
var _player_getter: Callable
var _root: Control
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 _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外)
var _move_from := -1
var _mobile_mode := false
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:
_state.append({"kind": "", "id": 0, "cd_end": 0.0})
_build(parent)
if client.has_signal("skill_cooldown_end"):
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)
# 物品槽引用的是背包 cell:装备/销毁/丢弃后服务器 SyncQuickslot 会推
# quickslots_changed;这里再挂 inventory_changed 兜住时序窗口(置灰失效引用)。
if client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): _refresh_page())
# 死亡/复活:死亡中槽位全部置灰(对齐原版 POS_DEAD 全链路禁用)。
if client.has_signal("entity_dead"):
client.entity_dead.connect(_on_entity_dead)
# 变身(AFFECT_POLYMORPH=220):技能槽整体遮罩,结束(re-broadcast)后解除。
if client.has_signal("affect_added"):
client.affect_added.connect(_on_affect_added)
if client.has_signal("affect_removed"):
client.affect_removed.connect(_on_affect_removed)
restore_from_server()
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
# 参考 ESlotType1 道具 / 2 技能 / 3 表情 / 4 商店(不可执行)。
func restore_from_server() -> void:
if client == null or not client.has_method("get_quickslots"):
return
var restored := []
for _i in SLOT_COUNT:
restored.append({"kind": "", "id": 0, "cd_end": 0.0})
for qs in client.get_quickslots():
var pos := int(qs.get("pos", -1))
if pos < 0 or pos >= SLOT_COUNT:
continue
match int(qs.get("type", 0)):
1: restored[pos] = {"kind": "item", "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()
func assign(slot: int, kind: String, id: int, persist := true) -> bool:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return false
if kind not in ["skill", "item", "emote"] or id <= 0:
return false
var global := _global_slot(slot)
if persist and client and client.has_method("quickslot_add"):
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": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
_refresh_slot(slot)
return true
# Mobile skill pages cannot rely on drag-and-drop into the small PC quickbar.
# Put a selected skill/item/emote into the first free slot on the current page
# while keeping the same server-backed CG_QUICKSLOT_ADD path.
func assign_mobile(kind: String, id: int) -> bool:
for slot in SLOTS_PER_PAGE:
if _state[_global_slot(slot)].kind == "":
return assign(slot, kind, id)
return false
func mobile_slot_state(slot: int) -> Dictionary:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return {}
return _state[_global_slot(slot)].duplicate(true)
func mobile_swap(local_a: int, local_b: int) -> bool:
if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE:
return false
if local_a == local_b:
return true
_swap(local_a, local_b)
return true
func activate(slot: int) -> void:
_activate_slot(slot, Vector2.ZERO)
## Mobile long-press skill release. `screen_direction` uses screen space:
## right is +X and up is -Y. A zero vector intentionally falls back to the
## player's current facing, so a long press without a drag is still usable.
func activate_aimed(slot: int, screen_direction: Vector2) -> void:
_activate_slot(slot, screen_direction)
func _activate_slot(slot: int, screen_direction: Vector2) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
var s: Dictionary = _state[global]
if s.kind == "" or s.id == 0:
return
if _now() < s.cd_end:
return
# 死亡中不可使用(服务器 POS_DEAD 拦截一切使用;这里提前本地拦截)。
if _player_dead():
return
if s.kind == "skill":
var mi: int = table.motion_idx_of(s.id) if table else 0
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
var yaw := 0.0
var xy := Vector2.ZERO
if p:
yaw = fposmod(90.0 - rad_to_deg(p.rotation.y), 360.0)
xy = Vector2(p.position.x * CM, -p.position.z * CM)
if screen_direction.length() > 0.01:
yaw = _aim_heading(p, screen_direction, yaw)
var target_vid := 0
if client.has_method("get_target"):
target_vid = int(client.get_target().get("vid", 0))
# §3.8 修改 1:三层合法性校验(ClickSkillSlot -> __CanUseSkill /
# __CheckSkillUsable / __UseSkill)。挡下就发 skill_rejected(静默码除外),
# TOGGLE_OFF 只发 use_skill 关掉开关技、不施法。
if _skill_gate:
if net_play and net_play.has_method("skill_context"):
var ctx: Dictionary = net_play.skill_context()
for k in ctx:
_skill_gate.set(k, ctx[k])
_skill_gate.slot_cd_end = s.cd_end
var gate: Dictionary = _skill_gate.click_skill_slot(int(s.id))
if not bool(gate.get("ok", false)):
var code := String(gate.get("code", ""))
# PythonPlayerSkill::__CheckSpecialSkill(123) 不是失败:参考端
# 直接进入 NEW_Fishing,并把 EQUIP_FISHING_ROD 作为唯一错误反馈。
if code == "SPECIAL_SKILL" and int(s.id) == PlayerSkill.SKILL_INDEX_FISHING \
and net_play and net_play.has_method("activate_fishing"):
var fishing_result := String(net_play.activate_fishing())
if fishing_result == "OK":
skill_activated.emit(int(s.id))
elif not PlayerSkill.is_silent_code(fishing_result):
skill_rejected.emit(int(s.id), fishing_result)
return
if code == "RESERVED":
# __ProcessEnemySkillTargetRange:射程外不发施法,蓄力技未预约时先
# __SendUseSkill(slot, 0),再 __ReserveUseSkill 交给 NetPlay 趋近。
_last_activation_reserved = true
if net_play and net_play.has_method("reserve_use_skill"):
var already: bool = net_play.has_method("is_use_skill_reserved") \
and bool(net_play.is_use_skill_reserved(global))
if bool(gate.get("charge", false)) and not already and client.has_method("use_skill"):
client.use_skill(int(s.id), 0)
net_play.reserve_use_skill(int(gate.get("target_vid", 0)), global, float(gate.get("range_cm", 0)))
return
if code == "TOGGLE_OFF":
if client.has_method("use_skill"):
client.use_skill(int(s.id), 0)
skill_activated.emit(int(s.id))
elif not PlayerSkill.is_silent_code(code):
skill_rejected.emit(int(s.id), code)
return
var resolved := int(gate.get("target_vid", 0))
if resolved != 0:
target_vid = resolved
# use_skill is the authoritative server intent. Keep the CG_MOVE skill
# state packet as a separate visual/action-state sync, just like the
# original client did.
var intent_sent := true
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 \
and net_play.has_method("send_fly_targeting") \
and (table.is_fan_range(int(s.id)) or table.is_circle_range(int(s.id))):
var lvl := _skill_level(int(s.id))
net_play.send_fly_targeting(target_vid, float(table.target_range(int(s.id))),
table.target_count(int(s.id), lvl), table.fly_shape(int(s.id)))
skill_activated.emit(int(s.id))
elif s.kind == "item":
client.use_item(1, s.id) # WINDOW_INVENTORY
elif s.kind == "emote" and client.has_method("send_emoticon"):
client.send_emoticon(s.id)
func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float:
# Keep the historical POC heading convention as the base and add the
# camera-relative drag angle. This makes an upward drag equal to the
# existing one-tap cast, while right/left drags rotate it predictably.
var angle_from_up := rad_to_deg(atan2(screen_direction.x, -screen_direction.y))
var camera_heading := player_node.rotation.y
if net_play and "camera" in net_play and net_play.camera \
and net_play.camera.has_method("heading"):
camera_heading = float(net_play.camera.heading())
var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y)
return fposmod(fallback + camera_delta + angle_from_up, 360.0)
# 服务器权威的死亡态(GC add chr state DEAD);复活后 entity_info 会再触发刷新。
func _player_dead() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var me: Dictionary = client.get_entity(client.get_main_vid())
return me != null and bool(me.get("dead", false))
# 变身态(POINT_POLYMORPH / affect 220):技能槽遮罩的数据源。
func _polymorphed() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var me: Dictionary = client.get_entity(client.get_main_vid())
return me != null and (int(me.get("polymorph", 0)) != 0 or int(me.get("poly_vnum", 0)) != 0)
func _on_entity_dead(vid: int) -> void:
if client and client.has_method("get_main_vid") and vid == client.get_main_vid():
_refresh_page()
# 只关心 AFFECT_POLYMORPH(220) 的增减,避免每次 buff 变化都整页重刷。
const AFFECT_POLYMORPH := 220
func _on_affect_added(affect: Dictionary) -> void:
if int(affect.get("type", 0)) == AFFECT_POLYMORPH:
_refresh_page()
func _on_affect_removed(type: int) -> void:
if type == AFFECT_POLYMORPH:
_refresh_page()
# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏
# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。
func activate_reserved(global_slot: int) -> bool:
if global_slot < 0 or global_slot >= SLOT_COUNT:
return false
var local := global_slot - _page * SLOTS_PER_PAGE
if local < 0 or local >= SLOTS_PER_PAGE:
return false
var s: Dictionary = _state[global_slot]
if s.kind != "skill" or s.id == 0 or _now() < s.cd_end:
return false
_last_activation_reserved = false
activate(local)
# 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。
return not _last_activation_reserved
# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。
func _skill_level(sid: int) -> int:
if client == null or not client.has_method("get_skills"):
return 0
for sk in client.get_skills():
if int(sk.get("id", 0)) == sid:
return int(sk.get("level", 0))
return 0
func set_page(page: int) -> void:
if page < 0 or page >= PAGE_COUNT or page == _page:
return
_page = page
_move_from = -1
_refresh_page()
func toggle_visible() -> void:
if _root:
_root.visible = not _root.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_root.visible = not enabled
func is_mobile_mode() -> bool:
return _mobile_mode
func is_visible() -> bool:
return _root != null and _root.visible
func _global_slot(local_slot: int) -> int:
return _page * SLOTS_PER_PAGE + local_slot
func _clear(slot: int) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
if _state[global].kind == "":
return
if client and client.has_method("quickslot_del") and not client.quickslot_del(global):
return
_state[global] = {"kind": "", "id": 0, "cd_end": 0.0}
_refresh_slot(slot)
func _swap(local_a: int, local_b: int) -> void:
var a := _global_slot(local_a)
var b := _global_slot(local_b)
if client and client.has_method("quickslot_swap") and not client.quickslot_swap(a, b):
return
var tmp: Dictionary = _state[a]
_state[a] = _state[b]
_state[b] = tmp
_refresh_slot(local_a)
_refresh_slot(local_b)
# --- UI ---------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
# Give the root a real extent. A zero-sized Control happened to work with
# some viewport sizes, but makes anchoring and hit testing fragile on Retina
# windows. The old HUD's empty row then made this look like missing skills.
_root.size = Vector2(400, 56)
_root.position = Vector2(-200, -62)
_root.z_index = 10
parent.add_child(_root)
var prev := Button.new()
prev.text = ""
prev.position = Vector2(-24, 7)
prev.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT)))
_root.add_child(prev)
var next := Button.new()
next.text = ""
next.position = Vector2(SLOTS_PER_PAGE * 44 + 2, 7)
next.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT)))
_root.add_child(next)
var page_label := Label.new()
page_label.name = "page"
page_label.position = Vector2(SLOTS_PER_PAGE * 44 + 7, 38)
page_label.add_theme_font_size_override("font_size", 9)
_root.add_child(page_label)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
_root.add_child(row)
for i in SLOTS_PER_PAGE:
var slot := Panel.new()
slot.custom_minimum_size = Vector2(40, 40)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.1, 0.11, 0.14, 0.9)
sb.border_color = Color(0.35, 0.32, 0.26)
sb.set_border_width_all(1)
slot.add_theme_stylebox_override("panel", sb)
var num := Label.new()
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):
if e is InputEventMouseButton and e.pressed:
if e.button_index == MOUSE_BUTTON_LEFT:
_try_drop(idx)
elif e.button_index == MOUSE_BUTTON_RIGHT:
_clear(idx))
row.add_child(slot)
_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,
func(payload: Dictionary): return _drop_mouse_item(payload, local_idx), self)
_refresh_page()
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
if payload.is_empty() or String(payload.get("source", "")) != "inventory":
return false
if int(payload.get("window", -1)) != 1 or int(payload.get("cell", -1)) < 0:
return false
# QuickSlot inventory entries store the source inventory cell, not the vnum.
return assign(slot, "item", int(payload.get("cell", -1)))
func _try_drop(slot: int) -> void:
# skill_ui 把待拖技能放在 drag_skill_id;这里落点接
var sk_ui := _find_skill_ui()
if sk_ui and sk_ui.drag_skill_id != 0:
assign(slot, "skill", sk_ui.drag_skill_id)
sk_ui.drag_skill_id = 0
_move_from = -1
return
if _move_from >= 0 and _move_from != slot:
_swap(_move_from, slot)
_move_from = -1
return
if _state[_global_slot(slot)].kind != "":
_move_from = slot
func _find_skill_ui() -> Node:
var tree := get_tree()
if tree == null or tree.root == null:
return null
for n in tree.root.find_children("*", "Node", true, false):
if n.get_script() and n.has_method("set_job") and "drag_skill_id" in n:
return n
return null
func _refresh_slot(slot: int) -> void:
if slot < 0 or slot >= _slots.size():
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
# 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。
var dim := false
if s.kind == "item" and client and client.has_method("get_item"):
var it: Dictionary = client.get_item(1, s.id)
if it.is_empty() or int(it.get("vnum", 0)) == 0:
dim = true
txt = "×%d" % s.id
# 变身中技能槽整体遮罩(服务器只放行箭矢类物品);死亡中全部置灰。
if (s.kind == "skill" and _polymorphed()) or _player_dead():
dim = true
var icon: TextureRect = _slots[slot].icon
icon.texture = tex
icon.visible = tex != null
_slots[slot].btn.modulate = Color(0.42, 0.42, 0.42) if dim else Color(1, 1, 1)
_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"):
_root.get_node("page").text = "F%d" % (_page + 1)
for slot in _slots.size():
_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:
s.cd_end = 0.0
_state[i] = s
func _process(_dt: float) -> void:
var t := _now()
for slot in _slots.size():
var s: Dictionary = _state[_global_slot(slot)]
var on_cd: bool = t < s.cd_end
_slots[slot].cd.visible = on_cd
if on_cd:
var duration := _skill_cooldown(int(s.id))
var frac: float = clampf((s.cd_end - t) / duration, 0.0, 1.0)
_slots[slot].cd.anchor_top = 1.0 - frac
func _skill_cooldown(skill_id: int) -> float:
if table and table.has_method("cooldown_of"):
var level := 0
if client and client.has_method("get_skills"):
for skill in client.get_skills():
if int(skill.get("id", 0)) == skill_id:
level = int(skill.get("level", 0))
break
var configured := float(table.cooldown_of(skill_id, level))
if configured > 0.0:
return configured
return DEFAULT_CD
func _now() -> float:
return Time.get_ticks_msec() / 1000.0