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)