feat: complete mobile UI implementation

This commit is contained in:
shenlei
2026-09-04 19:54:33 +09:00
parent 9a4a044da5
commit b19d5b5dd3
132 changed files with 6643 additions and 180 deletions
+57 -1
View File
@@ -8,7 +8,9 @@
# # 输入:数字 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)。
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)默认朝向 = 玩家 yaw)。
# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的
# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
@@ -35,6 +37,7 @@ var _slots := [] # 当前页 UI[{btn, cd, lbl}]
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:
client = m2client
@@ -84,7 +87,38 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool:
_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)
@@ -101,6 +135,8 @@ func activate(slot: int) -> void:
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))
@@ -159,6 +195,18 @@ func activate(slot: int) -> void:
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)
# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏
# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。
func activate_reserved(global_slot: int) -> bool:
@@ -193,6 +241,14 @@ 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