#84: move 215 root files into a folder hierarchy

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

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

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

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

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

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

View file

@ -0,0 +1,37 @@
class_name AsteroidExplosionVFX
extends ExplosionVFXBase
# Tumbling rock-chunk debris, distinct from the energy-ship light-burst
# style - asteroids are matter, not light.
const COLOR: Color = Color(0.65, 0.5, 0.35)
static func spawn(parent: Node, world_position: Vector2) -> void:
var vfx: AsteroidExplosionVFX = AsteroidExplosionVFX.new()
vfx.position = world_position
parent.add_child(vfx)
func _random_particle_size() -> float:
return randf_range(3.0, 7.0)
# 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),
])
visual.draw_callable = func(item: CanvasItem) -> void:
item.draw_colored_polygon(local_pts, COLOR)

View file

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

12
vfx/EnemyExplosionVFX.gd Normal file
View file

@ -0,0 +1,12 @@
class_name EnemyExplosionVFX
extends LightBurstVFX
func _init() -> void:
color = Color(1.0, 0.15, 0.2)
static func spawn(parent: Node, world_position: Vector2) -> void:
var vfx: EnemyExplosionVFX = EnemyExplosionVFX.new()
vfx.position = world_position
parent.add_child(vfx)

View file

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

22
vfx/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)

View file

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

82
vfx/ExplosionVFXBase.gd Normal file
View file

@ -0,0 +1,82 @@
class_name ExplosionVFXBase
extends Node2D
# Shared particle motion/fade/lifetime logic for all explosion VFX types.
# Subclasses fix their own visual style and color as facts about
# themselves (see LightBurstVFX/AsteroidExplosionVFX), not as parameters
# passed in by whatever destroyed the object - so "ships always explode
# blue" is guaranteed by the class hierarchy, not by remembering to pass
# the right Color() at each call site.
@export var particle_count: int = 12
@export var lifetime: float = 0.5
@export var min_speed: float = 60.0
@export var max_speed: float = 220.0
var age: float = 0.0
var particles: Array = []
func _ready() -> void:
randomize()
for i in range(particle_count):
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
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(),
}
# 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:
age += delta
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
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)
# 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

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

19
vfx/LightBurstVFX.gd Normal file
View file

@ -0,0 +1,19 @@
class_name LightBurstVFX
extends ExplosionVFXBase
# Shared radiating-neon-streak rendering for energy-based objects (player
# ships, enemies) - concrete subclasses only need to fix their own color.
var color: Color = Color(1, 1, 1)
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 line_color: Color = color
visual.draw_callable = func(item: CanvasItem) -> void:
NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6)

1
vfx/LightBurstVFX.gd.uid Normal file
View file

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

52
vfx/NeonDraw.gd Normal file
View file

