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
146 lines
5.1 KiB
GDScript
146 lines
5.1 KiB
GDScript
@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).
|
|
#
|
|
# 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.
|
|
# 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:
|
|
# _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
|
|
# 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()
|
|
|
|
|
|
# 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:
|
|
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
|
|
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
|
|
# 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()
|
|
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)
|