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>
This commit is contained in:
commit
85814ce88c
192 changed files with 9602 additions and 0 deletions
195
Fleet.gd
Normal file
195
Fleet.gd
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
extends Node2D
|
||||
|
||||
# Formation slots come from GameState.formation (editable at a command
|
||||
# centre) rather than being fixed here, so a layout the player arranged
|
||||
# survives scene swaps and fleet rebuilds. Ships are always
|
||||
# spawned programmatically (spawn_ships), using however many of these
|
||||
# offsets are needed - both for a fresh start (5 ships) and for restoring
|
||||
# a possibly-smaller surviving fleet after returning from a planet's
|
||||
# surface, so both cases share one code path instead of two that could
|
||||
# drift out of sync.
|
||||
# The fleet's cruise speed. This is the speed of the FLEET as a whole: the
|
||||
# anchor below advances at exactly this rate, and individual ships are
|
||||
# allowed to fly faster than it (Ship.max_speed) so they can close on their
|
||||
# formation slot - which is what makes a pivot while under way look sharp
|
||||
# instead of sluggish.
|
||||
@export var cruise_speed: float = 260.0
|
||||
|
||||
# Per-scene neon palette for the fleet. PlanetSurface.tscn overrides these
|
||||
# with darker values, since the lunar surface is bright grey and the default
|
||||
# near-white core would wash out against it. See NeonDraw.
|
||||
@export var ship_glow_color: Color = Color(0.25, 0.85, 1.0)
|
||||
@export var ship_core_color: Color = Color(1.0, 1.0, 1.0)
|
||||
|
||||
var ship_scene: PackedScene = preload("res://Ship.tscn")
|
||||
|
||||
var ships: Array = []
|
||||
var formation_offsets: Array = []
|
||||
var fleet_target: Vector2 = Vector2.ZERO
|
||||
# Formation centre while under joystick steering, and the heading it is
|
||||
# travelling along. Zero heading means the fleet is not being steered.
|
||||
var anchor: Vector2 = Vector2.ZERO
|
||||
var steer_direction: Vector2 = Vector2.ZERO
|
||||
|
||||
|
||||
func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
|
||||
for ship in ships:
|
||||
if is_instance_valid(ship):
|
||||
ship.queue_free()
|
||||
ships.clear()
|
||||
formation_offsets.clear()
|
||||
|
||||
var n: int = clamp(count, 1, GameState.formation.size())
|
||||
for i in range(n):
|
||||
var offset: Vector2 = GameState.formation[i]
|
||||
var ship: Area2D = ship_scene.instantiate()
|
||||
ship.position = center + offset
|
||||
ship.glow_color = ship_glow_color
|
||||
ship.core_color = ship_core_color
|
||||
add_child(ship)
|
||||
ships.append(ship)
|
||||
formation_offsets.append(offset)
|
||||
if ship.has_signal("destroyed"):
|
||||
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
|
||||
|
||||
fleet_target = center
|
||||
|
||||
|
||||
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():
|
||||
return false
|
||||
var offset: Vector2 = GameState.formation[ships.size()]
|
||||
var ship: Area2D = ship_scene.instantiate()
|
||||
# From the formation origin, not the centroid: with a lopsided layout the
|
||||
# centroid is already displaced by the average offset, so adding the new
|
||||
# ship's own offset to it would drop it well outside the wedge.
|
||||
ship.position = get_formation_origin() + offset
|
||||
ship.glow_color = ship_glow_color
|
||||
ship.core_color = ship_core_color
|
||||
add_child(ship)
|
||||
ships.append(ship)
|
||||
formation_offsets.append(offset)
|
||||
if ship.has_signal("destroyed"):
|
||||
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
|
||||
ship.set_move_target(fleet_target + offset)
|
||||
return true
|
||||
|
||||
|
||||
func apply_formation() -> void:
|
||||
# Re-seats the EXISTING ships into the current GameState.formation.
|
||||
# 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():
|
||||
break
|
||||
formation_offsets[i] = GameState.formation[i]
|
||||
_assign_slot_targets()
|
||||
|
||||
|
||||
func move_to(world_pos: Vector2) -> void:
|
||||
steer_direction = Vector2.ZERO
|
||||
fleet_target = world_pos
|
||||
anchor = world_pos
|
||||
_assign_slot_targets()
|
||||
|
||||
|
||||
func move_in_direction(direction: Vector2) -> void:
|
||||
# Joystick steering. Sets the heading; _process advances the anchor.
|
||||
#
|
||||
# This used to park a target 2000 units ahead and let the ships chase
|
||||
# it. That made formation changes barely register while flying: with
|
||||
# the aim point that far away, a 60-unit formation offset moved a
|
||||
# ship's heading by under two degrees, so pivoting mid-flight looked
|
||||
# like it did almost nothing. Anchoring the formation and having each
|
||||
# ship hold its own slot relative to it fixes that.
|
||||
if direction == Vector2.ZERO:
|
||||
stop()
|
||||
return
|
||||
if steer_direction == Vector2.ZERO:
|
||||
anchor = get_formation_origin()
|
||||
steer_direction = direction.normalized()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if steer_direction == Vector2.ZERO:
|
||||
return
|
||||
# The anchor - the fleet's formation centre - travels at cruise_speed.
|
||||
# Ships may exceed it individually to reach their slots.
|
||||
anchor += steer_direction * cruise_speed * delta
|
||||
fleet_target = anchor
|
||||
_assign_slot_targets()
|
||||
|
||||
|
||||
func _assign_slot_targets() -> void:
|
||||
for i in range(ships.size()):
|
||||
ships[i].set_move_target(fleet_target + formation_offsets[i])
|
||||
# Context, not a command: the ship adds this to its own velocity to
|
||||
# work out how it is really moving. See Ship.fleet_velocity.
|
||||
ships[i].fleet_velocity = steer_direction * cruise_speed
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
steer_direction = Vector2.ZERO
|
||||
# Aim each ship at the point where its own braking curve brings it to
|
||||
# rest, so releasing the stick coasts to a halt instead of snapping to
|
||||
# a dead stop (which is what targeting the current position would do,
|
||||
# since that lands inside arrive_radius immediately).
|
||||
fleet_target = get_formation_origin()
|
||||
for ship in ships:
|
||||
var speed: float = ship.velocity.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)
|
||||
|
||||
|
||||
func get_move_targets() -> Array:
|
||||
var result: Array = []
|
||||
for ship in ships:
|
||||
result.append(ship.move_target)
|
||||
return result
|
||||
|
||||
|
||||
func restore(previous_fleet_target: Vector2, previous_move_targets: Array) -> void:
|
||||
fleet_target = previous_fleet_target
|
||||
for i in range(ships.size()):
|
||||
ships[i].move_target = previous_move_targets[i]
|
||||
|
||||
|
||||
func _on_ship_destroyed(ship: Node) -> void:
|
||||
var idx: int = ships.find(ship)
|
||||
if idx != -1:
|
||||
ships.remove_at(idx)
|
||||
formation_offsets.remove_at(idx)
|
||||
|
||||
|
||||
func get_centroid() -> Vector2:
|
||||
if ships.is_empty():
|
||||
return fleet_target
|
||||
var sum: Vector2 = Vector2.ZERO
|
||||
for ship in ships:
|
||||
sum += ship.position
|
||||
return sum / ships.size()
|
||||
|
||||
|
||||
func get_formation_origin() -> Vector2:
|
||||
# Where the anchor must be for the ships to be standing in their slots
|
||||
# right now. This is NOT the centroid: ships sit at anchor + offset, so
|
||||
# the centroid is anchor + the AVERAGE offset, and the two only coincide
|
||||
# when the layout happens to be balanced around its origin.
|
||||
#
|
||||
# The default wedge is balanced, which is why using the centroid as the
|
||||
# anchor looked fine - until a layout dragged off to one corner at a
|
||||
# command centre made the average offset large. Seeding the anchor from
|
||||
# the centroid then displaced every slot by that average the instant
|
||||
# steering began, so the fleet lurched towards the formation's own
|
||||
# lopsided side before the anchor caught up and it flew the right way.
|
||||
if ships.is_empty():
|
||||
return fleet_target
|
||||
var sum: Vector2 = Vector2.ZERO
|
||||
for offset in formation_offsets:
|
||||
sum += offset
|
||||
return get_centroid() - sum / formation_offsets.size()
|
||||
Loading…
Reference in a new issue