#84: move 215 root files into a folder hierarchy

Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-21 19:44:14 +02:00
commit 2f161621c3
495 changed files with 571 additions and 567 deletions

138
ui/FormationEditor.gd Normal file
View file

@ -0,0 +1,138 @@
extends Control
# Drag-and-drop editor for the fleet's formation, opened at a command
# centre. Each slot is a draggable marker; dropping one writes the new
# offset straight back into GameState.formation, so the change survives
# scene swaps and applies to any fleet rebuilt later.
#
# Works in "formation space": the panel centre represents the fleet centre,
# and one formation unit maps to `scale_factor` screen pixels.
signal closed
signal formation_changed
@export var scale_factor: float = 1.4
@export var min_scale: float = 0.5
@export var max_scale: float = 4.0
@export var marker_radius: float = 26.0
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
var dragging_index: int = -1
var drag_touch_index: int = -1
# Second finger = pinch to zoom the layout view, so a spread-out formation
# can be inspected close up or seen whole. Zooming only changes how the
# formation is DISPLAYED - the stored offsets are untouched.
var touches: Dictionary = {}
var pinch_active: bool = false
var pinch_start_distance: float = 0.0
var pinch_start_scale: float = 1.0
func _ready() -> void:
queue_redraw()
func _centre() -> Vector2:
return size / 2.0
func _slot_screen_pos(i: int) -> Vector2:
return _centre() + GameState.formation[i] * scale_factor
func _screen_to_formation(p: Vector2) -> Vector2:
return (p - _centre()) / scale_factor
# Returns true when the event was consumed. Called from BuildController,
# which forwards input while the editor is open.
func handle_touch(event: InputEventScreenTouch) -> bool:
if not visible:
return false
var local: Vector2 = event.position - global_position
if event.pressed:
touches[event.index] = event.position
if touches.size() == 2:
# A second finger turns the gesture into a pinch: drop any slot
# being dragged so it isn't yanked around while zooming.
dragging_index = -1
drag_touch_index = -1
var positions: Array = touches.values()
pinch_start_distance = positions[0].distance_to(positions[1])
pinch_start_scale = scale_factor
pinch_active = true
queue_redraw()
return true
if dragging_index != -1:
return false
for i in range(GameState.max_fleet_size()):
if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4:
dragging_index = i
drag_touch_index = event.index
queue_redraw()
return true
return false
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
if event.index != drag_touch_index:
return false
dragging_index = -1
drag_touch_index = -1
queue_redraw()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not visible:
return false
if touches.has(event.index):
touches[event.index] = event.position
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
var positions: Array = touches.values()
var current: float = positions[0].distance_to(positions[1])
# Spreading apart zooms in, matching the world pinch.
scale_factor = clamp(
pinch_start_scale * (current / max(pinch_start_distance, 1.0)),
min_scale, max_scale
)
queue_redraw()
return true
if event.index != drag_touch_index or dragging_index == -1:
return false
var local: Vector2 = event.position - global_position
# Clamped so a slot can't be dragged off the panel and become
# unreachable (and so the fleet can't be spread absurdly far apart).
var f: Vector2 = _screen_to_formation(local)
var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor
GameState.formation[dragging_index] = Vector2(
clamp(f.x, -limit, limit), clamp(f.y, -limit, limit)
)
queue_redraw()
formation_changed.emit()
return true
func _draw() -> void:
var centre: Vector2 = _centre()
# Reference cross for the fleet centre.
var tick: float = 14.0
NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3)
NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3)
for i in range(GameState.max_fleet_size()):
var p: Vector2 = _slot_screen_pos(i)
var held: bool = i == dragging_index
# Ship glyph, so the layout reads the way it will look in flight.
var points: PackedVector2Array = PackedVector2Array([
p + Vector2(0, -marker_radius),
p + Vector2(-marker_radius * 0.66, marker_radius * 0.72),
p + Vector2(marker_radius * 0.66, marker_radius * 0.72),
])
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0 if held else 0.7)

View file

@ -0,0 +1 @@
uid://dv12yrraa6oer

771
ui/IconButton.gd Normal file
View file

