spaceship/ui/TouchJoystick.gd

146 lines
5.1 KiB
GDScript3
Raw Permalink Normal View History

Draw the neon components in the editor, not just at runtime Nothing under ui/ or vfx/ was marked @tool, so every hand-drawn control rendered as an empty rect in the editor. BuildMenu.tscn positions its buttons with hand-tuned pixel offsets (offset_left = -204) around a glyph identified only as `icon = 8` - laying out invisible boxes by number. @tool on the three scene-placed components that are safe to run in the editor, covering 23 of the 34 placements: IconButton (20 scenes), TouchJoystick (2), NeonPanel (1). Two of them mutate persisted properties on _ready, which under @tool would write computed values back into the .tscn every time the scene was opened, so both are guarded with Engine.is_editor_hint(): NeonPanel._fit_to_screen() rewrites all four offsets TouchJoystick._relayout() overwrites max_radius, which is @export IconButton's _gui_input is guarded too, so clicking a button on the editor canvas does not emit `pressed`. The autoload-heavy drawers (ResourceBar, PriceTag, QuestPanel, ResourceTransferList, SkillTreeView, FormationEditor - 6 to 16 GameState./Quests. references each) are left alone; they need their call sites guarded first. Verified: 172/172 headless, and a --headless --editor pass loads the project with no script errors. That pass only proves the scripts parse under @tool - it never opens a scene, so it does not exercise the in-editor _draw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-08 14:13:51 +02:00
@tool
class_name TouchJoystick
extends Control
# Touch joystick for steering the fleet. Lives in the bottom-left corner.
#
# Behaves like a "floating" stick within its own rect: the base re-centres
# on wherever the finger first lands, so the player doesn't have to hit an
# exact spot on a phone screen they can't feel. The knob then follows the
# finger out to max_radius, and `direction` reports the offset normalised
# against that radius (so magnitude is 0..1, usable for throttle later).
#
Recentre a released stick, slow extractors again, and unbreak the lab layout Both sticks float - the base jumps to wherever the finger lands, so a player does not have to hit an exact spot on a screen they cannot feel. Releasing put only the KNOB back onto the base and left the base parked wherever that touch had been, so an idle stick sat off-centre in a different place every time and read as a control still in use. Release now returns base and knob to the middle of the rect. Extractors produce at 0.5 ore per second, halved again: at 1.0 they still outran the rest of the economy. An extractor now pays back its 25-ore cost in fifty seconds rather than twelve. The ship lab and the crafter were both overlapping themselves. Enlarging the shared resource table to 136px (so it could breathe and show bigger type) pushed its bottom edge 34px into the vessel row beneath it in one and the status text in the other - my regression, visible immediately on a real screen and not at all in any test. The lab's second row of upgrade cards was also already running into the detail text below it, by 13px, before any of this. Both menus are re-spaced end to end rather than nudged, since moving one collision into another is how the second one got there. Two tests carried waits tuned to an extraction rate that has now changed twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass does not report a broken extractor that is working perfectly - the same fix test_extractor_income already needed one round ago, in the file next to it. Suite 112/112. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
# Releasing puts BOTH the base and the knob back in the middle of the rect, so
# an idle stick always looks the same and always looks idle.
#
# Only claims touches that start inside its own rect - anything else falls
# through untouched, which is what keeps pinch-to-zoom working everywhere
# else on screen.
signal direction_changed(direction: Vector2)
signal released
# How far the knob travels from centre, as a fraction of the control's
# shorter side. Derived rather than a fixed number of pixels so that
# enlarging the stick's rect enlarges the ring the player actually sees -
# otherwise the touch area grows and the visible control stays small.
2026-09-02 11:19:54 +02:00
# The scene zones are deliberately wider than they are tall. Forty percent
# makes the resting ring just over twice its former radius while leaving room
# for the floating base to move inside the touch rect.
const RADIUS_RATIO: float = 0.40
@export var max_radius: float = 90.0
@export var deadzone: float = 0.12
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var core_color: Color = Color(1.0, 1.0, 1.0)
var direction: Vector2 = Vector2.ZERO
var active_touch_index: int = -1
var base_pos: Vector2 = Vector2.ZERO
var knob_pos: Vector2 = Vector2.ZERO
func _ready() -> void:
Draw the neon components in the editor, not just at runtime Nothing under ui/ or vfx/ was marked @tool, so every hand-drawn control rendered as an empty rect in the editor. BuildMenu.tscn positions its buttons with hand-tuned pixel offsets (offset_left = -204) around a glyph identified only as `icon = 8` - laying out invisible boxes by number. @tool on the three scene-placed components that are safe to run in the editor, covering 23 of the 34 placements: IconButton (20 scenes), TouchJoystick (2), NeonPanel (1). Two of them mutate persisted properties on _ready, which under @tool would write computed values back into the .tscn every time the scene was opened, so both are guarded with Engine.is_editor_hint(): NeonPanel._fit_to_screen() rewrites all four offsets TouchJoystick._relayout() overwrites max_radius, which is @export IconButton's _gui_input is guarded too, so clicking a button on the editor canvas does not emit `pressed`. The autoload-heavy drawers (ResourceBar, PriceTag, QuestPanel, ResourceTransferList, SkillTreeView, FormationEditor - 6 to 16 GameState./Quests. references each) are left alone; they need their call sites guarded first. Verified: 172/172 headless, and a --headless --editor pass loads the project with no script errors. That pass only proves the scripts parse under @tool - it never opens a scene, so it does not exercise the in-editor _draw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-08 14:13:51 +02:00
# _relayout() writes max_radius, which is @export - under @tool that
# would persist a computed value into the scene. The preview uses the
# authored radius instead; only a running game refits.
if Engine.is_editor_hint():
return
2026-09-02 20:18:44 +02:00
# Dialogs release every spring-loaded stick through one compositional
# contract, without NarrationBanner knowing scene paths or joystick roles.
add_to_group("touch_joysticks")
resized.connect(_relayout)
_relayout()
Recentre a released stick, slow extractors again, and unbreak the lab layout Both sticks float - the base jumps to wherever the finger lands, so a player does not have to hit an exact spot on a screen they cannot feel. Releasing put only the KNOB back onto the base and left the base parked wherever that touch had been, so an idle stick sat off-centre in a different place every time and read as a control still in use. Release now returns base and knob to the middle of the rect. Extractors produce at 0.5 ore per second, halved again: at 1.0 they still outran the rest of the economy. An extractor now pays back its 25-ore cost in fifty seconds rather than twelve. The ship lab and the crafter were both overlapping themselves. Enlarging the shared resource table to 136px (so it could breathe and show bigger type) pushed its bottom edge 34px into the vessel row beneath it in one and the status text in the other - my regression, visible immediately on a real screen and not at all in any test. The lab's second row of upgrade cards was also already running into the detail text below it, by 13px, before any of this. Both menus are re-spaced end to end rather than nudged, since moving one collision into another is how the second one got there. Two tests carried waits tuned to an extraction rate that has now changed twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass does not report a broken extractor that is working perfectly - the same fix test_extractor_income already needed one round ago, in the file next to it. Suite 112/112. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
# Where the stick sits when nobody is touching it: the middle of its own rect.
# The base only moves away from here while a finger is down (see handle_touch),
# and returns the moment it lifts.
func home_pos() -> Vector2:
return size / 2.0
func _relayout() -> void:
Recentre a released stick, slow extractors again, and unbreak the lab layout Both sticks float - the base jumps to wherever the finger lands, so a player does not have to hit an exact spot on a screen they cannot feel. Releasing put only the KNOB back onto the base and left the base parked wherever that touch had been, so an idle stick sat off-centre in a different place every time and read as a control still in use. Release now returns base and knob to the middle of the rect. Extractors produce at 0.5 ore per second, halved again: at 1.0 they still outran the rest of the economy. An extractor now pays back its 25-ore cost in fifty seconds rather than twelve. The ship lab and the crafter were both overlapping themselves. Enlarging the shared resource table to 136px (so it could breathe and show bigger type) pushed its bottom edge 34px into the vessel row beneath it in one and the status text in the other - my regression, visible immediately on a real screen and not at all in any test. The lab's second row of upgrade cards was also already running into the detail text below it, by 13px, before any of this. Both menus are re-spaced end to end rather than nudged, since moving one collision into another is how the second one got there. Two tests carried waits tuned to an extraction rate that has now changed twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass does not report a broken extractor that is working perfectly - the same fix test_extractor_income already needed one round ago, in the file next to it. Suite 112/112. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
base_pos = home_pos()
knob_pos = base_pos
max_radius = minf(size.x, size.y) * RADIUS_RATIO
queue_redraw()
func is_active() -> bool:
return active_touch_index != -1
# Returns true if the event was consumed, so the caller knows not to treat
# it as a world gesture (a fleet move, or one finger of a pinch).
func handle_touch(event: InputEventScreenTouch) -> bool:
var local: Vector2 = event.position - global_position
if event.pressed:
if active_touch_index != -1:
return false
if not Rect2(Vector2.ZERO, size).has_point(local):
return false
active_touch_index = event.index
base_pos = local
knob_pos = local
_update_direction()
return true
if event.index != active_touch_index:
return false
2026-09-02 20:18:44 +02:00
release_input()
return true
# Returns this spring-loaded control to its neutral state. Public and
# idempotent so a dialog can safely release movement and fire sticks before
# pausing their controllers (#182), even when only one is currently held.
func release_input() -> void:
var was_active: bool = is_active() or direction != Vector2.ZERO
active_touch_index = -1
Recentre a released stick, slow extractors again, and unbreak the lab layout Both sticks float - the base jumps to wherever the finger lands, so a player does not have to hit an exact spot on a screen they cannot feel. Releasing put only the KNOB back onto the base and left the base parked wherever that touch had been, so an idle stick sat off-centre in a different place every time and read as a control still in use. Release now returns base and knob to the middle of the rect. Extractors produce at 0.5 ore per second, halved again: at 1.0 they still outran the rest of the economy. An extractor now pays back its 25-ore cost in fifty seconds rather than twelve. The ship lab and the crafter were both overlapping themselves. Enlarging the shared resource table to 136px (so it could breathe and show bigger type) pushed its bottom edge 34px into the vessel row beneath it in one and the status text in the other - my regression, visible immediately on a real screen and not at all in any test. The lab's second row of upgrade cards was also already running into the detail text below it, by 13px, before any of this. Both menus are re-spaced end to end rather than nudged, since moving one collision into another is how the second one got there. Two tests carried waits tuned to an extraction rate that has now changed twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass does not report a broken extractor that is working perfectly - the same fix test_extractor_income already needed one round ago, in the file next to it. Suite 112/112. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
# Back to the middle of the rect, BASE included. This used to reset the
# knob onto base_pos and leave the base wherever the finger had landed, so
# a released stick stayed parked off-centre - which reads as a control
# still in use, and left the ring somewhere different every time.
base_pos = home_pos()
knob_pos = base_pos
direction = Vector2.ZERO
queue_redraw()
2026-09-02 20:18:44 +02:00
if was_active:
direction_changed.emit(direction)
released.emit()
func handle_drag(event: InputEventScreenDrag) -> bool:
if event.index != active_touch_index:
return false
knob_pos = event.position - global_position
_update_direction()
return true
func _update_direction() -> void:
var offset: Vector2 = knob_pos - base_pos
if offset.length() > max_radius:
offset = offset.normalized() * max_radius
knob_pos = base_pos + offset
var raw: Vector2 = offset / max_radius
direction = Vector2.ZERO if raw.length() < deadzone else raw
queue_redraw()
direction_changed.emit(direction)
func _draw() -> void:
var ring: PackedVector2Array = PackedVector2Array()
var segments: int = 28
for i in range(segments):
var a: float = (float(i) / segments) * TAU
ring.append(base_pos + Vector2(cos(a), sin(a)) * max_radius)
NeonDraw.draw_glowing_polygon(self, ring, glow_color, 0.55, core_color)
var knob: PackedVector2Array = PackedVector2Array()
var knob_radius: float = max_radius * 0.34
for i in range(segments):
var a: float = (float(i) / segments) * TAU
knob.append(knob_pos + Vector2(cos(a), sin(a)) * knob_radius)
NeonDraw.draw_glowing_polygon(self, knob, glow_color, 1.0 if is_active() else 0.75, core_color)