@ -0,0 +1,52 @@
class_name NeonDraw
extends RefCounted
# Cheap "poor man's glow": layered translucent strokes of increasing width
# and decreasing opacity, topped with a bright core line. No solid fill -
# gives ships/enemies/shots a light-made, neon/laser-sabre look without
# needing an HDR bloom pipeline.
#
# The core colour is a parameter rather than always white: the effect is
# built for a dark background, and a near-white core all but disappears
# against a bright one (the lunar surface, whose colours are sampled from a
# real NASA photograph, is a light grey). Callers drawing onto a bright
# backdrop pass a dark core instead, which reads as an inked outline while
# keeping the same layered-glow construction.
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, width_scale: float = 1.0) -> void:
var closed: PackedVector2Array = points.duplicate()
closed.append(points[0])
_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:
_draw_layers(item, PackedVector2Array([from, to]), glow_color, pulse, width_scale, core_color)
static func draw_glowing_circle(item: CanvasItem, center: Vector2, radius: float,
glow_color: Color, pulse: float = 1.0,
core_color: Color = DEFAULT_CORE) -> void:
var points := PackedVector2Array()
for i in range(24):
var angle := TAU * float(i) / 24.0
points.append(center + Vector2(cos(angle), sin(angle)) * radius)
draw_glowing_polygon(item, points, glow_color, pulse, core_color)
# An open run of points - unlike draw_glowing_polygon, the ends are NOT
# joined. For arcs, cradle ribs and other shapes that are deliberately open.
static func draw_glowing_path(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:
if points.size() < 2:
return
_draw_layers(item, points, glow_color, pulse, width_scale, core_color)
static func _draw_layers(item: CanvasItem, poly: PackedVector2Array, glow_color: Color, pulse: float, width_scale: float, core_color: Color) -> void:
var c: Color = glow_color
item.draw_polyline(poly, Color(c.r, c.g, c.b, 0.12 * pulse), 18.0 * width_scale, true)
item.draw_polyline(poly, Color(c.r, c.g, c.b, 0.28 * pulse), 10.0 * width_scale, true)
item.draw_polyline(poly, Color(c.r, c.g, c.b, 0.60 * pulse), 5.0 * width_scale, true)
item.draw_polyline(poly, Color(core_color.r, core_color.g, core_color.b, 0.95 * pulse), 1.8 * width_scale, true)

1
vfx/NeonDraw.gd.uid Normal file
View file

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

47
vfx/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
vfx/NeonPiece.gd.uid Normal file
View file

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

101
vfx/ResourcePickupVFX.gd Normal file
View file

@ -0,0 +1,101 @@
extends Node2D
# Forgejo #79: a small "+N" with the resource's own mark, rising and fading
# where the fleet picked something up.
#
# Collecting used to be entirely silent - a rock vanished and a number in the
# corner of the screen changed. Which number, and by how much, was left for
# the player to notice on their own. This says it at the point it happened,
# in the resource's own colour, and gets out of the way.
#
# Built from the SAME glyph scene the resource tables and transfer rows use
# (ResourceDefinition.glyph_scene), so a new material added to the catalog
# gets a correct pickup mark with no work here - and can never end up with
# one mark in the HUD and a different one in the world.
const RESOURCE_DEFINITIONS = preload("res://resources/ResourceDefinitions.gd")
# Deliberately NO class_name, and the constructor below loads this file by
# path rather than naming the class. A global class-name identifier - even a
# script's own, inside its own static function - needs the class cache already
# warm, and it is not when this is reached indirectly through a preview scene
# loading Main.tscn. That failed to compile outright, taking Rock and the gas
# vents down with it. Callers preload the path too; see Rock.gd.
const SELF_PATH: String = "res://vfx/ResourcePickupVFX.gd"
const RISE_DISTANCE: float = 46.0
const DURATION: float = 0.85
const GLYPH_SIZE: float = 22.0
const DIGIT_HEIGHT: float = 20.0
# Held fully opaque for the first part of its life, so the eye has time to
# catch it before it starts going.
const FADE_START: float = 0.45
var age: float = 0.0
var colour: Color = Color.WHITE
var start_y: float = 0.0
static func spawn(parent: Node, world_position: Vector2, resource_type: String,
amount: int) -> void:
if amount <= 0:
return
var vfx: Node2D = (load(SELF_PATH) as GDScript).new()
vfx.position = world_position
vfx.build(resource_type, amount)
parent.add_child(vfx)
func build(resource_type: String, amount: int) -> void:
var definition: Resource = RESOURCE_DEFINITIONS.get_definition(resource_type)
colour = definition.color if definition != null else Color.WHITE
if definition != null and definition.glyph_scene != null:
var glyph: Control = definition.glyph_scene.instantiate()
glyph.colour = definition.color
# Anchors first. The glyph scenes are authored as full-rect Controls
# for the panels that host them, and Godot recomputes the size of any
# Control with non-equal opposite anchors right after _ready() - so
# assigning size without this is overwritten, with a warning, the
# moment it enters the tree.
glyph.set_anchors_preset(Control.PRESET_TOP_LEFT)
glyph.size = Vector2(GLYPH_SIZE, GLYPH_SIZE)
glyph.position = Vector2(-2.0, -GLYPH_SIZE * 0.5)
add_child(glyph)
# show_icon off: the resource's own glyph is already beside it, and
# NeonNumber's built-in mark is the ore hexagon whatever the amount is.
var number := NeonNumber.new()
number.show_icon = false
number.amount = amount
number.digit_height = DIGIT_HEIGHT
number.glow_color = colour
number.core_color = colour.darkened(0.8)
number.size = Vector2(60.0, DIGIT_HEIGHT)
number.position = Vector2(GLYPH_SIZE + 4.0, -DIGIT_HEIGHT * 0.5)
add_child(number)
start_y = position.y
z_index = 40
func _process(delta: float) -> void:
age += delta
var t: float = age / DURATION
if t >= 1.0:
queue_free()
return
# Eases out: quick off the mark, drifting by the end.
position.y = start_y - RISE_DISTANCE * (1.0 - pow(1.0 - t, 2.0))
modulate.a = 1.0 if t < FADE_START else 1.0 - (t - FADE_START) / (1.0 - FADE_START)
queue_redraw()
func _draw() -> void:
# The plus sign, drawn here rather than as a third child - it is two
# strokes and giving it its own node would cost more than it saves.
var centre := Vector2(-12.0, 0.0)
var arm: float = 5.0
NeonDraw.draw_glowing_line(self, centre - Vector2(arm, 0.0), centre + Vector2(arm, 0.0),
colour, 1.0, 0.5, colour.darkened(0.8))
NeonDraw.draw_glowing_line(self, centre - Vector2(0.0, arm), centre + Vector2(0.0, arm),
colour, 1.0, 0.5, colour.darkened(0.8))

View file

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

12
vfx/ShipExplosionVFX.gd Normal file
View file

@ -0,0 +1,12 @@
class_name ShipExplosionVFX
extends LightBurstVFX
func _init() -> void:
color = Color(0.25, 0.85, 1.0)
static func spawn(parent: Node, world_position: Vector2) -> void:
var vfx: ShipExplosionVFX = ShipExplosionVFX.new()
vfx.position = world_position
parent.add_child(vfx)

View file

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