@ -0,0 +1,771 @@
class_name IconButton
extends Control
# Square neon-outlined button, hand-drawn to match the game's existing
# no-texture glowing look (same NeonDraw technique as ships/enemies) -
# no icon image assets, just a glyph drawn inside the frame.
signal pressed
# Scenes store `icon` as a plain int, so NEW ENTRIES MUST BE APPENDED -
# inserting one would silently repoint every button after it.
enum Icon {
MAP, CLOSE, SHIP, DOWN_ARROW, UP_ARROW, BUILD, ROTATE_CW, ROTATE_CCW,
# One per building, each a miniature of what actually gets placed. The
# build menu used to pick from the generic set, which left the mothership
# and the command centre sharing a folded-map glyph and neither of them
# looking anything like the structure they build.
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, SHIP_LAB,
# Moving ore between the fleet and a building (#24). Two arrows passing
# each other: the single up/down arrows already mean "load" and "store"
# one way each, and this button is the place where both live.
TRANSFER,
# Ship-lab upgrade glyphs. These deliberately describe the effect rather
# than the building hosting it, so cards remain scannable at a glance.
UPGRADE_DAMAGE, UPGRADE_RATE, UPGRADE_RANGE, UPGRADE_THRUSTERS,
UPGRADE_SHIELD_HITS, UPGRADE_SHIELD_RECHARGE, UPGRADE_CARGO,
UPGRADE_ENGINES, UPGRADE_UNLOCK_SHIELD,
ACTION_MENU,
GAS,
LEFT_ARROW, RIGHT_ARROW,
MINUS, PLUS,
SHIELD_VESSEL,
GAS_BOMBER, GAS_BOMB,
# Appended, not inserted alongside the other building icons above -
# inserting there would silently renumber every icon int already
# stored in existing scenes (#54).
GAS_BURNER,
# Same append-only rule (#57): a wide hauler hull, distinct from every
# vessel glyph above.
CARRIER_VESSEL,
# #58: LASER_VESSEL is the ship glyph, LASER_SHOT the action button -
# same two-icon split as GAS_BOMBER/GAS_BOMB above.
LASER_VESSEL, LASER_SHOT,
# #59 drew this as a warped ring, for a vessel then called a space-time
# distorder. #77 renamed it the Energy Propulser and the glyph is now an
# engine bell; the enum name stays put because renumbering it would
# silently renumber every icon int stored in every scene.
DISTORDER_VESSEL,
# #78: switching a converter off and on. Appended, like everything above -
# inserting anywhere earlier renumbers every icon int already stored in a
# scene file.
PAUSE, RESUME,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
# scaled by (actual size / this), so the button can be resized in the scene
# without the icon inside it staying small.
const REFERENCE_SIZE: float = 56.0
@export var icon: int = Icon.MAP
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
# Selection is an additional frame, never a recolour. It lets menus retain
# semantic colours (vessel class, danger, cargo) while still making the
# active choice unmistakable.
@export var highlighted: bool = false: set = set_highlighted
# Colour of the bright core stroke. Defaults to near-white, which reads well
# on the dark space background; scenes drawing over a bright backdrop (the
# lunar surface) override this with a dark colour so the button doesn't wash
# out. See NeonDraw.
@export var core_color: Color = Color(1.0, 1.0, 1.0)
# When false the button is drawn greyed out, but STILL emits `pressed` -
# the caller decides what to do, which is what lets an unavailable option
# explain itself instead of silently doing nothing.
@export var enabled: bool = true: set = set_enabled
@export var disabled_color: Color = Color(0.42, 0.45, 0.5)
var pressed_down: bool = false
func set_enabled(value: bool) -> void:
if enabled == value:
return
enabled = value
queue_redraw()
func set_highlighted(value: bool) -> void:
if highlighted == value:
return
highlighted = value
queue_redraw()
func _ready() -> void:
queue_redraw()
func _blocked_by_modal() -> bool:
# A panel is open and this button is not part of it, so it is sitting
# BEHIND the panel and must not react.
#
# The panels cannot simply swallow GUI input with a mouse_filter of STOP:
# the skill tree and the formation editor are driven from raw
# _unhandled_input (see SkillTreeView), and a Control that consumes
# touches through the GUI system swallows them before they ever get
# there. So the panels stay transparent to input and the buttons check
# instead.
for menu in get_tree().get_nodes_in_group("modal_menus"):
# is_visible_in_tree(), NOT visible. Hiding a parent leaves the
# child's own `visible` flag true, and the build menu's info popup is
# itself a modal panel - so closing the build menu with the X left an
# invisible popup still claiming to be open, and every button in the
# game was blocked from then on. That is not a state a player can
# get out of.
if menu.is_visible_in_tree() and not menu.is_ancestor_of(self):
return true
return false
func _gui_input(event: InputEvent) -> void:
if _blocked_by_modal():
return
if event is InputEventScreenTouch:
_handle_press(event.pressed, event.position)
accept_event()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_handle_press(event.pressed, event.position)
accept_event()
# Complete the press immediately instead of waiting for a release.
#
# Measured on the device, in the web build: an on-screen probe reported
# `touch=1 mouse=1 held=y` after one tap - one touch down, one emulated mouse
# down, the button latched into its held state through ordinary GUI routing,
# and then nothing. The release is never delivered. A button that only emits
# on release therefore never emits at all.
#
# GUI routing itself works fine; `held=y` is the proof. An earlier attempt to
# bypass it made things worse and hid the evidence.
#
# The cost is the drag-off-to-cancel affordance below. Everywhere that
# delivers releases keeps it.
@export var fire_on_press: bool = OS.has_feature("web")
# Round rather than square. The in-world action controls - the abilities
# arranged around the fire stick, and the pivot pair - read as a different
# kind of thing from the square panel and menu buttons, and a round target is
# what a thumb reaching for an ability expects. Purely how the frame is drawn;
# the hit area is still the whole rect.
@export var round_frame: bool = false
# The frame outline, inset by `inset` pixels on every side. Round buttons trace
# a circle through the same inset box, so a round and a square button of the
# same size present the same footprint.
func _frame_points(inset: float) -> PackedVector2Array:
if not round_frame:
return PackedVector2Array([
Vector2(inset, inset),
Vector2(size.x - inset, inset),
Vector2(size.x - inset, size.y - inset),
Vector2(inset, size.y - inset),
])
var centre: Vector2 = size / 2.0
var radius: Vector2 = centre - Vector2(inset, inset)
var points: PackedVector2Array = PackedVector2Array()
for i in range(32):
var angle: float = TAU * float(i) / 32.0
points.append(centre + Vector2(cos(angle) * radius.x, sin(angle) * radius.y))
return points
func _handle_press(is_pressed: bool, at_position: Vector2) -> void:
if is_pressed:
pressed_down = true
queue_redraw()
if fire_on_press:
# Not latched: nothing is coming to clear it.
pressed_down = false
pressed.emit()
elif pressed_down:
pressed_down = false
queue_redraw()
# Only counts as a real press if the finger/cursor is still over
# the button when it lifts - dragging off before releasing (a
# common way to "cancel" a touch) must not still trigger it.
if Rect2(Vector2.ZERO, size).has_point(at_position):
pressed.emit()
func _active_color() -> Color:
return glow_color if enabled else disabled_color
func _draw() -> void:
var rect_size: Vector2 = size
var s: float = min(rect_size.x, rect_size.y) / REFERENCE_SIZE
# Constant glow, brighter while held. Only the press state changes it,
# so the button redraws on press/release instead of every frame.
var pulse: float = 1.0 if pressed_down else 0.85
var margin: float = 4.0 * s
NeonDraw.draw_glowing_polygon(self, _frame_points(margin), _active_color(), pulse, core_color)
if highlighted:
# The inset frame reads as a selection focus ring. It deliberately uses
# the bright core rather than the button's glow colour, so selecting a
# blue shield or pink transporter does not change what that colour means.
NeonDraw.draw_glowing_polygon(self, _frame_points(10.0 * s), core_color, 1.0,
core_color, 0.72 * s)
var center: Vector2 = rect_size / 2.0
match icon:
Icon.CLOSE:
_draw_close(center, pulse, s)
Icon.SHIP:
_draw_ship(center, pulse, s)
Icon.MAP:
_draw_map(center, pulse, s)
Icon.DOWN_ARROW:
_draw_arrow(center, pulse, 1.0, s)
Icon.UP_ARROW:
_draw_arrow(center, pulse, -1.0, s)
Icon.BUILD:
_draw_build(center, pulse, s)
Icon.ROTATE_CW:
_draw_rotate(center, pulse, s, 1.0)
Icon.ROTATE_CCW:
_draw_rotate(center, pulse, s, -1.0)
Icon.MOTHERSHIP:
_draw_mothership_icon(center, pulse, s)
Icon.CRAFTER:
_draw_crafter_icon(center, pulse, s)
Icon.COMMAND_CENTER:
_draw_command_icon(center, pulse, s)
Icon.EXTRACTOR:
_draw_extractor_icon(center, pulse, s)
Icon.TRANSPORT_HUB:
_draw_transport_hub_icon(center, pulse, s)
Icon.SHIP_LAB:
_draw_ship_lab_icon(center, pulse, s)
Icon.TRANSFER:
_draw_transfer_icon(center, pulse, s)
Icon.UPGRADE_DAMAGE:
_draw_upgrade_damage(center, pulse, s)
Icon.UPGRADE_RATE:
_draw_upgrade_rate(center, pulse, s)
Icon.UPGRADE_RANGE:
_draw_upgrade_range(center, pulse, s)
Icon.UPGRADE_THRUSTERS:
_draw_upgrade_thrusters(center, pulse, s)
Icon.UPGRADE_SHIELD_HITS:
_draw_upgrade_shield_hits(center, pulse, s)
Icon.UPGRADE_SHIELD_RECHARGE:
_draw_upgrade_shield_recharge(center, pulse, s)
Icon.UPGRADE_CARGO:
_draw_upgrade_cargo(center, pulse, s)
Icon.UPGRADE_ENGINES:
_draw_upgrade_engines(center, pulse, s)
Icon.UPGRADE_UNLOCK_SHIELD:
_draw_upgrade_unlock_shield(center, pulse, s)
Icon.ACTION_MENU:
_draw_action_menu(center, pulse, s)
Icon.GAS:
_draw_gas(center, pulse, s)
Icon.LEFT_ARROW:
_draw_horizontal_arrow(center, pulse, -1.0, s)
Icon.RIGHT_ARROW:
_draw_horizontal_arrow(center, pulse, 1.0, s)
Icon.MINUS:
_draw_minus(center, pulse, s)
Icon.PLUS:
_draw_plus(center, pulse, s)
Icon.SHIELD_VESSEL:
_draw_shield_vessel(center, pulse, s)
Icon.GAS_BOMBER:
_draw_gas_bomber(center, pulse, s)
Icon.GAS_BOMB:
_draw_gas_bomb(center, pulse, s)
Icon.GAS_BURNER:
_draw_gas_burner_icon(center, pulse, s)
Icon.PAUSE:
_draw_pause(center, pulse, s)
Icon.RESUME:
_draw_resume(center, pulse, s)
Icon.CARRIER_VESSEL:
_draw_carrier(center, pulse, s)
Icon.LASER_VESSEL:
_draw_laser_vessel(center, pulse, s)
Icon.LASER_SHOT:
_draw_laser_shot(center, pulse, s)
Icon.DISTORDER_VESSEL:
_draw_distorder_vessel(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
var r: float = 10.0 * s
NeonDraw.draw_glowing_line(self, center + Vector2(-r, -r), center + Vector2(r, r), _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-r, r), center + Vector2(r, -r), _active_color(), pulse, 0.5 * s, core_color)
func _draw_ship(center: Vector2, pulse: float, s: float) -> void:
var nose: Vector2 = center + Vector2(0, -11) * s
var left: Vector2 = center + Vector2(-7, 8) * s
var right: Vector2 = center + Vector2(7, 8) * s
var points: PackedVector2Array = PackedVector2Array([nose, left, right])
NeonDraw.draw_glowing_polygon(self, points, _active_color(), pulse, core_color)
func _draw_shield_vessel(center: Vector2, pulse: float, s: float) -> void:
var points: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = -PI / 2.0 + float(i) * TAU / 6.0
points.append(center + Vector2(cos(angle), sin(angle)) * 11.0 * s)
NeonDraw.draw_glowing_polygon(self, points, _active_color(), pulse, core_color)
func _draw_carrier(center: Vector2, pulse: float, s: float) -> void:
# Wide and flat-bottomed rather than pointed - reads as a hauler, not a
# combat silhouette, matching the ship's own hull_points.
var hull := PackedVector2Array([
center + Vector2(-13, -4) * s,
center + Vector2(-7, -8) * s,
center + Vector2(7, -8) * s,
center + Vector2(13, -4) * s,
center + Vector2(13, 6) * s,
center + Vector2(-13, 6) * s,
])
NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color)
func _draw_distorder_vessel(center: Vector2, pulse: float, s: float) -> void:
# #77: an engine bell with a thrust plume under it. The glyph used to be a
# ring with a wobbling radius, which read as warped space - correct for a
# "space-time distorder" and wrong for the Energy Propulser it now is.
var hull: PackedVector2Array = PackedVector2Array([
center + Vector2(0.0, -13.0) * s,
center + Vector2(5.0, -4.0) * s,
center + Vector2(7.0, 3.0) * s,
center + Vector2(10.0, 10.0) * s,
center + Vector2(-10.0, 10.0) * s,
center + Vector2(-7.0, 3.0) * s,
center + Vector2(-5.0, -4.0) * s,
])
NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color)
# Three exhaust strokes, the middle one longest, so the thrust reads as
# coming OUT of the bell rather than as decoration beside it.
for offset in [-5.0, 0.0, 5.0]:
var length: float = 16.0 if is_zero_approx(offset) else 12.0
NeonDraw.draw_glowing_line(self,
center + Vector2(offset, 11.0) * s,
center + Vector2(offset, length) * s,
_active_color(), pulse * 0.8, 0.5, core_color)
func _draw_laser_vessel(center: Vector2, pulse: float, s: float) -> void:
# A slim dart rather than the interceptor's plain triangle - the notch at
# the nose reads as a fixed forward-facing emitter, not a hull point.
var hull := PackedVector2Array([
center + Vector2(0, -13) * s,
center + Vector2(-8, 9) * s,
center + Vector2(-3, 6) * s,
center + Vector2(3, 6) * s,
center + Vector2(8, 9) * s,
])
NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color)
func _draw_laser_shot(center: Vector2, pulse: float, s: float) -> void:
var from: Vector2 = center + Vector2(-9, 9) * s
var to: Vector2 = center + Vector2(9, -9) * s
NeonDraw.draw_glowing_line(self, from, to, _active_color(), pulse, 1.1, core_color)
NeonDraw.draw_glowing_circle(self, to, 3.0 * s, _active_color(), pulse, core_color)
func _draw_gas_bomber(center: Vector2, pulse: float, s: float) -> void:
var hull := PackedVector2Array([
center + Vector2(0, -12) * s,
center + Vector2(-12, 7) * s,
center + Vector2(-5, 5) * s,
center + Vector2(0, 11) * s,
center + Vector2(5, 5) * s,
center + Vector2(12, 7) * s,
])
NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color)
NeonDraw.draw_glowing_circle(self, center + Vector2(0, 2) * s, 4.0 * s,
_active_color(), pulse, core_color)
func _draw_gas_bomb(center: Vector2, pulse: float, s: float) -> void:
NeonDraw.draw_glowing_circle(self, center, 7.0 * s, _active_color(), pulse, core_color)
for direction in [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT]:
NeonDraw.draw_glowing_circle(self, center + direction * 9.0 * s, 3.5 * s,
_active_color(), pulse * 0.85, core_color)
func _draw_gas_burner_icon(center: Vector2, pulse: float, s: float) -> void:
# A hexagonal chamber with a hot core and vents, miniaturised from the
# building's own silhouette (BuildingVisualBase._draw_gas_burner_static)
# so the menu icon reads as the same structure being placed.
var outer: PackedVector2Array = PackedVector2Array()
var core: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
outer.append(center + Vector2(cos(a), sin(a)) * 11.0 * s)
core.append(center + Vector2(cos(a), sin(a)) * 5.5 * s)
NeonDraw.draw_glowing_polygon(self, outer, _active_color(), pulse, core_color)
NeonDraw.draw_glowing_polygon(self, core, _active_color(), pulse * 0.85, core_color)
for i in range(4):
var a: float = (float(i) / 4.0) * TAU + PI / 4.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + dir * 11.0 * s, center + dir * 15.5 * s,
_active_color(), pulse, 0.35 * s, core_color)
func _draw_arrow(center: Vector2, pulse: float, direction: float, s: float) -> void:
var r: float = 9.0 * s
var tip: Vector2 = center + Vector2(0, r * direction)
var shaft_top: Vector2 = center + Vector2(0, -r * direction)
NeonDraw.draw_glowing_line(self, shaft_top, tip, _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color)
func _draw_horizontal_arrow(center: Vector2, pulse: float, direction: float, s: float) -> void:
var r: float = 10.0 * s
var tip: Vector2 = center + Vector2(r * direction, 0.0)
var tail: Vector2 = center + Vector2(-r * direction, 0.0)
NeonDraw.draw_glowing_line(self, tail, tip, _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-r * 0.8 * direction, -r * 0.7), _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-r * 0.8 * direction, r * 0.7), _active_color(), pulse, 0.5 * s, core_color)
func _draw_pause(center: Vector2, pulse: float, s: float) -> void:
# Two upright bars. Shown while something is RUNNING, because that is the
# action the button performs, not the state it is in.
for side in [-1.0, 1.0]:
var x: float = 5.0 * side * s
NeonDraw.draw_glowing_line(self, center + Vector2(x, -11.0 * s),
center + Vector2(x, 11.0 * s), _active_color(), pulse, 0.9, core_color)
func _draw_resume(center: Vector2, pulse: float, s: float) -> void:
# A play triangle, shown while something is stopped.
var play: PackedVector2Array = PackedVector2Array([
center + Vector2(-7.0, -11.0) * s,
center + Vector2(11.0, 0.0) * s,
center + Vector2(-7.0, 11.0) * s,
])
NeonDraw.draw_glowing_polygon(self, play, _active_color(), pulse, core_color)
func _draw_minus(center: Vector2, pulse: float, s: float) -> void:
var r: float = 9.0 * s
NeonDraw.draw_glowing_line(self, center + Vector2(-r, 0.0), center + Vector2(r, 0.0),
_active_color(), pulse, 0.55 * s, core_color)
func _draw_plus(center: Vector2, pulse: float, s: float) -> void:
_draw_minus(center, pulse, s)
var r: float = 9.0 * s
NeonDraw.draw_glowing_line(self, center + Vector2(0.0, -r), center + Vector2(0.0, r),
_active_color(), pulse, 0.55 * s, core_color)
func _draw_transfer_icon(center: Vector2, pulse: float, s: float) -> void:
# Two shafts side by side pointing opposite ways. Drawn as a pair rather
# than as one double-headed arrow because the button's whole job is that
# ore moves BOTH ways here, and a double-headed arrow reads as one thing
# sliding back and forth.
var r: float = 10.0 * s
var gap: float = 7.0 * s
for side in [-1.0, 1.0]:
# Left shaft points up (into the fleet), right shaft points down.
var direction: float = side
var x: float = gap * side
var tip: Vector2 = center + Vector2(x, r * direction)
var tail: Vector2 = center + Vector2(x, -r * direction)
NeonDraw.draw_glowing_line(self, tail, tip, _active_color(), pulse, 0.42 * s, core_color)
var head: float = 5.0 * s
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-head, -head * direction),
_active_color(), pulse, 0.42 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(head, -head * direction),
_active_color(), pulse, 0.42 * s, core_color)
# --- ship-lab upgrade glyphs ---------------------------------------------
func _draw_upgrade_damage(center: Vector2, pulse: float, s: float) -> void:
# A heavy projectile striking a target.
NeonDraw.draw_glowing_line(self, center + Vector2(-13, 0) * s, center + Vector2(5, 0) * s, _active_color(), pulse, 0.7 * s, core_color)
for a in [0.0, PI * 0.5, PI, PI * 1.5]:
var dir := Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + Vector2(8, 0) * s, center + Vector2(8, 0) * s + dir * 7.0 * s, _active_color(), pulse, 0.4 * s, core_color)
func _draw_upgrade_rate(center: Vector2, pulse: float, s: float) -> void:
# Three successive shots: more fire per second.
for x in [-9.0, 0.0, 9.0]:
NeonDraw.draw_glowing_line(self, center + Vector2(x - 3, 7) * s, center + Vector2(x + 3, -7) * s, _active_color(), pulse, 0.55 * s, core_color)
func _draw_upgrade_range(center: Vector2, pulse: float, s: float) -> void:
# Reticle with a long sight line.
var r := 10.0 * s
for i in range(12):
var a := TAU * float(i) / 12.0
var b := TAU * float(i + 1) / 12.0
NeonDraw.draw_glowing_line(self, center + Vector2(cos(a), sin(a)) * r, center + Vector2(cos(b), sin(b)) * r, _active_color(), pulse, 0.32 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-15, 0) * s, center + Vector2(15, 0) * s, _active_color(), pulse, 0.38 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(0, -15) * s, center + Vector2(0, 15) * s, _active_color(), pulse, 0.38 * s, core_color)
func _draw_upgrade_thrusters(center: Vector2, pulse: float, s: float) -> void:
# Ship nozzle with two exhaust flames.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([center + Vector2(-10, -7) * s, center + Vector2(5, -7) * s, center + Vector2(11, 0) * s, center + Vector2(5, 7) * s, center + Vector2(-10, 7) * s]), _active_color(), pulse, core_color)
for y in [-4.0, 4.0]:
NeonDraw.draw_glowing_line(self, center + Vector2(-10, y) * s, center + Vector2(-17, y * 1.5) * s, _active_color(), pulse, 0.45 * s, core_color)
func _draw_upgrade_shield_hits(center: Vector2, pulse: float, s: float) -> void:
# Layered hexagonal shield walls.
for r in [8.0, 13.0]:
var hex := PackedVector2Array()
for i in range(6):
var a := TAU * float(i) / 6.0 + PI / 6.0
hex.append(center + Vector2(cos(a), sin(a)) * r * s)
NeonDraw.draw_glowing_polygon(self, hex, _active_color(), pulse, core_color)
func _draw_upgrade_shield_recharge(center: Vector2, pulse: float, s: float) -> void:
# A shield with a circular recovery arrow.
var arc := PackedVector2Array()
for i in range(10):
var a := lerpf(-PI * 0.2, PI * 1.35, float(i) / 9.0)
arc.append(center + Vector2(cos(a), sin(a)) * 11.0 * s)
NeonDraw.draw_glowing_path(self, arc, _active_color(), pulse, 0.45 * s, core_color)
var tip: Vector2 = arc[arc.size() - 1]
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(6, -1) * s, _active_color(), pulse, 0.45 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(1, 6) * s, _active_color(), pulse, 0.45 * s, core_color)
func _draw_upgrade_cargo(center: Vector2, pulse: float, s: float) -> void:
# Freight crate, with outward arrows suggesting expanded volume.
var box := 8.0 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([center + Vector2(-box, -box) , center + Vector2(box, -box), center + Vector2(box, box), center + Vector2(-box, box)]), _active_color(), pulse, core_color)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(self, center + Vector2(side * 10, 0) * s, center + Vector2(side * 16, 0) * s, _active_color(), pulse, 0.38 * s, core_color)
func _draw_upgrade_engines(center: Vector2, pulse: float, s: float) -> void:
# Engine core with speed streaks.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([center + Vector2(-4, -8) * s, center + Vector2(7, -5) * s, center + Vector2(10, 0) * s, center + Vector2(7, 5) * s, center + Vector2(-4, 8) * s, center + Vector2(-8, 0) * s]), _active_color(), pulse, core_color)
for y in [-7.0, 0.0, 7.0]:
NeonDraw.draw_glowing_line(self, center + Vector2(-18, y) * s, center + Vector2(-9, y) * s, _active_color(), pulse, 0.4 * s, core_color)
func _draw_upgrade_unlock_shield(center: Vector2, pulse: float, s: float) -> void:
# A shield silhouette with a plus: unlock the whole vessel, not a stat.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([center + Vector2(0, -13) * s, center + Vector2(11, -7) * s, center + Vector2(8, 7) * s, center + Vector2(0, 13) * s, center + Vector2(-8, 7) * s, center + Vector2(-11, -7) * s]), _active_color(), pulse, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-4, 0) * s, center + Vector2(4, 0) * s, _active_color(), pulse, 0.45 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(0, -4) * s, center + Vector2(0, 4) * s, _active_color(), pulse, 0.45 * s, core_color)
func _draw_action_menu(center: Vector2, pulse: float, s: float) -> void:
# A small, intentionally neutral stack of choices. It marks a button that
# opens context actions rather than carrying out one particular action.
for y in [-8.0, 0.0, 8.0]:
NeonDraw.draw_glowing_line(self, center + Vector2(-10, y) * s, center + Vector2(10, y) * s, _active_color(), pulse, 0.42 * s, core_color)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(-15, y - 2) * s, center + Vector2(-11, y) * s,
center + Vector2(-15, y + 2) * s,
]), _active_color(), pulse, core_color)
func _draw_gas(center: Vector2, pulse: float, s: float) -> void:
# Three drifting bubbles: the visual language for gaseous resources.
for data in [[Vector2(-6,-3), 6.0], [Vector2(6,4), 8.0], [Vector2(4,-9), 4.0]]:
var ring := PackedVector2Array()
for i in range(10):
var a := TAU * float(i) / 10.0
ring.append(center + (data[0] + Vector2(cos(a), sin(a)) * data[1]) * s)
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
func _draw_build(center: Vector2, pulse: float, s: float) -> void:
# A plus inside a frame - "add a structure".
var r: float = 11.0 * s
var frame: PackedVector2Array = PackedVector2Array([
center + Vector2(-r, -r), center + Vector2(r, -r),
center + Vector2(r, r), center + Vector2(-r, r),
])
NeonDraw.draw_glowing_polygon(self, frame, _active_color(), pulse, core_color)
var arm: float = r * 0.55
NeonDraw.draw_glowing_line(self, center + Vector2(-arm, 0), center + Vector2(arm, 0), _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(0, -arm), center + Vector2(0, arm), _active_color(), pulse, 0.4 * s, core_color)
func _draw_rotate(center: Vector2, pulse: float, s: float, direction: float) -> void:
# Three-quarter arc with an arrowhead on the leading end, mirrored for
# the counter-clockwise variant.
var r: float = 11.0 * s
var segments: int = 14
var sweep: float = TAU * 0.75
var start: float = -PI * 0.5
var arc: PackedVector2Array = PackedVector2Array()
for i in range(segments + 1):
var t: float = float(i) / segments
var a: float = start + sweep * t * direction
arc.append(center + Vector2(cos(a), sin(a)) * r)
for i in range(segments):
NeonDraw.draw_glowing_line(self, arc[i], arc[i + 1], _active_color(), pulse, 0.34 * s, core_color)
var tip: Vector2 = arc[arc.size() - 1]
var tangent: Vector2 = (tip - arc[arc.size() - 2]).normalized()
var normal: Vector2 = Vector2(-tangent.y, tangent.x)
var head: float = 5.0 * s
NeonDraw.draw_glowing_line(self, tip, tip - tangent * head + normal * head, _active_color(), pulse, 0.34 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip - tangent * head - normal * head, _active_color(), pulse, 0.34 * s, core_color)
# --- building glyphs, each echoing the placed structure's silhouette ------
func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void:
# Radial flagship: a reactor core with six swept talons around it, the
# same silhouette as the placed structure.
for i in range(6):
var base: float = (float(i) / 6.0) * TAU
var talon: PackedVector2Array = PackedVector2Array()
for step in [0.0, 0.5, 1.0]:
var a: float = base + 0.72 * pow(step, 1.3)
talon.append(center + Vector2(cos(a), sin(a)) * lerpf(7.0, 16.0, step) * s)
for step in [1.0, 0.5, 0.0]:
var a: float = base + 0.72 * pow(step, 1.3) - (0.34 * (1.0 - step) + 0.05)
talon.append(center + Vector2(cos(a), sin(a)) * lerpf(7.0, 16.0, step) * s)
NeonDraw.draw_glowing_polygon(self, talon, _active_color(), pulse, core_color)
for radius in [6.5, 3.0]:
var ring: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
ring.append(center + Vector2(cos(a), sin(a)) * radius * s)
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
func _draw_ship_lab_icon(center: Vector2, pulse: float, s: float) -> void:
# A vessel inside brackets: something being examined, not built.
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(0, -9) * s, center + Vector2(5, 5) * s,
center + Vector2(0, 2) * s, center + Vector2(-5, 5) * s,
]), _active_color(), pulse, core_color)
for sx in [-1.0, 1.0]:
var x: float = sx * 11.0 * s
NeonDraw.draw_glowing_line(self, center + Vector2(x, -11 * s), center + Vector2(x, 11 * s),
_active_color(), pulse, 0.35 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(x, -11 * s), center + Vector2(x - sx * 4.0 * s, -11 * s),
_active_color(), pulse, 0.35 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(x, 11 * s), center + Vector2(x - sx * 4.0 * s, 11 * s),
_active_color(), pulse, 0.35 * s, core_color)
func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void:
# A ring of docking cradles with a freight box in the middle: reads as
# "things are stationed here and go out from here", which is what the hub
# does, and shares no silhouette with the crafter's open cradle.
var ring_r: float = 11.0 * s
var ring: PackedVector2Array = PackedVector2Array()
for i in range(8):
var a: float = (float(i) / 8.0) * TAU + PI / 8.0
ring.append(center + Vector2(cos(a), sin(a)) * ring_r)
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
var box: float = 4.2 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(-box, -box), center + Vector2(box, -box),
center + Vector2(box, box), center + Vector2(-box, box),
]), _active_color(), pulse, core_color)
# Three stubby berths, so the ring reads as occupied rather than as a
# plain octagon.
for i in range(3):
var a: float = (float(i) / 3.0) * TAU - PI / 2.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + dir * (box * 1.5),
center + dir * (ring_r - 1.5 * s), _active_color(), pulse, 0.35 * s, core_color)
func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
# Reactor core, truss spine, and an open cradle holding a hull.
var core_centre: Vector2 = center + Vector2(-11, 0) * s
var core: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
core.append(core_centre + Vector2(cos(a), sin(a)) * 5.0 * s)
NeonDraw.draw_glowing_polygon(self, core, _active_color(), pulse, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-6, 0) * s,
center + Vector2(-2, 0) * s, _active_color(), pulse, 0.35 * s, core_color)
var bay: Vector2 = center + Vector2(6, 0) * s
var cradle: PackedVector2Array = PackedVector2Array()
for i in range(13):
var a: float = lerpf(-0.25 * PI, 1.25 * PI, float(i) / 12.0)
cradle.append(bay + Vector2(cos(a), sin(a)) * 10.0 * s)
NeonDraw.draw_glowing_path(self, cradle, _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
bay + Vector2(0, -6) * s, bay + Vector2(-5, 4) * s, bay + Vector2(5, 4) * s,
]), _active_color(), pulse, core_color)
func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void:
# Hex plotting rim with spokes to a diamond hub, echoing the placed
# structure's silhouette: half the ports carry a formation token.
var hex_r: float = 11.0 * s
var hub_r: float = 4.0 * s
var hex: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
hex.append(center + Vector2(cos(a), sin(a)) * hex_r)
NeonDraw.draw_glowing_polygon(self, hex, _active_color(), pulse, core_color)
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
var dir: Vector2 = Vector2(cos(a), sin(a))
NeonDraw.draw_glowing_line(self, center + dir * hub_r, center + dir * hex_r, _active_color(), pulse, 0.32 * s, core_color)
if i % 2 == 0:
var tip: Vector2 = center + dir * hex_r
var t: float = 2.6 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
tip + Vector2(0, -t), tip + Vector2(t, 0), tip + Vector2(0, t), tip + Vector2(-t, 0),
]), _active_color(), pulse, core_color)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(0, -hub_r), center + Vector2(hub_r, 0),
center + Vector2(0, hub_r), center + Vector2(-hub_r, 0),
]), _active_color(), pulse, core_color)
func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void:
# A derrick standing on a planet's limb, with ore lifting off it - the
# one building that only works over a planet, so the planet is the point.
var limb: PackedVector2Array = PackedVector2Array()
for i in range(11):
var a: float = lerpf(-0.82 * PI, -0.18 * PI, float(i) / 10.0)
limb.append(center + (Vector2(0, 22) + Vector2(cos(a), sin(a)) * 17.0) * s)
NeonDraw.draw_glowing_path(self, limb, _active_color(), pulse, 0.45 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-6, 5) * s, center + Vector2(0, -9) * s, _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(6, 5) * s, center + Vector2(0, -9) * s, _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-3.5, -1) * s, center + Vector2(3.5, -1) * s, _active_color(), pulse, 0.35 * s, core_color)
# Shaft driven down through the limb - without it the derrick reads as a
# tower standing on a hill rather than as something mining the planet.
NeonDraw.draw_glowing_line(self, center + Vector2(0, -1) * s, center + Vector2(0, 14) * s, _active_color(), pulse, 0.3 * s, core_color)
var ore_centre: Vector2 = center + Vector2(0, -15) * s
var ore: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
ore.append(ore_centre + Vector2(cos(a), sin(a)) * 4.0 * s)
NeonDraw.draw_glowing_polygon(self, ore, _active_color(), pulse, core_color)
func _draw_map(center: Vector2, pulse: float, s: float) -> void:
var w: float = 11.0 * s
var h: float = 9.0 * s
var outline: PackedVector2Array = PackedVector2Array([
center + Vector2(-w, -h), center + Vector2(w, -h * 0.7),
center + Vector2(w, h), center + Vector2(-w, h * 0.7),
])
NeonDraw.draw_glowing_polygon(self, outline, _active_color(), pulse, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-w * 0.34, -h * 0.9), center + Vector2(-w * 0.34, h * 0.77), _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(w * 0.34, -h * 0.77), center + Vector2(w * 0.34, h * 0.9), _active_color(), pulse, 0.4 * s, core_color)

