56593c58cf
Show touch controls only on touch platforms and keyboard hints only on desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
316 lines
11 KiB
GDScript
316 lines
11 KiB
GDScript
extends CanvasLayer
|
||
## On-screen controls for touch devices: a floating movement joystick on the left
|
||
## and a cluster of ability buttons on the right. Instantiated by hud.gd.
|
||
##
|
||
## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive
|
||
## (via Input.action_press with an analog strength), so player.gd needs no touch
|
||
## awareness — it just reads Input.get_vector() as always. Ability buttons call the
|
||
## player's public try_activate_ability(), which shares the keyboard's cooldown gate.
|
||
##
|
||
## Shown only on touch platforms (see Controls.use_touch_ui), or when DP
|
||
## "force_touch_controls" is on so the layout can be exercised on a desktop build.
|
||
## emulate_touch_from_mouse (project.godot) lets a mouse stand in for a finger while
|
||
## testing.
|
||
|
||
# move_* actions, indexed by the four joystick push directions.
|
||
const _MOVE_RIGHT: StringName = &"move_right"
|
||
const _MOVE_LEFT: StringName = &"move_left"
|
||
const _MOVE_BACK: StringName = &"move_back"
|
||
const _MOVE_FORWARD: StringName = &"move_forward"
|
||
|
||
# Ability slots in player.gd order: kick=0, dash=1, slam=2, roll=3.
|
||
const _ABILITY_ICONS: Array[String] = [
|
||
"res://HUD/KICK.png",
|
||
"res://HUD/DASH.png",
|
||
"res://HUD/SLAM.png",
|
||
"res://HUD/ROLL.png",
|
||
]
|
||
const _ABILITY_LETTERS: Array[String] = ["K", "D", "S", "R"]
|
||
const _ABILITY_ACCENT: Array[Color] = [
|
||
Color(1.00, 0.62, 0.16), # Kick — ember orange
|
||
Color(0.45, 0.75, 1.00), # Dash — sky blue
|
||
Color(0.98, 0.34, 0.20), # Slam — crimson
|
||
Color(1.00, 0.82, 0.32), # Roll — gold
|
||
]
|
||
|
||
var _root: Control
|
||
var _stick: Control
|
||
var _cd_overlays: Array[ColorRect] = []
|
||
|
||
var _player: Node = null
|
||
|
||
# Floating-joystick state. _origin is where the thumb first touched (base centre),
|
||
# _knob is the current thumb position; both in _stick-local coordinates. _touch_index
|
||
# is the finger owning the stick (-1 = none, -2 = mouse), so a second finger tapping
|
||
# an ability button never steals the stick.
|
||
var _stick_active: bool = false
|
||
var _origin: Vector2 = Vector2.ZERO
|
||
var _knob: Vector2 = Vector2.ZERO
|
||
var _touch_index: int = -3
|
||
var _stick_radius: float = 110.0
|
||
|
||
|
||
func _ready() -> void:
|
||
layer = 10 # below the HUD (11) so the game-over screen always draws on top
|
||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||
|
||
_root = Control.new()
|
||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_root.visible = false
|
||
add_child(_root)
|
||
|
||
_build_joystick()
|
||
_build_ability_buttons()
|
||
_build_menu_button()
|
||
_relayout()
|
||
get_viewport().size_changed.connect(_relayout)
|
||
|
||
|
||
# ── Joystick ────────────────────────────────────────────────────────────────
|
||
# The stick zone is the whole left half of the screen: touching anywhere in it
|
||
# plants the base under the thumb, so the player never has to find a fixed pad.
|
||
|
||
func _build_joystick() -> void:
|
||
_stick = Control.new()
|
||
_stick.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_stick.gui_input.connect(_on_stick_input)
|
||
_stick.draw.connect(_draw_stick)
|
||
_root.add_child(_stick)
|
||
|
||
|
||
func _on_stick_input(event: InputEvent) -> void:
|
||
if event is InputEventScreenTouch:
|
||
var t := event as InputEventScreenTouch
|
||
if t.pressed and not _stick_active:
|
||
_begin_stick(t.position, t.index)
|
||
elif not t.pressed and _touch_index == t.index:
|
||
_end_stick()
|
||
elif event is InputEventScreenDrag:
|
||
var d := event as InputEventScreenDrag
|
||
if _touch_index == d.index:
|
||
_update_stick(d.position)
|
||
elif event is InputEventMouseButton:
|
||
var mb := event as InputEventMouseButton
|
||
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||
if mb.pressed and not _stick_active:
|
||
_begin_stick(mb.position, -2)
|
||
elif not mb.pressed and _touch_index == -2:
|
||
_end_stick()
|
||
elif event is InputEventMouseMotion and _touch_index == -2:
|
||
_update_stick((event as InputEventMouseMotion).position)
|
||
|
||
|
||
func _begin_stick(pos: Vector2, index: int) -> void:
|
||
_stick_active = true
|
||
_touch_index = index
|
||
_origin = pos
|
||
_knob = pos
|
||
_stick.queue_redraw()
|
||
|
||
|
||
func _update_stick(pos: Vector2) -> void:
|
||
if not _stick_active:
|
||
return
|
||
var offset := pos - _origin
|
||
if offset.length() > _stick_radius:
|
||
offset = offset.normalized() * _stick_radius
|
||
_knob = _origin + offset
|
||
_apply_move(offset / _stick_radius)
|
||
_stick.queue_redraw()
|
||
|
||
|
||
func _end_stick() -> void:
|
||
_stick_active = false
|
||
_touch_index = -3
|
||
_release_move()
|
||
_stick.queue_redraw()
|
||
|
||
|
||
# Translate a normalised stick offset (screen space: +x right, +y down) into analog
|
||
# strengths on the four movement actions. Opposite pairs are mutually exclusive, so
|
||
# only one of each axis is ever pressed at a time.
|
||
func _apply_move(v: Vector2) -> void:
|
||
Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x))
|
||
Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x))
|
||
Input.action_press(_MOVE_BACK, maxf(0.0, v.y))
|
||
Input.action_press(_MOVE_FORWARD, maxf(0.0, -v.y))
|
||
|
||
|
||
func _release_move() -> void:
|
||
for action: StringName in [_MOVE_RIGHT, _MOVE_LEFT, _MOVE_BACK, _MOVE_FORWARD]:
|
||
Input.action_release(action)
|
||
|
||
|
||
func _draw_stick() -> void:
|
||
if not _stick_active:
|
||
return
|
||
var ring := Color(0.93, 0.88, 0.72, 0.30)
|
||
var knob := Color(1.00, 0.82, 0.32, 0.55)
|
||
_stick.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20))
|
||
_stick.draw_arc(_origin, _stick_radius, 0.0, TAU, 48, ring, 4.0, true)
|
||
_stick.draw_circle(_knob, _stick_radius * 0.42, knob)
|
||
|
||
|
||
# ── Ability buttons ─────────────────────────────────────────────────────────
|
||
# A 2×2 cluster in the bottom-right thumb reach. Each button taps the matching
|
||
# ability; a dark overlay wipes down over it while that ability is cooling down.
|
||
|
||
func _build_ability_buttons() -> void:
|
||
var grid := GridContainer.new()
|
||
grid.name = "AbilityGrid"
|
||
grid.columns = 2
|
||
grid.add_theme_constant_override("h_separation", 14)
|
||
grid.add_theme_constant_override("v_separation", 14)
|
||
grid.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
grid.anchor_left = 1.0
|
||
grid.anchor_right = 1.0
|
||
grid.anchor_top = 1.0
|
||
grid.anchor_bottom = 1.0
|
||
grid.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||
grid.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||
grid.offset_left = -220.0
|
||
grid.offset_top = -220.0
|
||
grid.offset_right = -24.0
|
||
grid.offset_bottom = -24.0
|
||
_root.add_child(grid)
|
||
|
||
for slot: int in 4:
|
||
grid.add_child(_build_ability_button(slot))
|
||
|
||
|
||
func _build_ability_button(slot: int) -> Control:
|
||
var panel := PanelContainer.new()
|
||
panel.custom_minimum_size = Vector2(96.0, 96.0)
|
||
panel.add_theme_stylebox_override("panel", _button_style(slot))
|
||
panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
|
||
var stack := Control.new()
|
||
stack.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
panel.add_child(stack)
|
||
|
||
if ResourceLoader.exists(_ABILITY_ICONS[slot]):
|
||
var icon := TextureRect.new()
|
||
icon.texture = load(_ABILITY_ICONS[slot])
|
||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
icon.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
stack.add_child(icon)
|
||
else:
|
||
var lbl := Label.new()
|
||
lbl.text = _ABILITY_LETTERS[slot]
|
||
lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[slot])
|
||
lbl.add_theme_font_size_override("font_size", 40)
|
||
stack.add_child(lbl)
|
||
|
||
# Cooldown wipe — anchored to the bottom, its top edge driven in _process so it
|
||
# shrinks away as the ability comes off cooldown (mirrors the desktop HUD tiles).
|
||
var cd := ColorRect.new()
|
||
cd.color = Color(0.0, 0.0, 0.0, 0.62)
|
||
cd.anchor_left = 0.0
|
||
cd.anchor_right = 1.0
|
||
cd.anchor_top = 1.0
|
||
cd.anchor_bottom = 1.0
|
||
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
stack.add_child(cd)
|
||
_cd_overlays.append(cd)
|
||
|
||
# Fire on press (not release) so abilities feel instant under the thumb.
|
||
panel.gui_input.connect(_on_ability_input.bind(slot))
|
||
return panel
|
||
|
||
|
||
func _on_ability_input(event: InputEvent, slot: int) -> void:
|
||
var pressed := false
|
||
if event is InputEventScreenTouch:
|
||
pressed = (event as InputEventScreenTouch).pressed
|
||
elif event is InputEventMouseButton:
|
||
var mb := event as InputEventMouseButton
|
||
pressed = mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT
|
||
if pressed and _player != null:
|
||
_player.call(&"try_activate_ability", slot)
|
||
|
||
|
||
func _button_style(slot: int) -> StyleBoxFlat:
|
||
var s := StyleBoxFlat.new()
|
||
s.bg_color = Color(0.10, 0.07, 0.03, 0.80)
|
||
s.set_corner_radius_all(18)
|
||
s.corner_detail = 6
|
||
s.set_border_width_all(3)
|
||
s.border_color = _ABILITY_ACCENT[slot]
|
||
return s
|
||
|
||
|
||
# ── Menu button ──────────────────────────────────────────────────────────────
|
||
# Top-right "☰" — the touch stand-in for the keyboard's Esc-to-menu, so a phone
|
||
# player has a way out of a run.
|
||
|
||
func _build_menu_button() -> void:
|
||
var btn := Button.new()
|
||
btn.text = "☰"
|
||
btn.custom_minimum_size = Vector2(56.0, 56.0)
|
||
btn.focus_mode = Control.FOCUS_NONE
|
||
btn.anchor_left = 1.0
|
||
btn.anchor_right = 1.0
|
||
btn.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||
btn.offset_left = -72.0
|
||
btn.offset_top = 16.0
|
||
btn.offset_right = -16.0
|
||
btn.offset_bottom = 72.0
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = Color(0.10, 0.07, 0.03, 0.80)
|
||
style.set_corner_radius_all(12)
|
||
style.set_border_width_all(2)
|
||
style.border_color = Color(1.00, 0.78, 0.22, 0.8)
|
||
btn.add_theme_stylebox_override("normal", style)
|
||
btn.add_theme_stylebox_override("hover", style)
|
||
btn.add_theme_stylebox_override("pressed", style)
|
||
btn.add_theme_color_override("font_color", Color(1.00, 0.78, 0.22))
|
||
btn.add_theme_font_size_override("font_size", 28)
|
||
btn.pressed.connect(_return_to_menu)
|
||
_root.add_child(btn)
|
||
|
||
|
||
func _return_to_menu() -> void:
|
||
get_tree().paused = false
|
||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||
get_tree().change_scene_to_file("res://MainMenu.tscn")
|
||
|
||
|
||
# ── Layout & lifecycle ──────────────────────────────────────────────────────
|
||
|
||
func _relayout() -> void:
|
||
var vp := get_viewport().get_visible_rect().size
|
||
# Left half is the joystick zone; base radius scales with the shorter screen edge.
|
||
_stick.position = Vector2.ZERO
|
||
_stick.size = Vector2(vp.x * 0.5, vp.y)
|
||
_stick_radius = clampf(minf(vp.x, vp.y) * 0.16, 80.0, 150.0)
|
||
|
||
|
||
func _process(_delta: float) -> void:
|
||
var active := Controls.use_touch_ui()
|
||
# The game-over / pause screen owns the display when paused — stand down and drop
|
||
# any held movement so the bull doesn't coast on a stuck joystick.
|
||
if not active or get_tree().paused:
|
||
if _root.visible:
|
||
_root.visible = false
|
||
if _stick_active:
|
||
_end_stick()
|
||
return
|
||
_root.visible = true
|
||
|
||
if _player == null:
|
||
var players := get_tree().get_nodes_in_group(&"player")
|
||
if not players.is_empty():
|
||
_player = players[0]
|
||
return
|
||
|
||
for slot: int in _cd_overlays.size():
|
||
var fraction: float = _player.call(&"ability_cooldown_fraction", slot)
|
||
_cd_overlays[slot].anchor_top = 1.0 - clampf(fraction, 0.0, 1.0)
|