Skill tree, node-based building animation, bigger UI, and a 19x faster test suite

Gameplay and UI
- Research tree at the mothership gates which structures can be built and
  grants permanent fleet-capacity upgrades (GameState.SKILL_TREE,
  SkillTreeView). Node depth is derived from prerequisites, so the layout
  cannot drift out of step with the dependencies.
- Building info popup before committing to a build, with per-building cost
  and description.
- Ship crafter redrawn as a space factory with an assembly animation;
  mothership redrawn as a radial flagship; command centre as a plotting hub.
- Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build.
- UI and font scaled up for phone use, via a custom Theme resource -
  gui/theme/default_font_size does not reach the default theme.
- Asteroids no longer spawn on top of the fleet's start position.
- Fleet formation origin derived from the layout's own offsets rather than
  the centroid, fixing the lurch when a lopsided formation started moving.
- Fleet.stop() brakes from the ship's TRUE motion (velocity +
  fleet_velocity). Ship zeroes its velocity when seated in its slot even
  while the anchor drags that slot forward, so braking from velocity alone
  stopped the fleet dead instead of coasting - and whether it did depended
  on the frame rate.

Animation architecture (issue #10)
- Buildings, projectiles and explosion VFX no longer animate by redrawing.
  Static geometry is rasterised once in _draw(); moving parts are NeonPiece
  children animated through node properties, which reuse the cached
  geometry. Every NeonDraw call expands into four antialiased polylines, so
  redrawing to move something re-tessellated all of it.

Test suite (issue #8): 1075s -> ~55s
- Runs from a copy on Termux's own filesystem. The project lives on
  Android's FUSE bridge, read through proot, where load("res://Main.tscn")
  alone cost 8.5s per test against ~2s natively - and nearly every test
  loads it.
- Runs 6 tests at a time, in a single proot-distro login, with output
  buffered per test and printed only on failure.
- --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's
  result no longer depends on how busy the machine is. Without it,
  parallelism made two tests fail on 2 of 3 runs.
- A test passes only if it both exits 0 and prints ": PASS".
- 38 tests, green on three consecutive runs.

Bundles Carrois Gothic SC (Apache 2.0) with its licence text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 15:00:41 +02:00
commit b641fa5891
48 changed files with 1985 additions and 303 deletions

121
SkillTreeView.gd Normal file
View file

@ -0,0 +1,121 @@
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
var selected_id: String = ""
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func node_screen_pos(node: Dictionary) -> Vector2:
# Height comes from dependency depth, not from the data: the root sits at
# the bottom and each further prerequisite climbs a row, so the tree grows
# upwards as it is researched and stays correct when nodes are added.
var deepest: int = maxi(GameState.max_skill_depth(), 1)
var climb: float = float(GameState.skill_depth(node.id)) / float(deepest)
# Inset so a node on row 0 or column 0 is not clipped in half.
var margin: float = node_radius * 1.4
return Vector2(
lerpf(margin, size.x - margin, float(node.x)),
lerpf(size.y - margin, margin, climb)
)
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 event.pressed or not visible:
return false
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:
selected_id = node.id
queue_redraw()
node_picked.emit(node.id)
return true
return false
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)