1
ui/IconButton.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://dcvdmc68b3nve

134
ui/MainMenu.gd Normal file
View file

@ -0,0 +1,134 @@
extends Node2D
# The menu stacks whatever entries are actually available, rather than
# trusting the positions authored in the scene.
#
# Two of the three entries only exist when the game is run from Godot, so a
# shipped build shows one entry and an editor run shows three. There is no
# Quit: a web build has nothing to quit TO, and on Android the system back
# gesture already does it. Fixed
# offsets would leave holes where the hidden entries used to be - the same
# failure the HUD's button columns had, where a literal y meant "below the
# thing above me" and stopped being true when that thing moved. Here the
# stack is measured instead, and centred on whatever it ends up containing.
const ROW_BUTTON_HEIGHT: float = 128.0
const ROW_LABEL_OFFSET: float = 134.0
const ROW_LABEL_HEIGHT: float = 56.0
const ROW_HEIGHT: float = ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
const ROW_GAP: float = 20.0
const ROW_PITCH: float = ROW_HEIGHT + ROW_GAP
# The stack is centred on this rather than on the screen, to leave the title
# above it room to breathe.
const STACK_CENTER_Y: float = 40.0
const STATUS_GAP: float = 20.0
const STATUS_HEIGHT: float = 70.0
@onready var play_button: IconButton = $UI/PlayButton
@onready var play_label: Label = $UI/PlayLabel
@onready var editor_button: IconButton = $UI/EditorButton
@onready var editor_label: Label = $UI/EditorLabel
@onready var dev_button: IconButton = $UI/DevModeButton
@onready var dev_label: Label = $UI/DevModeLabel
@onready var status_label: Label = $UI/StatusLabel
func _ready() -> void:
play_button.pressed.connect(_on_play_pressed)
editor_button.pressed.connect(_on_editor_pressed)
editor_button.enabled = true
dev_button.pressed.connect(_on_dev_mode_pressed)
dev_button.enabled = true
# Dev mode and the map editor are development tools, not features of the
# game: they are for building and play-testing it, and a player who
# installed a build has no use for either. `editor` is the feature tag
# Godot sets when running from the editor and clears in an export, which
# is exactly the "started from Godot" distinction wanted here.
var developing: bool = is_developer_build()
editor_button.visible = developing
editor_label.visible = developing
dev_button.visible = developing
dev_label.visible = developing
_layout_stack()
# Wrapped so tests can state the rule they are checking rather than
# duplicating the feature-tag string.
func is_developer_build() -> bool:
return OS.has_feature("editor")
func _layout_stack() -> void:
var rows: Array = []
for row in [[play_button, play_label], [editor_button, editor_label],
[dev_button, dev_label]]:
if row[0].visible:
rows.append(row)
if rows.is_empty():
return
# The last row carries no trailing gap, so the block is one pitch per row
# minus that gap.
var block_height: float = float(rows.size()) * ROW_PITCH - ROW_GAP
var top: float = STACK_CENTER_Y - block_height * 0.5
for row in rows:
var button: Control = row[0]
var label: Label = row[1]
button.offset_top = top
button.offset_bottom = top + ROW_BUTTON_HEIGHT
label.offset_top = top + ROW_LABEL_OFFSET
label.offset_bottom = top + ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
top += ROW_PITCH
# Status sits under the whole stack, where a message about any entry can
# be read without it shifting the entries themselves.
var status_top: float = top - ROW_GAP + STATUS_GAP
status_label.offset_top = status_top
status_label.offset_bottom = status_top + STATUS_HEIGHT
func _on_play_pressed() -> void:
GameState.reset_new_game()
_start("res://world/Main.tscn")
# Loaded, built and added by hand rather than through change_scene_to_file().
# That call does all three in one opaque step, so when the web build hung
# there was no way to tell which part failed. Split out, each step can say so
# - and a scene change that fails otherwise leaves the menu up, which looks
# exactly like an unpressed button.
func _start(scene_path: String) -> void:
var packed: PackedScene = load(scene_path)
if packed == null:
status_label.text = "Could not load %s" % scene_path.get_file()
return
var scene: Node = packed.instantiate()
if scene == null:
status_label.text = "Could not build %s" % scene_path.get_file()
return
get_tree().root.add_child(scene)
get_tree().current_scene = scene
queue_free()
func _on_dev_mode_pressed() -> void:
# Guarded as well as hidden. Visibility is presentation; this is the rule.
if not is_developer_build():
status_label.text = "Dev mode only runs from Godot"
return
GameState.start_dev_game()
_start("res://world/Main.tscn")
func _on_editor_pressed() -> void:
if not is_developer_build():
status_label.text = "Map editor only runs from Godot"
return
if editor_button.enabled:
_start("res://surface/PlanetSetupEditor.tscn")
return
status_label.text = "Map editor coming soon"

