@@ -2,15 +2,23 @@ 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.
## All interaction is driven from raw `_input()` with manual hit-testing, NOT from
## Control.gui_input. On the web/wasm build the overlay lives in a CanvasLayer nested
## under the HUD, and GUI touch routing into a nested CanvasLayer renders fine but
## silently drops input — a standard Button here never fires. `_input()` sees every
## InputEventScreenTouch regardless of nesting, mouse_filter, or emulate_mouse_from_touch,
## so the joystick and buttons work reliably across browsers. Everything is drawn in one
## Control (_surface) whose rects are the single source of truth for both drawing and
## hit-testing.
##
## 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(), sharing 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.
## "force_touch_controls" is on so the layout can be exercised on a desktop build
## ( emulate_touch_from_mouse turns a mouse click into a touch event for that).
# move_* actions, indexed by the four joystick push directions.
const _MOVE_RIGHT : StringName = & " move_right "
@@ -33,80 +41,208 @@ const _ABILITY_ACCENT: Array[Color] = [
Color ( 1.00 , 0.82 , 0.32 ) , # Roll — gold
]
var _root : Control
var _stick : Control
var _cd_overlays : Array [ ColorRect ] = [ ]
# Sentinel touch indices. A real finger uses its own non-negative index; the mouse
# (desktop force-flag testing, when no real touch has been seen) uses _MOUSE.
const _MOUSE : int = - 2
var _surface : Control
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.
# Cached textures + styleboxes so the per-frame redraw (cooldowns animate) allocates nothing.
var _ability_tex : Array [ Texture2D ] = [ ]
var _ability_style : Array [ StyleBoxFlat ] = [ ]
var _menu_style : StyleBoxFlat
# Layout rects in screen space — the single source of truth for drawing AND hit-testing.
var _stick_zone : Rect2 = Rect2 ( )
var _ability_rects : Array [ Rect2 ] = [ ]
var _menu_rect : Rect2 = Rect2 ( )
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a
# first-time player sees where to plant their thumb instead of guessing. _hint_pulse gives
# it a slow breathing alpha for discoverability.
var _stick_hint_center : Vector2 = Vector2 ( )
var _hint_pulse : float = 0.0
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is
# the current thumb position; both in screen coordinates.
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
# Live fingers by index → screen position, plus what each finger is doing ("stick", a slot
# int, "menu", or "camera"). Ownership keeps a second finger on a button from stealing the
# stick, and keeps steering/ability fingers from being mistaken for a pinch gesture.
var _touches : Dictionary = { }
var _touch_owner : Dictionary = { }
var _saw_real_touch : bool = false
# Pinch-to-zoom: only "camera" fingers (right side, not on any button) count, so steering
# with one thumb while tapping an ability with the other never reads as a zoom.
var _pinching : bool = false
var _pinch_dist : float = 0.0
# Diagnostics surfaced by DP "touch_debug" — lets us see, on the actual device, whether
# events arrive and where, since no console is reachable there.
var _dbg_events : int = 0
var _dbg_last_pos : Vector2 = Vector2 . ZERO
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 )
for slot : int in 4 :
var tex : Texture2D = null
if ResourceLoader . exists ( _ABILITY_ICONS [ slot ] ) :
tex = load ( _ABILITY_ICONS [ slot ] )
_ability_tex . append ( tex )
_ability_style . append ( _button_style ( _ABILITY_ACCENT [ slot ] ) )
_menu_style = _button_style ( Color ( 1.00 , 0.78 , 0.22 , 0.8 ) )
_surface = Control . new ( )
_surface . set_anchors_preset ( Control . PRESET_FULL_RECT )
_surface . mouse_filter = Control . MOUSE_FILTER_IGNORE
_surface . visible = false
_surface . draw . connect ( _draw_surface )
add_child ( _surface )
_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 .
# ── Layout ──── ────────────────────────────────────────────────────────────────
# Left half is the joystick zone (touch anywhere in it to plant the base under the thumb);
# the ability cluster and menu button sit in the right half so they never overlap it .
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 _relayout ( ) - > void :
var vp : = get_viewport ( ) . get_visible_rect ( ) . size
var short : = minf ( vp . x , vp . y )
_stick_radius = clampf ( short * 0.16 , 80.0 , 150.0 )
_stick_zone = Rect2 ( Vector2 . ZERO , Vector2 ( vp . x * 0.5 , vp . y ) )
var bs := clampf ( short * 0.16 , 72.0 , 120.0 ) # ability button edge
var gap := bs * 0.18
var margin := bs * 0.28
var right := vp . x - margin
var bottom := vp . y - margin
var col0 := right - bs * 2.0 - gap
var col1 := right - bs
var row0 := bottom - bs * 2.0 - gap
var row1 := bottom - bs
_ability_rects = [
Rect2 ( col0 , row0 , bs , bs ) , # kick
Rect2 ( col1 , row0 , bs , bs ) , # dash
Rect2 ( col0 , row1 , bs , bs ) , # slam
Rect2 ( col1 , row1 , bs , bs ) , # roll
]
var ms := clampf ( short * 0.09 , 48.0 , 72.0 )
_menu_rect = Rect2 ( vp . x - ms - 16.0 , 16.0 , ms , ms )
_stick_hint_center = Vector2 ( vp . x * 0.20 , vp . y * 0.74 )
_surface . queue_redraw ( )
func _on_stick_input ( event : InputEvent ) - > void :
# ── Input ───────────────────────────────────────────────────────────────────
# Raw events only. Real touches drive the touch path; once any real touch is seen the
# mouse path is disabled (emulate_mouse_from_touch would otherwise double-fire every tap).
# The mouse path stays live for desktop force-flag testing where no touchscreen exists.
func _input ( event : InputEvent ) - > void :
if get_tree ( ) . paused or not Controls . use_touch_ui ( ) :
return
if event is InputEventScreenTouch :
_saw_real_touch = true
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 ( )
_dbg_events + = 1
_dbg_last_pos = t . position
if t . pressed :
_touches [ t . index ] = t . position
_on_press ( t . index , t . position )
else :
_on_release ( t . index , t . position )
_touches . erase ( t . index )
_update_pinch_state ( )
_surface . queue_redraw ( )
get_viewport ( ) . set_input_as_handled ( )
elif event is InputEventScreenDrag :
var d := event as InputEventScreenDrag
if _touch_index == d . index :
_update_stick ( d . position )
elif event is InputEventMouseButton :
_dbg_events + = 1
_dbg_last_pos = d . position
_touches [ d . index ] = d . position
_on_drag ( d . index , d . position )
_surface . queue_redraw ( )
get_viewport ( ) . set_input_as_handled ( )
elif not _saw_real_touch and 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 :
_dbg_events + = 1
_dbg_last_pos = mb . position
if mb . pressed :
_touches [ _MOUSE ] = mb . position
_on_press ( _MOUSE , mb . position )
else :
_on_release ( _MOUSE , mb . position )
_touches . erase ( _MOUSE )
_update_pinch_state ( )
_surface . queue_redraw ( )
elif not _saw_real_touch and event is InputEventMouseMotion :
if _touch_owner . get ( _MOUSE , null ) == " stick " :
_touches [ _MOUSE ] = ( event as InputEventMouseMotion ) . position
_update_stick ( _touches [ _MOUSE ] )
_surface . queue_redraw ( )
func _on_press ( index : int , pos : Vector2 ) - > void :
if _menu_rect . has_point ( pos ) :
_touch_owner [ index ] = " menu "
return
for slot : int in _ability_rects . size ( ) :
if _ability_rects [ slot ] . has_point ( pos ) :
_touch_owner [ index ] = slot
if _player != null :
_player . call ( & " try_activate_ability " , slot )
return
if _stick_zone . has_point ( pos ) and not _stick_active :
_touch_owner [ index ] = " stick "
_begin_stick ( pos )
return
_touch_owner [ index ] = " camera "
func _on_drag ( index : int , pos : Vector2 ) - > void :
match _touch_owner . get ( index , null ) :
" stick " :
_update_stick ( pos )
" camera " :
if _pinching :
_apply_pinch ( )
func _on_release ( index : int , pos : Vector2 ) - > void :
match _touch_owner . get ( index , null ) :
" stick " :
_end_stick ( )
elif event is InputEventMouseMotion and _touch_index == - 2 :
_update_stick ( ( event as InputEventMouseMotion ) . position )
" menu " :
if _menu_rect . has_point ( pos ) :
_touch_owner . erase ( index )
_return_to_menu ( )
return
_touch_owner . erase ( index )
func _begin_stick ( pos : Vector2 , index : int ) - > void :
# ── Joystick ────────────────────────────────────────────────────────────────
func _begin_stick ( pos : Vector2 ) - > void :
_stick_active = true
_touch_index = index
_origin = pos
_knob = pos
_stick . queue_redraw ( )
func _update_stick ( pos : Vector2 ) - > void :
@@ -117,19 +253,16 @@ func _update_stick(pos: Vector2) -> void:
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.
# 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 ) )
@@ -142,139 +275,184 @@ func _release_move() -> void:
Input . action_release ( action )
func _draw_stick ( ) - > void :
if not _stick_active :
# ── Pinch-to-zoom ─────────────────────────────────────────────────────────────
# Only "camera" fingers (open right-side area, not on a button) participate, so it can't
# fire while the player is steering or tapping abilities.
func _camera_touches ( ) - > Array :
var pts : Array = [ ]
for idx : int in _touches :
if _touch_owner . get ( idx , null ) == " camera " :
pts . append ( _touches [ idx ] )
return pts
func _update_pinch_state ( ) - > void :
var pts := _camera_touches ( )
var was := _pinching
_pinching = pts . size ( ) > = 2
if _pinching and not was :
if _stick_active :
_end_stick ( )
_pinch_dist = ( pts [ 0 ] as Vector2 ) . distance_to ( pts [ 1 ] as Vector2 )
elif was and not _pinching :
_pinch_dist = 0.0
func _apply_pinch ( ) - > void :
var pts := _camera_touches ( )
if pts . size ( ) < 2 :
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 )
var dist := ( pts [ 0 ] as Vector2 ) . distance_to ( pts [ 1 ] as Vector2 )
if _pinch_dist < = 0.0 or dist < = 0.0 :
_pinch_dist = dist
return
var cam := get_viewport ( ) . get_camera_3d ( )
if cam != null and cam . has_method ( & " adjust_zoom " ) :
cam . call ( & " adjust_zoom " , dist / _pinch_dist )
_pinch_dist = dist
# ── 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.
# ── Drawing ────────── ─────────────────────────────────────────────────────────
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 )
func _draw_surface ( ) - > void :
for slot : int in _ability_rects . size ( ) :
var r : Rect2 = _ability_rects [ slot ]
_surface . draw_style_box ( _ability_style [ slot ] , r )
var inner := r . grow ( - r . size . x * 0.14 )
if _ability_tex [ slot ] != null :
_draw_icon_fit ( _ability_tex [ slot ] , inner )
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 )
_draw_centered_text ( _ABILITY_LETTERS [ slot ] , r , 40 , _ABILITY_ACCENT [ slot ] )
var frac : = 0.0
if _player != null :
frac = clampf ( _player . call ( & " ability_cooldown_fraction " , slot ) , 0.0 , 1.0 )
if frac > 0.0 :
var ch : = r . size . y * frac
_surface . draw_rect (
Rect2 ( r . position . x , r . position . y + r . size . y - ch , r . size . x , ch ) ,
Color ( 0.0 , 0.0 , 0.0 , 0.55 ) )
# 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 )
_surface . draw_style_box ( _menu_style , _menu_rect )
_draw_hamburger ( _menu_rect )
# Fire on press (not release) so abilities feel instant under the thumb.
panel . gui_input . connect ( _on_ability_input . bind ( slot ) )
return panel
if _stick_active :
_surface . draw_circle ( _origin , _stick_radius , Color ( 0.0 , 0.0 , 0.0 , 0.20 ) )
_surface . draw_arc (
_origin , _stick_radius , 0.0 , TAU , 48 , Color ( 0.93 , 0.88 , 0.72 , 0.30 ) , 4.0 , true )
_surface . draw_circle ( _knob , _stick_radius * 0.42 , Color ( 1.00 , 0.82 , 0.32 , 0.55 ) )
elif not _pinching :
_draw_stick_hint ( )
if Controls . debug_overlay ( ) :
_draw_debug ( )
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 )
# A faint "ghost" joystick at rest in the lower-left with a caption, so the movement zone
# is discoverable. Breathes gently via _hint_pulse; vanishes the moment the stick is grabbed.
func _draw_stick_hint ( ) - > void :
var a : = 0.16 + 0.12 * ( 0.5 + 0.5 * sin ( _hint_pulse ) )
var c := _stick_hint_center
var r := _stick_radius * 0.72
_surface . draw_circle ( c , r , Color ( 0.0 , 0.0 , 0.0 , a * 0.45 ) )
_surface . draw_arc ( c , r , 0.0 , TAU , 40 , Color ( 0.93 , 0.88 , 0.72 , a + 0.14 ) , 3.0 , true )
_surface . draw_circle ( c , r * 0.32 , Color ( 1.00 , 0.82 , 0.32 , a + 0.16 ) )
var font := ThemeDB . fallback_font
if font != null :
var txt := " DRAG TO MOVE "
var fs := 18
var tw := font . get_string_size ( txt , HORIZONTAL_ALIGNMENT_LEFT , - 1 , fs )
_surface . draw_string (
font , c + Vector2 ( - tw . x * 0.5 , r + 28.0 ) , txt , HORIZONTAL_ALIGNMENT_LEFT , - 1 , fs ,
Color ( 0.93 , 0.88 , 0.72 , a + 0.30 ) )
func _button_style ( slot : int ) - > StyleBoxFlat :
func _draw_icon_fit ( tex : Texture2D , box : Rect2 ) - > void :
var ts := tex . get_size ( )
if ts . x < = 0.0 or ts . y < = 0.0 :
return
var scale := minf ( box . size . x / ts . x , box . size . y / ts . y )
var ds := ts * scale
_surface . draw_texture_rect ( tex , Rect2 ( box . position + ( box . size - ds ) * 0.5 , ds ) , false )
func _draw_centered_text ( text : String , box : Rect2 , size : int , color : Color ) - > void :
var font := ThemeDB . fallback_font
if font == null :
return
var tw := font . get_string_size ( text , HORIZONTAL_ALIGNMENT_LEFT , - 1 , size )
var pos := box . position + ( box . size - tw ) * 0.5 + Vector2 ( 0.0 , tw . y * 0.35 )
_surface . draw_string ( font , pos , text , HORIZONTAL_ALIGNMENT_LEFT , - 1 , size , color )
# Three stacked bars — a "☰" glyph drawn by hand, since the fallback font that ships with
# the web build has no U+2630 and renders it as tofu / a codepoint number.
func _draw_hamburger ( r : Rect2 ) - > void :
var col := Color ( 1.00 , 0.78 , 0.22 )
var bw := r . size . x * 0.5
var bh := maxf ( 3.0 , r . size . y * 0.09 )
var x := r . position . x + ( r . size . x - bw ) * 0.5
var cy := r . position . y + r . size . y * 0.5
var gap := r . size . y * 0.22
for i : int in [ - 1 , 0 , 1 ] :
_surface . draw_rect ( Rect2 ( x , cy + i * gap - bh * 0.5 , bw , bh ) , col )
func _draw_debug ( ) - > void :
var font := ThemeDB . fallback_font
if font == null :
return
var players := get_tree ( ) . get_nodes_in_group ( & " player " )
var mats := get_tree ( ) . get_nodes_in_group ( & " matador " )
var lines := [
" GPU: %s " % RenderingServer . get_video_adapter_name ( ) ,
" API: %s " % RenderingServer . get_video_adapter_api_version ( ) ,
" method= %s web= %s touch_ui= %s " % [
ProjectSettings . get_setting ( " rendering/renderer/rendering_method " , " ? " ) ,
OS . has_feature ( " web " ) , Controls . use_touch_ui ( ) ] ,
" bull: %s " % _node_report ( players ) ,
" matador( %d ): %s " % [ mats . size ( ) , _node_report ( mats ) ] ,
" events= %d last= %s touches= %d stick= %s " % [
_dbg_events , str ( _dbg_last_pos . round ( ) ) , _touches . size ( ) , _stick_active ] ,
]
var fs := 18
var line_h := 24.0
var w := 0.0
for line : String in lines :
w = maxf ( w , font . get_string_size ( line , HORIZONTAL_ALIGNMENT_LEFT , - 1 , fs ) . x )
_surface . draw_rect (
Rect2 ( 10.0 , 34.0 , w + 16.0 , line_h * lines . size ( ) + 12.0 ) , Color ( 0.0 , 0.0 , 0.0 , 0.62 ) )
var y := 34.0 + 22.0
for line : String in lines :
_surface . draw_string (
font , Vector2 ( 18.0 , y ) , line , HORIZONTAL_ALIGNMENT_LEFT , - 1 , fs ,
Color ( 1.0 , 1.0 , 0.3 ) )
y + = line_h
# One-line "does this character exist / is it drawable / where is it" report for the debug
# overlay, so an invisible bull/matador on-device can be pinned to spawn vs. position vs.
# GPU-draw failure.
func _node_report ( nodes : Array ) - > String :
if nodes . is_empty ( ) :
return " NONE "
var n := nodes [ 0 ] as Node3D
if n == null :
return " not Node3D "
return " vis= %s pos= %s " % [ n . is_visible_in_tree ( ) , str ( n . global_position . round ( ) ) ]
func _button_style ( accent : Color ) - > 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 ]
s . border_color = accent
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 )
# ── Menu ──────── ──────────────────────────────────────────────────────────────
func _return_to_menu ( ) - > void :
get_tree ( ) . paused = false
@@ -282,34 +460,26 @@ func _return_to_menu() -> void:
get_tree ( ) . change_scene_to_file ( " res://MainMenu.tscn " )
# ── Layout & l ifecycle ──────────────────────────────────────────────────────
# ── 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
func _process ( delta : float ) - > void :
_hint_pulse = fmod ( _hint_pulse + delta * 2.2 , TAU )
var active := Controls . use_touch_ui ( ) and not get_tree ( ) . paused
# 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 :
if _surface . visible :
_surface . visible = false
if _stick_active :
_end_stick ( )
_touches . clear ( )
_touch_owner . clear ( )
_pinching = false
return
_root . visible = true
_surface . 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 )
_surface . queue_redraw ( ) # cooldown overlays animate every frame