feat: complete mobile UI implementation
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
# MobileHud —— landscape in-game HUD.
|
||||
#
|
||||
# This is deliberately a presentation-only node. NetPlay updates it through
|
||||
# the same setter contract as hud.gd, while MobileUiRoot wires its buttons to
|
||||
# the existing feature controllers.
|
||||
extends Control
|
||||
|
||||
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
|
||||
|
||||
signal avatar_pressed
|
||||
signal inventory_pressed
|
||||
signal minimap_pressed
|
||||
signal menu_pressed
|
||||
signal quest_pressed
|
||||
signal party_member_pressed(vid: int)
|
||||
signal context_action_pressed
|
||||
|
||||
const AVATAR_SIZE := 78.0
|
||||
const SAFE_MARGIN := 16.0
|
||||
|
||||
var client: Node
|
||||
var _player_name: Label
|
||||
var _avatar: TouchButton
|
||||
var _hp_bar: ProgressBar
|
||||
var _mp_bar: ProgressBar
|
||||
var _hp_text: Label
|
||||
var _mp_text: Label
|
||||
var _level: Label
|
||||
var _party_host: VBoxContainer
|
||||
var _target_panel: Panel
|
||||
var _target_name: Label
|
||||
var _target_bar: ProgressBar
|
||||
var _quest: TouchButton
|
||||
var _context: TouchButton
|
||||
var _mini_host: Control
|
||||
var _mini_placeholder: Label
|
||||
var _channel: Label
|
||||
var _affects: HBoxContainer
|
||||
var _client_bound := false
|
||||
|
||||
func setup() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_player_card()
|
||||
_build_top_right()
|
||||
_build_target()
|
||||
_build_quest()
|
||||
_build_context_action()
|
||||
|
||||
func bind_client(m2client: Node) -> void:
|
||||
if _client_bound and client == m2client:
|
||||
_refresh_player()
|
||||
_refresh_party()
|
||||
return
|
||||
client = m2client
|
||||
_client_bound = client != null
|
||||
if client and client.has_signal("party_changed"):
|
||||
client.party_changed.connect(_refresh_party)
|
||||
if client and client.has_signal("vitals_changed"):
|
||||
client.vitals_changed.connect(func(_vid): _refresh_party())
|
||||
_refresh_player()
|
||||
_refresh_party()
|
||||
|
||||
func set_player_name(value: String) -> void:
|
||||
if _player_name:
|
||||
_player_name.text = value if value != "" else "冒险者"
|
||||
|
||||
func get_minimap_host() -> Control:
|
||||
return _mini_host
|
||||
|
||||
func get_quest_button() -> Control:
|
||||
return _quest
|
||||
|
||||
func get_context_button() -> Control:
|
||||
return _context
|
||||
|
||||
func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
|
||||
if _hp_bar:
|
||||
_hp_bar.max_value = maxi(1, max_hp)
|
||||
_hp_bar.value = clampi(hp, 0, maxi(1, max_hp))
|
||||
if _mp_bar:
|
||||
_mp_bar.max_value = maxi(1, max_sp)
|
||||
_mp_bar.value = clampi(sp, 0, maxi(1, max_sp))
|
||||
if _hp_text:
|
||||
_hp_text.text = "%d/%d" % [hp, max_hp]
|
||||
if _mp_text:
|
||||
_mp_text.text = "%d/%d" % [sp, max_sp]
|
||||
|
||||
func set_exp(xp: int, next_xp: int) -> void:
|
||||
# The mobile card keeps the main HUD quiet; experience is shown in the
|
||||
# level caption so it remains available without adding a bottom bar.
|
||||
if _level and next_xp > 0:
|
||||
_level.tooltip_text = "经验 %d / %d" % [xp, next_xp]
|
||||
|
||||
func set_level(value: int) -> void:
|
||||
if _level:
|
||||
_level.text = "Lv %d" % value
|
||||
|
||||
func set_energy(value: int, max_value: int = 100) -> void:
|
||||
if _avatar:
|
||||
_avatar.tooltip_text = "职业资源 %d/%d" % [value, max_value]
|
||||
|
||||
func set_stamina(value: int, max_value: int) -> void:
|
||||
if _avatar:
|
||||
_avatar.tooltip_text = "体力 %d/%d" % [value, max_value]
|
||||
|
||||
func set_affects(values: Array) -> void:
|
||||
if _affects == null:
|
||||
return
|
||||
for child in _affects.get_children():
|
||||
child.queue_free()
|
||||
for value in values:
|
||||
var chip := Label.new()
|
||||
chip.text = "◆"
|
||||
chip.add_theme_font_size_override("font_size", 11)
|
||||
chip.add_theme_color_override("font_color", Color.from_hsv(
|
||||
fmod(float(int(value.get("type", 0))) * 0.13, 1.0), 0.55, 0.95))
|
||||
chip.tooltip_text = "状态 #%d" % int(value.get("type", 0))
|
||||
_affects.add_child(chip)
|
||||
|
||||
func set_target(name: String, hp_pct: int) -> void:
|
||||
if _target_panel == null:
|
||||
_build_target()
|
||||
_target_panel.visible = true
|
||||
_target_name.text = name if name != "" else "目标"
|
||||
_target_bar.value = clampi(hp_pct, 0, 100)
|
||||
|
||||
func clear_target() -> void:
|
||||
if _target_panel:
|
||||
_target_panel.visible = false
|
||||
|
||||
func set_channel(value: int) -> void:
|
||||
if _channel:
|
||||
_channel.text = "CH %d" % value
|
||||
|
||||
func set_dungeon_destination(active: bool, _world_pos: Vector3) -> void:
|
||||
if _quest and active:
|
||||
_quest.set_caption("副本目标")
|
||||
|
||||
func set_context_action(text: String, visible: bool) -> void:
|
||||
if _context == null:
|
||||
return
|
||||
_context.set_caption(text)
|
||||
_context.visible = visible
|
||||
|
||||
func _build_player_card() -> void:
|
||||
var card := Control.new()
|
||||
card.position = Vector2(SAFE_MARGIN, SAFE_MARGIN)
|
||||
card.size = Vector2(110, 122)
|
||||
add_child(card)
|
||||
_player_name = Label.new()
|
||||
_player_name.text = "冒险者"
|
||||
_player_name.position = Vector2(0, 0)
|
||||
_player_name.size = Vector2(AVATAR_SIZE + 10, 20)
|
||||
_player_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_player_name.add_theme_font_size_override("font_size", 13)
|
||||
_player_name.add_theme_color_override("font_color", Color(0.98, 0.88, 0.61))
|
||||
_player_name.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.04, 0.95))
|
||||
_player_name.add_theme_constant_override("outline_size", 4)
|
||||
card.add_child(_player_name)
|
||||
|
||||
_avatar = TouchButton.new()
|
||||
_avatar.setup("角\n色", Color(0.92, 0.72, 0.32, 0.95))
|
||||
_avatar.position = Vector2(5, 20)
|
||||
_avatar.size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||||
_avatar.pressed.connect(func(): avatar_pressed.emit())
|
||||
card.add_child(_avatar)
|
||||
|
||||
_hp_bar = _bar(Color(0.86, 0.24, 0.22), Vector2(AVATAR_SIZE, 9))
|
||||
_hp_bar.position = Vector2(5, 100)
|
||||
card.add_child(_hp_bar)
|
||||
_hp_text = _bar_text(_hp_bar)
|
||||
|
||||
_mp_bar = _bar(Color(0.24, 0.48, 0.93), Vector2(AVATAR_SIZE, 7))
|
||||
_mp_bar.position = Vector2(5, 111)
|
||||
card.add_child(_mp_bar)
|
||||
_mp_text = _bar_text(_mp_bar)
|
||||
|
||||
_level = Label.new()
|
||||
_level.text = "Lv 1"
|
||||
_level.position = Vector2(86, 27)
|
||||
_level.add_theme_font_size_override("font_size", 11)
|
||||
_level.add_theme_color_override("font_color", Color(0.78, 0.86, 0.98))
|
||||
card.add_child(_level)
|
||||
|
||||
_party_host = VBoxContainer.new()
|
||||
_party_host.position = Vector2(0, 126)
|
||||
_party_host.size = Vector2(196, 180)
|
||||
_party_host.add_theme_constant_override("separation", 4)
|
||||
add_child(_party_host)
|
||||
|
||||
_affects = HBoxContainer.new()
|
||||
_affects.position = Vector2(5, 121)
|
||||
_affects.add_theme_constant_override("separation", 3)
|
||||
card.add_child(_affects)
|
||||
|
||||
func _build_top_right() -> void:
|
||||
var actions := Control.new()
|
||||
actions.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
actions.position = Vector2(-250, SAFE_MARGIN)
|
||||
actions.size = Vector2(234, 98)
|
||||
add_child(actions)
|
||||
|
||||
var bag := TouchButton.new()
|
||||
bag.setup("背包", Color(0.95, 0.72, 0.34, 0.95))
|
||||
bag.position = Vector2(0, 18)
|
||||
bag.size = Vector2(56, 56)
|
||||
bag.pressed.connect(func(): inventory_pressed.emit())
|
||||
actions.add_child(bag)
|
||||
|
||||
_mini_host = Control.new()
|
||||
_mini_host.position = Vector2(62, 0)
|
||||
_mini_host.size = Vector2(96, 96)
|
||||
_mini_host.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
actions.add_child(_mini_host)
|
||||
_mini_placeholder = Label.new()
|
||||
_mini_placeholder.text = "地图"
|
||||
_mini_placeholder.position = Vector2(28, 38)
|
||||
_mini_placeholder.add_theme_font_size_override("font_size", 12)
|
||||
_mini_placeholder.add_theme_color_override("font_color", Color(0.8, 0.9, 1.0, 0.85))
|
||||
_mini_placeholder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_mini_host.add_child(_mini_placeholder)
|
||||
|
||||
var map_button := TouchButton.new()
|
||||
map_button.setup("", Color(0.45, 0.7, 0.95, 0.95))
|
||||
map_button.position = Vector2(62, 0)
|
||||
map_button.size = Vector2(96, 96)
|
||||
map_button.set_idle_modulate(Color(1, 1, 1, 0.03))
|
||||
map_button.pressed.connect(func(): minimap_pressed.emit())
|
||||
actions.add_child(map_button)
|
||||
|
||||
var menu := TouchButton.new()
|
||||
menu.setup("菜单", Color(0.58, 0.74, 0.96, 0.95))
|
||||
menu.position = Vector2(164, 18)
|
||||
menu.size = Vector2(56, 56)
|
||||
menu.pressed.connect(func(): menu_pressed.emit())
|
||||
actions.add_child(menu)
|
||||
|
||||
_channel = Label.new()
|
||||
_channel.text = "CH -"
|
||||
_channel.position = Vector2(75, 98)
|
||||
_channel.add_theme_font_size_override("font_size", 10)
|
||||
_channel.add_theme_color_override("font_color", Color(0.64, 0.75, 0.9))
|
||||
actions.add_child(_channel)
|
||||
|
||||
func _build_target() -> void:
|
||||
_target_panel = Panel.new()
|
||||
_target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
_target_panel.position = Vector2(-112, 14)
|
||||
_target_panel.size = Vector2(224, 48)
|
||||
_target_panel.visible = false
|
||||
_target_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_target_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.12, 0.05, 0.06, 0.84), Color(0.82, 0.3, 0.3)))
|
||||
add_child(_target_panel)
|
||||
_target_name = Label.new()
|
||||
_target_name.position = Vector2(10, 4)
|
||||
_target_name.size = Vector2(204, 18)
|
||||
_target_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_target_name.add_theme_font_size_override("font_size", 12)
|
||||
_target_panel.add_child(_target_name)
|
||||
_target_bar = _bar(Color(0.88, 0.24, 0.23), Vector2(204, 9))
|
||||
_target_bar.position = Vector2(10, 28)
|
||||
_target_bar.max_value = 100
|
||||
_target_bar.value = 100
|
||||
_target_panel.add_child(_target_bar)
|
||||
|
||||
func _build_quest() -> void:
|
||||
_quest = TouchButton.new()
|
||||
_quest.setup("任务 · 点击查看", Color(1.0, 0.72, 0.3, 0.9))
|
||||
_quest.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_quest.position = Vector2(-244, 126)
|
||||
_quest.size = Vector2(224, 45)
|
||||
_quest.pressed.connect(func(): quest_pressed.emit())
|
||||
add_child(_quest)
|
||||
|
||||
func _build_context_action() -> void:
|
||||
_context = TouchButton.new()
|
||||
_context.setup("交互", Color(0.92, 0.68, 0.31, 0.95))
|
||||
_context.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
_context.position = Vector2(110, -116)
|
||||
_context.size = Vector2(74, 48)
|
||||
_context.visible = false
|
||||
_context.pressed.connect(func(): context_action_pressed.emit())
|
||||
add_child(_context)
|
||||
|
||||
func _refresh_player() -> void:
|
||||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||||
return
|
||||
var entity: Dictionary = client.get_entity(client.get_main_vid())
|
||||
set_player_name(String(entity.get("name", "冒险者")))
|
||||
set_level(int(entity.get("level", 1)))
|
||||
|
||||
func _refresh_party() -> void:
|
||||
if _party_host == null:
|
||||
return
|
||||
for child in _party_host.get_children():
|
||||
child.queue_free()
|
||||
if client == null or not client.has_method("get_party"):
|
||||
return
|
||||
var members: Array = client.get_party()
|
||||
for member in members:
|
||||
var vid := int(member.get("vid", 0))
|
||||
var row := TouchButton.new()
|
||||
row.setup(" %s %d%%" % [String(member.get("name", "队员")), int(member.get("hp_pct", 0))],
|
||||
Color(0.32, 0.52, 0.76, 0.8))
|
||||
row.set_rect_style()
|
||||
row.custom_minimum_size = Vector2(184, 29)
|
||||
row.size = Vector2(184, 29)
|
||||
row.pressed.connect(func(): party_member_pressed.emit(vid))
|
||||
_party_host.add_child(row)
|
||||
|
||||
func _bar(color: Color, bar_size: Vector2) -> ProgressBar:
|
||||
var bar := ProgressBar.new()
|
||||
bar.custom_minimum_size = bar_size
|
||||
bar.size = bar_size
|
||||
bar.min_value = 0
|
||||
bar.max_value = 100
|
||||
bar.value = 100
|
||||
bar.show_percentage = false
|
||||
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
bar.add_theme_stylebox_override("background", _panel_style(Color(0.01, 0.02, 0.04, 0.78), Color(0.1, 0.14, 0.2, 0.6), 2))
|
||||
bar.add_theme_stylebox_override("fill", _panel_style(color, color, 2))
|
||||
return bar
|
||||
|
||||
func _bar_text(bar: ProgressBar) -> Label:
|
||||
var label := Label.new()
|
||||
label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", 8)
|
||||
label.add_theme_color_override("font_color", Color(0.96, 0.98, 1.0))
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
bar.add_child(label)
|
||||
return label
|
||||
|
||||
func _panel_style(bg: Color, border: Color, width := 1) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = bg
|
||||
style.border_color = border
|
||||
style.set_border_width_all(width)
|
||||
style.set_corner_radius_all(6)
|
||||
return style
|
||||
@@ -0,0 +1 @@
|
||||
uid://bh0jqy5qycvad
|
||||
@@ -0,0 +1,222 @@
|
||||
# MobileInputOverlay —— touch controls layered over the world.
|
||||
#
|
||||
# Child controls consume their own touch events, so PlayerController and
|
||||
# GameCamera continue receiving unhandled world touches for tap-to-move,
|
||||
# camera orbit and pinch zoom.
|
||||
extends Control
|
||||
|
||||
const Joystick := preload("res://ui/mobile/virtual_joystick.gd")
|
||||
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
|
||||
|
||||
var player_controller: Node
|
||||
var net_play: Node
|
||||
var quickbar: Node
|
||||
var ground_items: Node
|
||||
var _joystick: Joystick
|
||||
var _attack: TouchButton
|
||||
var _potion: TouchButton
|
||||
var _skills: Array[TouchButton] = []
|
||||
var _ui_touch_indices := {}
|
||||
var _aim_slot := -1
|
||||
var _aim_direction := Vector2.ZERO
|
||||
var _skip_skill_release := {}
|
||||
var _aim_hint: Label
|
||||
|
||||
func setup() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_joystick()
|
||||
_build_combat_pad()
|
||||
_aim_hint = Label.new()
|
||||
_aim_hint.name = "SkillAimHint"
|
||||
_aim_hint.set_anchors_preset(Control.PRESET_CENTER_TOP)
|
||||
_aim_hint.position = Vector2(-170, 70)
|
||||
_aim_hint.size = Vector2(340, 30)
|
||||
_aim_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_aim_hint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_aim_hint.add_theme_font_size_override("font_size", 12)
|
||||
_aim_hint.add_theme_color_override("font_color", Color(1.0, 0.86, 0.52))
|
||||
_aim_hint.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95))
|
||||
_aim_hint.add_theme_constant_override("outline_size", 4)
|
||||
_aim_hint.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_aim_hint.visible = false
|
||||
add_child(_aim_hint)
|
||||
set_process_input(true)
|
||||
set_process_unhandled_input(true)
|
||||
|
||||
func bind_controls(pc: Node, np: Node, qb: Node, ground: Node = null) -> void:
|
||||
player_controller = pc
|
||||
net_play = np
|
||||
quickbar = qb
|
||||
ground_items = ground
|
||||
if _joystick:
|
||||
if not _joystick.axis_changed.is_connected(_on_axis):
|
||||
_joystick.axis_changed.connect(_on_axis)
|
||||
if _attack:
|
||||
if not _attack.press_state.is_connected(_on_attack_state):
|
||||
_attack.press_state.connect(_on_attack_state)
|
||||
|
||||
func _build_joystick() -> void:
|
||||
_joystick = Joystick.new()
|
||||
_joystick.setup(172.0)
|
||||
_joystick.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
_joystick.position = Vector2(20, -192)
|
||||
_joystick.size = Vector2(172, 172)
|
||||
add_child(_joystick)
|
||||
|
||||
func _build_combat_pad() -> void:
|
||||
_attack = TouchButton.new()
|
||||
_attack.setup("普攻", Color(1.0, 0.42, 0.25, 0.98))
|
||||
_attack.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
_attack.position = Vector2(-126, -168)
|
||||
_attack.size = Vector2(112, 112)
|
||||
add_child(_attack)
|
||||
|
||||
_potion = TouchButton.new()
|
||||
_potion.setup("药", Color(0.38, 0.88, 0.58, 0.96))
|
||||
_potion.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
_potion.position = Vector2(-388, -72)
|
||||
_potion.size = Vector2(52, 52)
|
||||
_potion.pressed.connect(func():
|
||||
# The first page's slot 5 is the conventional consumable slot in the
|
||||
# existing quickbar. Quickbar still validates item availability/server state.
|
||||
if quickbar and quickbar.has_method("activate"):
|
||||
quickbar.activate(4))
|
||||
add_child(_potion)
|
||||
|
||||
for slot in 4:
|
||||
var skill := TouchButton.new()
|
||||
skill.setup("技%d" % (slot + 1), Color(0.34, 0.65, 0.98, 0.96))
|
||||
skill.set_gesture_enabled(true)
|
||||
skill.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
skill.position = Vector2(-330 + slot * 58, -72)
|
||||
skill.size = Vector2(52, 52)
|
||||
var captured := slot
|
||||
skill.long_pressed.connect(func(): _begin_skill_aim(captured, skill))
|
||||
skill.drag_changed.connect(func(_delta: Vector2): _update_skill_aim(captured, skill))
|
||||
skill.drag_released.connect(func(_delta: Vector2): _release_skill_aim(captured, skill))
|
||||
skill.released.connect(func(): _finish_short_skill_press(captured, skill))
|
||||
add_child(skill)
|
||||
_skills.append(skill)
|
||||
|
||||
func _on_axis(axis: Vector2) -> void:
|
||||
if player_controller and player_controller.has_method("set_mobile_axis"):
|
||||
player_controller.set_mobile_axis(axis)
|
||||
|
||||
func _on_attack_state(down: bool) -> void:
|
||||
if net_play and net_play.has_method("set_attack_key"):
|
||||
net_play.set_attack_key(down)
|
||||
|
||||
func _begin_skill_aim(slot: int, button: TouchButton) -> void:
|
||||
if _aim_slot >= 0 and _aim_slot != slot:
|
||||
return
|
||||
_aim_slot = slot
|
||||
_aim_direction = Vector2.ZERO
|
||||
button.set_aiming(true)
|
||||
if _aim_hint:
|
||||
_aim_hint.text = "拖动调整方向,松手释放"
|
||||
_aim_hint.visible = true
|
||||
|
||||
func _update_skill_aim(slot: int, button: TouchButton) -> void:
|
||||
if _aim_slot != slot:
|
||||
return
|
||||
var total := button.drag_total()
|
||||
if total.length() >= TouchButton.DRAG_THRESHOLD:
|
||||
# Screen Y grows downwards; keep the conventional joystick direction
|
||||
# (up = forward) for Quickbar.activate_aimed().
|
||||
_aim_direction = Vector2(total.x, total.y).normalized()
|
||||
if _aim_hint:
|
||||
_aim_hint.text = "方向 %d° · 松手释放" % int(round(rad_to_deg(atan2(-_aim_direction.x, -_aim_direction.y))))
|
||||
|
||||
func _release_skill_aim(slot: int, button: TouchButton) -> void:
|
||||
if _aim_slot != slot:
|
||||
return
|
||||
_skip_skill_release[slot] = true
|
||||
if quickbar:
|
||||
if quickbar.has_method("activate_aimed"):
|
||||
quickbar.activate_aimed(slot, _aim_direction)
|
||||
elif quickbar.has_method("activate"):
|
||||
quickbar.activate(slot)
|
||||
_end_skill_aim(button)
|
||||
|
||||
func _finish_short_skill_press(slot: int, button: TouchButton) -> void:
|
||||
if bool(_skip_skill_release.get(slot, false)):
|
||||
_skip_skill_release.erase(slot)
|
||||
return
|
||||
if _aim_slot == slot:
|
||||
_end_skill_aim(button)
|
||||
return
|
||||
if quickbar and quickbar.has_method("activate"):
|
||||
quickbar.activate(slot)
|
||||
|
||||
func _end_skill_aim(button: TouchButton = null) -> void:
|
||||
if button:
|
||||
button.set_aiming(false)
|
||||
else:
|
||||
for skill in _skills:
|
||||
if skill:
|
||||
skill.set_aiming(false)
|
||||
_aim_slot = -1
|
||||
_aim_direction = Vector2.ZERO
|
||||
if _aim_hint:
|
||||
_aim_hint.visible = false
|
||||
|
||||
func cancel_all() -> void:
|
||||
if _joystick:
|
||||
_joystick.cancel_press()
|
||||
if _attack:
|
||||
_attack.cancel_press()
|
||||
if _potion:
|
||||
_potion.cancel_press()
|
||||
for skill in _skills:
|
||||
if skill:
|
||||
skill.cancel_press()
|
||||
_end_skill_aim()
|
||||
_skip_skill_release.clear()
|
||||
for index in _ui_touch_indices.keys():
|
||||
if player_controller and player_controller.has_method("set_mobile_ui_touch"):
|
||||
player_controller.set_mobile_ui_touch(int(index), false)
|
||||
_ui_touch_indices.clear()
|
||||
if player_controller:
|
||||
if player_controller.has_method("clear_mobile_input"):
|
||||
player_controller.clear_mobile_input()
|
||||
elif player_controller.has_method("set_mobile_axis"):
|
||||
player_controller.set_mobile_axis(Vector2.ZERO)
|
||||
if net_play and net_play.has_method("set_attack_key"):
|
||||
net_play.set_attack_key(false)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_APPLICATION_FOCUS_OUT \
|
||||
or what == NOTIFICATION_APPLICATION_PAUSED \
|
||||
or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||||
cancel_all()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not player_controller or not player_controller.has_method("set_mobile_ui_touch"):
|
||||
return
|
||||
if event is InputEventScreenTouch:
|
||||
var touch := event as InputEventScreenTouch
|
||||
if touch.pressed:
|
||||
var owns := _control_at(touch.position) != null
|
||||
if owns:
|
||||
_ui_touch_indices[touch.index] = true
|
||||
player_controller.set_mobile_ui_touch(touch.index, true)
|
||||
elif _ui_touch_indices.has(touch.index):
|
||||
_ui_touch_indices.erase(touch.index)
|
||||
player_controller.set_mobile_ui_touch(touch.index, false)
|
||||
|
||||
func _control_at(screen_position: Vector2) -> Control:
|
||||
var controls: Array[Control] = []
|
||||
if _joystick:
|
||||
controls.append(_joystick)
|
||||
if _attack:
|
||||
controls.append(_attack)
|
||||
if _potion:
|
||||
controls.append(_potion)
|
||||
for skill in _skills:
|
||||
if skill:
|
||||
controls.append(skill)
|
||||
for control in controls:
|
||||
if control.visible and control.get_global_rect().has_point(screen_position):
|
||||
return control
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqyoh0sj72ro1
|
||||
@@ -0,0 +1,154 @@
|
||||
# MobileMenuDrawer —— compact two-level menu for the top-right menu button.
|
||||
#
|
||||
# The drawer only routes actions. Feature nodes remain the owners of data,
|
||||
# server state and permission checks.
|
||||
extends Control
|
||||
|
||||
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
|
||||
|
||||
signal item_selected(category: String, item: String)
|
||||
signal opened
|
||||
signal closed
|
||||
|
||||
const CATEGORIES := ["社交", "成长", "活动", "商业", "系统"]
|
||||
const ITEMS := {
|
||||
"社交": ["聊天", "好友", "公会", "情侣", "观战"],
|
||||
"成长": ["精炼", "龙魂", "Cube 制作"],
|
||||
"活动": ["副本", "钓鱼"],
|
||||
"商业": ["商店", "商城", "交易", "私人商店", "仓库", "兑换"],
|
||||
"系统": ["系统设置", "游戏设置", "帮助", "选择角色", "登出", "退出游戏"],
|
||||
}
|
||||
|
||||
var _backdrop: ColorRect
|
||||
var _panel: Panel
|
||||
var _category_row: HBoxContainer
|
||||
var _item_scroll: ScrollContainer
|
||||
var _items: VBoxContainer
|
||||
var _title: Label
|
||||
var _hint: Label
|
||||
var _active_category := "社交"
|
||||
|
||||
func setup() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build()
|
||||
visible = false
|
||||
|
||||
func is_open() -> bool:
|
||||
return visible
|
||||
|
||||
func toggle() -> void:
|
||||
if visible:
|
||||
close()
|
||||
else:
|
||||
open()
|
||||
|
||||
func open() -> void:
|
||||
if visible:
|
||||
return
|
||||
visible = true
|
||||
_refresh_items()
|
||||
opened.emit()
|
||||
|
||||
func close() -> void:
|
||||
if not visible:
|
||||
return
|
||||
visible = false
|
||||
closed.emit()
|
||||
|
||||
func _build() -> void:
|
||||
_backdrop = ColorRect.new()
|
||||
_backdrop.color = Color(0.01, 0.02, 0.035, 0.38)
|
||||
_backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_backdrop.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventScreenTouch and not event.pressed:
|
||||
close()
|
||||
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:
|
||||
close())
|
||||
add_child(_backdrop)
|
||||
|
||||
_panel = Panel.new()
|
||||
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_panel.position = Vector2(-366, 72)
|
||||
_panel.size = Vector2(350, 300)
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _panel_style())
|
||||
add_child(_panel)
|
||||
|
||||
_title = Label.new()
|
||||
_title.position = Vector2(18, 13)
|
||||
_title.add_theme_font_size_override("font_size", 18)
|
||||
_title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.52))
|
||||
_panel.add_child(_title)
|
||||
|
||||
var close_button := TouchButton.new()
|
||||
close_button.setup("×", Color(0.6, 0.75, 0.96, 0.9))
|
||||
close_button.set_rect_style()
|
||||
close_button.position = Vector2(310, 7)
|
||||
close_button.size = Vector2(32, 32)
|
||||
close_button.custom_minimum_size = Vector2(32, 32)
|
||||
close_button.pressed.connect(close)
|
||||
_panel.add_child(close_button)
|
||||
|
||||
_category_row = HBoxContainer.new()
|
||||
_category_row.position = Vector2(14, 48)
|
||||
_category_row.size = Vector2(322, 38)
|
||||
_category_row.add_theme_constant_override("separation", 4)
|
||||
_panel.add_child(_category_row)
|
||||
for category in CATEGORIES:
|
||||
var button := TouchButton.new()
|
||||
button.setup(category, Color(0.45, 0.7, 0.95, 0.9))
|
||||
button.set_rect_style()
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.custom_minimum_size = Vector2(0, 36)
|
||||
var selected: String = String(category)
|
||||
button.pressed.connect(func():
|
||||
_active_category = selected
|
||||
_refresh_items())
|
||||
_category_row.add_child(button)
|
||||
|
||||
_item_scroll = ScrollContainer.new()
|
||||
_item_scroll.position = Vector2(16, 96)
|
||||
_item_scroll.size = Vector2(318, 160)
|
||||
_item_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
_item_scroll.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_child(_item_scroll)
|
||||
_items = VBoxContainer.new()
|
||||
_items.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_items.add_theme_constant_override("separation", 5)
|
||||
_item_scroll.add_child(_items)
|
||||
|
||||
_hint = Label.new()
|
||||
_hint.position = Vector2(18, 264)
|
||||
_hint.size = Vector2(314, 26)
|
||||
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_hint.add_theme_font_size_override("font_size", 11)
|
||||
_hint.add_theme_color_override("font_color", Color(0.66, 0.72, 0.8))
|
||||
_panel.add_child(_hint)
|
||||
|
||||
func _refresh_items() -> void:
|
||||
if _panel == null:
|
||||
return
|
||||
_title.text = _active_category
|
||||
_hint.text = "功能入口仍受当前场景和服务器状态限制。"
|
||||
for child in _items.get_children():
|
||||
child.queue_free()
|
||||
for item in ITEMS.get(_active_category, []):
|
||||
var button := TouchButton.new()
|
||||
button.setup(" " + String(item), Color(0.42, 0.65, 0.9, 0.8))
|
||||
button.set_rect_style()
|
||||
button.custom_minimum_size = Vector2(0, 34)
|
||||
var selected: String = String(item)
|
||||
button.pressed.connect(func(): item_selected.emit(_active_category, selected))
|
||||
_items.add_child(button)
|
||||
|
||||
func _panel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.045, 0.065, 0.1, 0.96)
|
||||
style.border_color = Color(0.52, 0.68, 0.9, 0.82)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(12)
|
||||
style.shadow_color = Color(0, 0, 0, 0.45)
|
||||
style.shadow_size = 12
|
||||
return style
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2ut2qatd5mbu
|
||||
@@ -0,0 +1,226 @@
|
||||
# MobileTouchButton —— a mouse and screen-touch friendly action surface.
|
||||
#
|
||||
# BaseButton's mouse emulation is intentionally disabled in project.godot, so
|
||||
# the mobile layer owns the small amount of press/release handling it needs.
|
||||
# This keeps a touch index alive until release and gives gameplay a reliable
|
||||
# cancellation path when the app loses focus.
|
||||
extends Control
|
||||
|
||||
signal pressed
|
||||
signal released
|
||||
signal press_state(down: bool)
|
||||
signal long_pressed
|
||||
signal drag_started
|
||||
signal drag_changed(delta: Vector2)
|
||||
signal drag_released(delta: Vector2)
|
||||
|
||||
var label_text := ""
|
||||
var accent := Color(0.36, 0.64, 0.95, 0.95)
|
||||
var idle_modulate := Color.WHITE
|
||||
var circular := true
|
||||
var active := false
|
||||
var _touch_index := -1
|
||||
var _caption: Label
|
||||
var _press_position := Vector2.ZERO
|
||||
var _last_position := Vector2.ZERO
|
||||
var _drag_delta := Vector2.ZERO
|
||||
var _long_press_token := 0
|
||||
var _long_pressed := false
|
||||
var _dragging := false
|
||||
var _aiming := false
|
||||
const LONG_PRESS_SECONDS := 0.55
|
||||
const DRAG_THRESHOLD := 10.0
|
||||
|
||||
func setup(text: String, color: Color = Color(0.36, 0.64, 0.95, 0.95)) -> void:
|
||||
label_text = text
|
||||
accent = color
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
custom_minimum_size = Vector2(56, 56)
|
||||
_caption = Label.new()
|
||||
_caption.text = label_text
|
||||
_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_caption.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_caption.add_theme_font_size_override("font_size", 13)
|
||||
_caption.add_theme_color_override("font_color", Color(0.94, 0.97, 1.0))
|
||||
_caption.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95))
|
||||
_caption.add_theme_constant_override("outline_size", 3)
|
||||
add_child(_caption)
|
||||
queue_redraw()
|
||||
|
||||
func set_caption(text: String) -> void:
|
||||
label_text = text
|
||||
if _caption:
|
||||
_caption.text = text
|
||||
|
||||
func set_idle_modulate(value: Color) -> void:
|
||||
idle_modulate = value
|
||||
modulate = value if not active else Color(0.82, 0.9, 1.0, value.a)
|
||||
|
||||
## Enables the long-press/drag gesture used by mobile skill aiming and
|
||||
## inventory movement. The ordinary pressed/released signals remain intact
|
||||
## for buttons that do not need a gesture.
|
||||
func set_gesture_enabled(enabled: bool) -> void:
|
||||
set_meta("gesture_enabled", enabled)
|
||||
|
||||
func was_long_pressed() -> bool:
|
||||
return _long_pressed
|
||||
|
||||
func is_dragging() -> bool:
|
||||
return _dragging
|
||||
|
||||
func drag_total() -> Vector2:
|
||||
return _drag_delta
|
||||
|
||||
func drag_end_position() -> Vector2:
|
||||
return _last_position
|
||||
|
||||
func set_aiming(value: bool) -> void:
|
||||
_aiming = value
|
||||
queue_redraw()
|
||||
|
||||
func set_rect_style() -> void:
|
||||
circular = false
|
||||
queue_redraw()
|
||||
|
||||
func _ready() -> void:
|
||||
set_process_unhandled_input(true)
|
||||
queue_redraw()
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventScreenTouch:
|
||||
if event.pressed:
|
||||
if _touch_index < 0:
|
||||
_touch_index = event.index
|
||||
_begin_press(_event_position(event))
|
||||
else:
|
||||
if event.index == _touch_index:
|
||||
_touch_index = -1
|
||||
_end_press(_event_position(event))
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventScreenDrag and event.index == _touch_index:
|
||||
_update_drag(_event_position(event))
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if event.pressed:
|
||||
_touch_index = -2
|
||||
_begin_press(_event_position(event))
|
||||
else:
|
||||
_touch_index = -1
|
||||
_end_press(_event_position(event))
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventMouseMotion and _touch_index == -2:
|
||||
_update_drag(_event_position(event))
|
||||
accept_event()
|
||||
|
||||
func _event_position(event: InputEvent) -> Vector2:
|
||||
if event is InputEventMouse:
|
||||
return (event as InputEventMouse).position
|
||||
if event is InputEventScreenTouch:
|
||||
return (event as InputEventScreenTouch).position
|
||||
if event is InputEventScreenDrag:
|
||||
return (event as InputEventScreenDrag).position
|
||||
return Vector2.ZERO
|
||||
|
||||
func _begin_press(position: Vector2) -> void:
|
||||
if active:
|
||||
return
|
||||
active = true
|
||||
_press_position = position
|
||||
_last_position = position
|
||||
_drag_delta = Vector2.ZERO
|
||||
_long_pressed = false
|
||||
_dragging = false
|
||||
_aiming = false
|
||||
_long_press_token += 1
|
||||
var token := _long_press_token
|
||||
if bool(get_meta("gesture_enabled", false)) and get_tree() != null:
|
||||
get_tree().create_timer(LONG_PRESS_SECONDS).timeout.connect(func():
|
||||
if token != _long_press_token or not active or _long_pressed:
|
||||
return
|
||||
_long_pressed = true
|
||||
_dragging = true
|
||||
long_pressed.emit()
|
||||
queue_redraw())
|
||||
modulate = Color(0.82, 0.9, 1.0, idle_modulate.a)
|
||||
pressed.emit()
|
||||
press_state.emit(true)
|
||||
queue_redraw()
|
||||
|
||||
func _update_drag(position: Vector2) -> void:
|
||||
if not active:
|
||||
return
|
||||
var delta := position - _last_position
|
||||
_last_position = position
|
||||
_drag_delta = position - _press_position
|
||||
if not bool(get_meta("gesture_enabled", false)):
|
||||
return
|
||||
if not _long_pressed:
|
||||
if _drag_delta.length() > DRAG_THRESHOLD:
|
||||
_long_press_token += 1
|
||||
return
|
||||
if not _dragging:
|
||||
_dragging = true
|
||||
drag_started.emit()
|
||||
drag_changed.emit(delta)
|
||||
queue_redraw()
|
||||
|
||||
func _end_press(position: Vector2) -> void:
|
||||
if not active:
|
||||
return
|
||||
_update_drag(position)
|
||||
_long_press_token += 1
|
||||
if _dragging and _long_pressed:
|
||||
drag_released.emit(_drag_delta)
|
||||
active = false
|
||||
modulate = idle_modulate
|
||||
released.emit()
|
||||
press_state.emit(false)
|
||||
queue_redraw()
|
||||
|
||||
func cancel_press() -> void:
|
||||
_long_press_token += 1
|
||||
_touch_index = -1
|
||||
if active:
|
||||
# A cancelled gesture must never look like a completed drag to its
|
||||
# receiver. Clear the gesture flags before ending the visual press.
|
||||
_dragging = false
|
||||
_long_pressed = false
|
||||
_aiming = false
|
||||
_end_press(_last_position)
|
||||
else:
|
||||
_long_pressed = false
|
||||
_dragging = false
|
||||
_aiming = false
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# A focus loss / window pause can leave a touch-up event outside the
|
||||
# control. MobileUiRoot also calls cancel_all() for the full overlay.
|
||||
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
|
||||
_touch_index = -1
|
||||
_end_press(_last_position)
|
||||
|
||||
func _draw() -> void:
|
||||
if not circular:
|
||||
var rect_style := StyleBoxFlat.new()
|
||||
rect_style.bg_color = Color(0.06, 0.1, 0.16, 0.86)
|
||||
rect_style.border_color = accent
|
||||
rect_style.set_border_width_all(1)
|
||||
rect_style.set_corner_radius_all(6)
|
||||
draw_style_box(rect_style, Rect2(Vector2.ZERO, size))
|
||||
return
|
||||
var center := size * 0.5
|
||||
var radius := minf(size.x, size.y) * 0.5 - 3.0
|
||||
if radius <= 0.0:
|
||||
return
|
||||
draw_circle(center, radius, Color(0.025, 0.04, 0.07, 0.68))
|
||||
draw_arc(center, radius, 0.0, TAU, 48, accent, 3.0 if active or _aiming else 2.0)
|
||||
draw_arc(center, radius - 5.0, 0.0, TAU, 48, Color(accent, 0.22), 1.0)
|
||||
if _aiming:
|
||||
draw_circle(center, 5.0, Color(1.0, 0.88, 0.52, 0.9))
|
||||
draw_arc(center, radius - 12.0, -PI * 0.5, PI * 0.5, 24,
|
||||
Color(1.0, 0.88, 0.52, 0.8), 2.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpg433tb2c1qn
|
||||
@@ -0,0 +1,453 @@
|
||||
# MobileUiRoot —— the mobile presentation root.
|
||||
#
|
||||
# It owns only mobile layout, routing and touch surfaces. Existing feature
|
||||
# controllers are injected after GameScene has created them and remain the
|
||||
# source of truth for server-backed state.
|
||||
extends CanvasLayer
|
||||
|
||||
const MobileHud := preload("res://ui/mobile/mobile_hud.gd")
|
||||
const MobileInputOverlay := preload("res://ui/mobile/mobile_input_overlay.gd")
|
||||
const MobileMenuDrawer := preload("res://ui/mobile/mobile_menu_drawer.gd")
|
||||
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
|
||||
const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd")
|
||||
const DESIGN_SIZE := Vector2(844, 390)
|
||||
|
||||
var world: Node
|
||||
var player: Node3D
|
||||
var client: Node
|
||||
var hud_view: Control
|
||||
var input_overlay: Control
|
||||
var menu_drawer: Control
|
||||
var _avatar_panel: Panel
|
||||
var _toast: Label
|
||||
var _bindings := {}
|
||||
var _content_root: Control
|
||||
var _orientation_guard: ColorRect
|
||||
var _orientation_label: Label
|
||||
var _last_viewport_size := Vector2.ZERO
|
||||
var _last_safe_rect := Rect2(-1, -1, 0, 0)
|
||||
var ui: Node
|
||||
|
||||
func setup(metin_world: Node, local_player: Node3D, m2client: Node = null) -> void:
|
||||
world = metin_world
|
||||
player = local_player
|
||||
client = m2client
|
||||
layer = 20
|
||||
_build()
|
||||
_layout_root()
|
||||
set_process(true)
|
||||
if client:
|
||||
hud_view.bind_client(client)
|
||||
|
||||
func bind_controls(bindings: Dictionary) -> void:
|
||||
_bindings = bindings
|
||||
client = bindings.get("client", client)
|
||||
ui = bindings.get("ui", ui)
|
||||
if ui and ui.has_method("set_mobile_mode"):
|
||||
ui.set_mobile_mode(true)
|
||||
if hud_view:
|
||||
hud_view.bind_client(client)
|
||||
if input_overlay:
|
||||
input_overlay.bind_controls(bindings.get("player_controller"),
|
||||
bindings.get("net_play"), bindings.get("quickbar"), bindings.get("ground_items"))
|
||||
if bindings.get("party_ui") and bindings["party_ui"].has_method("set_mobile_mode"):
|
||||
bindings["party_ui"].set_mobile_mode(true)
|
||||
if bindings.get("quickbar") and bindings["quickbar"].has_method("set_mobile_mode"):
|
||||
bindings["quickbar"].set_mobile_mode(true)
|
||||
if bindings.get("skills") and bindings["skills"].has_method("set_mobile_quickbar"):
|
||||
bindings["skills"].set_mobile_quickbar(bindings.get("quickbar"))
|
||||
if bindings.get("inventory") and bindings["inventory"].has_method("set_mobile_mode"):
|
||||
bindings["inventory"].set_mobile_mode(true)
|
||||
if bindings.get("quest_log") and bindings["quest_log"].has_method("set_mobile_mode"):
|
||||
bindings["quest_log"].set_mobile_mode(true)
|
||||
if bindings.get("chat") and bindings["chat"].has_method("set_mobile_mode"):
|
||||
bindings["chat"].set_mobile_mode(true)
|
||||
for key in ["char_status_ui", "skills", "friend_ui", "guild_ui", "shop_ui", "exchange_ui", "safebox_ui", "mall_ui", "cube_ui", "private_shop_ui", "refine_ui", "love_ui", "system_option_ui", "game_option_ui", "system_menu_ui"]:
|
||||
var feature: Node = bindings.get(key)
|
||||
if feature and feature.has_method("set_mobile_mode"):
|
||||
feature.set_mobile_mode(true)
|
||||
_register_mobile_windows()
|
||||
var minimap: Node = bindings.get("minimap")
|
||||
if minimap and minimap.has_method("mount_mobile") and hud_view.has_method("get_minimap_host"):
|
||||
minimap.mount_mobile(hud_view.get_minimap_host())
|
||||
_wire_signals()
|
||||
_connect_lifecycle()
|
||||
|
||||
# NetPlay/HUD compatibility surface -------------------------------------
|
||||
|
||||
func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_vitals(hp, max_hp, sp, max_sp)
|
||||
|
||||
func set_exp(xp: int, next_xp: int) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_exp(xp, next_xp)
|
||||
|
||||
func set_level(value: int) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_level(value)
|
||||
|
||||
func set_energy(value: int, max_value: int = 100) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_energy(value, max_value)
|
||||
|
||||
func set_stamina(value: int, max_value: int) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_stamina(value, max_value)
|
||||
|
||||
func set_affects(values: Array) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_affects(values)
|
||||
|
||||
func set_target(name: String, hp_pct: int) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_target(name, hp_pct)
|
||||
|
||||
func clear_target() -> void:
|
||||
if hud_view:
|
||||
hud_view.clear_target()
|
||||
|
||||
func set_channel(value: int) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_channel(value)
|
||||
|
||||
func set_dungeon_destination(active: bool, world_pos: Vector3) -> void:
|
||||
if hud_view:
|
||||
hud_view.set_dungeon_destination(active, world_pos)
|
||||
|
||||
func cancel_touch_state() -> void:
|
||||
if input_overlay and input_overlay.has_method("cancel_all"):
|
||||
input_overlay.cancel_all()
|
||||
|
||||
static func requires_landscape(viewport_size: Vector2) -> bool:
|
||||
return viewport_size.y > viewport_size.x and viewport_size.x > 0.0
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_layout_root()
|
||||
_refresh_context_action()
|
||||
|
||||
func _layout_root() -> void:
|
||||
if _content_root == null or get_viewport() == null:
|
||||
return
|
||||
var viewport_size := get_viewport().get_visible_rect().size
|
||||
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||
return
|
||||
var safe := _safe_viewport_rect(viewport_size)
|
||||
var portrait := requires_landscape(viewport_size)
|
||||
var geometry_changed := not viewport_size.is_equal_approx(_last_viewport_size) \
|
||||
or not safe.is_equal_approx(_last_safe_rect)
|
||||
if _orientation_guard:
|
||||
_orientation_guard.visible = portrait
|
||||
if _content_root:
|
||||
_content_root.visible = not portrait
|
||||
_last_viewport_size = viewport_size
|
||||
_last_safe_rect = safe
|
||||
if portrait:
|
||||
# The project requests landscape on mobile. The guard is still needed
|
||||
# for rotation races and desktop previews so controls never collapse into
|
||||
# a portrait layout for one or two frames.
|
||||
cancel_touch_state()
|
||||
return
|
||||
if not geometry_changed:
|
||||
return
|
||||
var fit := minf(safe.size.x / DESIGN_SIZE.x, safe.size.y / DESIGN_SIZE.y)
|
||||
var configured := float(ProjectSettings.get_setting("mt/ui/mobile_scale", 1.0))
|
||||
fit = maxf(0.1, fit * maxf(0.5, configured))
|
||||
_content_root.size = DESIGN_SIZE
|
||||
_content_root.scale = Vector2.ONE * fit
|
||||
_content_root.position = safe.position + (safe.size - DESIGN_SIZE * fit) * 0.5
|
||||
|
||||
func _safe_viewport_rect(viewport_size: Vector2) -> Rect2:
|
||||
return MobileWindowHost.safe_rect_for(viewport_size, DisplayServer.get_display_safe_area())
|
||||
|
||||
func _refresh_context_action() -> void:
|
||||
var ground: Node = _bindings.get("ground_items")
|
||||
if ground == null or not ground.has_method("has_nearby_item"):
|
||||
return
|
||||
var available := bool(ground.has_nearby_item())
|
||||
if hud_view and hud_view.has_method("set_context_action"):
|
||||
hud_view.set_context_action("拾取", available)
|
||||
|
||||
func _build() -> void:
|
||||
_content_root = Control.new()
|
||||
_content_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
_content_root.size = DESIGN_SIZE
|
||||
_content_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_content_root)
|
||||
|
||||
hud_view = MobileHud.new()
|
||||
hud_view.setup()
|
||||
_content_root.add_child(hud_view)
|
||||
|
||||
input_overlay = MobileInputOverlay.new()
|
||||
input_overlay.setup()
|
||||
_content_root.add_child(input_overlay)
|
||||
|
||||
menu_drawer = MobileMenuDrawer.new()
|
||||
menu_drawer.setup()
|
||||
_content_root.add_child(menu_drawer)
|
||||
|
||||
hud_view.avatar_pressed.connect(_on_avatar_pressed)
|
||||
hud_view.inventory_pressed.connect(_on_inventory_pressed)
|
||||
hud_view.minimap_pressed.connect(_on_minimap_pressed)
|
||||
hud_view.menu_pressed.connect(_on_menu_pressed)
|
||||
hud_view.quest_pressed.connect(_on_quest_pressed)
|
||||
hud_view.party_member_pressed.connect(_on_party_member_pressed)
|
||||
hud_view.context_action_pressed.connect(_on_context_action_pressed)
|
||||
menu_drawer.item_selected.connect(_on_menu_item)
|
||||
_build_avatar_panel(_content_root)
|
||||
_build_orientation_guard()
|
||||
|
||||
func _build_orientation_guard() -> void:
|
||||
_orientation_guard = ColorRect.new()
|
||||
_orientation_guard.name = "LandscapeRequired"
|
||||
_orientation_guard.color = Color(0.015, 0.025, 0.045, 0.98)
|
||||
_orientation_guard.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_orientation_guard.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_orientation_guard.visible = false
|
||||
_orientation_guard.z_index = 200
|
||||
add_child(_orientation_guard)
|
||||
_orientation_label = Label.new()
|
||||
_orientation_label.text = "请横屏使用"
|
||||
_orientation_label.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_orientation_label.position = Vector2(-120, -20)
|
||||
_orientation_label.size = Vector2(240, 40)
|
||||
_orientation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_orientation_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_orientation_label.add_theme_font_size_override("font_size", 20)
|
||||
_orientation_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58))
|
||||
_orientation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_orientation_guard.add_child(_orientation_label)
|
||||
|
||||
func _wire_signals() -> void:
|
||||
# Calling bind_controls twice is safe during a preview rebuild; signals are
|
||||
# connected once because this method is guarded by metadata.
|
||||
if get_meta("mobile_signals_wired", false):
|
||||
return
|
||||
set_meta("mobile_signals_wired", true)
|
||||
var np: Node = _bindings.get("net_play")
|
||||
if np and np.has_signal("cannot_act"):
|
||||
np.cannot_act.connect(func(code: String): _show_message(code))
|
||||
var quickbar: Node = _bindings.get("quickbar")
|
||||
if quickbar and quickbar.has_signal("skill_rejected"):
|
||||
quickbar.skill_rejected.connect(func(_sid: int, code: String): _show_message(code))
|
||||
|
||||
func _connect_lifecycle() -> void:
|
||||
var life: Node = _bindings.get("lifecycle")
|
||||
if life == null or get_meta("lifecycle_wired", false):
|
||||
return
|
||||
set_meta("lifecycle_wired", true)
|
||||
if life.has_signal("back_requested"):
|
||||
life.back_requested.connect(_on_back_requested)
|
||||
if life.has_signal("resumed"):
|
||||
life.resumed.connect(func(): cancel_touch_state())
|
||||
|
||||
func _on_back_requested() -> void:
|
||||
if ui and ui.has_method("close_top") and bool(ui.close_top()):
|
||||
return
|
||||
if menu_drawer and menu_drawer.has_method("is_open") and menu_drawer.is_open():
|
||||
menu_drawer.close()
|
||||
return
|
||||
if _avatar_panel and _avatar_panel.visible:
|
||||
_avatar_panel.visible = false
|
||||
return
|
||||
cancel_touch_state()
|
||||
|
||||
func _register_mobile_windows() -> void:
|
||||
if ui == null or not ui.has_method("track_mobile_window"):
|
||||
return
|
||||
_register_window(_bindings.get("quest_log"), "任务日志")
|
||||
_register_window(_bindings.get("friend_ui"), "好友")
|
||||
_register_window(_bindings.get("guild_ui"), "公会")
|
||||
_register_window(_bindings.get("chat"), "聊天")
|
||||
_register_window(_bindings.get("shop_ui"), "商店")
|
||||
_register_window(_bindings.get("exchange_ui"), "交易")
|
||||
_register_window(_bindings.get("safebox_ui"), "仓库")
|
||||
_register_window(_bindings.get("mall_ui"), "商城仓库")
|
||||
_register_window(_bindings.get("cube_ui"), "制作")
|
||||
_register_window(_bindings.get("private_shop_ui"), "私人商店")
|
||||
_register_window(_bindings.get("refine_ui"), "精炼")
|
||||
|
||||
func _register_window(target: Node, title: String) -> void:
|
||||
if target == null:
|
||||
return
|
||||
var close_cb := Callable(target, "close") if target.has_method("close") else Callable()
|
||||
if target.has_method("get_mobile_windows"):
|
||||
for root in target.get_mobile_windows():
|
||||
if root is Control:
|
||||
ui.track_mobile_window(root, title, close_cb)
|
||||
elif target.has_method("get_mobile_window"):
|
||||
var root: Control = target.get_mobile_window()
|
||||
if root:
|
||||
ui.track_mobile_window(root, title, close_cb)
|
||||
|
||||
func _build_avatar_panel(parent: Control) -> void:
|
||||
_avatar_panel = Panel.new()
|
||||
_avatar_panel.position = Vector2(112, 18)
|
||||
_avatar_panel.size = Vector2(170, 218)
|
||||
_avatar_panel.visible = false
|
||||
_avatar_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_avatar_panel.add_theme_stylebox_override("panel", _panel_style())
|
||||
parent.add_child(_avatar_panel)
|
||||
var title := Label.new()
|
||||
title.text = "角色"
|
||||
title.position = Vector2(14, 10)
|
||||
title.add_theme_font_size_override("font_size", 16)
|
||||
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
|
||||
_avatar_panel.add_child(title)
|
||||
var close := TouchButton.new()
|
||||
close.setup("×", Color(0.6, 0.75, 0.96, 0.9))
|
||||
close.set_rect_style()
|
||||
close.position = Vector2(132, 5)
|
||||
close.size = Vector2(30, 28)
|
||||
close.pressed.connect(func(): _avatar_panel.visible = false)
|
||||
_avatar_panel.add_child(close)
|
||||
for data in [["角色 / 装备", "status"], ["技能", "skills"], ["坐骑", "mount"], ["表情", "emoticon"]]:
|
||||
var button := TouchButton.new()
|
||||
button.setup(String(data[0]), Color(0.45, 0.7, 0.95, 0.9))
|
||||
button.set_rect_style()
|
||||
button.position = Vector2(12, 42 + (data[1] as String).length() * 0)
|
||||
button.size = Vector2(146, 34)
|
||||
button.position.y = 42 + _avatar_panel.get_child_count() * 0
|
||||
var action := String(data[1])
|
||||
button.pressed.connect(func(): _open_avatar_feature(action))
|
||||
_avatar_panel.add_child(button)
|
||||
# Reposition after the title/close children have been added.
|
||||
for i in range(4):
|
||||
var button := _avatar_panel.get_child(2 + i) as TouchButton
|
||||
if button:
|
||||
button.position = Vector2(12, 44 + i * 39)
|
||||
|
||||
func _on_avatar_pressed() -> void:
|
||||
_avatar_panel.visible = not _avatar_panel.visible
|
||||
|
||||
func _open_avatar_feature(action: String) -> void:
|
||||
_avatar_panel.visible = false
|
||||
var cs: Node = _bindings.get("char_status_ui")
|
||||
var skills: Node = _bindings.get("skills")
|
||||
match action:
|
||||
"status":
|
||||
if cs and cs.has_method("open"): cs.open("STATUS")
|
||||
"emoticon":
|
||||
if cs and cs.has_method("open"): cs.open("EMOTICON")
|
||||
"skills", "mount":
|
||||
if skills:
|
||||
if skills.has_method("set_job"):
|
||||
skills.set_job(_job_name())
|
||||
if skills.has_method("open"): skills.open()
|
||||
|
||||
func _on_inventory_pressed() -> void:
|
||||
var inventory: Node = _bindings.get("inventory")
|
||||
if inventory and inventory.has_method("toggle"):
|
||||
inventory.toggle()
|
||||
|
||||
func _on_minimap_pressed() -> void:
|
||||
var atlas: Node = _bindings.get("atlas_ui")
|
||||
if atlas and atlas.has_method("open"):
|
||||
atlas.open()
|
||||
|
||||
func _on_menu_pressed() -> void:
|
||||
if menu_drawer:
|
||||
menu_drawer.toggle()
|
||||
|
||||
func _on_quest_pressed() -> void:
|
||||
var quests: Node = _bindings.get("quest_log")
|
||||
if quests and quests.has_method("toggle"):
|
||||
quests.toggle()
|
||||
|
||||
func _on_party_member_pressed(vid: int) -> void:
|
||||
if client and vid > 0 and client.has_method("set_target"):
|
||||
client.set_target(vid)
|
||||
|
||||
func _on_context_action_pressed() -> void:
|
||||
var ground: Node = _bindings.get("ground_items")
|
||||
if ground and ground.has_method("try_pickup") and int(ground.try_pickup()) != 0:
|
||||
return
|
||||
_show_message("附近没有可拾取的物品")
|
||||
|
||||
func _on_menu_item(category: String, item: String) -> void:
|
||||
if menu_drawer:
|
||||
menu_drawer.close()
|
||||
var target: Node
|
||||
match category:
|
||||
"社交":
|
||||
match item:
|
||||
"聊天": target = _bindings.get("chat"); _call_toggle(target, "toggle_log")
|
||||
"好友": target = _bindings.get("friend_ui"); _call_toggle(target)
|
||||
"公会": target = _bindings.get("guild_ui"); _call_toggle(target)
|
||||
"情侣": target = _bindings.get("love_ui"); _call_toggle(target)
|
||||
_: _show_message("观战功能将在进入观战状态后可用")
|
||||
"成长":
|
||||
if item == "龙魂":
|
||||
_show_message("龙魂功能当前未开放")
|
||||
else:
|
||||
_show_message("请靠近对应 NPC 后使用:" + item)
|
||||
"活动":
|
||||
if item == "钓鱼":
|
||||
var np: Node = _bindings.get("net_play")
|
||||
if np and np.has_method("activate_fishing"):
|
||||
_show_message(String(np.activate_fishing()))
|
||||
else:
|
||||
_show_message("请从任务或活动区域进入副本")
|
||||
"商业":
|
||||
if item == "商城":
|
||||
if client and client.has_method("say"): client.say(0, "/in_game_mall")
|
||||
else:
|
||||
_show_message("请靠近对应 NPC 或摊位后使用:" + item)
|
||||
"系统":
|
||||
_system_action(item)
|
||||
|
||||
func _system_action(item: String) -> void:
|
||||
var system_menu: Node = _bindings.get("system_menu_ui")
|
||||
var sys_opt: Node = _bindings.get("system_option_ui")
|
||||
var game_opt: Node = _bindings.get("game_option_ui")
|
||||
match item:
|
||||
"系统设置": _call_open(sys_opt)
|
||||
"游戏设置": _call_open(game_opt)
|
||||
"帮助":
|
||||
if system_menu and system_menu.has_method("open_help"): system_menu.open_help()
|
||||
"选择角色":
|
||||
if client and client.has_method("say"): client.say(0, "/phase_select")
|
||||
"登出":
|
||||
if client and client.has_method("say"): client.say(0, "/logout")
|
||||
"退出游戏": get_tree().quit()
|
||||
|
||||
func _call_toggle(target: Node, method := "toggle") -> void:
|
||||
if target and target.has_method(method):
|
||||
target.call(method)
|
||||
|
||||
func _call_open(target: Node) -> void:
|
||||
if target and target.has_method("open"):
|
||||
target.open()
|
||||
|
||||
func _job_name() -> String:
|
||||
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||||
return "WARRIOR"
|
||||
var race := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3
|
||||
return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][race]
|
||||
|
||||
func _show_message(message: String) -> void:
|
||||
if _toast == null:
|
||||
_toast = Label.new()
|
||||
_toast.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
_toast.position = Vector2(-170, -62)
|
||||
_toast.size = Vector2(340, 34)
|
||||
_toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_toast.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_toast.add_theme_font_size_override("font_size", 12)
|
||||
_toast.add_theme_color_override("font_color", Color(1.0, 0.86, 0.58))
|
||||
_toast.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_content_root.add_child(_toast)
|
||||
_toast.text = message
|
||||
_toast.visible = true
|
||||
var tween := create_tween()
|
||||
tween.tween_interval(1.8)
|
||||
tween.tween_callback(func(): if _toast: _toast.visible = false)
|
||||
|
||||
func _panel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.04, 0.065, 0.1, 0.96)
|
||||
style.border_color = Color(0.55, 0.7, 0.92, 0.9)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(10)
|
||||
return style
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0etc5yr8cuuy
|
||||
@@ -0,0 +1,163 @@
|
||||
# MobileWindowHost —— touch-safe presentation for existing feature windows.
|
||||
#
|
||||
# Feature controllers keep ownership of data, packets and permissions. This
|
||||
# helper only gives their Control roots a landscape-safe frame, a predictable
|
||||
# back/close affordance and a screen-touch bridge for legacy Button nodes.
|
||||
extends RefCounted
|
||||
|
||||
const NAV_HEIGHT := 40.0
|
||||
const EDGE_MARGIN := 12.0
|
||||
const MIN_SCALE := 0.42
|
||||
|
||||
static func safe_rect(viewport: Viewport) -> Rect2:
|
||||
if viewport == null:
|
||||
return Rect2(Vector2.ZERO, Vector2(844, 390))
|
||||
var viewport_size := viewport.get_visible_rect().size
|
||||
return safe_rect_for(viewport_size, DisplayServer.get_display_safe_area())
|
||||
|
||||
## Resolve a platform-reported safe area against the current logical viewport.
|
||||
## Keeping this pure makes the edge cases testable without an Android/iOS
|
||||
## device and avoids trusting desktop backends that return physical-screen
|
||||
## coordinates unrelated to the current viewport.
|
||||
static func safe_rect_for(viewport_size: Vector2, reported: Rect2) -> Rect2:
|
||||
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||||
return Rect2(Vector2.ZERO, viewport_size)
|
||||
if reported.size.x <= 0.0 or reported.size.y <= 0.0:
|
||||
return Rect2(Vector2.ZERO, viewport_size)
|
||||
var result := reported
|
||||
# Some desktop backends report a screen-space safe area larger than the
|
||||
# current viewport. Treat that as unavailable rather than moving windows
|
||||
# outside the viewport.
|
||||
if result.size.x < 1.0 or result.size.y < 1.0:
|
||||
return Rect2(Vector2.ZERO, viewport_size)
|
||||
if result.position.x < 0.0 or result.position.y < 0.0 \
|
||||
or result.end.x > viewport_size.x + 1.0 \
|
||||
or result.end.y > viewport_size.y + 1.0:
|
||||
return Rect2(Vector2.ZERO, viewport_size)
|
||||
return result
|
||||
|
||||
static func content_size(root: Control) -> Vector2:
|
||||
if root == null:
|
||||
return Vector2(320, 240)
|
||||
var result := root.size
|
||||
if result.x <= 1.0 or result.y <= 1.0:
|
||||
result = root.get_combined_minimum_size()
|
||||
if result.x <= 1.0 or result.y <= 1.0:
|
||||
result = Vector2(320, 240)
|
||||
return result
|
||||
|
||||
static func install(root: Control, area: Rect2, title: String,
|
||||
close_cb: Callable, back_cb: Callable = Callable()) -> Dictionary:
|
||||
if root == null:
|
||||
return {}
|
||||
var base_size := content_size(root)
|
||||
var available := area.size - Vector2(EDGE_MARGIN * 2.0, EDGE_MARGIN * 2.0)
|
||||
var scale_value := minf(available.x / base_size.x, available.y / base_size.y)
|
||||
scale_value = maxf(MIN_SCALE, minf(1.0, scale_value))
|
||||
root.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
root.size = base_size
|
||||
root.pivot_offset = Vector2.ZERO
|
||||
root.scale = Vector2.ONE * scale_value
|
||||
root.position = area.position + (area.size - base_size * scale_value) * 0.5
|
||||
root.set_meta("mobile_hosted", true)
|
||||
root.set_meta("mobile_base_size", base_size)
|
||||
root.set_meta("mobile_scale", scale_value)
|
||||
|
||||
var nav := root.get_node_or_null("__mobile_nav") as Control
|
||||
if nav == null:
|
||||
nav = Panel.new()
|
||||
nav.name = "__mobile_nav"
|
||||
nav.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
nav.z_index = 100
|
||||
var nav_style := StyleBoxFlat.new()
|
||||
nav_style.bg_color = Color(0.025, 0.045, 0.08, 0.96)
|
||||
nav_style.border_color = Color(0.36, 0.6, 0.92, 0.82)
|
||||
nav_style.border_width_bottom = 1
|
||||
nav.add_theme_stylebox_override("panel", nav_style)
|
||||
root.add_child(nav)
|
||||
nav.position = Vector2.ZERO
|
||||
nav.size = Vector2(base_size.x, NAV_HEIGHT)
|
||||
nav.visible = true
|
||||
|
||||
var title_label := nav.get_node_or_null("Title") as Label
|
||||
if title_label == null:
|
||||
title_label = Label.new()
|
||||
title_label.name = "Title"
|
||||
title_label.position = Vector2(88, 0)
|
||||
title_label.size = Vector2(maxf(80.0, base_size.x - 176.0), NAV_HEIGHT)
|
||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
title_label.add_theme_font_size_override("font_size", 15)
|
||||
title_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58))
|
||||
title_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
nav.add_child(title_label)
|
||||
title_label.text = title if title != "" else String(root.name)
|
||||
|
||||
var back := nav.get_node_or_null("Back") as Button
|
||||
if back == null:
|
||||
back = Button.new()
|
||||
back.name = "Back"
|
||||
back.text = "返回"
|
||||
back.position = Vector2(8, 4)
|
||||
back.size = Vector2(70, 32)
|
||||
back.add_theme_font_size_override("font_size", 12)
|
||||
nav.add_child(back)
|
||||
if not back.has_meta("mobile_host_wired"):
|
||||
back.set_meta("mobile_host_wired", true)
|
||||
back.pressed.connect(func():
|
||||
if back_cb.is_valid():
|
||||
back_cb.call()
|
||||
elif close_cb.is_valid():
|
||||
close_cb.call())
|
||||
|
||||
var close := nav.get_node_or_null("Close") as Button
|
||||
if close == null:
|
||||
close = Button.new()
|
||||
close.name = "Close"
|
||||
close.text = "×"
|
||||
close.add_theme_font_size_override("font_size", 18)
|
||||
nav.add_child(close)
|
||||
if not close.has_meta("mobile_host_wired"):
|
||||
close.set_meta("mobile_host_wired", true)
|
||||
close.pressed.connect(func(): if close_cb.is_valid(): close_cb.call())
|
||||
close.position = Vector2(maxf(78.0, base_size.x - 78.0), 4)
|
||||
close.size = Vector2(70, 32)
|
||||
|
||||
_wire_buttons(root)
|
||||
return {"size": base_size, "scale": scale_value, "area": area}
|
||||
|
||||
static func uninstall(root: Control) -> void:
|
||||
if root == null:
|
||||
return
|
||||
var nav := root.get_node_or_null("__mobile_nav") as Control
|
||||
if nav:
|
||||
nav.visible = false
|
||||
root.set_meta("mobile_hosted", false)
|
||||
|
||||
static func wire_buttons(root: Node) -> void:
|
||||
if root:
|
||||
_wire_buttons(root)
|
||||
|
||||
static func _wire_buttons(root: Node) -> void:
|
||||
for node in root.find_children("*", "BaseButton", true, false):
|
||||
var button := node as BaseButton
|
||||
if button == null or button.has_meta("mobile_touch_wired"):
|
||||
continue
|
||||
button.set_meta("mobile_touch_wired", true)
|
||||
button.gui_input.connect(func(event: InputEvent):
|
||||
_handle_button_touch(button, event))
|
||||
|
||||
static func _handle_button_touch(button: BaseButton, event: InputEvent) -> void:
|
||||
if button == null or not is_instance_valid(button):
|
||||
return
|
||||
if event is InputEventScreenTouch:
|
||||
var touch := event as InputEventScreenTouch
|
||||
if touch.pressed:
|
||||
if int(button.get_meta("mobile_touch_index", -1)) < 0:
|
||||
button.set_meta("mobile_touch_index", touch.index)
|
||||
else:
|
||||
if int(button.get_meta("mobile_touch_index", -1)) == touch.index:
|
||||
button.set_meta("mobile_touch_index", -1)
|
||||
if not button.disabled:
|
||||
button.pressed.emit()
|
||||
button.accept_event()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqvgte43jbj2a
|
||||
@@ -0,0 +1,86 @@
|
||||
# VirtualJoystick —— continuous left-thumb movement input.
|
||||
extends Control
|
||||
|
||||
signal axis_changed(axis: Vector2)
|
||||
|
||||
const DEAD_ZONE := 0.12
|
||||
const KNOB_RADIUS := 29.0
|
||||
|
||||
var _touch_index := -1
|
||||
var _axis := Vector2.ZERO
|
||||
var _knob := Vector2.ZERO
|
||||
|
||||
func setup(diameter := 164.0) -> void:
|
||||
custom_minimum_size = Vector2(diameter, diameter)
|
||||
size = Vector2(diameter, diameter)
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
queue_redraw()
|
||||
|
||||
func axis() -> Vector2:
|
||||
return _axis
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventScreenTouch:
|
||||
if event.pressed and _touch_index < 0:
|
||||
_touch_index = event.index
|
||||
_update_from_position(event.position)
|
||||
elif not event.pressed and event.index == _touch_index:
|
||||
_touch_index = -1
|
||||
_set_axis(Vector2.ZERO)
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventScreenDrag and event.index == _touch_index:
|
||||
_update_from_position(event.position)
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if event.pressed:
|
||||
_touch_index = -2
|
||||
_update_from_position(event.position)
|
||||
else:
|
||||
_touch_index = -1
|
||||
_set_axis(Vector2.ZERO)
|
||||
accept_event()
|
||||
return
|
||||
if event is InputEventMouseMotion and _touch_index == -2:
|
||||
_update_from_position(event.position)
|
||||
accept_event()
|
||||
|
||||
func _update_from_position(pos: Vector2) -> void:
|
||||
var center := size * 0.5
|
||||
var radius := maxf(1.0, minf(size.x, size.y) * 0.5 - KNOB_RADIUS - 8.0)
|
||||
var delta := pos - center
|
||||
var raw := delta / radius
|
||||
var magnitude := minf(raw.length(), 1.0)
|
||||
var direction := raw.normalized() if magnitude > 0.001 else Vector2.ZERO
|
||||
var output := direction * magnitude
|
||||
if output.length() < DEAD_ZONE:
|
||||
output = Vector2.ZERO
|
||||
else:
|
||||
output = output.normalized() * ((output.length() - DEAD_ZONE) / (1.0 - DEAD_ZONE))
|
||||
_knob = direction * radius * magnitude
|
||||
_set_axis(output)
|
||||
queue_redraw()
|
||||
|
||||
func _set_axis(value: Vector2) -> void:
|
||||
var next := value.limit_length(1.0)
|
||||
if next.is_equal_approx(_axis):
|
||||
return
|
||||
_axis = next
|
||||
axis_changed.emit(_axis)
|
||||
queue_redraw()
|
||||
|
||||
func cancel_press() -> void:
|
||||
_touch_index = -1
|
||||
_knob = Vector2.ZERO
|
||||
_set_axis(Vector2.ZERO)
|
||||
|
||||
func _draw() -> void:
|
||||
var center := size * 0.5
|
||||
var outer := minf(size.x, size.y) * 0.5 - 5.0
|
||||
if outer <= 0.0:
|
||||
return
|
||||
draw_circle(center, outer, Color(0.025, 0.04, 0.07, 0.38))
|
||||
draw_arc(center, outer, 0.0, TAU, 48, Color(0.72, 0.83, 0.96, 0.42), 2.0)
|
||||
draw_circle(center + _knob, KNOB_RADIUS, Color(0.18, 0.3, 0.48, 0.78))
|
||||
draw_arc(center + _knob, KNOB_RADIUS, 0.0, TAU, 32, Color(0.7, 0.85, 1.0, 0.8), 2.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://snrdki713in1
|
||||
Reference in New Issue
Block a user