1
ui/MainMenu.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://p7yl0hc43yai

128
ui/MainMenu.tscn Normal file
View file

@ -0,0 +1,128 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/MainMenu.gd" id="1"]
[ext_resource type="Script" path="res://ui/IconButton.gd" id="2"]
[node name="MainMenu" type="Node2D"]
script = ExtResource("1")
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
layer = -10
[node name="Starfield" type="ColorRect" parent="BackgroundLayer"]
color = Color(0, 0, 0, 1)
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="UI" type="CanvasLayer" parent="."]
[node name="TitleLabel" type="Label" parent="UI"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -300
offset_right = 300
offset_top = -560
offset_bottom = -460
theme_override_font_sizes/font_size = 60
horizontal_alignment = 1
text = "SPACESHIP"
[node name="PlayButton" type="Control" parent="UI"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = -300
offset_bottom = -172
script = ExtResource("2")
icon = 2
glow_color = Color(0.35, 0.95, 0.55, 1)
[node name="PlayLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -160
offset_right = 160
offset_top = -160
offset_bottom = -94
theme_override_font_sizes/font_size = 32
horizontal_alignment = 1
text = "Play"
[node name="EditorButton" type="Control" parent="UI"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = -20
offset_bottom = 108
script = ExtResource("2")
icon = 0
glow_color = Color(0.5, 0.75, 1, 1)
[node name="EditorLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -200
offset_right = 200
offset_top = 120
offset_bottom = 186
theme_override_font_sizes/font_size = 32
horizontal_alignment = 1
text = "Map Editor"
[node name="DevModeButton" type="Control" parent="UI"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = 190
offset_bottom = 318
script = ExtResource("2")
icon = 29
glow_color = Color(1, 0.7, 0.25, 1)
[node name="DevModeLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -200
offset_right = 200
offset_top = 324
offset_bottom = 380
theme_override_font_sizes/font_size = 32
horizontal_alignment = 1
text = "Dev Mode"
[node name="StatusLabel" type="Label" parent="UI"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -260
offset_right = 260
offset_top = 200
offset_bottom = 260
theme_override_font_sizes/font_size = 26
horizontal_alignment = 1
autowrap_mode = 2
text = ""

92
ui/NeonNumber.gd Normal file
View file

@ -0,0 +1,92 @@
class_name NeonNumber
extends Control
# An ore amount drawn in the game's own hand-stroked style: the resource
# hexagon followed by segment-drawn digits. Godot's default Label font has
# no place in this look, which is why the digits are strokes rather than
# text.
#
# Used both for the live resource readout (see ResourceReadout, which feeds
# it from GameState) and for the fixed prices under each build option.
@export var amount: int = 0: set = set_amount
@export var show_icon: bool = true
@export var glow_color: Color = Color(0.0, 0.62, 0.7)
@export var core_color: Color = Color(0.0, 0.12, 0.15)
@export var digit_height: float = 30.0
@export var digit_gap: float = 9.0
# Segment weight as a fraction of digit height, so the strokes keep the same
# visual heft at any size. This used to be a fixed width regardless of the
# digit height, which left large readouts looking hairline next to the solid
# ore hexagon beside them.
const STROKE_RATIO: float = 0.014
const HEX_STROKE_RATIO: float = 0.030
# Which of the seven segments each digit lights, in the usual order:
# top, top-right, bottom-right, bottom, bottom-left, top-left, middle.
const DIGIT_SEGMENTS: Array = [
[true, true, true, true, true, true, false], # 0
[false, true, true, false, false, false, false], # 1
[true, true, false, true, true, false, true], # 2
[true, true, true, true, false, false, true], # 3
[false, true, true, false, false, true, true], # 4
[true, false, true, true, false, true, true], # 5
[true, false, true, true, true, true, true], # 6
[true, true, true, false, false, false, false], # 7
[true, true, true, true, true, true, true], # 8
[true, true, true, true, false, true, true], # 9
]
func set_amount(value: int) -> void:
if amount == value:
return
amount = value
queue_redraw()
func _draw() -> void:
var h: float = digit_height
var w: float = h * 0.56
var x: float = 0.0
if show_icon:
_draw_hexagon(Vector2(h * 0.42, h * 0.5), h * 0.62)
x = h * 1.15
for ch in str(maxi(amount, 0)):
_draw_digit(int(ch), Vector2(x, 0.0), w, h)
x += w + digit_gap
func _draw_hexagon(centre: Vector2, radius: float) -> void:
# Same silhouette as the ore pickup itself, so a price and the thing it
# is priced in are unmistakably the same resource.
var pts: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU - PI / 2.0
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color, radius * HEX_STROKE_RATIO)
NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, radius * HEX_STROKE_RATIO * 0.8, core_color)
func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
if value < 0 or value > 9:
return
var on: Array = DIGIT_SEGMENTS[value]
var mid: float = h * 0.5
var seg: Array = [
[Vector2(0.0, 0.0), Vector2(w, 0.0)], # top
[Vector2(w, 0.0), Vector2(w, mid)], # top-right
[Vector2(w, mid), Vector2(w, h)], # bottom-right
[Vector2(0.0, h), Vector2(w, h)], # bottom
[Vector2(0.0, mid), Vector2(0.0, h)], # bottom-left
[Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left
[Vector2(0.0, mid), Vector2(w, mid)], # middle
]
var stroke: float = h * STROKE_RATIO
for i in range(seg.size()):
if not on[i]:
continue
NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, stroke, core_color)

1
ui/NeonNumber.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://on4hmiwsv7yu

47
ui/NeonPanel.gd Normal file
View file

@ -0,0 +1,47 @@
class_name NeonPanel
extends Control
# A bounded dialog card drawn in the game's own style: a dark chamfered
# body with a glowing border and bright corner brackets. Chamfered rather
# than square because cut corners read as "console panel" and match the
# hard-edged neon of the buttons and HUD.
#
# Draws itself from its own rect, so it can be sized freely in the scene.
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var core_color: Color = Color(1.0, 1.0, 1.0)
# Fully opaque: at even 97% the bright neon of whatever sits behind the
# panel ghosts through and makes the text harder to read.
@export var fill_color: Color = Color(0.02, 0.03, 0.07, 1.0)
@export var corner_cut: float = 26.0
@export var bracket_length: float = 46.0
func _draw() -> void:
var w: float = size.x
var h: float = size.y
var c: float = corner_cut
var body: PackedVector2Array = PackedVector2Array([
Vector2(c, 0.0), Vector2(w - c, 0.0),
Vector2(w, c), Vector2(w, h - c),
Vector2(w - c, h), Vector2(c, h),
Vector2(0.0, h - c), Vector2(0.0, c),
])
draw_colored_polygon(body, fill_color)
NeonDraw.draw_glowing_polygon(self, body, glow_color, 1.0, core_color)
# Corner brackets: short doubled strokes just inside each cut corner,
# which is what gives it the heads-up-display feel rather than looking
# like a plain outlined box.
var b: float = bracket_length
var inset: float = 10.0
var corners: Array = [
[Vector2(inset, c + inset), Vector2(inset, c + inset + b), Vector2(c + inset, inset), Vector2(c + inset + b, inset)],
[Vector2(w - inset, c + inset), Vector2(w - inset, c + inset + b), Vector2(w - c - inset, inset), Vector2(w - c - inset - b, inset)],
[Vector2(inset, h - c - inset), Vector2(inset, h - c - inset - b), Vector2(c + inset, h - inset), Vector2(c + inset + b, h - inset)],
[Vector2(w - inset, h - c - inset), Vector2(w - inset, h - c - inset - b), Vector2(w - c - inset, h - inset), Vector2(w - c - inset - b, h - inset)],
]
for corner in corners:
NeonDraw.draw_glowing_line(self, corner[0], corner[1], glow_color, 0.9, 0.3, core_color)
NeonDraw.draw_glowing_line(self, corner[2], corner[3], glow_color, 0.9, 0.3, core_color)

1
ui/NeonPanel.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://c8abuj0i78t06

58
ui/PaletteEntryButton.gd Normal file
View file

@ -0,0 +1,58 @@
class_name PaletteEntryButton
extends Control
# One selectable tile in the setup editor's palette: either the generic
# capture-point marker, or one enemy definition's own hull/colour. Drawn
# directly from the definition rather than a fixed icon set, so a new
# EnemyDefinitions entry shows up automatically with no editor code change.
signal pressed
@export var is_capture_point: bool = false
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
@export var enemy_definition: Resource
@export var selected: bool = false: set = set_selected
const RING_COLOR: Color = Color(0.55, 0.85, 1.0)
func set_selected(value: bool) -> void:
selected = value
queue_redraw()
func _ready() -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventScreenTouch and not event.pressed \
and Rect2(Vector2.ZERO, size).has_point(event.position):
pressed.emit()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:
pressed.emit()
func _draw() -> void:
var center: Vector2 = size / 2.0
var margin: float = 4.0
var frame: PackedVector2Array = PackedVector2Array([
Vector2(margin, margin), Vector2(size.x - margin, margin),
Vector2(size.x - margin, size.y - margin), Vector2(margin, size.y - margin),
])
var frame_color: Color = Color(1.0, 0.95, 0.9) if selected else Color(0.4, 0.45, 0.5)
NeonDraw.draw_glowing_polygon(self, frame, frame_color, 0.85 if selected else 0.5)
if is_capture_point:
var points: PackedVector2Array = PackedVector2Array()
for i in range(16):
var angle: float = float(i) / 16.0 * TAU
points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28))
NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8)
elif enemy_definition != null:
# Same routine as the marker and the enemy, so the button previews
# what will actually be placed.
var scale_factor: float = (size.x * 0.5) / 28.0 * 0.6
ENEMY_DEFINITIONS.draw_definition(self, enemy_definition,
enemy_definition.glow_color, scale_factor, center)

View file

@ -0,0 +1 @@
uid://ge3e0dc3cekb

128
ui/ResourceBar.gd Normal file
View file

@ -0,0 +1,128 @@
class_name ResourceBar
extends Control
# Compact resource table shared by crafting, research and the ship lab.
# Rows are resources; columns are locations. Adding another material to
# The resource-definition catalog automatically gives each material a row.
var building_id: int = 0
# Header band and font sizes were all set for a 84px-tall box, which made the
# table read as a dense strip of small numbers. The hosting menus now give it
# 136px, so the rows have somewhere to breathe and the text can be legible at
# arm's length on a phone.
const HEADER_HEIGHT: float = 30.0
# Vertical gap between rows. Previously rows were flush against each other and
# the only separation was colour.
const ROW_GAP: float = 4.0
const HEADER_FONT_SIZE: int = 20
const NAME_FONT_SIZE: int = 23
const VALUE_FONT_SIZE: int = 24
const RULE_COLOR: Color = Color(0.25, 0.55, 0.7)
const FLEET_COLOR: Color = Color(1.0, 0.78, 0.25)
const LOCAL_COLOR: Color = Color(0.45, 0.9, 0.55)
var _header_labels: Array[Label] = []
var _rows: Dictionary = {}
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
for heading in ["", "BANKED", "FLEET", "LOCAL"]:
var label := _make_label(heading, HEADER_FONT_SIZE, Color(0.62, 0.78, 0.86))
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(label)
_header_labels.append(label)
for resource_type in GameState.resource_types():
var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type
add_child(glyph)
var name_label := _make_label(GameState.resource_name(resource_type).to_upper(),
NAME_FONT_SIZE, GameState.resource_color(resource_type))
add_child(name_label)
var bank_label := _make_value_label(GameState.resource_color(resource_type))
var fleet_label := _make_value_label(FLEET_COLOR)
var local_label := _make_value_label(LOCAL_COLOR)
for label in [bank_label, fleet_label, local_label]:
add_child(label)
_rows[resource_type] = {
"glyph": glyph, "name": name_label, "bank": bank_label,
"fleet": fleet_label, "local": local_label,
}
resized.connect(_layout)
_layout()
queue_redraw()
func _make_label(text_value: String, font_size: int, colour: Color) -> Label:
var label := Label.new()
label.text = text_value
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", colour)
label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95))
label.add_theme_constant_override("outline_size", 2)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
return label
func _make_value_label(colour: Color) -> Label:
var label := _make_label("0", VALUE_FONT_SIZE, colour)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
return label
func _layout() -> void:
if _header_labels.is_empty():
return
var resource_width: float = size.x * 0.25
var value_width: float = (size.x - resource_width) / 3.0
var columns: Array = [0.0, resource_width, resource_width + value_width,
resource_width + value_width * 2.0]
var widths: Array = [resource_width, value_width, value_width, value_width]
for i in range(_header_labels.size()):
_header_labels[i].position = Vector2(columns[i], 0.0)
_header_labels[i].size = Vector2(widths[i], HEADER_HEIGHT)
var rows: float = maxf(1.0, float(_rows.size()))
# The gap is taken OUT of the available height rather than added on top of
# each row, so the table still ends exactly where its box does however many
# resources the catalog grows to.
var row_height: float = (size.y - HEADER_HEIGHT - ROW_GAP) / rows
var row_index: int = 0
for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height
var content_height: float = row_height - ROW_GAP
row.glyph.position = Vector2(2.0, y + ROW_GAP * 0.5)
row.glyph.size = Vector2(content_height, content_height)
row.name.position = Vector2(content_height + 10.0, y)
row.name.size = Vector2(resource_width - content_height - 10.0, row_height)
for i in range(3):
var label: Label = row[["bank", "fleet", "local"][i]]
label.position = Vector2(columns[i + 1], y)
label.size = Vector2(widths[i + 1], row_height)
row_index += 1
queue_redraw()
func _process(_delta: float) -> void:
for resource_type in GameState.resource_types():
var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty():
continue
row.bank.text = str(GameState.banked_resource(resource_type))
row.fleet.text = str(GameState.fleet_resource(resource_type))
row.local.text = str(GameState.building_resource(building_id, resource_type)) \
if building_id != 0 else "—"
static func natural_height() -> float:
return 84.0
func _draw() -> void:
NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y),
RULE_COLOR, 0.55, 1.0)

