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

View file

@ -17,13 +17,21 @@ func _random_particle_size() -> float:
return randf_range(3.0, 7.0)
func _draw_particle(p: Dictionary, alpha: float) -> void:
# Debris tumbles independently of its flight path, unlike the light-burst
# streaks - each chunk needs its own evolving rotation, so the base class
# must keep writing visual.rotation every frame instead of leaving it fixed.
func _particle_spins() -> bool:
return true
func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void:
var s: float = p["size"]
# Unrotated local points - the child node's own `rotation` (driven by
# the base class from p["rot"]) supplies the tumble that used to be
# baked into a fresh pt.rotated(p["rot"]) call every frame.
var local_pts: PackedVector2Array = PackedVector2Array([
Vector2(-s, -s * 0.6), Vector2(s * 0.7, -s * 0.5),
Vector2(s, s * 0.5), Vector2(-s * 0.6, s),
])
var world_pts: PackedVector2Array = PackedVector2Array()
for pt in local_pts:
world_pts.append(p["pos"] + pt.rotated(p["rot"]))
draw_colored_polygon(world_pts, Color(COLOR.r, COLOR.g, COLOR.b, alpha))
visual.draw_callable = func(item: CanvasItem) -> void:
item.draw_colored_polygon(local_pts, COLOR)

View file

@ -7,6 +7,10 @@ extends Node2D
@export var check_interval: float = 1.0
@export var spawn_radius_chunks: int = 2
# No asteroid may be created within this distance of the fleet. At cruise
# speed this is well over a second of warning, so anything that reaches the
# fleet was visible on its way in.
@export var safe_spawn_radius: float = 420.0
@export var despawn_radius_chunks: int = 4
@export var asteroids_per_chunk: int = 6
@export var min_speed: float = 12.0
@ -63,14 +67,31 @@ func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int:
return max(abs(a.x - b.x), abs(a.y - b.y))
func _safe_position(candidate: Vector2, centre: Vector2) -> Vector2:
# Nothing may materialise on top of the fleet. Chunks are populated around
# wherever the fleet happens to be - including the chunk it is standing in
# - so without this an asteroid could appear directly on a ship and
# destroy it with no chance to react. That is worst on the frame a scene
# loads: arriving back from a planet could cost a ship instantly.
if candidate.distance_to(centre) >= safe_spawn_radius:
return candidate
# Push it out to the edge of the bubble rather than dropping it, so the
# field keeps its density near the fleet instead of thinning out.
var away: Vector2 = candidate - centre
if away.length() < 0.001:
away = Vector2.RIGHT.rotated(randf() * TAU)
return centre + away.normalized() * safe_spawn_radius
func _spawn_chunk_asteroids(chunk: Vector2i) -> void:
var origin: Vector2 = WorldGen.chunk_origin(chunk)
var spawned: Array = []
var fleet_centre: Vector2 = fleet.get_centroid()
for i in range(asteroids_per_chunk):
var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE)
var asteroid: Node2D = asteroid_scene.instantiate()
asteroid.position = origin + local_pos
asteroid.position = _safe_position(origin + local_pos, fleet_centre)
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed

View file

@ -19,7 +19,6 @@ extends Node2D
@onready var world_map: Control = get_node("../UI/WorldMap")
@onready var build_button: IconButton = get_node("../UI/BuildButton")
@onready var action_button: IconButton = get_node("../UI/BuildActionButton")
@onready var action_label: Label = get_node("../UI/BuildActionLabel")
@onready var build_menu: Control = get_node("../UI/BuildMenu")
@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton")
@ -41,6 +40,14 @@ extends Node2D
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber")
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton")
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
@onready var formation_menu: Control = get_node("../UI/FormationMenu")
@onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor")
@onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton")
@ -101,24 +108,34 @@ func _ready() -> void:
formation_reset_button.pressed.connect(_on_reset_formation)
formation_editor.formation_changed.connect(_on_formation_changed)
skill_close_button.pressed.connect(func(): skill_menu.visible = false)
skill_unlock_button.pressed.connect(_on_unlock_pressed)
skill_tree.node_picked.connect(_on_skill_node_picked)
build_menu.visible = false
info_popup.visible = false
craft_menu.visible = false
formation_menu.visible = false
skill_menu.visible = false
action_button.visible = false
action_label.visible = false
# True while a building prompt or panel owns the shared action slot, so the
# enter-atmosphere prompt knows to stay out of the way.
func is_showing_building_prompt() -> bool:
return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible
return current_building != null or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible
func _unhandled_input(event: InputEvent) -> void:
# The formation editor is driven from here rather than via _gui_input,
# for the same reason as the joystick: its Control must not swallow
# touches, or dragging a slot would never reach it.
# The formation editor and the skill tree are driven from here rather than
# via _gui_input, for the same reason as the joystick: a Control that
# consumes touches through the GUI system swallows them before they get
# here, which is what made dragging a formation slot silently do nothing.
if skill_menu.visible:
if event is InputEventScreenTouch and skill_tree.handle_touch(event):
get_viewport().set_input_as_handled()
return
if not formation_menu.visible:
return
if event is InputEventScreenTouch and formation_editor.handle_touch(event):
@ -133,20 +150,19 @@ func _process(delta: float) -> void:
if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it.
build_extractor_button.enabled = _planet_under_fleet() != null
build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible:
if world_map.visible or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible:
current_building = null
pending_building = null
action_button.visible = false
action_label.visible = false
return
# Debounced exactly like the enter-atmosphere prompt, so the action
# button can't flicker while the fleet drifts on the interact boundary.
var detected: Node2D = building_field.get_building_fleet_is_on()
if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP:
detected = null # nothing to do at a mothership beyond respawning
if detected != pending_building:
pending_building = detected
@ -159,13 +175,21 @@ func _process(delta: float) -> void:
current_building = pending_building
action_button.visible = current_building != null
action_label.visible = current_building != null
if current_building != null:
action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP
action_label.text = current_building.get_display_name()
action_button.icon = _action_icon_for(current_building.building_type)
action_button.queue_redraw()
func _action_icon_for(type: String) -> int:
match type:
GameState.BUILDING_SHIP_CRAFTER:
return IconButton.Icon.SHIP
GameState.BUILDING_MOTHERSHIP:
return IconButton.Icon.MOTHERSHIP
_:
return IconButton.Icon.COMMAND_CENTER
func _check_fleet_wipe() -> void:
if not fleet.ships.is_empty():
return
@ -178,7 +202,10 @@ func _check_fleet_wipe() -> void:
func _on_action_pressed() -> void:
if current_building == null:
return
if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
skill_menu.visible = true
_refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
craft_menu.visible = true
_refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
@ -199,10 +226,14 @@ func _planet_under_fleet() -> Node2D:
func _refresh_build_status() -> void:
# The extractor option is always listed, but greyed unless the fleet is
# actually over a planet - showing it only sometimes would leave the
# player wondering whether the game has it at all.
build_extractor_button.enabled = _planet_under_fleet() != null
# Every option is always listed, greyed when it cannot be used - showing
# them only sometimes would leave the player wondering whether the game
# has them at all. Greyed buttons still emit `pressed`, so each one can
# explain itself in the info popup.
build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER)
build_extractor_button.enabled = _planet_under_fleet() != null \
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
# Prices are shown under each option now, so this only needs to say
# whether the fleet is somewhere an extractor could go.
@ -248,9 +279,15 @@ func _show_building_info(type: String) -> void:
var lines: Array = [info.body]
var can_build: bool = true
# Locked structures still list themselves, greyed, so the player can see
# what exists and what research would open it up.
if not GameState.is_building_unlocked(type):
lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type))
can_build = false
# Anything that would stop this particular placement is spelled out in
# the same popup, rather than silently failing after the press.
if type == GameState.BUILDING_EXTRACTOR:
if can_build and type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null:
lines.append("No planet in reach - fly over one.")
@ -260,7 +297,7 @@ func _show_building_info(type: String) -> void:
can_build = false
else:
lines.append("Will mine %s." % planet.planet_name)
elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
lines.append("WARNING: only one may exist - this destroys your current one.")
if can_build and not GameState.can_afford(cost):
@ -290,6 +327,9 @@ func _confirm_build() -> void:
# Everything is re-checked here rather than trusted from when the popup
# opened: the fleet keeps flying while it is up, so it can drift off the
# planet an extractor was going to mine.
if not GameState.is_building_unlocked(type):
return
if type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null or GameState.has_extractor_at(planet.planet_name):
@ -324,15 +364,70 @@ func _try_place(type: String, cost: int) -> void:
build_menu.visible = false
# --- research (the mothership's skill tree) --------------------------------
func _on_skill_node_picked(id: String) -> void:
_refresh_skill_menu(id)
func _refresh_skill_menu(id: String = "") -> void:
if id == "":
id = skill_tree.selected_id
if id == "":
# Nothing chosen yet: point the player at the tree rather than
# leaving the panel blank, which reads as broken.
skill_detail.text = "Select a node to see what it unlocks."
skill_cost.amount = 0
skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size()
skill_unlock_button.visible = false
return
var node: Dictionary = GameState.skill_node(id)
if node.is_empty():
return
skill_detail.text = "%s\n%s" % [node.name, node.desc]
skill_cost.amount = int(node.cost)
if GameState.is_unlocked(id):
skill_status.text = "Researched"
skill_unlock_button.visible = false
elif not GameState.prerequisite_met(id):
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
skill_unlock_button.visible = false
elif not GameState.can_afford(int(node.cost)):
skill_status.text = "Not enough rocks - you have %d" % GameState.rocks
skill_unlock_button.visible = false
else:
skill_status.text = ""
skill_unlock_button.visible = true
func _on_unlock_pressed() -> void:
var id: String = skill_tree.selected_id
if id == "":
return
if not GameState.unlock(id):
# Re-check rather than trust the button: rocks can be spent elsewhere
# while this menu is open, since haulers keep delivering.
_refresh_skill_menu(id)
return
# A capacity upgrade has to reach the fleet that is already flying, not
# just ones rebuilt later.
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:
fleet.apply_formation()
skill_tree.queue_redraw()
_refresh_skill_menu(id)
func _refresh_craft_status() -> void:
craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size()
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.max_fleet_size()
]
func _on_craft_interceptor() -> void:
if fleet.ships.size() >= GameState.formation.size():
craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size()
if fleet.ships.size() >= GameState.max_fleet_size():
craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size()
return
if not GameState.spend(GameState.SHIP_COST):
craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks]

