Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
extends Area2D
|
|
|
|
|
|
|
|
|
|
# A gatherable crystal of asteroid ore lying on a planet's surface. Picked
|
|
|
|
|
# up by flying any ship over it; the fleet doesn't need to stop.
|
|
|
|
|
#
|
|
|
|
|
# Deliberately drawn as a PERFECT HEXAGON rather than the irregular lumpy
|
|
|
|
|
# polygon Asteroid.gd uses. The two are never on screen
|
|
|
|
|
# together (asteroids are space-only, rocks surface-only), so the player
|
|
|
|
|
# can only tell "collect this" from "this hurts" by learned shape language:
|
|
|
|
|
# irregular and jagged reads as a natural hazard, regular and faceted reads
|
|
|
|
|
# as something valuable and meant to be picked up.
|
|
|
|
|
#
|
|
|
|
|
# It also drifts toward a nearby ship (see _physics_process). Nothing
|
|
|
|
|
# harmful moves helpfully toward you, so the pull doubles as a readability
|
|
|
|
|
# cue on top of making gathering feel less fiddly.
|
|
|
|
|
|
|
|
|
|
signal collected
|
|
|
|
|
|
|
|
|
|
@export var attract_radius: float = 220.0
|
|
|
|
|
@export var attract_speed: float = 260.0
|
|
|
|
|
@export var attract_accel: float = 420.0
|
|
|
|
|
|
|
|
|
|
var shape_points: PackedVector2Array = PackedVector2Array()
|
|
|
|
|
var facet_points: PackedVector2Array = PackedVector2Array()
|
|
|
|
|
var size_scale: float = 1.0
|
|
|
|
|
var velocity: Vector2 = Vector2.ZERO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
add_to_group("rocks")
|
|
|
|
|
z_index = -1
|
|
|
|
|
area_entered.connect(_on_area_entered)
|
|
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func build_shape(shape_seed: int) -> void:
|
|
|
|
|
# Only the SIZE varies, never the silhouette: every ore node is the same
|
|
|
|
|
# regular hexagon, which is the whole point of the cue.
|
|
|
|
|
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
|
|
|
|
rng.seed = shape_seed
|
|
|
|
|
size_scale = rng.randf_range(0.85, 1.25)
|
|
|
|
|
|
|
|
|
|
var r: float = 13.0 * size_scale
|
|
|
|
|
shape_points = PackedVector2Array()
|
|
|
|
|
for i in range(6):
|
|
|
|
|
var a: float = (float(i) / 6.0) * TAU - PI / 2.0
|
|
|
|
|
shape_points.append(Vector2(cos(a), sin(a)) * r)
|
|
|
|
|
# One flat-to-flat line across the middle, so it reads as a cut face
|
|
|
|
|
# rather than a plain outline at small sizes.
|
|
|
|
|
facet_points = PackedVector2Array([
|
|
|
|
|
shape_points[5], shape_points[2],
|
|
|
|
|
])
|
|
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
|
|
|
# Drift toward the closest ship inside attract_radius. No redraw here:
|
|
|
|
|
# position is a transform property and the crystal's shape is fixed.
|
|
|
|
|
var target: Node2D = _nearest_ship()
|
|
|
|
|
if target == null:
|
|
|
|
|
velocity = velocity.move_toward(Vector2.ZERO, attract_accel * delta)
|
|
|
|
|
else:
|
|
|
|
|
var desired: Vector2 = (target.position - position).normalized() * attract_speed
|
|
|
|
|
velocity = velocity.move_toward(desired, attract_accel * delta)
|
|
|
|
|
position += velocity * delta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _nearest_ship() -> Node2D:
|
|
|
|
|
var nearest: Node2D = null
|
|
|
|
|
var best: float = attract_radius
|
|
|
|
|
for ship in get_tree().get_nodes_in_group("player_ships"):
|
|
|
|
|
if not is_instance_valid(ship):
|
|
|
|
|
continue
|
|
|
|
|
var d: float = position.distance_to(ship.position)
|
|
|
|
|
if d <= best:
|
|
|
|
|
best = d
|
|
|
|
|
nearest = ship
|
|
|
|
|
return nearest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_area_entered(area: Area2D) -> void:
|
|
|
|
|
if area.is_in_group("player_ships"):
|
2026-08-11 15:55:45 +02:00
|
|
|
# Into the fleet's hold, not the bank (#14). It only counts as
|
|
|
|
|
# spendable once the fleet has flown it back to the mothership.
|
2026-08-16 14:53:26 +02:00
|
|
|
# #56: the hold has a finite total_capacity now - a full fleet must
|
|
|
|
|
# leave the rock behind rather than despawning it for nothing.
|
|
|
|
|
if GameState.collect_cargo(1) <= 0:
|
|
|
|
|
return
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
collected.emit()
|
|
|
|
|
queue_free()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _draw() -> void:
|
|
|
|
|
if shape_points.is_empty():
|
|
|
|
|
return
|
|
|
|
|
# Deep teal with a near-black core: a dark, saturated colour reads
|
|
|
|
|
# clearly against the bright lunar regolith (sampled at ~0.75 grey),
|
|
|
|
|
# where the original warm yellow disappeared into it.
|
|
|
|
|
var glow: Color = Color(0.0, 0.62, 0.7)
|
|
|
|
|
var core: Color = Color(0.0, 0.12, 0.15)
|
|
|
|
|
NeonDraw.draw_glowing_polygon(self, shape_points, glow, 1.0, core)
|
|
|
|
|
# One line only - the hexagon has a single flat-to-flat facet. This used
|
|
|
|
|
# to draw two lines from a 4-point array; the hexagon rewrite left only
|
|
|
|
|
# 2 points, so the second line indexed out of bounds and every rock
|
|
|
|
|
# threw on every draw.
|
|
|
|
|
if facet_points.size() >= 2:
|
|
|
|
|
NeonDraw.draw_glowing_line(self, facet_points[0], facet_points[1], glow, 0.7, 0.3, core)
|