1
ui/ResourceBar.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://c3x1o7bh4tl5

130
ui/ResourceReadout.gd Normal file
View file

@ -0,0 +1,130 @@
class_name ResourceReadout
extends Control
# Generalizes ResourceCounter/GasCounter/CargoCounter/GasCargoCounter - one
# hardcoded node plus one script per resource, times two scenes - into one
# row per GameState.resource_types() entry, built at _ready(). A new
# resource added to ResourceDefinitions.ORDER/DEFINITIONS gets a HUD row
# with no scene edit and no new script, the same way ResourceBar and
# ResourceTransferList already pick up a new resource automatically.
#
# Layout mirrors what it replaces: a BANKED column (left) and a FLEET HOLD
# column (right), one row per resource, each row a resource glyph plus a
# digit readout. Banked rows are always visible, even at zero (matching
# ResourceCounter/GasCounter today); fleet rows hide at zero, since that
# hold isn't the player's yet (see CargoCounter's own note on why).
const FLEET_GLOW: Color = Color(1.0, 0.78, 0.25)
const FLEET_CORE: Color = Color(0.22, 0.12, 0.0)
const ROW_HEIGHT: float = 58.0
const COLUMN_GAP: float = 328.0
const GLYPH_GAP: float = 8.0
@export var digit_height: float = 38.0
# The bright rocky/gas surfaces need a darker banked glow than deep space
# for the same contrast reason PlanetSurface.tscn's old ResourceCounter
# node overrode its colour - set true on the surface-scene instance only.
@export var bright_background: bool = false
var _banked_numbers: Dictionary = {}
var _fleet_numbers: Dictionary = {}
# #56: the fleet hold now has a finite total capacity. Rather than a new
# node, this reuses the existing "FLEET HOLD" caption Label that both Main
# and PlanetSurface already place as a sibling - get_node_or_null so a
# scene that happens to embed ResourceReadout without that sibling (none
# does today) degrades to just not showing a limit, instead of erroring.
@onready var _fleet_caption: Label = get_node_or_null("../FleetCaption")
var _fleet_caption_base_text: String = ""
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
if _fleet_caption != null:
_fleet_caption_base_text = _fleet_caption.text
var y: float = 0.0
for resource_type in GameState.resource_types():
var banked_color: Color = GameState.resource_color(resource_type)
if bright_background:
banked_color = banked_color.darkened(0.4)
_add_glyph(resource_type, Vector2(0.0, y))
var banked: NeonNumber = _add_number(Vector2(digit_height + GLYPH_GAP, y),
banked_color, banked_color.darkened(0.8))
_banked_numbers[resource_type] = banked
_add_glyph(resource_type, Vector2(COLUMN_GAP, y))
var fleet: NeonNumber = _add_number(Vector2(COLUMN_GAP + digit_height + GLYPH_GAP, y),
FLEET_GLOW, FLEET_CORE)
_fleet_numbers[resource_type] = fleet
y += ROW_HEIGHT
# Stop understating our own extent. The rows are drawn by child nodes,
# which happily paint outside this Control's rect, so the scene-authored
# height stayed at its original guess no matter how many rows appeared.
size.y = rows_height()
# How tall the rows are, derived from the live resource list rather than from
# whatever _ready() happened to have built. That matters: _ready() runs in
# scene-tree order, so a sibling laying itself out relative to this readout
# (BuildController._seat_hud_columns) can legitimately ask BEFORE this node
# has built anything, and a cached height would answer zero.
func rows_height() -> float:
return float(GameState.resource_types().size()) * ROW_HEIGHT
# Bottom edge of the rows in this Control's parent space - the line anything
# stacked below the readout has to clear.
func content_bottom() -> float:
return position.y + rows_height()
func _add_glyph(resource_type: String, at: Vector2) -> void:
var glyph: ResourceGlyph = ResourceGlyph.new()
glyph.resource_type = resource_type
glyph.position = at
glyph.size = Vector2(digit_height, digit_height)
add_child(glyph)
func _add_number(at: Vector2, glow: Color, core: Color) -> NeonNumber:
var number: NeonNumber = NeonNumber.new()
number.show_icon = false
number.position = at
number.size = Vector2(0.0, digit_height)
number.glow_color = glow
number.core_color = core
number.digit_height = digit_height
add_child(number)
return number
func banked_number(resource_type: String) -> NeonNumber:
return _banked_numbers.get(resource_type)
func fleet_number(resource_type: String) -> NeonNumber:
return _fleet_numbers.get(resource_type)
func _process(_delta: float) -> void:
if _fleet_caption != null:
var capacity: int = GameState.fleet_cargo_total_capacity()
_fleet_caption.text = "%s %d/%d" % [_fleet_caption_base_text, GameState.fleet_cargo_used(), capacity] \
if capacity >= 0 else _fleet_caption_base_text
for resource_type in GameState.resource_types():
var banked: NeonNumber = _banked_numbers.get(resource_type)
if banked != null:
banked.set_amount(GameState.banked_resource(resource_type))
var fleet: NeonNumber = _fleet_numbers.get(resource_type)
if fleet == null:
continue
var held: int = GameState.fleet_resource(resource_type)
fleet.visible = held > 0
if fleet.visible:
fleet.set_amount(held)