View file

@ -39,6 +39,7 @@ const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0)
const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0)
const RIB_RADIUS: float = 36.0
const RAIL_Y: float = -32.0
const FLASH_RING_RADIUS: float = 20.0
# The hull in four pieces, attached in this order. Each carries the point
# the crane welds it on at, which is where the sparks fly from.
@ -66,26 +67,56 @@ var phase_time: float = 0.0
var _hull_parts: Array[PackedVector2Array] = []
# Every moving element is its own NeonPiece, rasterised once when the
# building is created and thereafter only moved, scaled, faded or hidden.
# Nothing here ever calls queue_redraw() again: the building's own _draw()
# lays down the fixed structure a single time, and the animation is pure
# node-property work from then on.
var _core_cell: NeonPiece = null
var _satellites: Node2D = null
var _beacons: Array = []
var _clamps: NeonPiece = null
var _crane: Node2D = null
var _hoist: NeonPiece = null
var _sparks: Node2D = null
var _hull_holder: Node2D = null
var _hull_pieces: Array = []
var _flash_ring: NeonPiece = null
var _wash: NeonPiece = null
func _ready() -> void:
add_to_group("buildings")
z_index = -1
# The crafter runs an assembly line and the mothership holds a live
# reactor with satellites in orbit, so both redraw. The command centre is
# static art and is drawn exactly once - per-frame redraws of art that
# never changes were a real framerate cost earlier on.
# reactor with satellites in orbit, so both animate. The command centre is
# entirely static and needs no per-frame work at all.
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER
set_process(animated)
if building_type == GameState.BUILDING_SHIP_CRAFTER:
for part in SHIP_PARTS:
_hull_parts.append(PackedVector2Array(part.points))
_build_crafter_pieces()
elif building_type == GameState.BUILDING_MOTHERSHIP:
_build_mothership_pieces()
queue_redraw()
func _process(delta: float) -> void:
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
queue_redraw()
apply_animation()
# Pushes the current clock onto the movable pieces. Normally driven from
# _process; public so a preview or a test can pin the clock and see exactly
# that frame without having to run the animation up to it in real time.
func apply_animation() -> void:
match building_type:
GameState.BUILDING_MOTHERSHIP:
_animate_mothership()
GameState.BUILDING_SHIP_CRAFTER:
_animate_crafter()
# Where the satellites are, and how open the reactor core is. Both are read
@ -109,14 +140,25 @@ func get_display_name() -> String:
return "Command Center"
# How many times this building's geometry has actually been rasterised. The
# whole point of the NeonPiece decomposition is that this stays at 1 for the
# building's lifetime however long it animates, so it is worth being able to
# assert rather than assume.
var draw_count: int = 0
func _draw() -> void:
draw_count += 1
# Runs exactly once per building: this is the fixed structure. Everything
# that moves is a NeonPiece child, animated through node properties, so
# nothing here ever asks for a redraw again.
match building_type:
GameState.BUILDING_COMMAND_CENTER:
_draw_command_center()
GameState.BUILDING_MOTHERSHIP:
_draw_mothership()
_draw_mothership_static(self)
_:
_draw_crafter()
_draw_crafter_static(self)
# --- shared helpers -------------------------------------------------------
@ -139,17 +181,57 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int
func _draw_command_center() -> void:
# Diamond with three formation pips inside - reads as "arrangement".
var body: PackedVector2Array = PackedVector2Array([
Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0),
# --- command centre: a tactical plotting hub ------------------------------
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
# readout, and every spoke ends in a formation port - half of them holding a
# token, half left open - so the structure itself shows fleet slots waiting
# to be filled, which is exactly what the drag-and-drop editor inside does.
const CMD_HEX_RADIUS: float = 88.0
const CMD_DECK_RADIUS: float = 66.0
const CMD_HUB_RADIUS: float = 30.0
const CMD_SCREEN_RADIUS: float = 14.0
const CMD_PORT_COUNT: int = 6
const CMD_PORT_LENGTH: float = 16.0
const CMD_TOKEN_RADIUS: float = 9.0
const CMD_STUD_RADIUS: float = 7.0
func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
return PackedVector2Array([
centre + Vector2(0, -r), centre + Vector2(r, 0),
centre + Vector2(0, r), centre + Vector2(-r, 0),
])
NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0)
for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]:
var pip: PackedVector2Array = PackedVector2Array([
p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8),
])
NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0)
func _draw_command_center() -> void:
var c: Color = COMMAND_COLOR
var readout: Color = Color(0.82, 0.86, 1.0)
# The rim: a hex table holding the whole layout together, with a fainter
# deck ring nested just inside it - the same "plating has layers" read the
# mothership gets from its nested reactor rings.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7)
for i in range(CMD_PORT_COUNT):
var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0
var dir: Vector2 = Vector2(cos(a), sin(a))
# Spoke bracing the hub to the rim.
NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36)
# Socket where the mast plugs into the rim.
NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85)
# Port mast standing proud of the rim; every other one is manned.
var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH)
NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4)
if i % 2 == 0:
NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0)
# Command core: a diamond hub with a lit readout set inside it.
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
# --- mothership: a radial flagship ----------------------------------------
@ -187,49 +269,65 @@ func _talon(base_angle: float) -> PackedVector2Array:
return pts
func _draw_mothership() -> void:
func _draw_mothership_static(canvas: CanvasItem) -> void:
var c: Color = MOTHERSHIP_COLOR
var core_color: Color = Color(0.75, 1.0, 0.85)
for i in range(ARM_COUNT):
var base: float = (float(i) / float(ARM_COUNT)) * TAU
NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95)
NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95)
# Crystal panel set into the gap behind each arm.
var pa: float = base - 0.30
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([
Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0,
Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0,
Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0,
Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0,
]), c, 0.7)
# Reactor core: nested rings around a cell that breathes.
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
var breath: float = core_pulse()
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0),
core_color, lerpf(0.55, 1.0, breath))
# Reactor housing: the nested rings around the cell that breathes.
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
# Satellites holding station around it, drifting slowly round.
var orbit: float = orbit_angle()
const CORE_CELL_RADIUS: float = 12.0
func _build_mothership_pieces() -> void:
var core_color: Color = Color(0.75, 1.0, 0.85)
# The breathing cell: drawn at a reference size and then scaled, rather
# than rebuilt at a new radius every frame.
_core_cell = NeonPiece.create(self, Vector2.ZERO, core_color)
_core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0))
# Satellites sit at fixed offsets inside a holder; the orbit is the
# holder's rotation, so four hexagon pairs become one transform update.
_satellites = Node2D.new()
add_child(_satellites)
for i in range(SATELLITE_COUNT):
var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU
var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85)
NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8)
var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR)
shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85)
var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color)
cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8)
func _animate_mothership() -> void:
var breath: float = core_pulse()
var radius: float = lerpf(9.0, 15.0, breath)
_core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS)
_core_cell.modulate.a = lerpf(0.55, 1.0, breath)
_satellites.rotation = orbit_angle()
# --- ship crafter: an orbital yard with a hull in the cradle --------------
func _draw_crafter() -> void:
func _draw_crafter_static(canvas: CanvasItem) -> void:
var c: Color = CRAFTER_COLOR
var t: float = anim_time / CYCLE
# Fabricator core: a reactor housing whose inner cell pulses as it feeds
# the yard. This replaces the chimney - nothing vents smoke in vacuum.
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow)
# Fabricator core housing. This replaces the chimney - nothing vents
# smoke in vacuum.
NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
# Radiator fins above and below the core, slatted like real ones.
for dir_y in [-1.0, 1.0]:
@ -237,44 +335,114 @@ func _draw_crafter() -> void:
CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y),
CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y),
])
NeonDraw.draw_glowing_polygon(self, fin, c, 0.75)
NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75)
for f in [0.35, 0.68]:
NeonDraw.draw_glowing_line(self,
NeonDraw.draw_glowing_line(canvas,
fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28)
# Truss spine linking the core to the cradle: two rails with X-bracing,
# the standard "this was assembled in orbit" read.
var spine_top: float = 4.0
var spine_bottom: float = 22.0
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
for i in range(3):
var x0: float = -32.0 + float(i) * 15.0
var x1: float = x0 + 15.0
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
# Construction cradle: two open ribs holding the hull, with the crane
# rail overhead. Open at the top so the finished hull can clear it.
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(self,
NeonDraw.draw_glowing_line(canvas,
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
func _build_crafter_pieces() -> void:
var c: Color = CRAFTER_COLOR
# The core's inner cell: fades with the reactor rather than being redrawn
# at a new brightness each frame.
_core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6))
_core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0))
# Docking beacons, blinking out of step with each other.
for i in range(2):
var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0)
var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink)
var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75))
beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4))
_beacons.append(beacon)
# Clamp arms reaching in from each rib. They let go as the hull launches,
# which is a fade rather than a redraw.
_clamps = NeonPiece.create(self, Vector2.ZERO, c)
for side in [-1.0, 1.0]:
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
_clamps.add_line(outer, inner, 0.35)
_clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3)
# Crane: the head rides the rail, so only its x moves. The hoist is drawn
# one unit long and stretched with scale.y to reach whatever seam is
# being welded - stretching a line needs no redraw, redrawing it would.
_crane = Node2D.new()
add_child(_crane)
var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c)
head.add_polygon(PackedVector2Array([
Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6),
]))
head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8)
_hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5)
_hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3)
# Weld sparks: four spokes on a holder that spins and stretches. The
# jitter is genuinely procedural, but rotating and scaling a holder gives
# the same flicker without re-tessellating the spokes every frame.
_sparks = Node2D.new()
add_child(_sparks)
for i in range(4):
var a: float = float(i) * TAU / 4.0
var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7))
spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3)
# The hull under construction. Each part is its own piece so attaching one
# is a `visible` flip, and the launch is the holder's position and alpha.
_hull_holder = Node2D.new()
add_child(_hull_holder)
for i in range(_hull_parts.size()):
var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR)
part.add_polygon(_hull_parts[i])
part.visible = false
_hull_pieces.append(part)
# Engine wash under the hull, and the completion ring. The ring is drawn
# at a reference radius and scaled outwards.
_wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0)
for dx in [-4.0, 4.0]:
_wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45)
_flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0)
_flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0))
func _animate_crafter() -> void:
var t: float = anim_time / CYCLE
_core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
for i in range(_beacons.size()):
_beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
if t < ASSEMBLE_END:
_draw_assembly(t / ASSEMBLE_END, c)
_animate_assembly(t / ASSEMBLE_END)
elif t < FLASH_END:
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
else:
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
_animate_launch((t - FLASH_END) / (1.0 - FLASH_END))
# How many pieces are on the hull at this point through the assembly phase.
@ -287,78 +455,73 @@ func attached_part_count(p: float) -> int:
return n
func _draw_clamps(c: Color, pulse: float) -> void:
# Arms reaching in from each rib to hold the hull steady. They let go
# once it launches, so this is not drawn during the launch phase.
for side in [-1.0, 1.0]:
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35)
NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3)
func _place_hull(centre: Vector2, alpha: float) -> void:
# The parts are defined around their own origin, so the holder goes
# straight to the point they should be centred on.
_hull_holder.position = centre
_hull_holder.modulate.a = alpha
func _draw_assembly(p: float, c: Color) -> void:
_draw_clamps(c, 0.8)
func _animate_assembly(p: float) -> void:
_clamps.visible = true
_clamps.modulate.a = 0.8
_flash_ring.modulate.a = 0.0
_wash.modulate.a = 0.0
_place_hull(BAY_CENTRE, 1.0)
# Everything welded on so far, newest part still glowing from the seam.
var next_part: int = attached_part_count(p)
for i in range(next_part):
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
_draw_hull_part(i, BAY_CENTRE, pulse)
for i in range(_hull_pieces.size()):
var attached: bool = i < next_part
_hull_pieces[i].visible = attached
if attached:
# Freshly attached parts flare, then settle to full brightness.
var age: float = p - float(SHIP_PARTS[i].at)
_hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
# The crane rides the rail over whichever seam is being worked, and
# retracts once there is nothing left to attach.
var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2)
if next_part >= _hull_parts.size():
if next_part >= _hull_pieces.size():
weld.y = RAIL_Y + 16.0
var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
head + Vector2(-10, -7), head + Vector2(10, -7),
head + Vector2(6, 6), head + Vector2(-6, 6),
]), c, 1.0)
NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35)
NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3)
_crane.position = Vector2(weld.x, RAIL_Y + 13.0)
_hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0)
if next_part < _hull_parts.size():
# Weld sparks: short spokes whose angles jitter frame to frame.
var welding: bool = next_part < _hull_pieces.size()
_sparks.visible = welding
if welding:
var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0))
for i in range(4):
var a: float = phase_time * 7.0 + float(i) * TAU / 4.0
var length: float = 6.0 + 5.0 * flicker
NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length,
Color(1.0, 0.95, 0.7), flicker, 0.3)
_sparks.position = weld
_sparks.rotation = phase_time * 7.0
_sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker)
_sparks.modulate.a = flicker
func _draw_completion_flash(p: float, c: Color) -> void:
func _animate_completion_flash(p: float) -> void:
# Whole hull, clamps springing open, and a ring pushing outwards to mark
# it as finished.
_draw_clamps(c, 1.0 - p)
for i in range(_hull_parts.size()):
_draw_hull_part(i, BAY_CENTRE, 1.0)
NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p)
_clamps.modulate.a = 1.0 - p
_sparks.visible = false
_place_hull(BAY_CENTRE, 1.0)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Retracted, so it does not hang over the finished hull.
_crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0)
_hoist.scale.y = 1.0
_flash_ring.modulate.a = 1.0 - p
_flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS)
func _draw_launch(p: float) -> void:
func _animate_launch(p: float) -> void:
# Clears the cradle and fades out; the next cycle starts from bare ribs.
_clamps.modulate.a = 0.0
_sparks.visible = false
_flash_ring.modulate.a = 0.0
var eased: float = p * p
var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased)
var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0)
for i in range(_hull_parts.size()):
_draw_hull_part(i, centre, fade)
_place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade)
for piece in _hull_pieces:
piece.visible = true
piece.modulate.a = 1.0
# Engine wash, strongest just after it breaks free of the clamps.
var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI)))
for dx in [-4.0, 4.0]:
NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14),
centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust),
Color(1.0, 0.85, 0.45), thrust, 0.45)
func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void:
if pulse <= 0.01:
return
var moved: PackedVector2Array = PackedVector2Array()
for point in _hull_parts[index]:
moved.append(centre + point)
NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse)
_wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI))

