- 桥梁与静态物体高度采样修复: - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程 - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题 - 新增 test_bridge_height_parity.gd 自动化对拍测试 - 40250 怪物击杀经验动效: - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附 - 40250 客户端全系统功能对齐(Batches 1-31): - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试 - 文档沉淀: - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
1550 lines
56 KiB
GDScript
1550 lines
56 KiB
GDScript
# 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..3,F1..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 SkillSlotMap := preload("res://skill_slot_map.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)
|
||
|
||
# 底部任务栏功能按钮信号(40250 uitaskbar.py 对齐)
|
||
signal character_requested
|
||
signal inventory_requested
|
||
signal messenger_requested
|
||
signal safebox_requested
|
||
signal system_requested
|
||
signal chat_requested
|
||
|
||
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 proto: Node:
|
||
set(v):
|
||
proto = v
|
||
_refresh_page()
|
||
var net_play: Node # NetPlay(可空)——技能三层校验的运行期上下文来源
|
||
var _skill_gate: RefCounted # PlayerSkill(§3.8 修改 1)
|
||
var _player_getter: Callable
|
||
var _root: Control
|
||
var _dock: Control
|
||
var _buttons := {}
|
||
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
|
||
var _left_mouse_mode := 0 # 0: 移动与攻击, 1: 自动攻击, 2: 镜头视角
|
||
var _right_mouse_mode := 2 # 0: 移动与攻击, 1: 镜头视角, 2: 技能施放
|
||
var _right_mouse_skill_id := 0
|
||
|
||
# 40250 TaskBar 控件引用
|
||
var _hp_board: Control
|
||
var _hp_recovery_bar: ColorRect
|
||
var _hp_fill: Control
|
||
var _hp_gauge_img: TextureRect
|
||
var _hp_frames: Array[Texture2D] = []
|
||
var _sp_board: Control
|
||
var _sp_recovery_bar: ColorRect
|
||
var _sp_fill: Control
|
||
var _sp_gauge_img: TextureRect
|
||
var _sp_frames: Array[Texture2D] = []
|
||
var _st_board: Control
|
||
var _st_fill: Control
|
||
var _st_gauge_img: TextureRect
|
||
var _st_frames: Array[Texture2D] = []
|
||
var _gauge_timer := 0.0
|
||
var _gauge_idx := 0
|
||
var _exp_board: Control
|
||
var _exp_points: Array[TextureRect] = []
|
||
var _exp_clips: Array[Control] = []
|
||
var _rampage_gauge: TextureRect
|
||
var _rampage_frames: Array[Texture2D] = []
|
||
var _rampage_idx := 0
|
||
var _rampage_timer := 0.0
|
||
var _quick_page_num_img: TextureRect
|
||
var _cur_hp := 0
|
||
var _max_hp := 0
|
||
var _cur_sp := 0
|
||
var _max_sp := 0
|
||
var _cur_st := 0
|
||
var _max_st := 0
|
||
var _cur_exp := 0
|
||
var _next_exp := 0
|
||
|
||
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(func():
|
||
restore_from_server()
|
||
_refresh_page())
|
||
if client.has_signal("skill_group_changed"):
|
||
client.skill_group_changed.connect(func(_group: int): restore_from_server())
|
||
if client.has_signal("entity_main_set"):
|
||
client.entity_main_set.connect(func(_vid: int): restore_from_server())
|
||
# 物品槽引用的是背包 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)
|
||
if client.has_signal("points_changed"):
|
||
client.points_changed.connect(_on_points_changed)
|
||
if client.has_method("get_points"):
|
||
_on_points_changed(client.get_points())
|
||
restore_from_server()
|
||
|
||
# 从服务器 GC_QUICKSLOT_* 恢复全部 36 个快捷栏槽位。
|
||
# 参考 ESlotType:1 道具 / 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_slot := int(qs.get("ref", 0))
|
||
var skill_id := _skill_id_from_slot(skill_slot)
|
||
# Unknown custom slots remain usable as a compatibility fallback; all
|
||
# stock 40250 slots are mapped above and therefore show the correct
|
||
# skill tree icon (e.g. warrior group 2 slot 2 -> skill 17, not 2).
|
||
if skill_id == 0:
|
||
skill_id = skill_slot
|
||
restored[pos] = {"kind": "skill", "id": skill_id, "ref": skill_slot,
|
||
"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
|
||
var server_ref := _skill_slot_for_id(id) if kind == "skill" else id
|
||
if server_ref == 0:
|
||
server_ref = id
|
||
if type == 0 or not client.quickslot_add(global, type, server_ref):
|
||
return false
|
||
_state[global] = {"kind": kind, "id": id,
|
||
"ref": _skill_slot_for_id(id) if kind == "skill" else id,
|
||
"cd_end": float(_skill_cooldowns.get(id, 0.0)) if kind == "skill" else 0.0}
|
||
_refresh_slot(slot)
|
||
return true
|
||
|
||
func _main_race() -> int:
|
||
if client and client.has_method("get_main_vid") and client.has_method("get_entity"):
|
||
var vid := int(client.get_main_vid())
|
||
if vid != 0:
|
||
return int(client.get_entity(vid).get("race", 0))
|
||
return 0
|
||
|
||
func _skill_group() -> int:
|
||
var grp := int(client.get_skill_group()) if client and client.has_method("get_skill_group") else 0
|
||
if grp > 0:
|
||
return grp
|
||
grp = _infer_skill_group()
|
||
if grp > 0:
|
||
return grp
|
||
return 1
|
||
|
||
func _infer_skill_group() -> int:
|
||
if client == null or not client.has_method("get_skills"):
|
||
return 0
|
||
var race := _main_race()
|
||
for sk in client.get_skills():
|
||
var sid := int(sk.get("id", 0))
|
||
var lvl := int(sk.get("level", 0))
|
||
if lvl <= 0 and int(sk.get("master", 0)) == 0:
|
||
continue
|
||
for g in [1, 2]:
|
||
var slot := SkillSlotMap.skill_slot_for_id(race, g, sid)
|
||
if slot >= 1 and slot <= 8:
|
||
return g
|
||
return 0
|
||
|
||
func _skill_id_from_slot(slot: int) -> int:
|
||
return SkillSlotMap.skill_id_for_slot(_main_race(), _skill_group(), slot)
|
||
|
||
func _skill_slot_for_id(skill_id: int) -> int:
|
||
return SkillSlotMap.skill_slot_for_id(_main_race(), _skill_group(), skill_id)
|
||
|
||
# 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 = MapCoord.yaw_to_heading(p.rotation.y)
|
||
xy = MapCoord.to_server_cm(p.position)
|
||
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:
|
||
if client.has_method("cast_skill"):
|
||
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 activate_skill_direct(skill_id: int) -> void:
|
||
if skill_id <= 0 or _player_dead():
|
||
return
|
||
var s := {"kind": "skill", "id": skill_id, "cd_end": float(_skill_cooldowns.get(skill_id, 0.0))}
|
||
if _now() < s.cd_end:
|
||
return
|
||
var mi: int = table.motion_idx_of(skill_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 = MapCoord.yaw_to_heading(p.rotation.y)
|
||
xy = MapCoord.to_server_cm(p.position)
|
||
var target_vid := 0
|
||
if client.has_method("get_target"):
|
||
target_vid = int(client.get_target().get("vid", 0))
|
||
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(skill_id)
|
||
if not bool(gate.get("ok", false)):
|
||
var code := String(gate.get("code", ""))
|
||
if code == "TOGGLE_OFF":
|
||
if client.has_method("use_skill"):
|
||
client.use_skill(skill_id, 0)
|
||
skill_activated.emit(skill_id)
|
||
elif not PlayerSkill.is_silent_code(code):
|
||
skill_rejected.emit(skill_id, code)
|
||
return
|
||
var resolved := int(gate.get("target_vid", 0))
|
||
if resolved != 0:
|
||
target_vid = resolved
|
||
var intent_sent := true
|
||
if client.has_method("use_skill"):
|
||
intent_sent = client.use_skill(skill_id, target_vid)
|
||
if intent_sent:
|
||
if client.has_method("cast_skill"):
|
||
client.cast_skill(mi, yaw, int(xy.x), int(xy.y))
|
||
skill_cast_started.emit(skill_id, target_vid)
|
||
var cd_end := _now() + _skill_cooldown(skill_id)
|
||
_skill_cooldowns[skill_id] = cd_end
|
||
for peer in _state:
|
||
if peer.kind == "skill" and peer.id == skill_id:
|
||
peer.cd_end = cd_end
|
||
if target_vid != 0 and net_play and table \
|
||
and net_play.has_method("send_fly_targeting") \
|
||
and (table.is_fan_range(skill_id) or table.is_circle_range(skill_id)):
|
||
var lvl := _skill_level(skill_id)
|
||
net_play.send_fly_targeting(target_vid, float(table.target_range(skill_id)),
|
||
table.target_count(skill_id, lvl), table.fly_shape(skill_id))
|
||
skill_activated.emit(skill_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 get_page() -> int:
|
||
return _page
|
||
|
||
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 get_left_mouse_mode() -> int:
|
||
return _left_mouse_mode
|
||
|
||
func set_left_mouse_mode(mode: int) -> void:
|
||
_left_mouse_mode = posmod(mode, 3)
|
||
_update_mouse_mode_buttons()
|
||
|
||
func get_right_mouse_mode() -> int:
|
||
return _right_mouse_mode
|
||
|
||
func set_right_mouse_mode(mode: int) -> void:
|
||
_right_mouse_mode = posmod(mode, 3)
|
||
_update_mouse_mode_buttons()
|
||
|
||
func set_right_mouse_skill(skill_id: int) -> void:
|
||
_right_mouse_skill_id = skill_id
|
||
_right_mouse_mode = 2
|
||
_update_mouse_mode_buttons()
|
||
|
||
func get_right_mouse_skill() -> int:
|
||
return _right_mouse_skill_id
|
||
|
||
func _update_mouse_mode_buttons() -> void:
|
||
var btn_l: BaseButton = _buttons.get("mouse_left", null)
|
||
if btn_l:
|
||
match _left_mouse_mode:
|
||
0: btn_l.tooltip_text = "攻击 / 移动 (鼠标左键)"
|
||
1: btn_l.tooltip_text = "自动攻击 (鼠标左键)"
|
||
2: btn_l.tooltip_text = "镜头旋转 (鼠标左键)"
|
||
var btn_r: BaseButton = _buttons.get("mouse_right", null)
|
||
if btn_r:
|
||
match _right_mouse_mode:
|
||
0: btn_r.tooltip_text = "攻击 / 移动 (鼠标右键)"
|
||
1: btn_r.tooltip_text = "镜头旋转 (鼠标右键)"
|
||
2:
|
||
if _right_mouse_skill_id > 0 and table and table.has_method("skill_name"):
|
||
btn_r.tooltip_text = "技能快捷施放: %s (鼠标右键)" % table.skill_name(_right_mouse_skill_id)
|
||
else:
|
||
btn_r.tooltip_text = "技能快捷施放 (鼠标右键)"
|
||
|
||
func toggle_visible() -> void:
|
||
if _root:
|
||
_root.visible = not _root.visible
|
||
if _dock:
|
||
_dock.visible = not _dock.visible
|
||
|
||
func set_mobile_mode(enabled: bool) -> void:
|
||
_mobile_mode = enabled
|
||
if _root:
|
||
_root.visible = not enabled
|
||
if _dock:
|
||
_dock.visible = not enabled
|
||
|
||
func get_dock() -> Control:
|
||
return _dock
|
||
|
||
func get_button(btn_name: String) -> Control:
|
||
return _buttons.get(btn_name, null)
|
||
|
||
func _exit_tree() -> void:
|
||
if _dock and is_instance_valid(_dock):
|
||
_dock.queue_free()
|
||
_dock = null
|
||
|
||
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.name = "TaskBar"
|
||
_root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||
_root.offset_top = -37
|
||
_root.offset_bottom = 0
|
||
_root.offset_left = 0
|
||
_root.offset_right = 0
|
||
_root.custom_minimum_size = Vector2(0, 37)
|
||
_root.z_index = 10
|
||
_root.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
parent.add_child(_root)
|
||
|
||
# 1. Base_Board_01: TaskBar_Base.tga 全屏平铺底纹
|
||
var base_bg := TextureRect.new()
|
||
base_bg.name = "Base_Board_01"
|
||
base_bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
base_bg.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/taskbar_base.tga")
|
||
base_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
base_bg.stretch_mode = TextureRect.STRETCH_TILE
|
||
base_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_root.add_child(base_bg)
|
||
|
||
# 2. Gauge_Board: gauge.sub 位于 (0, -10)
|
||
var gauge_board := TextureRect.new()
|
||
gauge_board.name = "Gauge_Board"
|
||
gauge_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/gauge.sub")
|
||
gauge_board.position = Vector2(0, -10)
|
||
gauge_board.size = Vector2(158, 47)
|
||
gauge_board.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
_root.add_child(gauge_board)
|
||
|
||
# Rampage 暴气红球(17帧循环)
|
||
_rampage_gauge = TextureRect.new()
|
||
_rampage_gauge.name = "RampageGauge"
|
||
_rampage_gauge.position = Vector2(8, 4)
|
||
_rampage_gauge.size = Vector2(40, 40)
|
||
_rampage_gauge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
gauge_board.add_child(_rampage_gauge)
|
||
_rampage_frames.clear()
|
||
for i in range(17):
|
||
var frame_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/rampage_01/%02d.sub" % i)
|
||
if frame_tex:
|
||
_rampage_frames.append(frame_tex)
|
||
if not _rampage_frames.is_empty():
|
||
_rampage_gauge.texture = _rampage_frames[0]
|
||
|
||
# HPGauge_Board: (59, 14, 95, 11)
|
||
_hp_board = Control.new()
|
||
_hp_board.name = "HPGauge_Board"
|
||
_hp_board.position = Vector2(59, 14)
|
||
_hp_board.size = Vector2(95, 11)
|
||
_hp_board.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_hp_board.tooltip_text = "HP : 0 / 0"
|
||
gauge_board.add_child(_hp_board)
|
||
|
||
_hp_recovery_bar = ColorRect.new()
|
||
_hp_recovery_bar.name = "HPRecoveryGaugeBar"
|
||
_hp_recovery_bar.color = Color(1.0, 0.0, 0.0, 0.33)
|
||
_hp_recovery_bar.position = Vector2(0, 0)
|
||
_hp_recovery_bar.size = Vector2(0, 11)
|
||
_hp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_hp_recovery_bar.visible = false
|
||
_hp_board.add_child(_hp_recovery_bar)
|
||
|
||
_hp_fill = Control.new()
|
||
_hp_fill.name = "HPFill"
|
||
_hp_fill.clip_contents = true
|
||
_hp_fill.size = Vector2(95, 11)
|
||
_hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_hp_board.add_child(_hp_fill)
|
||
|
||
_hp_gauge_img = TextureRect.new()
|
||
_hp_gauge_img.name = "HPGauge"
|
||
_hp_gauge_img.size = Vector2(95, 11)
|
||
_hp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_hp_fill.add_child(_hp_gauge_img)
|
||
|
||
_hp_frames.clear()
|
||
for i in range(1, 8):
|
||
var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/hpgauge/%02d.tga" % i)
|
||
if tex:
|
||
_hp_frames.append(tex)
|
||
if not _hp_frames.is_empty():
|
||
_hp_gauge_img.texture = _hp_frames[0]
|
||
|
||
# SPGauge_Board: (59, 24, 95, 11)
|
||
_sp_board = Control.new()
|
||
_sp_board.name = "SPGauge_Board"
|
||
_sp_board.position = Vector2(59, 24)
|
||
_sp_board.size = Vector2(95, 11)
|
||
_sp_board.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_sp_board.tooltip_text = "SP : 0 / 0"
|
||
gauge_board.add_child(_sp_board)
|
||
|
||
_sp_recovery_bar = ColorRect.new()
|
||
_sp_recovery_bar.name = "SPRecoveryGaugeBar"
|
||
_sp_recovery_bar.color = Color(0.0, 0.0, 1.0, 0.33)
|
||
_sp_recovery_bar.position = Vector2(0, 0)
|
||
_sp_recovery_bar.size = Vector2(0, 11)
|
||
_sp_recovery_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_sp_recovery_bar.visible = false
|
||
_sp_board.add_child(_sp_recovery_bar)
|
||
|
||
_sp_fill = Control.new()
|
||
_sp_fill.name = "SPFill"
|
||
_sp_fill.clip_contents = true
|
||
_sp_fill.size = Vector2(95, 11)
|
||
_sp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_sp_board.add_child(_sp_fill)
|
||
|
||
_sp_gauge_img = TextureRect.new()
|
||
_sp_gauge_img.name = "SPGauge"
|
||
_sp_gauge_img.size = Vector2(95, 11)
|
||
_sp_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_sp_fill.add_child(_sp_gauge_img)
|
||
|
||
_sp_frames.clear()
|
||
for i in range(1, 8):
|
||
var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/spgauge/%02d.tga" % i)
|
||
if tex:
|
||
_sp_frames.append(tex)
|
||
if not _sp_frames.is_empty():
|
||
_sp_gauge_img.texture = _sp_frames[0]
|
||
|
||
# STGauge_Board: (59, 38, 95, 6)
|
||
_st_board = Control.new()
|
||
_st_board.name = "STGauge_Board"
|
||
_st_board.position = Vector2(59, 38)
|
||
_st_board.size = Vector2(95, 6)
|
||
_st_board.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_st_board.tooltip_text = "ST : 0 / 0"
|
||
gauge_board.add_child(_st_board)
|
||
|
||
_st_fill = Control.new()
|
||
_st_fill.name = "STFill"
|
||
_st_fill.clip_contents = true
|
||
_st_fill.size = Vector2(95, 6)
|
||
_st_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_st_board.add_child(_st_fill)
|
||
|
||
_st_gauge_img = TextureRect.new()
|
||
_st_gauge_img.name = "STGauge"
|
||
_st_gauge_img.size = Vector2(95, 6)
|
||
_st_gauge_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_st_fill.add_child(_st_gauge_img)
|
||
|
||
_st_frames.clear()
|
||
for i in range(1, 8):
|
||
var tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/pattern/stgauge/%02d.tga" % i)
|
||
if tex:
|
||
_st_frames.append(tex)
|
||
if not _st_frames.is_empty():
|
||
_st_gauge_img.texture = _st_frames[0]
|
||
|
||
# 3. EXP_Gauge_Board: (158, 0, 105, 37)
|
||
_exp_board = TextureRect.new()
|
||
_exp_board.name = "EXP_Gauge_Board"
|
||
_exp_board.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge.sub")
|
||
_exp_board.position = Vector2(158, 0)
|
||
_exp_board.size = Vector2(105, 37)
|
||
_exp_board.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_exp_board.tooltip_text = "EXP : 0.00%"
|
||
_root.add_child(_exp_board)
|
||
|
||
_exp_points.clear()
|
||
_exp_clips.clear()
|
||
for i in 4:
|
||
var clip_box := Control.new()
|
||
clip_box.name = "EXPGauge_Clip_%02d" % (i + 1)
|
||
clip_box.position = Vector2(5 + i * 25, 9)
|
||
clip_box.size = Vector2(19, 19)
|
||
clip_box.clip_contents = true
|
||
clip_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
clip_box.visible = false
|
||
_exp_board.add_child(clip_box)
|
||
_exp_clips.append(clip_box)
|
||
|
||
var pt := TextureRect.new()
|
||
pt.name = "EXPGauge_%02d" % (i + 1)
|
||
pt.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/exp_gauge_point.sub")
|
||
pt.position = Vector2(0, 0)
|
||
pt.size = Vector2(19, 19)
|
||
pt.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
clip_box.add_child(pt)
|
||
_exp_points.append(pt)
|
||
|
||
# 4. 中央控制栏 (CenterBar): 水平居中 (SCREEN_WIDTH/2 - 128 到 SCREEN_WIDTH/2 + 237)
|
||
var center_bar := Control.new()
|
||
center_bar.name = "CenterBar"
|
||
center_bar.anchor_left = 0.5
|
||
center_bar.anchor_right = 0.5
|
||
center_bar.anchor_top = 0.0
|
||
center_bar.anchor_bottom = 0.0
|
||
center_bar.offset_left = -128
|
||
center_bar.offset_right = 237
|
||
center_bar.offset_top = 0
|
||
center_bar.offset_bottom = 37
|
||
center_bar.size = Vector2(365, 37)
|
||
center_bar.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
_root.add_child(center_bar)
|
||
|
||
# LeftMouseButton: (0, 3) -> SCREEN_WIDTH/2 - 128
|
||
var mouse_left := _create_taskbar_btn("LeftMouseButton",
|
||
"ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub",
|
||
"ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub",
|
||
"ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub",
|
||
"攻击 / 移动 (鼠标左键)", func(): set_left_mouse_mode(_left_mouse_mode + 1))
|
||
mouse_left.position = Vector2(0, 3)
|
||
center_bar.add_child(mouse_left)
|
||
_buttons["mouse_left"] = mouse_left
|
||
|
||
# quickslot_board: (42, 0, 283, 37) -> SCREEN_WIDTH/2 - 86
|
||
var qb_board := Control.new()
|
||
qb_board.name = "quickslot_board"
|
||
qb_board.position = Vector2(42, 0)
|
||
qb_board.size = Vector2(283, 37)
|
||
qb_board.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
center_bar.add_child(qb_board)
|
||
|
||
if item_mouse and item_mouse.has_method("register_target"):
|
||
item_mouse.register_target(qb_board, func(payload: Dictionary, pos: Vector2 = Vector2.ZERO):
|
||
return _drop_on_quickslot_board(payload, pos), self)
|
||
|
||
qb_board.set_drag_forwarding(
|
||
Callable(),
|
||
func(_at_pos: Vector2, data: Variant) -> bool:
|
||
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
|
||
func(at_pos: Vector2, data: Variant) -> void:
|
||
if data is Dictionary:
|
||
var slot_idx := -1
|
||
if at_pos.x < 135.0:
|
||
slot_idx = clampi(int(floor(at_pos.x / 32.0)), 0, 3)
|
||
elif at_pos.x >= 135.0:
|
||
slot_idx = clampi(int(floor((at_pos.x - 142.0) / 32.0)), 0, 3) + 4
|
||
if slot_idx >= 0 and slot_idx < 8:
|
||
_apply_drag_data_to_slot(data, slot_idx)
|
||
)
|
||
|
||
_slots.clear()
|
||
var slot_base_tex := UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/public/slot_base.sub")
|
||
|
||
# quick_slot_1: 4 格 (0..3)
|
||
var qs1 := Control.new()
|
||
qs1.name = "quick_slot_1"
|
||
qs1.position = Vector2(0, 3)
|
||
qs1.size = Vector2(128, 32)
|
||
qb_board.add_child(qs1)
|
||
|
||
qs1.set_drag_forwarding(
|
||
Callable(),
|
||
func(_at_pos: Vector2, data: Variant) -> bool:
|
||
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
|
||
func(at_pos: Vector2, data: Variant) -> void:
|
||
if data is Dictionary:
|
||
var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3)
|
||
_apply_drag_data_to_slot(data, slot_idx)
|
||
)
|
||
|
||
for i in 4:
|
||
var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/%d.sub" % (i + 1))
|
||
slot_ctrl.position = Vector2(i * 32, 0)
|
||
qs1.add_child(slot_ctrl)
|
||
|
||
# ChatButton / ExpandButton: (128, 1)
|
||
var chat_btn := _create_chat_btn()
|
||
chat_btn.position = Vector2(128, 1)
|
||
qb_board.add_child(chat_btn)
|
||
_buttons["chat"] = chat_btn
|
||
|
||
# quick_slot_2: 4 格 (4..7)
|
||
var qs2 := Control.new()
|
||
qs2.name = "quick_slot_2"
|
||
qs2.position = Vector2(142, 3)
|
||
qs2.size = Vector2(128, 32)
|
||
qb_board.add_child(qs2)
|
||
|
||
qs2.set_drag_forwarding(
|
||
Callable(),
|
||
func(_at_pos: Vector2, data: Variant) -> bool:
|
||
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
|
||
func(at_pos: Vector2, data: Variant) -> void:
|
||
if data is Dictionary:
|
||
var slot_idx := clampi(int(floor(at_pos.x / 32.0)), 0, 3) + 4
|
||
_apply_drag_data_to_slot(data, slot_idx)
|
||
)
|
||
|
||
for i in range(4, 8):
|
||
var slot_ctrl := _create_quickslot_widget(i, slot_base_tex, "ETC/ymir work/ui/game/taskbar/f%d.sub" % (i - 3))
|
||
slot_ctrl.position = Vector2((i - 4) * 32, 0)
|
||
qs2.add_child(slot_ctrl)
|
||
|
||
# QuickSlotBoard (翻页板): (272, 0, 11, 37)
|
||
var qsb := Control.new()
|
||
qsb.name = "QuickSlotBoard"
|
||
qsb.position = Vector2(272, 0)
|
||
qsb.size = Vector2(11, 37)
|
||
qb_board.add_child(qsb)
|
||
|
||
var num_box := TextureRect.new()
|
||
num_box.name = "QuickSlotNumberBox"
|
||
num_box.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_button_board.sub")
|
||
num_box.position = Vector2(1, 15)
|
||
num_box.size = Vector2(11, 11)
|
||
num_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
qsb.add_child(num_box)
|
||
|
||
var page_up := TextureButton.new()
|
||
page_up.name = "QuickPageUpButton"
|
||
page_up.position = Vector2(1, 9)
|
||
page_up.size = Vector2(11, 6)
|
||
page_up.tooltip_text = "上一页 (Shift+1)"
|
||
page_up.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_01.sub")
|
||
page_up.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_02.sub")
|
||
page_up.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_upbutton_03.sub")
|
||
page_up.pressed.connect(func(): set_page(posmod(_page - 1, PAGE_COUNT)))
|
||
qsb.add_child(page_up)
|
||
|
||
_quick_page_num_img = TextureRect.new()
|
||
_quick_page_num_img.name = "QuickPageNumber"
|
||
_quick_page_num_img.texture = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/1.sub")
|
||
_quick_page_num_img.position = Vector2(3, 15)
|
||
_quick_page_num_img.size = Vector2(7, 9)
|
||
_quick_page_num_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
qsb.add_child(_quick_page_num_img)
|
||
|
||
var page_down := TextureButton.new()
|
||
page_down.name = "QuickPageDownButton"
|
||
page_down.position = Vector2(1, 24)
|
||
page_down.size = Vector2(11, 6)
|
||
page_down.tooltip_text = "下一页 (Shift+2)"
|
||
page_down.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_01.sub")
|
||
page_down.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_02.sub")
|
||
page_down.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/quickslot_downbutton_03.sub")
|
||
page_down.pressed.connect(func(): set_page(posmod(_page + 1, PAGE_COUNT)))
|
||
qsb.add_child(page_down)
|
||
|
||
# RightMouseButton: (333, 3) -> SCREEN_WIDTH/2 + 205
|
||
var mouse_right := _create_taskbar_btn("RightMouseButton",
|
||
"ETC/ymir work/ui/game/taskbar/mouse_button_move_01.sub",
|
||
"ETC/ymir work/ui/game/taskbar/mouse_button_move_02.sub",
|
||
"ETC/ymir work/ui/game/taskbar/mouse_button_move_03.sub",
|
||
"技能快捷施放 (鼠标右键)", func(): set_right_mouse_mode(_right_mouse_mode + 1))
|
||
mouse_right.position = Vector2(333, 3)
|
||
center_bar.add_child(mouse_right)
|
||
_buttons["mouse_right"] = mouse_right
|
||
_update_mouse_mode_buttons()
|
||
|
||
_refresh_page()
|
||
_build_dock(_root)
|
||
|
||
func _create_quickslot_widget(i: int, slot_base_tex: Texture2D, corner_sub: String) -> Control:
|
||
var slot := Panel.new()
|
||
slot.name = "slot_%d" % i
|
||
slot.custom_minimum_size = Vector2(32, 32)
|
||
slot.size = Vector2(32, 32)
|
||
if slot_base_tex:
|
||
var sbt := StyleBoxTexture.new()
|
||
sbt.texture = slot_base_tex
|
||
slot.add_theme_stylebox_override("panel", sbt)
|
||
else:
|
||
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 icon := TextureRect.new()
|
||
icon.name = "icon"
|
||
icon.position = Vector2(0, 0)
|
||
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)
|
||
|
||
# 40250 原版角标 (1..4 / F1..F4) 在左上角 (3, 3) 处显示
|
||
if corner_sub != "":
|
||
var corner_tex := UiAssets.load_tex(_assets_root, corner_sub)
|
||
if corner_tex:
|
||
var corner := TextureRect.new()
|
||
corner.name = "Corner"
|
||
corner.texture = corner_tex
|
||
corner.position = Vector2(3, 3)
|
||
corner.size = corner_tex.get_size()
|
||
corner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
corner.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
slot.add_child(corner)
|
||
|
||
var lbl := Label.new()
|
||
lbl.name = "lbl"
|
||
lbl.position = Vector2(2, 16)
|
||
lbl.size = Vector2(28, 14)
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
lbl.add_theme_font_size_override("font_size", 9)
|
||
lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
|
||
lbl.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.95))
|
||
lbl.add_theme_constant_override("shadow_offset_x", 1)
|
||
lbl.add_theme_constant_override("shadow_offset_y", 1)
|
||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
slot.add_child(lbl)
|
||
|
||
var grade := Label.new()
|
||
grade.name = "grade"
|
||
grade.position = Vector2(16, 16)
|
||
grade.size = Vector2(14, 14)
|
||
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_RIGHT:
|
||
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||
if item_mouse.has_method("cancel"):
|
||
item_mouse.cancel()
|
||
_move_from = -1
|
||
elif e.shift_pressed:
|
||
_clear(idx)
|
||
else:
|
||
activate(idx)
|
||
elif e.button_index == MOUSE_BUTTON_LEFT:
|
||
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||
_try_drop(idx)
|
||
else:
|
||
var sk_ui := _find_skill_ui()
|
||
if sk_ui and sk_ui.drag_skill_id != 0:
|
||
_try_drop(idx)
|
||
elif _move_from >= 0:
|
||
_try_drop(idx)
|
||
else:
|
||
activate(idx))
|
||
_slots.append({"btn": slot, "cd": cd, "lbl": lbl, "icon": icon, "grade": grade})
|
||
slot.set_drag_forwarding(
|
||
Callable(),
|
||
func(_at_pos: Vector2, data: Variant) -> bool:
|
||
return data is Dictionary and data.get("kind", data.get("type", "")) in ["item", "skill", "emotion", "emote"],
|
||
func(_at_pos: Vector2, data: Variant) -> void:
|
||
if data is Dictionary:
|
||
_apply_drag_data_to_slot(data, idx)
|
||
)
|
||
if item_mouse and item_mouse.has_method("register_target"):
|
||
var local_idx: int = i
|
||
item_mouse.register_target(slot,
|
||
func(payload: Dictionary, _pos: Vector2 = Vector2.ZERO): return _drop_mouse_item(payload, local_idx), self)
|
||
return slot
|
||
|
||
func _build_dock(parent: Node) -> void:
|
||
if _dock and is_instance_valid(_dock):
|
||
_dock.queue_free()
|
||
_dock = Control.new()
|
||
_dock.name = "TaskBarDock"
|
||
_dock.anchor_left = 1.0
|
||
_dock.anchor_top = 0.0
|
||
_dock.anchor_right = 1.0
|
||
_dock.anchor_bottom = 0.0
|
||
_dock.offset_left = -178
|
||
_dock.offset_top = 0
|
||
_dock.offset_right = 0
|
||
_dock.offset_bottom = 37
|
||
_dock.size = Vector2(178, 37)
|
||
_dock.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
parent.add_child(_dock)
|
||
|
||
# 1. 仓库 (Safebox / Mall) -> SCREEN_WIDTH - 178
|
||
var btn_safe := _create_taskbar_btn(
|
||
"SafeboxButton",
|
||
"ETC/ymir work/ui/game/taskbar/mall_button_01.tga",
|
||
"ETC/ymir work/ui/game/taskbar/mall_button_02.tga",
|
||
"ETC/ymir work/ui/game/taskbar/mall_button_03.tga",
|
||
"仓库",
|
||
func(): safebox_requested.emit()
|
||
)
|
||
btn_safe.position = Vector2(0, 3)
|
||
_buttons["safebox"] = btn_safe
|
||
_dock.add_child(btn_safe)
|
||
|
||
# 2. 角色 (C) -> SCREEN_WIDTH - 144
|
||
var btn_char := _create_taskbar_btn(
|
||
"CharacterButton",
|
||
"ETC/ymir work/ui/game/taskbar/character_button_01.sub",
|
||
"ETC/ymir work/ui/game/taskbar/character_button_02.sub",
|
||
"ETC/ymir work/ui/game/taskbar/character_button_03.sub",
|
||
"角色 (C)",
|
||
func(): character_requested.emit()
|
||
)
|
||
btn_char.position = Vector2(34, 3)
|
||
_buttons["character"] = btn_char
|
||
_dock.add_child(btn_char)
|
||
|
||
# 3. 道具 (I) -> SCREEN_WIDTH - 110
|
||
var btn_inv := _create_taskbar_btn(
|
||
"InventoryButton",
|
||
"ETC/ymir work/ui/game/taskbar/inventory_button_01.sub",
|
||
"ETC/ymir work/ui/game/taskbar/inventory_button_02.sub",
|
||
"ETC/ymir work/ui/game/taskbar/inventory_button_03.sub",
|
||
"道具 (I)",
|
||
func(): inventory_requested.emit()
|
||
)
|
||
btn_inv.position = Vector2(68, 3)
|
||
_buttons["inventory"] = btn_inv
|
||
_dock.add_child(btn_inv)
|
||
|
||
# 4. 好友 / 社群 (Alt+M) -> SCREEN_WIDTH - 76
|
||
var btn_msg := _create_taskbar_btn(
|
||
"MessengerButton",
|
||
"ETC/ymir work/ui/game/taskbar/community_button_01.sub",
|
||
"ETC/ymir work/ui/game/taskbar/community_button_02.sub",
|
||
"ETC/ymir work/ui/game/taskbar/community_button_03.sub",
|
||
"社群 (Alt+M)",
|
||
func(): messenger_requested.emit()
|
||
)
|
||
btn_msg.position = Vector2(102, 3)
|
||
_buttons["messenger"] = btn_msg
|
||
_dock.add_child(btn_msg)
|
||
|
||
# 5. 系统设置 (ESC) -> SCREEN_WIDTH - 42
|
||
var btn_sys := _create_taskbar_btn(
|
||
"SystemButton",
|
||
"ETC/ymir work/ui/game/taskbar/system_button_01.sub",
|
||
"ETC/ymir work/ui/game/taskbar/system_button_02.sub",
|
||
"ETC/ymir work/ui/game/taskbar/system_button_03.sub",
|
||
"系统 (ESC)",
|
||
func(): system_requested.emit()
|
||
)
|
||
btn_sys.position = Vector2(136, 3)
|
||
_buttons["system"] = btn_sys
|
||
_dock.add_child(btn_sys)
|
||
|
||
func _on_points_changed(pd: Dictionary) -> void:
|
||
if pd.is_empty():
|
||
return
|
||
var pts: Array = pd.get("points", [])
|
||
var get_pt := func(i: int) -> int: return int(pts[i]) if i >= 0 and i < pts.size() else 0
|
||
var hp := int(pd.get("hp", get_pt.call(5)))
|
||
var max_hp := int(pd.get("max_hp", get_pt.call(6)))
|
||
var sp := int(pd.get("sp", get_pt.call(7)))
|
||
var max_sp := int(pd.get("max_sp", get_pt.call(8)))
|
||
var st: int = int(get_pt.call(9))
|
||
var max_st: int = int(get_pt.call(10))
|
||
if max_st <= 0:
|
||
max_st = 100
|
||
st = 100
|
||
var exp_val := int(pd.get("exp", get_pt.call(3)))
|
||
var next_exp_val := int(pd.get("next_exp", get_pt.call(4)))
|
||
set_hp(hp, max_hp)
|
||
set_sp(sp, max_sp)
|
||
set_st(st, max_st)
|
||
set_exp(exp_val, next_exp_val)
|
||
|
||
func set_hp(cur: int, max_val: int) -> void:
|
||
_cur_hp = cur
|
||
_max_hp = max_val
|
||
if _hp_fill:
|
||
var pct := clampf(float(_cur_hp) / maxf(1.0, float(_max_hp)), 0.0, 1.0)
|
||
_hp_fill.size = Vector2(int(roundf(95.0 * pct)), 11)
|
||
if _hp_board:
|
||
_hp_board.tooltip_text = "HP : %d / %d" % [_cur_hp, _max_hp]
|
||
|
||
func set_sp(cur: int, max_val: int) -> void:
|
||
_cur_sp = cur
|
||
_max_sp = max_val
|
||
if _sp_fill:
|
||
var pct := clampf(float(_cur_sp) / maxf(1.0, float(_max_sp)), 0.0, 1.0)
|
||
_sp_fill.size = Vector2(int(roundf(95.0 * pct)), 11)
|
||
if _sp_board:
|
||
_sp_board.tooltip_text = "SP : %d / %d" % [_cur_sp, _max_sp]
|
||
|
||
func set_st(cur: int, max_val: int) -> void:
|
||
_cur_st = cur
|
||
_max_st = max_val
|
||
if _st_fill:
|
||
var pct := clampf(float(_cur_st) / maxf(1.0, float(_max_st)), 0.0, 1.0)
|
||
_st_fill.size = Vector2(int(roundf(95.0 * pct)), 6)
|
||
if _st_board:
|
||
_st_board.tooltip_text = "ST : %d / %d" % [_cur_st, _max_st]
|
||
|
||
func set_exp(cur: int, next_val: int) -> void:
|
||
_cur_exp = cur
|
||
_next_exp = next_val
|
||
var quarter_pt := _next_exp / 4
|
||
var full_count := 0
|
||
if quarter_pt > 0:
|
||
full_count = mini(4, _cur_exp / quarter_pt)
|
||
|
||
for i in 4:
|
||
if i < _exp_clips.size() and _exp_clips[i]:
|
||
var clip: Control = _exp_clips[i]
|
||
var pt: TextureRect = _exp_points[i] if i < _exp_points.size() else null
|
||
var base_x := 5 + i * 25
|
||
var base_y := 9
|
||
if _next_exp <= 0 or _cur_exp <= 0:
|
||
clip.visible = false
|
||
elif i < full_count:
|
||
clip.position = Vector2(base_x, base_y)
|
||
clip.size = Vector2(19, 19)
|
||
if pt:
|
||
pt.position = Vector2(0, 0)
|
||
clip.visible = true
|
||
elif i == full_count and full_count < 4 and quarter_pt > 0:
|
||
var frac := float(_cur_exp % quarter_pt) / float(quarter_pt)
|
||
var fill_h := int(roundf(19.0 * frac))
|
||
if fill_h > 0:
|
||
clip.position = Vector2(base_x, base_y + 19 - fill_h)
|
||
clip.size = Vector2(19, fill_h)
|
||
if pt:
|
||
pt.position = Vector2(0, fill_h - 19)
|
||
clip.visible = true
|
||
else:
|
||
clip.visible = false
|
||
else:
|
||
clip.visible = false
|
||
|
||
if _exp_board:
|
||
var pct := float(_cur_exp) / maxf(1.0, float(_next_exp)) * 100.0
|
||
_exp_board.tooltip_text = "EXP : %.2f%%" % pct
|
||
|
||
func _create_taskbar_btn(btn_name: String, rel_nrm: String, rel_ovr: String, rel_dwn: String, tooltip: String, on_press: Callable) -> Control:
|
||
var btn := TextureButton.new()
|
||
btn.name = btn_name
|
||
btn.custom_minimum_size = Vector2(32, 32)
|
||
btn.tooltip_text = tooltip
|
||
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
if _assets_root != "":
|
||
btn.texture_normal = UiAssets.load_tex(_assets_root, rel_nrm)
|
||
btn.texture_hover = UiAssets.load_tex(_assets_root, rel_ovr)
|
||
btn.texture_pressed = UiAssets.load_tex(_assets_root, rel_dwn)
|
||
if btn.texture_normal == null:
|
||
var fallback := Button.new()
|
||
fallback.name = btn_name
|
||
fallback.text = tooltip.substr(0, 2)
|
||
fallback.custom_minimum_size = Vector2(32, 32)
|
||
fallback.tooltip_text = tooltip
|
||
fallback.pressed.connect(on_press)
|
||
return fallback
|
||
btn.pressed.connect(on_press)
|
||
return btn
|
||
|
||
func _create_chat_btn() -> Control:
|
||
var btn := TextureButton.new()
|
||
btn.name = "ChatButton"
|
||
btn.custom_minimum_size = Vector2(14, 35)
|
||
btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
btn.tooltip_text = "聊天 (L)"
|
||
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
if _assets_root != "":
|
||
btn.texture_normal = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_01.sub")
|
||
btn.texture_hover = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_02.sub")
|
||
btn.texture_pressed = UiAssets.load_tex(_assets_root, "ETC/ymir work/ui/game/taskbar/chat_button_03.sub")
|
||
if btn.texture_normal == null:
|
||
var fallback := Button.new()
|
||
fallback.name = "ChatButton"
|
||
fallback.text = "C"
|
||
fallback.custom_minimum_size = Vector2(14, 35)
|
||
fallback.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
fallback.tooltip_text = "聊天 (L)"
|
||
fallback.pressed.connect(func(): chat_requested.emit())
|
||
return fallback
|
||
btn.pressed.connect(func(): chat_requested.emit())
|
||
return btn
|
||
|
||
func _drop_mouse_payload(payload: Dictionary, slot: int) -> bool:
|
||
if payload.is_empty():
|
||
return false
|
||
var src := String(payload.get("source", ""))
|
||
if src == "inventory":
|
||
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)))
|
||
elif src == "skill":
|
||
var sid := int(payload.get("skill_id", 0))
|
||
if sid > 0:
|
||
return assign(slot, "skill", sid)
|
||
elif src == "emotion":
|
||
var eid := int(payload.get("emote_id", 0))
|
||
if eid > 0:
|
||
return assign(slot, "emote", eid)
|
||
return false
|
||
|
||
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
|
||
return _drop_mouse_payload(payload, slot)
|
||
|
||
func _apply_drag_data_to_slot(data: Dictionary, slot_idx: int) -> bool:
|
||
if data.is_empty() or slot_idx < 0 or slot_idx >= SLOTS_PER_PAGE:
|
||
return false
|
||
var k := String(data.get("kind", data.get("type", "")))
|
||
if k == "skill":
|
||
var sid := int(data.get("id", data.get("skill_id", 0)))
|
||
if sid > 0:
|
||
return assign(slot_idx, "skill", sid)
|
||
elif k in ["emotion", "emote"]:
|
||
var eid := int(data.get("id", data.get("emote_id", 0)))
|
||
if eid > 0:
|
||
return assign(slot_idx, "emote", eid)
|
||
elif k == "item":
|
||
var cell := int(data.get("cell", -1))
|
||
if cell >= 0:
|
||
return assign(slot_idx, "item", cell)
|
||
return false
|
||
|
||
func _drop_on_quickslot_board(payload: Dictionary, global_pos: Vector2) -> bool:
|
||
if _root == null or not is_instance_valid(_root):
|
||
return false
|
||
var qb_board: Control = _root.find_child("quickslot_board", true, false)
|
||
if qb_board == null:
|
||
return false
|
||
var local_pos := qb_board.get_global_transform().affine_inverse() * global_pos
|
||
var slot_idx := -1
|
||
if local_pos.x < 135.0:
|
||
slot_idx = clampi(int(floor(local_pos.x / 32.0)), 0, 3)
|
||
elif local_pos.x >= 135.0:
|
||
slot_idx = clampi(int(floor((local_pos.x - 142.0) / 32.0)), 0, 3) + 4
|
||
if slot_idx >= 0 and slot_idx < 8:
|
||
return _drop_mouse_payload(payload, slot_idx)
|
||
return false
|
||
|
||
func _try_drop(slot: int) -> void:
|
||
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
|
||
var payload: Dictionary = item_mouse.attached() if item_mouse.has_method("attached") else {}
|
||
if not payload.is_empty() and _drop_mouse_payload(payload, slot):
|
||
if item_mouse.has_method("cancel"):
|
||
item_mouse.cancel()
|
||
_move_from = -1
|
||
return
|
||
# 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 "drag_skill_id" in n and int(n.drag_skill_id) != 0:
|
||
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 := ""
|
||
var vnum := 0
|
||
var count := 0
|
||
var item_name := ""
|
||
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":
|
||
if client and client.has_method("get_item"):
|
||
var it: Dictionary = client.get_item(1, s.id)
|
||
vnum = int(it.get("vnum", 0))
|
||
count = int(it.get("count", 1))
|
||
if vnum > 0:
|
||
tex = _item_icon(vnum)
|
||
txt = str(count) if count > 1 else ""
|
||
if proto and proto.has_method("item"):
|
||
var idata: Dictionary = proto.item(vnum)
|
||
item_name = String(idata.get("locale_name", idata.get("name", "")))
|
||
if item_name == "":
|
||
item_name = "item %d" % vnum
|
||
else:
|
||
txt = "#%d" % s.id
|
||
elif s.kind == "emote":
|
||
txt = "E%d" % s.id
|
||
|
||
# 引用失效(物品已装备/销毁/掉落,服务器 SyncQuickslot 尚未推或已删)→ 置灰。
|
||
var dim := false
|
||
if s.kind == "item":
|
||
if vnum == 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)
|
||
|
||
var lbl: Label = _slots[slot].lbl
|
||
if tex != null and s.kind == "item":
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
lbl.position = Vector2(2, 16)
|
||
lbl.size = Vector2(28, 14)
|
||
lbl.text = txt
|
||
elif tex == null:
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
lbl.position = Vector2(2, 12)
|
||
lbl.size = Vector2(28, 14)
|
||
lbl.text = txt
|
||
else:
|
||
lbl.text = ""
|
||
|
||
_slots[slot].grade.text = grade_text
|
||
_slots[slot].grade.visible = grade_text != "" and tex != null
|
||
|
||
# Tooltip
|
||
if s.kind == "skill" and table:
|
||
_slots[slot].btn.tooltip_text = table.name_of(s.id)
|
||
elif s.kind == "item" and vnum > 0:
|
||
_slots[slot].btn.tooltip_text = "%s\n#%d%s" % [item_name, vnum, (" x%d" % count) if count > 1 else ""]
|
||
else:
|
||
_slots[slot].btn.tooltip_text = ""
|
||
|
||
func _item_icon(vnum: int) -> Texture2D:
|
||
if _assets_root == "" or vnum <= 0:
|
||
return null
|
||
var rel := "icon/item/%05d.tga" % vnum
|
||
var tex: Texture2D = UiAssets.load_tex(_assets_root, rel)
|
||
if tex == null:
|
||
rel = "icon/item/%05d.tga" % ((vnum / 10) * 10)
|
||
tex = UiAssets.load_tex(_assets_root, rel)
|
||
return tex
|
||
|
||
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 _quick_page_num_img:
|
||
var page_sub := "ETC/ymir work/ui/game/taskbar/%d.sub" % (_page + 1)
|
||
_quick_page_num_img.texture = UiAssets.load_tex(_assets_root, page_sub)
|
||
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
|
||
|
||
# 暴气红球旋转动画 (delay 6 帧即约 0.08s 步进)
|
||
_rampage_timer += dt
|
||
if _rampage_timer >= 0.08:
|
||
_rampage_timer = 0.0
|
||
if not _rampage_frames.is_empty() and _rampage_gauge:
|
||
_rampage_idx = (_rampage_idx + 1) % _rampage_frames.size()
|
||
_rampage_gauge.texture = _rampage_frames[_rampage_idx]
|
||
|
||
# HP/SP/ST 水流动波纹动画 (40250 delay 6 帧 = 0.08s 步进,循环 7 帧)
|
||
_gauge_timer += dt
|
||
if _gauge_timer >= 0.08:
|
||
_gauge_timer = 0.0
|
||
_gauge_idx = (_gauge_idx + 1) % 7
|
||
if not _hp_frames.is_empty() and _hp_gauge_img:
|
||
_hp_gauge_img.texture = _hp_frames[_gauge_idx % _hp_frames.size()]
|
||
if not _sp_frames.is_empty() and _sp_gauge_img:
|
||
_sp_gauge_img.texture = _sp_frames[_gauge_idx % _sp_frames.size()]
|
||
if not _st_frames.is_empty() and _st_gauge_img:
|
||
_st_gauge_img.texture = _st_frames[_gauge_idx % _st_frames.size()]
|
||
|
||
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
|