View file

@ -0,0 +1 @@
uid://ctbtddn7ewmvv

248
ui/ResourceTransferList.gd Normal file
View file

@ -0,0 +1,248 @@
class_name ResourceTransferList
extends Control
# Two-sided cargo control. Resources are rows and locations stay fixed:
# fleet on the left, the current building on the right. Horizontal arrows
# make the result of a press visible before the player commits to it.
signal transfer_requested(resource_type: String, to_building: bool, amount: int)
const HEADER_HEIGHT: float = 66.0
const FLEET_COLOR: Color = Color(1.0, 0.78, 0.25)
const LOCAL_COLOR: Color = Color(0.45, 0.9, 0.55)
const RULE_COLOR: Color = Color(0.18, 0.38, 0.52)
const ALL_AMOUNT: int = 1 << 30
const AMOUNT_OPTIONS: Array[int] = [1, 5, 25, ALL_AMOUNT]
var building_id: int = 0
var extractor_index: int = -1
var _destination_heading: Label
var _rows: Dictionary = {}
func _ready() -> void:
var fleet_heading := _make_label("FLEET HOLD", 26, FLEET_COLOR)
fleet_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(fleet_heading)
_destination_heading = _make_label("THIS BUILDING", 26, LOCAL_COLOR)
_destination_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(_destination_heading)
for resource_type in GameState.resource_types():
var colour: Color = GameState.resource_color(resource_type)
var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type
add_child(glyph)
var name_label := _make_label(GameState.resource_name(resource_type).to_upper(), 29, colour)
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(name_label)
var fleet_amount := _make_label("0", 48, FLEET_COLOR)
fleet_amount.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(fleet_amount)
var local_amount := _make_label("0", 48, LOCAL_COLOR)
local_amount.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(local_amount)
var unload := IconButton.new()
unload.name = "%sToBuildingButton" % GameState.resource_name(resource_type)
unload.icon = IconButton.Icon.RIGHT_ARROW
unload.glow_color = colour
unload.pressed.connect(_request_transfer.bind(resource_type, true))
add_child(unload)
var unload_label := _make_label("UNLOAD →", 23, colour)
unload_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(unload_label)
var load := IconButton.new()
load.name = "%sToFleetButton" % GameState.resource_name(resource_type)
load.icon = IconButton.Icon.LEFT_ARROW
load.glow_color = colour
load.pressed.connect(_request_transfer.bind(resource_type, false))
add_child(load)
var load_label := _make_label("← LOAD", 23, colour)
load_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(load_label)
var decrease := IconButton.new()
decrease.name = "%sDecreaseButton" % GameState.resource_name(resource_type)
decrease.icon = IconButton.Icon.MINUS
decrease.glow_color = colour
decrease.pressed.connect(_change_amount.bind(resource_type, -1))
add_child(decrease)
var amount_label := _make_label("ALL", 28, colour)
amount_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(amount_label)
var increase := IconButton.new()
increase.name = "%sIncreaseButton" % GameState.resource_name(resource_type)
increase.icon = IconButton.Icon.PLUS
increase.glow_color = colour
increase.pressed.connect(_change_amount.bind(resource_type, 1))
add_child(increase)
_rows[resource_type] = {
"glyph": glyph, "name": name_label,
"fleet_amount": fleet_amount, "local_amount": local_amount,
"unload": unload, "unload_label": unload_label,
"load": load, "load_label": load_label,
"decrease": decrease, "amount_label": amount_label,
"increase": increase,
"amount_index": AMOUNT_OPTIONS.size() - 1,
}
resized.connect(_layout)
_layout()
refresh()
func _make_label(text_value: String, font_size: int, colour: Color) -> Label:
var label := Label.new()
label.text = text_value
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", colour)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
return label
func _request_transfer(resource_type: String, to_building: bool) -> void:
var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty():
return
transfer_requested.emit(resource_type, to_building,
AMOUNT_OPTIONS[int(row.amount_index)])
func _change_amount(resource_type: String, direction: int) -> void:
var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty():
return
row.amount_index = clampi(int(row.amount_index) + direction, 0, AMOUNT_OPTIONS.size() - 1)
_refresh_amount_controls(row)
func _refresh_amount_controls(row: Dictionary) -> void:
var index: int = int(row.amount_index)
var amount: int = AMOUNT_OPTIONS[index]
row.amount_label.text = "ALL" if amount == ALL_AMOUNT else str(amount)
row.decrease.enabled = index > 0
row.increase.enabled = index < AMOUNT_OPTIONS.size() - 1
row.decrease.queue_redraw()
row.increase.queue_redraw()
func set_destination(id: int, heading: String) -> void:
building_id = id
extractor_index = -1
if _destination_heading != null:
_destination_heading.text = heading.to_upper()
refresh()
func set_extractor_destination(index: int, heading: String) -> void:
building_id = 0
extractor_index = index
if _destination_heading != null:
_destination_heading.text = heading.to_upper()
refresh()
func refresh() -> void:
for resource_type in GameState.resource_types():
var row: Dictionary = _rows.get(resource_type, {})
if row.is_empty():
continue
var fleet_amount: int = GameState.fleet_resource(resource_type)
var local_amount: int = GameState.extractor_resource(extractor_index, resource_type) \
if extractor_index >= 0 else GameState.building_resource(building_id, resource_type)
row.fleet_amount.text = str(fleet_amount)
row.local_amount.text = str(local_amount)
row.unload.enabled = fleet_amount > 0
row.load.enabled = local_amount > 0
row.unload.queue_redraw()
row.load.queue_redraw()
_refresh_amount_controls(row)
func _layout() -> void:
if _destination_heading == null:
return
var side_width: float = size.x * 0.35
var centre_width: float = size.x - side_width * 2.0
var right_x: float = side_width + centre_width
var fleet_heading: Label = get_child(0)
fleet_heading.position = Vector2(0.0, 0.0)
fleet_heading.size = Vector2(side_width, HEADER_HEIGHT)
_destination_heading.position = Vector2(right_x, 0.0)
_destination_heading.size = Vector2(side_width, HEADER_HEIGHT)
var row_height: float = (size.y - HEADER_HEIGHT) / maxf(1.0, float(_rows.size()))
var row_index: int = 0
for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height
# EVERY offset below is a fraction of row_height. It used to be a
# fixed pixel ladder - glyph at +10, buttons at +88, selector at
# +142, captions under that - which needed about 244px inside a row
# whose height is divided from the available space. At three
# resources that fit; the fourth cut the row to 180 and every row
# spilled 64px into the one below it, and the last one into the hint
# text under the list. Proportions cannot overflow: the bands always
# sum to the row.
var pad: float = row_height * 0.04
var content: float = row_height - pad * 2.0
var top: float = y + pad
# amounts, with the resource's glyph and name between them
var band_totals: float = content * 0.32
# the arrows, and the amount selector between them
var band_controls: float = content * 0.48
# the arrow captions
var band_captions: float = content * 0.20
row.fleet_amount.position = Vector2(0.0, top)
row.fleet_amount.size = Vector2(side_width, band_totals)
row.local_amount.position = Vector2(right_x, top)
row.local_amount.size = Vector2(side_width, band_totals)
# Glyph and name sit at the TOP of their row rather than under its
# controls, so a row reads as one thing. Previously the name hung
# low enough to look like a heading for the row beneath it.
var glyph_size: float = minf(band_totals * 0.52, 44.0)
row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, top)
row.glyph.size = Vector2(glyph_size, glyph_size)
row.name.position = Vector2(side_width, top + glyph_size)
row.name.size = Vector2(centre_width, maxf(band_totals - glyph_size, 1.0))
var controls_y: float = top + band_totals
var button_size: float = minf(band_controls, side_width * 0.5)
var button_y: float = controls_y + (band_controls - button_size) * 0.5
row.unload.position = Vector2(side_width * 0.5 - button_size * 0.5, button_y)
row.unload.size = Vector2(button_size, button_size)
row.load.position = Vector2(right_x + side_width * 0.5 - button_size * 0.5, button_y)
row.load.size = Vector2(button_size, button_size)
var selector_size: float = minf(band_controls * 0.62, centre_width * 0.30)
var selector_y: float = controls_y + (band_controls - selector_size) * 0.5
row.decrease.position = Vector2(side_width + 2.0, selector_y)
row.decrease.size = Vector2(selector_size, selector_size)
row.increase.position = Vector2(right_x - selector_size - 2.0, selector_y)
row.increase.size = Vector2(selector_size, selector_size)
row.amount_label.position = Vector2(side_width + selector_size + 2.0, selector_y)
row.amount_label.size = Vector2(centre_width - selector_size * 2.0 - 4.0, selector_size)
var caption_y: float = controls_y + band_controls
row.unload_label.position = Vector2(0.0, caption_y)
row.unload_label.size = Vector2(side_width, band_captions)
row.load_label.position = Vector2(right_x, caption_y)
row.load_label.size = Vector2(side_width, band_captions)
row_index += 1
queue_redraw()
func _draw() -> void:
var row_height: float = (size.y - HEADER_HEIGHT) / maxf(1.0, float(_rows.size()))
NeonDraw.draw_glowing_line(self, Vector2(0.0, HEADER_HEIGHT), Vector2(size.x, HEADER_HEIGHT),
RULE_COLOR, 0.5, 0.7)
for i in range(1, _rows.size()):
var y: float = HEADER_HEIGHT + float(i) * row_height
NeonDraw.draw_glowing_line(self, Vector2(0.0, y), Vector2(size.x, y),
RULE_COLOR, 0.4, 0.6)