22
ExplosionParticle.gd Normal file
View file

@ -0,0 +1,22 @@
class_name ExplosionParticle
extends Node2D
# One debris chunk / energy streak inside an explosion. Draws its fixed
# local-space shape exactly once (like Ship.gd/Asteroid.gd draw once and
# animate via node properties), then ExplosionVFXBase drives it every frame
# purely through `position` and `rotation` - no further redraw needed.
#
# The shape itself is supplied by the owning VFX class via a Callable rather
# than being known here, since different explosion styles (glowing streak
# vs. rock-chunk polygon) draw completely differently; this class only owns
# the "draw once, then just move" mechanics.
var draw_callable: Callable
func _ready() -> void:
queue_redraw()
func _draw() -> void:
draw_callable.call(self)

1
ExplosionParticle.gd.uid Normal file
View file

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

View file

@ -22,14 +22,25 @@ func _ready() -> void:
for i in range(particle_count):
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
particles.append({
var p: Dictionary = {
"pos": Vector2.ZERO,
"vel": Vector2(cos(angle), sin(angle)) * speed,
"rot": randf() * TAU,
"rot_speed": randf_range(-6.0, 6.0),
"size": _random_particle_size(),
})
queue_redraw()
}
# Each particle is its own child CanvasItem, drawn once at its own
# fixed local shape (subclass decides the shape via
# _configure_particle). From here on the "animation" is just this
# child's position/rotation, exactly like Ship.gd/Asteroid.gd - no
# redraw, no re-tessellating four polylines per particle per frame.
var visual: ExplosionParticle = ExplosionParticle.new()
_configure_particle(visual, p)
if _particle_spins():
visual.rotation = p["rot"]
add_child(visual)
p["visual"] = visual
particles.append(p)
func _process(delta: float) -> void:
@ -37,22 +48,35 @@ func _process(delta: float) -> void:
if age >= lifetime:
queue_free()
return
# CanvasItem modulate is inherited down the tree, so setting it once
# here fades every particle's already-drawn shape for free - no need
# to recompute or re-draw a single one of them.
modulate.a = clamp(1.0 - (age / lifetime), 0.0, 1.0)
for p in particles:
p["pos"] += p["vel"] * delta
p["vel"] *= 0.9
p["rot"] += p["rot_speed"] * delta
queue_redraw()
func _draw() -> void:
var alpha: float = clamp(1.0 - (age / lifetime), 0.0, 1.0)
for p in particles:
_draw_particle(p, alpha)
var visual: ExplosionParticle = p["visual"]
visual.position = p["pos"]
if _particle_spins():
visual.rotation = p["rot"]
func _random_particle_size() -> float:
return randf_range(10.0, 20.0)
func _draw_particle(_p: Dictionary, _alpha: float) -> void:
# Called once per particle at spawn time. Subclasses set visual.draw_callable
# to a closure that draws their own fixed local shape (a streak, a rock
# chunk, ...) - it must not depend on `p` beyond values fixed at spawn (size,
# initial velocity direction), since it never runs again.
func _configure_particle(_visual: ExplosionParticle, _p: Dictionary) -> void:
pass
# True for particle styles whose shape itself spins independently of its
# direction of travel (tumbling debris). Energy streaks are drawn already
# aligned to their (fixed) velocity direction, so giving them this unrelated
# per-particle spin as well would visibly rotate them off their travel line.
func _particle_spins() -> bool:
return false