View file

@ -0,0 +1 @@
uid://bv6u4t3oabews

170
ui/SkillTreeView.gd Normal file
View file

@ -0,0 +1,170 @@
extends Control
# The mothership's research tree, drawn in the game's own neon style rather
# than with Godot UI widgets, for the same reason everything else here is
# hand-drawn: a themed Button would look nothing like the rest of the game.
#
# Nodes are laid out from the normalised `pos` in GameState.SKILL_TREE, so
# the tree's shape is data next to the upgrades themselves instead of being
# duplicated here.
#
# Input arrives through BuildController._unhandled_input, NOT _gui_input -
# a Control that consumes touches through the GUI system swallows them
# before the menu can act on them, which is the bug that made the formation
# editor's drag silently do nothing. Everything in this menu must stay
# mouse_filter = IGNORE.
signal node_picked(id: String)
const LOCKED_COLOR: Color = Color(0.36, 0.39, 0.5)
const READY_COLOR: Color = Color(0.55, 0.6, 1.0)
const DONE_COLOR: Color = Color(0.45, 0.9, 0.55)
const SELECTED_RING: Color = Color(1.0, 0.85, 0.4)
# A locked node needs a dark core as well as a dim glow. NeonDraw tops every
# shape with a near-white core stroke, which at this size dominates the glow
# entirely - so a "dim" colour alone still came out the brightest thing on
# screen, making unreachable nodes look like the highlighted ones.
const LOCKED_CORE: Color = Color(0.17, 0.19, 0.26)
const LOCKED_PULSE: float = 0.5
@export var node_radius: float = 46.0
@export var row_spacing: float = 250.0
# #83: a second tap on the node already selected buys it. The separate buy
# button is gone - it sat at the bottom of the panel, a long way from the node
# being chosen, and meant every purchase was look-down-tap-look-up. Tapping
# the thing you want is the shorter sentence.
#
# Deliberately "tap the SAME node twice" rather than a timed double-tap: no
# window to miss on a slow phone, no accidental purchase from a fast
# double-tap while browsing, and the first tap always does something visible
# (it selects, and the panel fills in with the cost and what it unlocks) so
# the second one is never a blind commitment.
signal node_confirmed(id: String)
var selected_id: String = ""
var scroll_y: float = 0.0
var _dragging: bool = false
func _content_height() -> float:
# The canvas deliberately exceeds the viewport. Four depths at this
# spacing leave clear breathing room around 92px-wide nodes and make each
# research branch read as a route rising away from Shipyards.
return maxf(size.y, GameState.max_skill_depth() * row_spacing + node_radius * 3.0)
func _max_scroll() -> float:
return maxf(0.0, _content_height() - size.y)
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
call_deferred("reset_scroll")
func reset_scroll() -> void:
# Start at Shipyards, the base of the tree, whenever the menu opens.
# The player then swipes up to travel along the branches.
scroll_y = _max_scroll()
queue_redraw()
func node_screen_pos(node: Dictionary) -> Vector2:
# Every dependency depth gets its own widely-spaced horizontal row. The
# x lane is stable within a branch (set beside SKILL_TREE), so Logistics,
# Freight and Freight Yards rise vertically instead of zig-zagging across
# the display. scroll_y exposes the part of this tall canvas in view.
var margin: float = node_radius * 1.4
return Vector2(
lerpf(margin, size.x - margin, float(node.x)),
_content_height() - margin - float(GameState.skill_depth(node.id)) * row_spacing - scroll_y
)
func _state_color(node: Dictionary) -> Color:
if GameState.is_unlocked(node.id):
return DONE_COLOR
if GameState.prerequisite_met(node.id):
return READY_COLOR
return LOCKED_COLOR
# Returns true when the press landed on a node, so the caller knows the
# touch was consumed.
func handle_touch(event: InputEventScreenTouch) -> bool:
if not visible:
return false
if not event.pressed:
_dragging = false
return true
_dragging = true
var local: Vector2 = event.position - global_position
for node in GameState.SKILL_TREE:
if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25:
var confirming: bool = selected_id == node.id
selected_id = node.id
queue_redraw()
node_picked.emit(node.id)
if confirming:
node_confirmed.emit(node.id)
return true
return false
func handle_drag(event: InputEventScreenDrag) -> bool:
if not visible:
return false
# Dragging a finger upward reveals higher research; dragging downward
# returns to the root. Clamp hard so the canvas never leaves an empty gap.
scroll_y = clampf(scroll_y - event.relative.y, 0.0, _max_scroll())
queue_redraw()
return true
func _draw() -> void:
# Links first, so nodes sit on top of them.
for node in GameState.SKILL_TREE:
if node.requires == "":
continue
var parent: Dictionary = GameState.skill_node(node.requires)
if parent.is_empty():
continue
var from: Vector2 = node_screen_pos(parent)
var to: Vector2 = node_screen_pos(node)
# A link reads as "live" only once the step it leads to is actually
# reachable, so the eye follows the researched path outwards.
var lit: bool = GameState.is_unlocked(node.id)
var reachable: bool = GameState.prerequisite_met(node.id)
var link_color: Color = DONE_COLOR if lit else (READY_COLOR if reachable else LOCKED_COLOR)
var trim: Vector2 = (to - from).normalized() * node_radius
NeonDraw.draw_glowing_line(self, from + trim, to - trim, link_color,
1.0 if reachable else LOCKED_PULSE, 0.4,
NeonDraw.DEFAULT_CORE if reachable else LOCKED_CORE)
for node in GameState.SKILL_TREE:
var centre: Vector2 = node_screen_pos(node)
var colour: Color = _state_color(node)
var reach: bool = GameState.prerequisite_met(node.id)
var pulse: float = 1.0 if reach else LOCKED_PULSE
var core_stroke: Color = NeonDraw.DEFAULT_CORE if reach else LOCKED_CORE
var hexagon: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
hexagon.append(centre + Vector2(cos(a), sin(a)) * node_radius)
NeonDraw.draw_glowing_polygon(self, hexagon, colour, pulse, core_stroke)
# Researched nodes get a filled-in core; the rest stay hollow.
if GameState.is_unlocked(node.id):
var core: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
core.append(centre + Vector2(cos(a), sin(a)) * node_radius * 0.42)
NeonDraw.draw_glowing_polygon(self, core, colour, 1.0)
if node.id == selected_id:
var ring: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU
ring.append(centre + Vector2(cos(a), sin(a)) * node_radius * 1.32)
NeonDraw.draw_glowing_polygon(self, ring, SELECTED_RING, 0.9)

1
ui/SkillTreeView.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://ob1hc06hf21t

127
ui/SpeedFader.gd Normal file
View file

@ -0,0 +1,127 @@
class_name SpeedFader
extends Control
# Vertical drag control for GameState.fleet_speed_multiplier. Same raw-touch
# idiom as TouchJoystick (handle_touch/handle_drag offered first by
# GameController, not _gui_input) and the same NeonDraw look, but a fader
# rather than a stick: the knob's Y position along a fixed vertical track IS
# the value, no spring-back to centre.
#
# Self-contained: writes straight to GameState.fleet_speed_multiplier rather
# than reporting a value for something else to apply, since the multiplier
# already lives in the one place (the autoload) that needs to survive scene
# swaps.
@export var glow_color: Color = Color(0.7, 0.3, 1.0)
@export var core_color: Color = Color(1.0, 1.0, 1.0)
@export var track_margin: float = 28.0
@onready var fleet: Node2D = get_node("../../Fleet")
@onready var label: Label = get_node("Label")
var active_touch_index: int = -1
# 0 = bottom of the track (1.0x), 1 = top. The top is
# GameState.max_fleet_speed_multiplier, which widens with each distorder
# vessel past the first - so the track always spans the whole usable range.
var knob_t: float = 0.0
var status_message_until: int = 0
func _ready() -> void:
visible = false
queue_redraw()
func _process(_delta: float) -> void:
var has_distorder: bool = fleet.has_capability("distorder")
if has_distorder != visible:
visible = has_distorder
if not has_distorder:
# Fleet owns persistent composition state. This outgoing UI node can
# briefly observe an empty fleet during a scene swap, so it must only
# clear its own interaction state here.
active_touch_index = -1
knob_t = 0.0
return
knob_t = (GameState.fleet_speed_multiplier - 1.0) \
/ maxf(GameState.max_fleet_speed_multiplier - 1.0, 0.001)
# #62: GameState._process() (not this node's own _process()) is what
# drains energy and force-resets the multiplier, since it has to keep
# running across the space<->surface swap and while this control might
# not even be in the current scene tree. It leaves a one-shot flag for
# whichever SpeedFader picks it up next.
if GameState.boost_depleted_notice:
GameState.boost_depleted_notice = false
# Still holding the drag at the moment energy ran out would
# otherwise re-boost on the very next drag event, from a knob
# position the finger never actually moved away from.
active_touch_index = -1
label.text = "Out of energy!"
status_message_until = Time.get_ticks_msec() + 3000
elif Time.get_ticks_msec() >= status_message_until:
label.text = "x%.1f" % GameState.fleet_speed_multiplier
queue_redraw()
func is_active() -> bool:
return active_touch_index != -1
func _blocked_by_modal() -> bool:
# Same check as IconButton._blocked_by_modal() - panels stay transparent
# to raw input (the skill tree/formation editor need _unhandled_input),
# so every raw-touch control checks for itself instead.
for menu in get_tree().get_nodes_in_group("modal_menus"):
if menu.is_visible_in_tree() and not menu.is_ancestor_of(self):
return true
return false
func handle_touch(event: InputEventScreenTouch) -> bool:
var local: Vector2 = event.position - global_position
if event.pressed:
if not visible or active_touch_index != -1 or _blocked_by_modal():
return false
if not Rect2(Vector2.ZERO, size).has_point(local):
return false
active_touch_index = event.index
_update_from_local(local)
return true
if event.index != active_touch_index:
return false
active_touch_index = -1
queue_redraw()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if event.index != active_touch_index:
return false
_update_from_local(event.position - global_position)
return true
func _update_from_local(local: Vector2) -> void:
var usable: float = maxf(size.y - track_margin * 2.0, 1.0)
# Up is 1.0 (top of the track), matching how a real fader/throttle reads.
var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0)
knob_t = t
GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.max_fleet_speed_multiplier - 1.0))
label.text = "x%.1f" % GameState.fleet_speed_multiplier
queue_redraw()
func _draw() -> void:
var top: Vector2 = Vector2(size.x * 0.5, track_margin)
var bottom: Vector2 = Vector2(size.x * 0.5, size.y - track_margin)
NeonDraw.draw_glowing_line(self, bottom, top, glow_color, 0.55, 0.5, core_color)
# Tick marks at the extremes read as "min" and "max" at a glance.
for t in [0.0, 1.0]:
var y: float = lerpf(bottom.y, top.y, t)
NeonDraw.draw_glowing_line(self, Vector2(size.x * 0.5 - 12.0, y),
Vector2(size.x * 0.5 + 12.0, y), glow_color, 0.4, 0.35, core_color)
var knob_pos: Vector2 = bottom.lerp(top, clampf(knob_t, 0.0, 1.0))
NeonDraw.draw_glowing_circle(self, knob_pos, 16.0, glow_color,
1.0 if is_active() else 0.8, core_color)

1
ui/SpeedFader.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://m16rgs2ed7w1

125
ui/TouchJoystick.gd Normal file
View file

@ -0,0 +1,125 @@
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.
const RADIUS_RATIO: float = 0.28
@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:
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
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()
direction_changed.emit(direction)
released.emit()
return true
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)

1
ui/TouchJoystick.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bfd1d4343vg1d