View file

@ -39,7 +39,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
ships.clear()
formation_offsets.clear()
var n: int = clamp(count, 1, GameState.formation.size())
var n: int = clamp(count, 1, GameState.max_fleet_size())
for i in range(n):
var offset: Vector2 = GameState.formation[i]
var ship: Area2D = ship_scene.instantiate()
@ -58,7 +58,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
func add_ship() -> bool:
# Adds one vessel in the next free formation slot (bought from a ship
# crafter). Returns false when the wedge is already full.
if ships.size() >= GameState.formation.size():
if ships.size() >= GameState.max_fleet_size():
return false
var offset: Vector2 = GameState.formation[ships.size()]
var ship: Area2D = ship_scene.instantiate()
@ -82,7 +82,7 @@ func apply_formation() -> void:
# Without this, editing the layout at a command centre (or pivoting it)
# only affected ships built later, which read as the edit doing nothing.
for i in range(ships.size()):
if i >= GameState.formation.size():
if i >= GameState.max_fleet_size():
break
formation_offsets[i] = GameState.formation[i]
_assign_slot_targets()
@ -138,12 +138,32 @@ func stop() -> void:
# since that lands inside arrive_radius immediately).
fleet_target = get_formation_origin()
for ship in ships:
var speed: float = ship.velocity.length()
# A ship's own `velocity` is NOT how fast it is really travelling
# while the fleet cruises. Ship snaps to its slot and reports zero
# the moment it is within arrive_radius of it, even though the
# anchor is dragging that slot forward at cruise_speed underneath
# it. Its true motion is its catch-up velocity PLUS the fleet's,
# which it already carries as `fleet_velocity` (zero for a
# point-to-point move_to, where `velocity` alone is the whole
# story).
#
# Braking from `velocity` alone therefore stopped the fleet dead
# on any frame where the ships happened to be seated - and how
# often that happens depends on the frame rate, because the anchor
# advances cruise_speed * delta per frame. At ~25fps it steps far
# enough that ships always lag and always report motion, which is
# the only reason releasing the stick looked right.
var motion: Vector2 = ship.velocity + ship.fleet_velocity
# Context that is no longer true: the fleet is not cruising any
# more, so leaving it set would have Ship face a heading it is not
# travelling on.
ship.fleet_velocity = Vector2.ZERO
var speed: float = motion.length()
if speed <= 0.001:
ship.set_move_target(ship.position)
continue
var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration)
ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance)
ship.set_move_target(ship.position + motion.normalized() * braking_distance)
func get_move_targets() -> Array:

View file

@ -67,7 +67,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool:
return true
if dragging_index != -1:
return false
for i in range(GameState.formation.size()):
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
@ -126,7 +126,7 @@ func _draw() -> void:
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.formation.size()):
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.

View file

@ -48,6 +48,138 @@ const EXTRACTOR_COST: int = 25
var buildings: Array = []
# --- Skill tree ------------------------------------------------------------
# Researched at the mothership. Two things hang off it: which structures the
# player is allowed to build at all, and permanent upgrades that have no
# building of their own. The mothership itself is deliberately NOT in the
# tree - it is the thing you research AT, so gating it behind research would
# be a deadlock.
#
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
# stored, it is derived from how many prerequisites deep the node is, so the
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_COMMAND_CENTER,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.65, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.3, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
]
const BASE_FLEET_SIZE: int = 5
var unlocked: Array = []
func skill_node(id: String) -> Dictionary:
for node in SKILL_TREE:
if node.id == id:
return node
return {}
func skill_depth(id: String) -> int:
# How many prerequisites deep this node sits. Drives its height in the
# tree view: roots at the bottom, everything that depends on something
# above it.
var depth: int = 0
var node: Dictionary = skill_node(id)
while not node.is_empty() and node.requires != "":
depth += 1
node = skill_node(node.requires)
if depth > SKILL_TREE.size():
break # a cycle in the data would otherwise hang here
return depth
func max_skill_depth() -> int:
var deepest: int = 0
for node in SKILL_TREE:
deepest = maxi(deepest, skill_depth(node.id))
return deepest
func is_unlocked(id: String) -> bool:
return id in unlocked
func prerequisite_met(id: String) -> bool:
var node: Dictionary = skill_node(id)
if node.is_empty():
return false
return node.requires == "" or is_unlocked(node.requires)
func can_unlock(id: String) -> bool:
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
if not spend(int(skill_node(id).cost)):
return false
unlocked.append(id)
return true
func is_building_unlocked(type: String) -> bool:
# The mothership is always buildable: it is where research happens, so
# locking it would leave a player with no mothership unable to ever get
# one.
if type == BUILDING_MOTHERSHIP:
return true
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
return true
func research_for_building(type: String) -> String:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return node.name
return ""
func max_fleet_size() -> int:
var n: int = BASE_FLEET_SIZE
for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0))
return mini(n, DEFAULT_FORMATION.size())
func reset_progression() -> void:
unlocked.clear()
func add_building(type: String, at_position: Vector2) -> void:
buildings.append({"type": type, "position": at_position})
@ -84,8 +216,13 @@ func get_mothership_position() -> Variant:
# centre. Lives here (not as a const on Fleet) so a layout the player has
# arranged survives the trip to a planet's surface and back, and so a
# rebuilt fleet keeps their arrangement.
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
]
var formation: Array = DEFAULT_FORMATION.duplicate()

View file

@ -227,18 +227,31 @@ func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void:
# Diamond with three formation pips - "arrangement".
var d: float = 14.0 * s
# 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, -d), center + Vector2(d, 0),
center + Vector2(0, d), center + Vector2(-d, 0),
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)
for p in [Vector2(0, -5), Vector2(-5, 4), Vector2(5, 4)]:
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + (p + Vector2(0, -3)) * s,
center + (p + Vector2(-2.6, 2.4)) * s,
center + (p + Vector2(2.6, 2.4)) * s,
]), _active_color(), pulse, core_color)
func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void:

View file

@ -7,9 +7,13 @@ extends ExplosionVFXBase
var color: Color = Color(1, 1, 1)
func _draw_particle(p: Dictionary, alpha: float) -> void:
func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void:
# Direction only ever scales (vel *= 0.9 each frame in the base class),
# it never turns - so the streak's local orientation is safe to fix
# once here rather than recomputed from vel every frame.
var vel: Vector2 = p["vel"]
var dir: Vector2 = vel.normalized() if vel.length() > 0.01 else Vector2.RIGHT
var half: Vector2 = dir * p["size"] * 0.5
var pos: Vector2 = p["pos"]
NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6)
var line_color: Color = color
visual.draw_callable = func(item: CanvasItem) -> void:
NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6)

203
Main.tscn
View file

@ -20,6 +20,7 @@
[ext_resource type="Script" path="res://HaulerField.gd" id="18"]
[ext_resource type="Script" path="res://NeonNumber.gd" id="19"]
[ext_resource type="Script" path="res://NeonPanel.gd" id="20"]
[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5")
@ -83,10 +84,10 @@ layer = 1
mouse_filter = 2
offset_left = 28
offset_top = 24
offset_right = 520
offset_bottom = 140
offset_right = 320
offset_bottom = 92
script = ExtResource("16")
digit_height = 100.0
digit_height = 60.0
[node name="PlanetNameLabel" type="Label" parent="UI"]
visible = false
@ -129,19 +130,6 @@ offset_bottom = -572
script = ExtResource("10")
icon = 2
[node name="BuildActionLabel" type="Label" parent="UI"]
visible = false
mouse_filter = 2
anchor_top = 1.0
anchor_bottom = 1.0
anchor_right = 1.0
offset_left = 24
offset_right = -24
offset_top = -566
offset_bottom = -440
text = ""
horizontal_alignment = 2
[node name="MapButton" type="Control" parent="UI"]
anchor_left = 1.0
anchor_right = 1.0
@ -201,19 +189,6 @@ offset_bottom = -24
script = ExtResource("10")
icon = 6
[node name="NavLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -322
offset_bottom = -196
text = "Pivot"
horizontal_alignment = 1
[node name="WorldMap" type="Control" parent="UI"]
visible = false
anchor_right = 1.0
@ -287,8 +262,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -214
offset_right = -66
offset_top = -224
offset_bottom = -76
offset_top = -254
offset_bottom = -106
script = ExtResource("10")
icon = 8
glow_color = Color(0.45, 0.9, 0.55, 1)
@ -301,9 +276,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -290
offset_right = 10
offset_top = -70
offset_bottom = 60
theme_override_font_sizes/font_size = 44
offset_top = -96
offset_bottom = 4
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = "Mothership"
horizontal_alignment = 1
@ -314,13 +289,13 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -220
offset_right = -60
offset_top = 70
offset_bottom = 150
offset_left = -204
offset_right = -64
offset_top = 14
offset_bottom = 78
script = ExtResource("19")
amount = 20
digit_height = 66.0
digit_height = 52.0
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
@ -329,8 +304,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 66
offset_right = 214
offset_top = -224
offset_bottom = -76
offset_top = -254
offset_bottom = -106
script = ExtResource("10")
icon = 9
glow_color = Color(1, 0.72, 0.25, 1)
@ -343,9 +318,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -10
offset_right = 290
offset_top = -70
offset_bottom = 60
theme_override_font_sizes/font_size = 44
offset_top = -96
offset_bottom = 4
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = "Ship Crafter"
horizontal_alignment = 1
@ -356,13 +331,13 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 60
offset_right = 220
offset_top = 70
offset_bottom = 150
offset_left = 76
offset_right = 216
offset_top = 14
offset_bottom = 78
script = ExtResource("19")
amount = 12
digit_height = 66.0
digit_height = 52.0
[node name="CommandButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
@ -371,8 +346,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -214
offset_right = -66
offset_top = 175
offset_bottom = 323
offset_top = 118
offset_bottom = 266
script = ExtResource("10")
icon = 10
glow_color = Color(0.55, 0.6, 1, 1)
@ -385,9 +360,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -290
offset_right = 10
offset_top = 333
offset_bottom = 463
theme_override_font_sizes/font_size = 44
offset_top = 276
offset_bottom = 376
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = "Command Center"
horizontal_alignment = 1
@ -398,13 +373,13 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -220
offset_right = -60
offset_top = 473
offset_bottom = 553
offset_left = -204
offset_right = -64
offset_top = 386
offset_bottom = 450
script = ExtResource("19")
amount = 15
digit_height = 66.0
digit_height = 52.0
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
@ -413,8 +388,8 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 66
offset_right = 214
offset_top = 175
offset_bottom = 323
offset_top = 118
offset_bottom = 266
script = ExtResource("10")
icon = 11
glow_color = Color(0, 0.62, 0.7, 1)
@ -427,9 +402,9 @@ anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -10
offset_right = 290
offset_top = 333
offset_bottom = 463
theme_override_font_sizes/font_size = 44
offset_top = 276
offset_bottom = 376
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = "Extractor"
horizontal_alignment = 1
@ -440,13 +415,13 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 60
offset_right = 220
offset_top = 473
offset_bottom = 553
offset_left = 76
offset_right = 216
offset_top = 386
offset_bottom = 450
script = ExtResource("19")
amount = 25
digit_height = 66.0
digit_height = 52.0
[node name="CloseButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 1.0
@ -696,3 +671,93 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0)
[node name="SkillMenu" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.94)
[node name="TitleLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 70.0
offset_right = -24.0
offset_bottom = 196.0
text = "Research"
horizontal_alignment = 1
[node name="Tree" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 210.0
offset_right = -24.0
offset_bottom = 760.0
script = ExtResource("21")
[node name="DetailLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 780.0
offset_right = -32.0
offset_bottom = 926.0
theme_override_font_sizes/font_size = 40
autowrap_mode = 2
text = ""
horizontal_alignment = 1
vertical_alignment = 1
[node name="CostNumber" type="Control" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -64.0
offset_top = 936.0
offset_right = 76.0
offset_bottom = 1000.0
script = ExtResource("19")
amount = 0
digit_height = 52.0
[node name="StatusLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 1006.0
offset_right = -32.0
offset_bottom = 1070.0
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = ""
horizontal_alignment = 1
[node name="UnlockButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -74.0
offset_top = 1090.0
offset_right = 74.0
offset_bottom = 1238.0
script = ExtResource("10")
icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="CloseButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172.0
offset_top = 24.0
offset_right = -24.0
offset_bottom = 172.0
script = ExtResource("10")
icon = 1
glow_color = Color(0.55, 0.6, 1, 1)

View file

@ -16,10 +16,10 @@ extends RefCounted
const DEFAULT_CORE: Color = Color(1.0, 1.0, 1.0)
static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:
static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE, width_scale: float = 1.0) -> void:
var closed: PackedVector2Array = points.duplicate()
closed.append(points[0])
_draw_layers(item, closed, glow_color, pulse, 1.0, core_color)
_draw_layers(item, closed, glow_color, pulse, width_scale, core_color)
static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:

View file

@ -16,6 +16,13 @@ extends Control
@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 = [
@ -60,8 +67,8 @@ func _draw_hexagon(centre: Vector2, radius: float) -> void:
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)
NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, 0.26, core_color)
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:
@ -78,7 +85,8 @@ func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
[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, 0.3, core_color)
NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, stroke, core_color)

47
NeonPiece.gd Normal file
View file

@ -0,0 +1,47 @@
class_name NeonPiece
extends Node2D
# One movable piece of neon art, rasterised ONCE and thereafter animated
# only through node properties.
#
# Godot re-runs _draw() solely when a redraw is requested; changing
# position/rotation/scale/modulate/visible reuses the cached geometry
# instead. Since every NeonDraw call expands into four antialiased
# polylines, redrawing a shape each frame just to move or fade it is the
# expensive way to do the cheapest kind of animation. Ship.gd and
# Asteroid.gd already work this way - this is the same idea packaged so a
# building can be assembled out of independently movable parts.
#
# Geometry is defined in local space around the piece's own origin, so the
# owner can move, spin, scale and fade it without touching the shape.
var polygons: Array = []
var lines: Array = []
var glow_color: Color = Color.WHITE
var core_color: Color = NeonDraw.DEFAULT_CORE
var pulse: float = 1.0
static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0) -> NeonPiece:
var piece: NeonPiece = NeonPiece.new()
piece.position = at
piece.glow_color = colour
piece.modulate.a = alpha
parent.add_child(piece)
return piece
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void:
polygons.append({"points": points, "pulse": piece_pulse})
func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0) -> void:
lines.append({"from": from, "to": to, "width": width, "pulse": piece_pulse})
func _draw() -> void:
for poly in polygons:
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color)
for line in lines:
NeonDraw.draw_glowing_line(self, line.from, line.to, glow_color,
line.pulse * pulse, line.width, core_color)

1
NeonPiece.gd.uid Normal file
View file

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

View file

@ -58,10 +58,10 @@ layer = 1
mouse_filter = 2
offset_left = 28
offset_top = 24
offset_right = 520
offset_bottom = 140
offset_right = 320
offset_bottom = 92
script = ExtResource("9")
digit_height = 100.0
digit_height = 60.0
glow_color = Color(0, 0.42, 0.48, 1)
core_color = Color(0, 0.08, 0.1, 1)
@ -143,22 +143,6 @@ icon = 6
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="NavLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -322
offset_bottom = -196
text = "Pivot"
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
theme_override_constants/outline_size = 5
horizontal_alignment = 1
[node name="FadeOverlay" type="CanvasLayer" parent="."]
layer = 10

View file

@ -12,6 +12,13 @@ var age: float = 0.0
func _ready() -> void:
add_to_group("player_projectiles")
area_entered.connect(_on_area_entered)
# Draw once. The earlier invisible-bolt bug came from _draw() deriving
# the bolt's look from `age` - drawing only at age==0 froze it at
# whatever the age==0 case rendered as. _draw() below is now a fixed
# shape with no age dependency, so one draw is safe and matches the
# Ship.gd/Asteroid.gd pattern: position/rotation are transform
# properties Godot applies at render time without re-tessellating.
queue_redraw()
func launch(direction: Vector2) -> void:
@ -22,14 +29,6 @@ func launch(direction: Vector2) -> void:
func _physics_process(delta: float) -> void:
position += velocity * delta
age += delta
# Redrawn every frame. In principle a fixed shape only needs drawing
# once (see Asteroid.gd, where dropping this was a large win), but
# doing that here left bolts invisible on screen - verified by
# rendering a frame mid-combat and finding zero pixels of the bolt
# colour. Rather than ship invisible shots a second time, this keeps
# the redraw: projectiles are few and live under two seconds, so the
# cost is negligible.
queue_redraw()
if age >= lifetime:
queue_free()

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)

1
SkillTreeView.gd.uid Normal file
View file

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

Binary file not shown.

View file

@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://co1qw50ehq2tl"
path="res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"
[deps]
source_file="res://fonts/CarroisGothicSC-Regular.ttf"
dest_files=["res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

202
fonts/LICENSE.txt Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

15
fonts/README.md Normal file
View file

@ -0,0 +1,15 @@
# Bundled fonts
## Carrois Gothic SC
`CarroisGothicSC-Regular.ttf` — designed by Ralph du Carrois, distributed via
Google Fonts.
Licensed under the Apache License, Version 2.0. The full licence text is in
`LICENSE.txt` alongside this file; it ships with the game because Apache 2.0
requires a copy of the licence to accompany any distribution of the work.
It is the project-wide UI typeface, wired up through `ui_theme.tres`
(`default_font`), which `project.godot` loads as `gui/theme/custom`. Note that
the `gui/theme/default_font_size` project setting does **not** reach the
default theme — the size lives on the theme resource too.

View file

@ -33,7 +33,7 @@ func _ready() -> void:
if cell.has("phase"):
b.set_process(false)
b.anim_time = float(cell.phase) * b.CYCLE
b.queue_redraw()
b.apply_animation()
var cam: Camera2D = Camera2D.new()
cam.zoom = Vector2(1.65, 1.65)

View file

@ -19,8 +19,7 @@ func _initialize() -> void:
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
"UI/BuildMenu/MothershipLabel",
"UI/BuildMenu/TitleLabel",
"UI/NavLabel"]:
"UI/BuildMenu/TitleLabel"]:
var label: Label = scene.get_node_or_null(path)
if label == null:
print("%s: MISSING" % path)

40
tests/_profile_startup.gd Normal file
View file

@ -0,0 +1,40 @@
extends SceneTree
# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays
# before it can assert anything, so tuning aims at the part that is actually
# expensive.
# godot --headless -s res://tests/_profile_startup.gd
var scene: Node
var t0: int = 0
var frames: int = 0
var frame_start: int = 0
func _initialize() -> void:
var boot: int = Time.get_ticks_msec()
print("engine boot to _initialize: %d ms" % boot)
t0 = Time.get_ticks_msec()
var packed: PackedScene = load("res://Main.tscn")
print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0))
t0 = Time.get_ticks_msec()
scene = packed.instantiate()
print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0))
t0 = Time.get_ticks_msec()
root.add_child(scene)
print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0))
frame_start = Time.get_ticks_msec()
func _process(_delta: float) -> bool:
frames += 1
if frames < 60:
return false
var span: int = Time.get_ticks_msec() - frame_start
print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)])
quit(0)
return true

View file

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

93
tests/_run_inside.sh Normal file
View file

@ -0,0 +1,93 @@
#!/bin/bash
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
# call tests/run_tests.sh from Termux instead.
#
# It exists as its own file so the whole suite runs in ONE `proot-distro
# login`: entering the container costs ~0.5s, which used to be paid 38 times
# over. Everything below is the container-side half of the runner.
#
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
set -uo pipefail
PROJECT_DIR="$1"; shift
JOBS="$1"; shift
VERBOSE="$1"; shift
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
cd "$PROJECT_DIR" || exit 2
tests=("$@")
total=${#tests[@]}
if (( total == 0 )); then
echo "no tests matched"
exit 2
fi
outdir="$(mktemp -d)"
trap 'rm -rf "$outdir"' EXIT
# Import once, serially, before anything runs in parallel. Godot writes to
# .godot/ while importing; several instances discovering a cold cache at the
# same moment would race on it. After this the workers only read it.
"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1
run_one() {
local name="$1"
local log="$outdir/$name.log"
local start end
start=$(date +%s%N)
# --fixed-fps pins delta to exactly 1/60 instead of measuring the wall
# clock. Without it a test's `elapsed` counts REAL seconds, so running
# several at once stretches every frame and the gameplay under test sees
# deltas it never sees in the game: ships overshoot their braking curve,
# settle late, and tests time out for no reason but load. That made
# test_camera_settles and test_planet_prompt_map_interaction fail on 2 of
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
# the phone is doing, and its result no longer depends on how busy the
# machine was.
"$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1
local rc=$?
end=$(date +%s%N)
local ms=$(( (end - start) / 1000000 ))
# Two independent criteria, because either one alone can lie: a test that
# crashes before reaching its verdict exits non-zero without ever printing
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
# while the log says otherwise.
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
else
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
fi
cat "$outdir/$name.status"
}
running=0
for name in "${tests[@]}"; do
run_one "$name" &
running=$(( running + 1 ))
if (( running >= JOBS )); then
wait -n
running=$(( running - 1 ))
fi
done
wait
failed=0
for name in "${tests[@]}"; do
if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then
failed=$(( failed + 1 ))
echo
echo "=== $name FAILED, full output ==="
cat "$outdir/$name.log" 2>/dev/null
elif [[ "$VERBOSE" == "1" ]]; then
echo
echo "=== $name ==="
cat "$outdir/$name.log" 2>/dev/null
fi
done
echo
echo "$(( total - failed ))/$total passed"
(( failed == 0 ))

View file

@ -0,0 +1,29 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
# mothership's research tree with one branch already bought and a node
# selected, so the laid-out tree, its state colours and the detail panel can
# all be checked in one render.
#
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.reset_progression()
GameState.rocks = 60
# One branch researched, so the render shows all three node states at
# once: done, reachable, and still locked further out.
GameState.unlock("shipyards")
GameState.unlock("coordination")
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
var controller: Node = main.get_node("BuildController")
controller.skill_menu.visible = true
controller.skill_tree.selected_id = "wing"
controller._refresh_skill_menu("wing")

View file

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

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_skill_menu_preview.gd" id="1"]
[node name="SkillMenuPreview" type="Node2D"]
script = ExtResource("1")

47
tests/_vfx_preview.gd Normal file
View file

@ -0,0 +1,47 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders
# projectiles and every explosion type so the node-animation conversion
# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a
# similar optimisation and headless tests did not catch it, because
# --headless has no GPU - so this exists to make that failure visible.
#
# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20
const VFX_SCRIPTS: Array = [
"res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd",
"res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd",
]
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
# Bolts at several headings, held still so they can be inspected.
var bolt_scene: PackedScene = load("res://Projectile.tscn")
for i in range(6):
var bolt: Area2D = bolt_scene.instantiate()
bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0)
bolt.rotation = float(i) * 0.35
add_child(bolt)
bolt.set_physics_process(false)
# One of each explosion, stepped forward by hand then frozen. Their real
# lifetime is under a second, and a softpipe frame takes far longer than
# that in wall-clock, so they would expire before the capture.
for i in range(VFX_SCRIPTS.size()):
var script: Script = load(VFX_SCRIPTS[i])
if script == null:
continue
var vfx: Node2D = Node2D.new()
vfx.set_script(script)
vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0)
vfx.lifetime = 999.0
add_child(vfx)
for step in range(8):
vfx._process(0.05)
vfx.set_process(false)
var cam: Camera2D = Camera2D.new()
add_child(cam)
cam.make_current()

View file

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

6
tests/_vfx_preview.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_vfx_preview.gd" id="1"]
[node name="VFXPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -1,20 +1,78 @@
#!/data/data/com.termux/files/usr/bin/bash
# Runs the headless GDScript gameplay tests from Termux, via the Godot
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
# Usage: bash tests/run_tests.sh
#
# bash tests/run_tests.sh # everything
# bash tests/run_tests.sh formation # only tests matching a substring
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
#
# Three things make this fast, in descending order of how much they matter
# (measured, not guessed - see tests/_profile_startup.gd):
#
# 1. It runs from a COPY on Termux's own filesystem, not from the project
# directory. The project lives under ~/storage/shared, which is Android's
# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")`
# alone took 8.5s per test, against ~2s from a native path. Nearly every
# test loads Main.tscn, and none of that time is gameplay. The copy is
# ~1.4s for the whole suite and its .godot import cache is kept between
# runs.
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
# quit() and stamp on the GameState autoload, so they cannot share one -
# which makes the suite a pile of independent, single-core-bound jobs on
# an 8-core phone.
# 3. The whole run happens in ONE `proot-distro login`. Entering the
# container costs ~0.5s, which used to be paid once per test.
#
# Output is buffered per test and printed only for failures; six interleaved
# logs are unreadable.
set -uo pipefail
set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
status=0
for test_script in "$PROJECT_DIR"/tests/test_*.gd; do
name="$(basename "$test_script")"
echo "=== Running $name ==="
if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then
status=1
fi
echo
# Leave headroom: Godot is memory-hungry and this is a phone, not a build
# server. Override with JOBS= if you know better.
JOBS="${JOBS:-6}"
VERBOSE="${VERBOSE:-0}"
IN_PLACE="${IN_PLACE:-0}"
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
filter="${1:-}"
tests=()
for path in "$PROJECT_DIR"/tests/test_*.gd; do
name="$(basename "$path")"
if [[ -z "$filter" || "$name" == *"$filter"* ]]; then
tests+=("$name")
fi
done
if (( ${#tests[@]} == 0 )); then
echo "no tests match '$filter'"
exit 2
fi
start=$(date +%s)
run_dir="$PROJECT_DIR"
if [[ "$IN_PLACE" != "1" ]]; then
run_dir="$WORK_DIR"
mkdir -p "$run_dir"
# Clear last run's sources but keep .godot, so imports stay warm. Without
# the clear, a test deleted from the project would live on in the copy and
# keep being run.
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
# android/ and builds/ are export artefacts no test touches, and the .apk
# is 30MB of nothing useful here.
tar -C "$PROJECT_DIR" \
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
fi
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
status=$?
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
exit $status

View file

@ -0,0 +1,114 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_asteroid_safe_spawn.gd
# Asteroid chunks are populated around wherever the fleet is - including the
# chunk it is standing in - and positions used to be uniform within a chunk.
# So an asteroid could be created directly on top of a ship and destroy it
# with no chance to react. Worst on the frame a scene loads: coming back from
# a planet's surface could cost a vessel instantly, which is also what made
# test_return_position_matches_planet fail intermittently.
#
# Verifies:
# 1. _safe_position never returns a point inside the bubble, for candidates
# all around and including the exact centre. This is checked directly
# rather than by looking at where asteroids happened to land: with ~54
# asteroids spread over 25 chunks, any single run only rarely produces a
# near miss, so an observational check passes just as happily with the
# guard removed and proves nothing.
# 2. candidates already outside the bubble are left exactly where they are,
# so the field is not distorted away from the fleet
# 3. the field still spawns a full complement, and a stationary fleet loses
# no ships
var scene: Node
var fleet: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
var ships_at_start: int = 0
func _initialize() -> void:
root.get_node("GameState").reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("AsteroidField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("EnemySpawner").set_process(false)
ships_at_start = fleet.ships.size()
elapsed = 0.0
stage = 1
return false
1:
# Let the field populate around the fleet a few times over.
if elapsed < 1.5:
return false
var centre: Vector2 = fleet.get_centroid()
var safe: float = field.safe_spawn_radius
# --- the guard itself, deterministically ----------------------
var worst_inside: float = 1e9
var moved_an_outside_one: bool = false
for i in range(72):
var a: float = (float(i) / 72.0) * TAU
var dir: Vector2 = Vector2(cos(a), sin(a))
# Candidates well inside, just inside, and exactly on centre.
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
var got: Vector2 = field._safe_position(centre + dir * d, centre)
worst_inside = minf(worst_inside, got.distance_to(centre))
# Candidates already outside must be returned untouched.
var outside: Vector2 = centre + dir * (safe * 1.5)
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
moved_an_outside_one = true
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
worst_inside, safe
])
if worst_inside < safe - 0.001:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
quit(1)
return true
if moved_an_outside_one:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
quit(1)
return true
var asteroids: Array = get_nodes_in_group("asteroids")
var closest: float = 1e9
for a in asteroids:
if is_instance_valid(a):
closest = minf(closest, a.position.distance_to(centre))
print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [
asteroids.size(), closest, safe
])
if asteroids.is_empty():
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)")
quit(1)
return true
# Asteroids drift, so one may have travelled inward since it was
# created. Allow for that, but nothing should be anywhere near
# on top of the fleet.
if closest < safe * 0.5:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)")
quit(1)
return true
var kept_ships: bool = fleet.ships.size() == ships_at_start
print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships])
if not kept_ships:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)")
quit(1)
return true
print("TEST_ASTEROID_SAFE_SPAWN: PASS")
quit(0)
return true
return false

View file

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

View file

@ -39,6 +39,12 @@ func _initialize() -> void:
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
# Structures are gated behind the mothership's research tree. That gate
# is test_skill_tree's subject, not this one's - here every option needs
# to be available so the popup itself can be exercised.
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)

View file

@ -0,0 +1,110 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_building_redraw_cost.gd
# Animated buildings must not re-run _draw() to animate (Forgejo issues #6
# and #10).
#
# Every NeonDraw call expands into four antialiased polylines, so redrawing
# a whole shipyard each frame to advance a crane re-tessellates all of it.
# The moving elements are now separate NeonPiece children, rasterised once
# and thereafter only moved, scaled, faded or hidden - operations that reuse
# the cached geometry. Ship.gd and Asteroid.gd already worked this way.
#
# Verifies:
# 1. an animated building's geometry is rasterised exactly ONCE, however
# long it animates
# 2. it is genuinely still animating (so this cannot be "passed" by
# freezing the animation)
# 3. the moving parts really do move - the crane travels and the
# mothership's satellites orbit - proving the animation reaches the
# nodes rather than being silently dropped
# 4. the static command centre needs no per-frame work either
var scene: Node
var field: Node
var crafter: Node2D
var mothership: Node2D
var command: Node2D
var stage: int = 0
var elapsed: float = 0.0
var crane_x_start: float = 0.0
var satellite_rotation_start: float = 0.0
func _initialize() -> void:
var game_state: Node = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0))
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0))
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
for b in field.spawned:
if not is_instance_valid(b):
continue
match b.building_type:
GameState.BUILDING_SHIP_CRAFTER:
crafter = b
GameState.BUILDING_MOTHERSHIP:
mothership = b
GameState.BUILDING_COMMAND_CENTER:
command = b
if crafter == null or mothership == null or command == null:
print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)")
quit(1)
return true
crane_x_start = crafter._crane.position.x
satellite_rotation_start = mothership._satellites.rotation
elapsed = 0.0
stage = 2
return false
2:
# Long enough to cross several assembly phases and a good arc of
# the satellites' orbit.
if elapsed < 2.5:
return false
print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [
elapsed, crafter.draw_count, mothership.draw_count, command.draw_count
])
for b in [crafter, mothership, command]:
if b.draw_count != 1:
print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [
b.building_type, b.draw_count
])
quit(1)
return true
var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0
var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0
var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05
print("still animating=%s crane travelled=%s satellites orbited=%s" % [
animating, crane_moved, orbit_moved
])
if not (animating and crane_moved and orbit_moved):
print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)")
quit(1)
return true
print("TEST_BUILDING_REDRAW_COST: PASS")
quit(0)
return true
return false

View file

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

View file

@ -42,6 +42,11 @@ func _initialize() -> void:
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
# Extractors are gated behind research (see test_skill_tree). This test
# is about the planet-proximity rule, so grant the research up front.
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)

View file

@ -14,7 +14,13 @@ extends SceneTree
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
# The test has to sit through a whole there-and-back hauler cycle, and the
# cycle length is distance/HAULER_SPEED - so this fixture distance IS the
# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s
# for the 4000 it used to be. Nothing here asserts on the distance itself,
# only on payout and drawn position tracking the same `progress`, so a short
# route exercises exactly the same code.
var mothership_pos: Vector2 = Vector2(600.0, 0.0)
var planet_pos: Vector2 = Vector2.ZERO
var rocks_before_arrival: int = 0

View file

@ -120,9 +120,17 @@ func _process(delta: float) -> bool:
for ship in fleet.ships:
speed = max(speed, ship.velocity.length())
var stopped: bool = speed < 1.0
var coasted: bool = fleet.get_centroid().x > moving_centroid.x
print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [
speed, stopped, coasted
# Not merely "moved forward at all": the fleet cruises at
# cruise_speed and brakes at Ship.acceleration, so the coast is
# cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a
# real distance is what makes this a test of coasting - the old
# `> moving_centroid.x` was satisfied by any float wobble, and
# passed happily while the fleet stopped dead.
var coast: float = fleet.get_centroid().x - moving_centroid.x
var coasted: bool = coast > 40.0
print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [
speed, stopped, coast,
(fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration)
])
if stopped and coasted:
print("TEST_JOYSTICK_CONTROL: PASS")

169
tests/test_skill_tree.gd Normal file
View file

@ -0,0 +1,169 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_skill_tree.gd
# The mothership's research tree (Forgejo issue #4). It gates which
# structures may be built at all, and carries permanent upgrades that have
# no building of their own.
#
# Verifies:
# 1. from a fresh start only the mothership is buildable - the others are
# greyed, and pressing one explains which research opens it rather than
# silently failing
# 2. a locked option cannot be built even if the confirm is forced
# 3. research costs rocks, needs its prerequisite, and unlocks its building
# 4. the mothership's action button opens the tree (it used to have no
# action at all)
# 5. fleet capacity upgrades raise the cap, and the extra vessel can
# actually be crafted afterwards
var scene: Node
var game_state: Node
var controller: Node
var fleet: Node
var field: Node
var popup: Control
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
popup = scene.get_node("UI/BuildMenu/InfoPopup")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- everything but the mothership starts locked --------------
var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP)
var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("fresh start: mothership buildable=%s crafter locked=%s" % [
mothership_free, crafter_locked
])
if not (mothership_free and crafter_locked):
print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)")
quit(1)
return true
controller._on_build_pressed()
var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled
controller._on_place_crafter()
var explains: bool = popup.visible \
and "research" in popup.get_node("Panel/MessageLabel").text.to_lower()
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [
greyed, explains, offered
])
if not (greyed and explains) or offered:
print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)")
quit(1)
return true
# Forcing the confirm must still build nothing.
var before: int = game_state.buildings.size()
controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER
controller._confirm_build()
if game_state.buildings.size() != before:
print("TEST_SKILL_TREE: FAIL (built a locked structure)")
quit(1)
return true
controller._close_info()
# --- the mothership opens the tree ----------------------------
var mothership: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
mothership = b
controller.current_building = mothership
controller._on_action_pressed()
var tree_open: bool = scene.get_node("UI/SkillMenu").visible
print("mothership action opens research: %s" % tree_open)
if not tree_open:
print("TEST_SKILL_TREE: FAIL (mothership has no research action)")
quit(1)
return true
# --- prerequisites --------------------------------------------
if game_state.can_unlock("wing"):
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
quit(1)
return true
# --- research costs rocks and unlocks its building ------------
var rocks_before: int = game_state.rocks
var cost: int = int(game_state.skill_node("shipyards").cost)
var ok: bool = game_state.unlock("shipyards")
var charged: bool = game_state.rocks == rocks_before - cost
var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [
ok, rocks_before, game_state.rocks, charged, now_buildable
])
if not (ok and charged and now_buildable):
print("TEST_SKILL_TREE: FAIL (research did not unlock its building)")
quit(1)
return true
# The menu must show it as buildable now, without reopening.
controller._on_build_pressed()
if not scene.get_node("UI/BuildMenu/CrafterButton").enabled:
print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)")
quit(1)
return true
controller._close_info()
scene.get_node("UI/BuildMenu").visible = false
# --- fleet capacity -------------------------------------------
var base_cap: int = game_state.max_fleet_size()
game_state.unlock("coordination")
var wing_ok: bool = game_state.unlock("wing")
var raised_cap: int = game_state.max_fleet_size()
print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [
base_cap, raised_cap, wing_ok
])
if not (wing_ok and raised_cap == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)")
quit(1)
return true
# And the extra vessel must actually be craftable.
fleet.spawn_ships(base_cap, Vector2.ZERO)
var added: bool = fleet.add_ship()
print("fleet %d ships, add one more -> %s (now %d)" % [
base_cap, added, fleet.ships.size()
])
if not (added and fleet.ships.size() == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)")
quit(1)
return true
if fleet.add_ship():
print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)")
quit(1)
return true
print("TEST_SKILL_TREE: PASS")
quit(0)
return true
return false

View file

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

View file

@ -1,15 +1,8 @@
[gd_resource type="Theme" format=3]
[gd_resource type="Theme" load_steps=2 format=3]
; Project-wide default font size for every Control.
;
; This exists because the `gui/theme/default_font_size` project setting does
; NOT reach the default theme in this build - labels without an explicit
; override resolve to the engine fallback of 16 no matter what that setting
; says, which is why earlier attempts to enlarge the UI text did nothing.
; A custom Theme assigned to `gui/theme/custom` does apply.
;
; Individual labels that cannot fit their own text at this size carry a
; smaller theme_override_font_sizes/font_size in the scene.
[ext_resource type="FontFile" path="res://fonts/CarroisGothicSC-Regular.ttf" id="1"]
[resource]
default_font = ExtResource("1")
default_font_size = 78
Label/colors/font_color = Color(0.36, 0.9, 1, 1)