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:
Loïc Wisniewski 2026-08-10 18:41:58 +02:00
commit 85814ce88c
192 changed files with 9602 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# Godot's import/cache folder. Regenerated on open, and it also holds
# .godot/export_credentials.cfg, which must never be published.
.godot/
# Build outputs and the generated Android build template (~325 MB combined).
# All reproducible: the template comes from Godot's "Install Android Build
# Template", the rest from an export.
builds/
android/build/
*.apk
*.aab
*.idsig
# Android's "don't index this as media" marker, not project content.
.nomedia

40
Asteroid.gd Normal file
View file

@ -0,0 +1,40 @@
extends Area2D
var velocity: Vector2 = Vector2.ZERO
var rotation_speed: float = 0.0
var shape_points: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
randomize()
add_to_group("asteroids")
area_entered.connect(_on_area_entered)
var point_count: int = 7
for i in range(point_count):
var angle: float = (float(i) / point_count) * TAU
var radius: float = randf_range(14.0, 22.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
queue_redraw()
func _process(delta: float) -> void:
# Deliberately no queue_redraw() here. position and rotation are node
# TRANSFORM properties - Godot applies them when rendering, and the
# polygon built in _ready() is in local space, so it never changes.
# Redrawing each frame re-tessellated four antialiased polylines per
# asteroid for an identical result; with ~150 asteroids on screen that
# was the single largest source of stutter.
position += velocity * delta
rotation += rotation_speed * delta
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("player_ships"):
AsteroidExplosionVFX.spawn(get_tree().root, position)
queue_free()
func _draw() -> void:
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.65, 0.5, 0.35), 1.0)

1
Asteroid.gd.uid Normal file
View file

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

12
Asteroid.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://Asteroid.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 18.0
[node name="Asteroid" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

29
AsteroidExplosionVFX.gd Normal file
View file

@ -0,0 +1,29 @@
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)
func _draw_particle(p: Dictionary, alpha: float) -> void:
var s: float = p["size"]
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))

View file

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

86
AsteroidField.gd Normal file
View file

@ -0,0 +1,86 @@
extends Node2D
# Spawns slow-drifting asteroids inside ASTEROID_FIELD biome chunks near
# the fleet, and despawns them once the fleet has moved far enough away -
# keeps the scene from accumulating unbounded nodes over a long session
# while still populating newly-entered asteroid regions on the fly.
@export var check_interval: float = 1.0
@export var spawn_radius_chunks: int = 2
@export var despawn_radius_chunks: int = 4
@export var asteroids_per_chunk: int = 6
@export var min_speed: float = 12.0
@export var max_speed: float = 35.0
@onready var fleet: Node2D = get_node("../Fleet")
var asteroid_scene: PackedScene = preload("res://Asteroid.tscn")
var populated_chunks: Dictionary = {}
var timer: float = 0.0
func _ready() -> void:
randomize()
func _process(delta: float) -> void:
timer += delta
if timer < check_interval:
return
timer = 0.0
update_chunks()
func update_chunks() -> void:
# Public (not just called from _process's own timer): also invoked
# once synchronously right after the fleet is positioned on a fresh
# Main.tscn load, so nearby asteroids exist immediately instead of
# only appearing ~check_interval seconds later - most noticeable as a
# jarring pop-in right after returning from a planet's surface.
var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid())
for dx in range(-spawn_radius_chunks, spawn_radius_chunks + 1):
for dy in range(-spawn_radius_chunks, spawn_radius_chunks + 1):
var chunk: Vector2i = center_chunk + Vector2i(dx, dy)
if populated_chunks.has(chunk):
continue
if WorldGen.biome_at_chunk(chunk) != WorldGen.Biome.ASTEROID_FIELD:
continue
_spawn_chunk_asteroids(chunk)
var to_remove: Array = []
for chunk in populated_chunks.keys():
if _chebyshev_chunk_distance(chunk, center_chunk) > despawn_radius_chunks:
for asteroid in populated_chunks[chunk]:
if is_instance_valid(asteroid):
asteroid.queue_free()
to_remove.append(chunk)
for chunk in to_remove:
populated_chunks.erase(chunk)
func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int:
return max(abs(a.x - b.x), abs(a.y - b.y))
func _spawn_chunk_asteroids(chunk: Vector2i) -> void:
var origin: Vector2 = WorldGen.chunk_origin(chunk)
var spawned: Array = []
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
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed
asteroid.rotation_speed = randf_range(-0.4, 0.4)
# Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet
# is positioned, to avoid a pop-in on scene load) - Godot refuses
# a direct add_child() while the tree is still "busy setting up
# children" in that situation, so this must be deferred either way.
get_parent().add_child.call_deferred(asteroid)
spawned.append(asteroid)
populated_chunks[chunk] = spawned

1
AsteroidField.gd.uid Normal file
View file

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

351
BuildController.gd Normal file
View file

@ -0,0 +1,351 @@
extends Node2D
# Space-side building UI. Owns:
# - the build menu (place a mothership, ship crafter or command centre)
# - the shared ACTION button, which adapts to whatever the fleet is
# standing on (craft ships at a crafter, configure formation at a
# command centre). The enter-atmosphere prompt uses the same slot, and
# PlanetAtmosphereController suppresses itself while a building prompt
# is showing so the two can never fight over it.
# - rebuilding a wiped-out fleet at the mothership
#
# Buildings themselves live in GameState (they must outlive the trip to a
# planet's surface); this only drives the UI and spends resources.
@export var prompt_debounce_time: float = 0.15
@onready var fleet: Node2D = get_node("../Fleet")
@onready var building_field: Node2D = get_node("../BuildingField")
@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")
@onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton")
@onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton")
@onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton")
@onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel")
@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton")
@onready var planet_field: Node2D = get_node("../PlanetField")
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
@onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel")
@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
@onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton")
@onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton")
@onready var craft_menu: Control = get_node("../UI/CraftMenu")
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton")
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
@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")
@onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton")
# What each option is and does, shown before anything is spent. Picking a
# structure is a real commitment - it costs ore, and a mothership placement
# can destroy the one you already have - so no option builds straight from
# the menu; each one explains itself first and waits for a confirmation.
#
# The bodies are kept short on purpose: at the UI's font size the popup holds
# roughly eight lines, and a placement condition or a price warning is
# appended below them and needs some of those lines.
const BUILDING_INFO: Dictionary = {
GameState.BUILDING_MOTHERSHIP: {
"title": "Mothership",
"body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.",
},
GameState.BUILDING_SHIP_CRAFTER: {
"title": "Ship Crafter",
"body": "Fly the fleet onto it, then use the action button to build extra vessels.",
},
GameState.BUILDING_COMMAND_CENTER: {
"title": "Command Center",
"body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.",
},
GameState.BUILDING_EXTRACTOR: {
"title": "Extractor",
"body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.",
},
}
# Which structure the popup is currently offering to build, or "" when it is
# only explaining something that cannot be built right now.
var pending_build_type: String = ""
var current_building: Node2D = null
var pending_building: Node2D = null
var pending_time: float = 0.0
func _ready() -> void:
build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(func(): build_menu.visible = false)
build_mothership_button.pressed.connect(_on_place_mothership)
build_crafter_button.pressed.connect(_on_place_crafter)
build_command_button.pressed.connect(_on_place_command_center)
build_extractor_button.pressed.connect(_on_place_extractor)
info_close_button.pressed.connect(_close_info)
info_build_button.pressed.connect(_confirm_build)
action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
formation_close_button.pressed.connect(func(): formation_menu.visible = false)
formation_reset_button.pressed.connect(_on_reset_formation)
formation_editor.formation_changed.connect(_on_formation_changed)
build_menu.visible = false
info_popup.visible = false
craft_menu.visible = false
formation_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
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.
if not formation_menu.visible:
return
if event is InputEventScreenTouch and formation_editor.handle_touch(event):
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag and formation_editor.handle_drag(event):
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
_check_fleet_wipe()
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
if world_map.visible or build_menu.visible or craft_menu.visible or formation_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
pending_time = 0.0
else:
pending_time += delta
if pending_time < prompt_debounce_time or pending_building == current_building:
return
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.queue_redraw()
func _check_fleet_wipe() -> void:
if not fleet.ships.is_empty():
return
# Whole fleet destroyed: rebuild a single vessel at the mothership, or
# at the world origin when none has been built yet.
var spawn_pos: Variant = GameState.get_mothership_position()
fleet.spawn_ships(1, spawn_pos if spawn_pos != null else Vector2.ZERO)
func _on_action_pressed() -> void:
if current_building == null:
return
if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
craft_menu.visible = true
_refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
formation_menu.visible = true
formation_editor.queue_redraw()
func _on_build_pressed() -> void:
build_menu.visible = true
info_popup.visible = false
pending_build_type = ""
_refresh_build_status()
func _planet_under_fleet() -> Node2D:
# An extractor mines a specific planet, so one has to be within reach.
return planet_field.get_planet_fleet_is_on()
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
# Prices are shown under each option now, so this only needs to say
# whether the fleet is somewhere an extractor could go.
if _planet_under_fleet() != null:
build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name
else:
build_status.text = ""
func _on_place_mothership() -> void:
_show_building_info(GameState.BUILDING_MOTHERSHIP)
func _on_place_crafter() -> void:
_show_building_info(GameState.BUILDING_SHIP_CRAFTER)
func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
func _on_place_extractor() -> void:
# The option stays pressable while greyed, so this is also how a
# not-over-a-planet extractor explains itself.
_show_building_info(GameState.BUILDING_EXTRACTOR)
func cost_of(type: String) -> int:
match type:
GameState.BUILDING_MOTHERSHIP:
return GameState.MOTHERSHIP_COST
GameState.BUILDING_SHIP_CRAFTER:
return GameState.SHIP_CRAFTER_COST
GameState.BUILDING_COMMAND_CENTER:
return GameState.COMMAND_CENTER_COST
_:
return GameState.EXTRACTOR_COST
func _show_building_info(type: String) -> void:
var info: Dictionary = BUILDING_INFO[type]
var cost: int = cost_of(type)
var lines: Array = [info.body]
var can_build: bool = true
# 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:
var planet: Node2D = _planet_under_fleet()
if planet == null:
lines.append("No planet in reach - fly over one.")
can_build = false
elif GameState.has_extractor_at(planet.planet_name):
lines.append("%s already has an extractor." % planet.planet_name)
can_build = false
else:
lines.append("Will mine %s." % planet.planet_name)
elif 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):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks])
can_build = false
info_title.text = info.title
info_message.text = "\n\n".join(lines)
info_cost.amount = cost
info_build_button.visible = can_build
pending_build_type = type if can_build else ""
info_popup.visible = true
func _close_info() -> void:
info_popup.visible = false
pending_build_type = ""
func _confirm_build() -> void:
var type: String = pending_build_type
if type == "":
return
pending_build_type = ""
info_popup.visible = false
# 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 type == GameState.BUILDING_EXTRACTOR:
var planet: Node2D = _planet_under_fleet()
if planet == null or GameState.has_extractor_at(planet.planet_name):
build_status.text = "No planet in reach any more"
return
if not GameState.spend(GameState.EXTRACTOR_COST):
build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks]
return
GameState.add_extractor(planet.planet_name, planet.position)
build_menu.visible = false
return
if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
if not GameState.can_afford(GameState.MOTHERSHIP_COST):
build_status.text = "Not enough rocks (need %d, have %d)" % [
GameState.MOTHERSHIP_COST, GameState.rocks
]
return
# Only now is the old one destroyed - never before the new one is
# known to be affordable, or the player would be left with none.
GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP)
building_field.rebuild_all()
_try_place(type, cost_of(type))
func _try_place(type: String, cost: int) -> void:
if not GameState.spend(cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks]
return
building_field.place(type, fleet.get_centroid())
build_menu.visible = false
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()
]
func _on_craft_interceptor() -> void:
if fleet.ships.size() >= GameState.formation.size():
craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size()
return
if not GameState.spend(GameState.SHIP_COST):
craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks]
return
fleet.add_ship()
_refresh_craft_status()
func _on_formation_changed() -> void:
fleet.apply_formation()
func _on_reset_formation() -> void:
GameState.reset_formation()
fleet.apply_formation()
formation_editor.queue_redraw()

1
BuildController.gd.uid Normal file
View file

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

364
Building.gd Normal file
View file

@ -0,0 +1,364 @@
extends Node2D
# A player-placed structure in space. Three kinds for now:
# mothership - where a wiped-out fleet is rebuilt
# ship_crafter - fly the fleet onto it to buy additional ships
# command_center - reconfigure the fleet formation
#
# Buildings are stationary and harmless; whether the fleet is "on" one is
# decided by BuildingField comparing the fleet CENTROID against
# interact_radius, the same stable single-number test Planet uses (see
# Planet.gd for why per-ship overlap was a poor fit).
#
# Nothing here rests on ground or vents into an atmosphere - these are
# orbital structures, so they are built out of trusses, radiator fins,
# reactor cores and open cradles rather than walls and roofs.
#
# The crafter and the mothership animate, so they run _process. The command
# centre is drawn once and never redrawn - per-frame redraws of art that
# never changes were a real framerate cost earlier on.
@export var building_type: String = "mothership"
@export var interact_radius: float = 150.0
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
# The vessel on the assembly line is drawn in the fleet's own colour, so it
# reads as "one of yours being built" rather than as shipyard machinery.
const HULL_COLOR: Color = Color(0.25, 0.85, 1.0)
# One assembly cycle: parts are welded on, the finished hull flashes, then
# it clears the cradle and the line starts over.
const CYCLE: float = 3.6
const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding
const FLASH_END: float = 0.76 # ... then the completion flash
# ... then launch, to 1.0
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
# 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.
const SHIP_PARTS: Array = [
{"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]},
{"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]},
{"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]},
{"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]},
]
# The assembly clock, wrapped at CYCLE because the whole build sequence is
# expressed as a fraction of it.
var anim_time: float = 0.0
# Free-running seconds for everything that is merely cyclic - reactor pulses,
# orbiting satellites, blinking beacons. These must NOT share anim_time:
# CYCLE is 3.6s, which is not a whole number of periods for any of them, so
# wrapping made every one of them leap at the wrap. The satellites were the
# obvious casualty, snapping back 66 degrees every 3.6 seconds.
#
# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours
# of play the argument to sin() keeps far more precision than a frame needs,
# and any wrap point would reintroduce exactly the discontinuity above.
var phase_time: float = 0.0
var _hull_parts: Array[PackedVector2Array] = []
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.
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))
queue_redraw()
func _process(delta: float) -> void:
anim_time = fmod(anim_time + delta, CYCLE)
phase_time += delta
queue_redraw()
# Where the satellites are, and how open the reactor core is. Both are read
# by _draw and by the test that checks they stay continuous across the
# assembly clock's wrap.
func orbit_angle() -> float:
return phase_time * ORBIT_SPEED
func core_pulse() -> float:
return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED)
func get_display_name() -> String:
match building_type:
GameState.BUILDING_MOTHERSHIP:
return "Mothership"
GameState.BUILDING_SHIP_CRAFTER:
return "Ship Crafter"
_:
return "Command Center"
func _draw() -> void:
match building_type:
GameState.BUILDING_COMMAND_CENTER:
_draw_command_center()
GameState.BUILDING_MOTHERSHIP:
_draw_mothership()
_:
_draw_crafter()
# --- shared helpers -------------------------------------------------------
func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(sides):
var a: float = (float(i) / float(sides)) * TAU + phase
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var a: float = lerpf(from_a, to_a, float(i) / float(steps))
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
return pts
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),
])
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)
# --- mothership: a radial flagship ----------------------------------------
# Not a big fighter. A mothership is the thing everything else orbits, so it
# is built radially around a reactor core rather than pointed in a direction:
# six swept talons, crystal panels set between them, and satellites circling
# the whole assembly. A forward-facing hull, however large, just reads as
# another ship.
const ARM_COUNT: int = 6
const ARM_INNER: float = 42.0
const ARM_OUTER: float = 106.0
const ARM_SWEEP: float = 0.72
const SATELLITE_COUNT: int = 4
const SATELLITE_ORBIT: float = 126.0
const ORBIT_SPEED: float = 0.32
const PULSE_SPEED: float = 2.4
func _talon(base_angle: float) -> PackedVector2Array:
# One swept claw: the outer edge runs from the core to the tip, and the
# inner edge comes back a little behind it, closing to a point so the arm
# tapers instead of ending in a blunt stub.
var steps: int = 7
var pts: PackedVector2Array = PackedVector2Array()
for i in range(steps + 1):
var f: float = float(i) / float(steps)
var a: float = base_angle + ARM_SWEEP * pow(f, 1.3)
pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f))
for i in range(steps, -1, -1):
var f: float = float(i) / float(steps)
var trail: float = 0.34 * (1.0 - f) + 0.04
var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail
pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f))
return pts
func _draw_mothership() -> 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)
# Crystal panel set into the gap behind each arm.
var pa: float = base - 0.30
NeonDraw.draw_glowing_polygon(self, 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))
# Satellites holding station around it, drifting slowly round.
var orbit: float = orbit_angle()
for i in range(SATELLITE_COUNT):
var a: float = orbit + (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)
# --- ship crafter: an orbital yard with a hull in the cradle --------------
func _draw_crafter() -> 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)
# Radiator fins above and below the core, slatted like real ones.
for dir_y in [-1.0, 1.0]:
var fin: PackedVector2Array = PackedVector2Array([
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)
for f in [0.35, 0.68]:
NeonDraw.draw_glowing_line(self,
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)
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)
# 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)
for side in [-1.0, 1.0]:
NeonDraw.draw_glowing_line(self,
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
# 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)
if t < ASSEMBLE_END:
_draw_assembly(t / ASSEMBLE_END, c)
elif t < FLASH_END:
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
else:
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
# How many pieces are on the hull at this point through the assembly phase.
# Parts attach in order, so this is just how many thresholds have passed.
func attached_part_count(p: float) -> int:
var n: int = 0
for part in SHIP_PARTS:
if p >= float(part.at):
n += 1
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 _draw_assembly(p: float, c: Color) -> void:
_draw_clamps(c, 0.8)
# 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)
# 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():
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)
if next_part < _hull_parts.size():
# Weld sparks: short spokes whose angles jitter frame to frame.
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)
func _draw_completion_flash(p: float, c: Color) -> 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)
func _draw_launch(p: float) -> void:
# Clears the cradle and fades out; the next cycle starts from bare ribs.
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)
# 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)

1
Building.gd.uid Normal file
View file

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

6
Building.tscn Normal file
View file

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

50
BuildingField.gd Normal file
View file

@ -0,0 +1,50 @@
extends Node2D
# Rebuilds every player-placed structure from GameState when the space
# scene loads, and answers "is the fleet standing on one?".
#
# Buildings live in GameState rather than in the scene because Main.tscn is
# torn down and recreated on every trip to a planet's surface - anything
# kept only as a node would be lost on the first landing.
@onready var fleet: Node2D = get_node("../Fleet")
var building_scene: PackedScene = preload("res://Building.tscn")
var spawned: Array = []
func _ready() -> void:
rebuild_all()
func rebuild_all() -> void:
for b in spawned:
if is_instance_valid(b):
b.queue_free()
spawned.clear()
for record in GameState.buildings:
_spawn(record)
func place(type: String, at_position: Vector2) -> void:
GameState.add_building(type, at_position)
_spawn(GameState.buildings[-1])
func get_building_fleet_is_on() -> Node2D:
var centre: Vector2 = fleet.get_centroid()
for b in spawned:
if is_instance_valid(b) and b.position.distance_to(centre) <= b.interact_radius:
return b
return null
func _spawn(record: Dictionary) -> void:
var b: Node2D = building_scene.instantiate()
b.position = record.position
b.building_type = record.type
# Deferred for the same reason as the other field spawners: this can run
# from inside a parent's _ready() chain, where a direct add_child() is
# rejected while the tree is still setting up children.
get_parent().add_child.call_deferred(b)
spawned.append(b)

1
BuildingField.gd.uid Normal file
View file

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

64
Enemy.gd Normal file
View file

@ -0,0 +1,64 @@
extends Area2D
signal destroyed
@export var speed: float = 140.0
@export var acceleration: float = 260.0
@export var rotation_speed: float = 6.0
@export var max_hp: int = 3
var velocity: Vector2 = Vector2.ZERO
var hp: int
@onready var fleet: Node2D = get_node("../Fleet")
func _ready() -> void:
hp = max_hp
add_to_group("enemies")
queue_redraw()
func _physics_process(delta: float) -> void:
var target_pos: Vector2 = _nearest_ship_position()
var to_target: Vector2 = target_pos - position
if to_target.length() > 1.0:
var desired_velocity: Vector2 = to_target.normalized() * speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
position += velocity * delta
if velocity.length() > 5.0:
var target_angle: float = velocity.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
func _nearest_ship_position() -> Vector2:
if fleet.ships.is_empty():
return position
var nearest: Node2D = fleet.ships[0]
var best_dist: float = position.distance_to(nearest.position)
for ship in fleet.ships:
var d: float = position.distance_to(ship.position)
if d < best_dist:
best_dist = d
nearest = ship
return nearest.position
func take_damage(amount: int) -> void:
hp -= amount
if hp <= 0:
EnemyExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _draw() -> void:
var points: PackedVector2Array = PackedVector2Array([
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0)
])
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
NeonDraw.draw_glowing_polygon(self, points, Color(1.0, 0.15, 0.2), 1.0)

1
Enemy.gd.uid Normal file
View file

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

12
Enemy.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://Enemy.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 14.0
[node name="Enemy" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

12
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)

1
EnemyExplosionVFX.gd.uid Normal file
View file

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

42
EnemySpawner.gd Normal file
View file

@ -0,0 +1,42 @@
extends Node2D
@export var min_spawn_interval: float = 3.0
@export var max_spawn_interval: float = 6.0
@export var spawn_margin: float = 150.0
@onready var fleet: Node2D = get_node("../Fleet")
var enemy_scene: PackedScene = preload("res://Enemy.tscn")
var timer: float = 0.0
var next_spawn_time: float = 0.0
func _ready() -> void:
randomize()
_reset_timer()
func _process(delta: float) -> void:
timer += delta
if timer >= next_spawn_time:
timer = 0.0
_reset_timer()
_spawn_enemy()
func _reset_timer() -> void:
next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval)
func _spawn_enemy() -> void:
if fleet.ships.is_empty():
return
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
var angle: float = randf() * TAU
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius
var enemy: Node2D = enemy_scene.instantiate()
enemy.position = spawn_pos
get_parent().add_child(enemy)

1
EnemySpawner.gd.uid Normal file
View file

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

23
ExplorationTracker.gd Normal file
View file

@ -0,0 +1,23 @@
extends Node2D
# Tracks which world chunks have been explored (fleet has come within
# sensor range), so the world map can reveal only chunks actually visited
# instead of the whole procedurally generated world at once. Writes
# directly into GameState.explored_chunks (not a local dict) so this
# history survives the Main.tscn <-> PlanetSurface.tscn scene swap, which
# tears down and recreates this node from scratch each time.
@export var sensor_radius_chunks: int = 1
@onready var fleet: Node2D = get_node("../Fleet")
func _process(_delta: float) -> void:
var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid())
for dx in range(-sensor_radius_chunks, sensor_radius_chunks + 1):
for dy in range(-sensor_radius_chunks, sensor_radius_chunks + 1):
GameState.explored_chunks[center_chunk + Vector2i(dx, dy)] = true
func get_explored_chunks() -> Array:
return GameState.explored_chunks.keys()

View file

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

58
ExplosionVFXBase.gd Normal file
View file

@ -0,0 +1,58 @@
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)
particles.append({
"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()
func _process(delta: float) -> void:
age += delta
if age >= lifetime:
queue_free()
return
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)
func _random_particle_size() -> float:
return randf_range(10.0, 20.0)
func _draw_particle(_p: Dictionary, _alpha: float) -> void:
pass

1
ExplosionVFXBase.gd.uid Normal file
View file

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

195
Fleet.gd Normal file
View 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()

1
Fleet.gd.uid Normal file
View file

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

138
FormationEditor.gd Normal file
View file

@ -0,0 +1,138 @@
extends Control
# Drag-and-drop editor for the fleet's formation, opened at a command
# centre. Each slot is a draggable marker; dropping one writes the new
# offset straight back into GameState.formation, so the change survives
# scene swaps and applies to any fleet rebuilt later.
#
# Works in "formation space": the panel centre represents the fleet centre,
# and one formation unit maps to `scale_factor` screen pixels.
signal closed
signal formation_changed
@export var scale_factor: float = 1.4
@export var min_scale: float = 0.5
@export var max_scale: float = 4.0
@export var marker_radius: float = 26.0
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
var dragging_index: int = -1
var drag_touch_index: int = -1
# Second finger = pinch to zoom the layout view, so a spread-out formation
# can be inspected close up or seen whole. Zooming only changes how the
# formation is DISPLAYED - the stored offsets are untouched.
var touches: Dictionary = {}
var pinch_active: bool = false
var pinch_start_distance: float = 0.0
var pinch_start_scale: float = 1.0
func _ready() -> void:
queue_redraw()
func _centre() -> Vector2:
return size / 2.0
func _slot_screen_pos(i: int) -> Vector2:
return _centre() + GameState.formation[i] * scale_factor
func _screen_to_formation(p: Vector2) -> Vector2:
return (p - _centre()) / scale_factor
# Returns true when the event was consumed. Called from BuildController,
# which forwards input while the editor is open.
func handle_touch(event: InputEventScreenTouch) -> bool:
if not visible:
return false
var local: Vector2 = event.position - global_position
if event.pressed:
touches[event.index] = event.position
if touches.size() == 2:
# A second finger turns the gesture into a pinch: drop any slot
# being dragged so it isn't yanked around while zooming.
dragging_index = -1
drag_touch_index = -1
var positions: Array = touches.values()
pinch_start_distance = positions[0].distance_to(positions[1])
pinch_start_scale = scale_factor
pinch_active = true
queue_redraw()
return true
if dragging_index != -1:
return false
for i in range(GameState.formation.size()):
if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4:
dragging_index = i
drag_touch_index = event.index
queue_redraw()
return true
return false
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
if event.index != drag_touch_index:
return false
dragging_index = -1
drag_touch_index = -1
queue_redraw()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not visible:
return false
if touches.has(event.index):
touches[event.index] = event.position
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
var positions: Array = touches.values()
var current: float = positions[0].distance_to(positions[1])
# Spreading apart zooms in, matching the world pinch.
scale_factor = clamp(
pinch_start_scale * (current / max(pinch_start_distance, 1.0)),
min_scale, max_scale
)
queue_redraw()
return true
if event.index != drag_touch_index or dragging_index == -1:
return false
var local: Vector2 = event.position - global_position
# Clamped so a slot can't be dragged off the panel and become
# unreachable (and so the fleet can't be spread absurdly far apart).
var f: Vector2 = _screen_to_formation(local)
var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor
GameState.formation[dragging_index] = Vector2(
clamp(f.x, -limit, limit), clamp(f.y, -limit, limit)
)
queue_redraw()
formation_changed.emit()
return true
func _draw() -> void:
var centre: Vector2 = _centre()
# Reference cross for the fleet centre.
var tick: float = 14.0
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()):
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.
var points: PackedVector2Array = PackedVector2Array([
p + Vector2(0, -marker_radius),
p + Vector2(-marker_radius * 0.66, marker_radius * 0.72),
p + Vector2(marker_radius * 0.66, marker_radius * 0.72),
])
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0 if held else 0.7)

1
FormationEditor.gd.uid Normal file
View file

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

313
GameController.gd Normal file
View file

@ -0,0 +1,313 @@
extends Node2D
@export var min_zoom: float = 0.4
@export var max_zoom: float = 6.0
@export var camera_follow_speed: float = 5.0
# Distance (world units) below which the camera snaps exactly onto the fleet
# instead of continuing to lerp. Without this the exponential lerp only ever
# approaches the target asymptotically and NEVER arrives, so the camera keeps
# drifting by a fraction of a pixel every single frame - forever, even with
# the fleet completely stationary. That kept the background shaders' camera_pos
# uniform changing every frame, which made screen tearing permanently visible
# as horizontal bands of the background shifted sideways against each other.
# Same class of bug (and same fix) as the ship arrival oscillation.
@export var camera_snap_distance: float = 0.5
@export var debug_input_logging: bool = false
@onready var camera: Camera2D = $Camera2D
@onready var fleet: Node2D = $Fleet
# Optional: scenes without a joystick (none currently) still work.
@onready var joystick: TouchJoystick = get_node_or_null("UI/Joystick")
# Navigation section. Optional so a scene without it still works.
@onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton")
@onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton")
var touches: Dictionary = {}
var pinch_active: bool = false
var pinch_start_distance: float = 0.0
var pinch_start_zoom: float = 1.0
# Held pivot buttons turn the formation continuously at this rate; a quick
# tap that accumulates less than PIVOT_MIN_STEP is topped up to it on
# release, so tapping still produces a clear, visible nudge.
const PIVOT_SPEED: float = 1.6
const PIVOT_MIN_STEP: float = PI / 10.0
var pivot_hold_amount: float = 0.0
# Touch index currently holding each pivot button, or -1. These buttons are
# driven straight from raw touch events rather than through _gui_input,
# because Godot's Control GUI treats touch as a single emulated pointer and
# will not reliably deliver a SECOND simultaneous finger to another
# Control - which meant pivoting while steering with the joystick silently
# did nothing. The joystick already bypasses the GUI for the same reason.
var pivot_cw_touch: int = -1
var pivot_ccw_touch: int = -1
# --- keyboard and mouse ----------------------------------------------------
# The touch UI is the whole story on a phone. On a machine with a keyboard -
# which in practice means the web build - WASD or the arrow keys steer, Q/E
# pivot the formation, and the wheel zooms. Clicking the icon buttons already
# works without any code here, because the project emulates touch from the
# mouse (see project.godot), which is the same path the buttons already read.
#
# Keys are tracked from events rather than polled off the Input singleton so
# the whole thing is drivable from a headless test.
const KEY_DIRECTIONS: Dictionary = {
KEY_W: Vector2.UP, KEY_UP: Vector2.UP,
KEY_S: Vector2.DOWN, KEY_DOWN: Vector2.DOWN,
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
}
const KEY_PIVOT_CW: int = KEY_E
const KEY_PIVOT_CCW: int = KEY_Q
const WHEEL_ZOOM_STEP: float = 1.12
@onready var keyboard_hint: Label = get_node_or_null("UI/KeyboardHintLabel")
var keys_down: Dictionary = {}
var keyboard_seen: bool = false
var keyboard_steering: bool = false
func _ready() -> void:
if joystick != null:
joystick.direction_changed.connect(_on_joystick_direction)
if keyboard_hint != null:
keyboard_hint.visible = false
func _held_pivot_direction() -> float:
var dir: float = 0.0
if pivot_cw_button != null and pivot_cw_button.pressed_down:
dir += 1.0
if pivot_ccw_button != null and pivot_ccw_button.pressed_down:
dir -= 1.0
if keys_down.has(KEY_PIVOT_CW):
dir += 1.0
if keys_down.has(KEY_PIVOT_CCW):
dir -= 1.0
return clampf(dir, -1.0, 1.0)
func keyboard_direction() -> Vector2:
var dir: Vector2 = Vector2.ZERO
for key in KEY_DIRECTIONS:
if keys_down.has(key):
dir += KEY_DIRECTIONS[key]
return Vector2.ZERO if dir == Vector2.ZERO else dir.normalized()
func _handle_key(event: InputEventKey) -> void:
if event.echo:
return
if event.pressed:
keys_down[event.keycode] = true
_note_keyboard()
else:
keys_down.erase(event.keycode)
# A quick tap of a pivot key gets the same minimum step a tapped
# button does, rather than turning by a barely-visible sliver.
if event.keycode == KEY_PIVOT_CW:
_finish_pivot_tap(1.0)
elif event.keycode == KEY_PIVOT_CCW:
_finish_pivot_tap(-1.0)
func _note_keyboard() -> void:
if keyboard_seen:
return
keyboard_seen = true
if keyboard_hint != null:
keyboard_hint.visible = true
# The stick is only taken away where there is no touchscreen to work it
# with. A touch device that also has a keyboard keeps both, and a device
# wrongly reported as non-touch still shows the stick until the player
# has demonstrably used a keyboard - so nobody is ever left with no way
# to steer.
if joystick != null and not DisplayServer.is_touchscreen_available():
joystick.visible = false
func _button_holds(button: IconButton, at_position: Vector2) -> bool:
return button != null and Rect2(button.global_position, button.size).has_point(at_position)
func _set_button_held(button: IconButton, held: bool) -> void:
if button == null or button.pressed_down == held:
return
button.pressed_down = held
button.queue_redraw()
# Returns true when a pivot button consumed the touch.
func _handle_pivot_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if _button_holds(pivot_cw_button, event.position) and pivot_cw_touch == -1:
pivot_cw_touch = event.index
_set_button_held(pivot_cw_button, true)
return true
if _button_holds(pivot_ccw_button, event.position) and pivot_ccw_touch == -1:
pivot_ccw_touch = event.index
_set_button_held(pivot_ccw_button, true)
return true
return false
if event.index == pivot_cw_touch:
pivot_cw_touch = -1
_set_button_held(pivot_cw_button, false)
_finish_pivot_tap(1.0)
return true
if event.index == pivot_ccw_touch:
pivot_ccw_touch = -1
_set_button_held(pivot_ccw_button, false)
_finish_pivot_tap(-1.0)
return true
return false
func _update_pivot(delta: float) -> void:
var dir: float = _held_pivot_direction()
if dir == 0.0:
return
var amount: float = PIVOT_SPEED * delta * dir
pivot_hold_amount += absf(amount)
_pivot(amount)
func _finish_pivot_tap(direction: float) -> void:
# Fired on release. A press held long enough has already turned far
# enough on its own; a brief tap gets topped up to the minimum step.
if pivot_hold_amount < PIVOT_MIN_STEP:
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
pivot_hold_amount = 0.0
func _pivot(radians: float) -> void:
# Turns the formation and re-seats the ships already flying it, so the
# change is visible immediately rather than only on the next rebuild.
# apply_formation() writes the new offsets into Fleet.formation_offsets,
# which move_to()/move_in_direction() also read - so a pivot applied
# while the joystick is steering survives the next steering update
# instead of being overwritten by the old layout.
GameState.rotate_formation(radians)
fleet.apply_formation()
func _on_joystick_direction(direction: Vector2) -> void:
# Steering is re-issued every frame in _process while the stick is held,
# so this only needs to handle the release (stick back to centre).
if direction == Vector2.ZERO:
fleet.stop()
func _process(delta: float) -> void:
_update_pivot(delta)
# Keep the heading fresh while a control is held. Fleet advances its own
# anchor in _process, so this only needs to report the direction. The
# keyboard takes precedence, and releasing the keys has to stop the fleet
# itself - unlike the stick, there is no centred-again signal to hook.
var keyed: Vector2 = keyboard_direction()
if keyed != Vector2.ZERO:
keyboard_steering = true
fleet.move_in_direction(keyed)
elif keyboard_steering:
keyboard_steering = false
fleet.stop()
elif joystick != null and joystick.direction != Vector2.ZERO:
fleet.move_in_direction(joystick.direction)
var target: Vector2 = fleet.get_centroid()
if camera.global_position.distance_to(target) <= camera_snap_distance:
# Snap and stop dead - see camera_snap_distance. Assigning the exact
# target means a settled fleet produces a bit-identical camera position
# frame after frame, so the background shaders stop re-rendering a
# shifted image and tearing has nothing to reveal.
camera.global_position = target
return
var t: float = clamp(camera_follow_speed * delta, 0.0, 1.0)
camera.global_position = camera.global_position.lerp(target, t)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
_handle_touch(event)
elif event is InputEventScreenDrag:
_handle_drag(event)
elif event is InputEventMagnifyGesture:
_handle_magnify(event)
elif event is InputEventKey:
_handle_key(event)
elif event is InputEventMouseButton and event.pressed:
# Wheel zoom, the desktop equivalent of the pinch.
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_set_zoom(camera.zoom.x / WHEEL_ZOOM_STEP)
func _handle_touch(event: InputEventScreenTouch) -> void:
# The joystick gets first refusal on every touch, and only claims ones
# that begin inside its own rect - so pinch-to-zoom still works anywhere
# else on screen.
if _handle_pivot_touch(event):
return
if joystick != null and joystick.visible and joystick.handle_touch(event):
return
if event.pressed:
touches[event.index] = event.position
if touches.size() == 2:
_start_pinch()
if debug_input_logging:
print("DEBUG touch pressed idx=%d pos=%s active_touches=%d" % [event.index, event.position, touches.size()])
else:
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
if debug_input_logging:
print("DEBUG touch released idx=%d active_touches=%d" % [event.index, touches.size()])
func _handle_drag(event: InputEventScreenDrag) -> void:
if joystick != null and joystick.visible and joystick.handle_drag(event):
return
if not touches.has(event.index):
return
touches[event.index] = event.position
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
var positions: Array = touches.values()
var current_distance: float = positions[0].distance_to(positions[1])
# Spreading the fingers apart zooms IN. Camera2D.zoom is a
# magnification (larger = closer), so the ratio goes
# current/start, not start/current.
var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0))
_set_zoom(new_zoom)
if debug_input_logging:
print("DEBUG pinch drag distance=%.1f zoom=%.3f" % [current_distance, camera.zoom.x])
func _handle_magnify(event: InputEventMagnifyGesture) -> void:
# Fallback path: some platforms (and desktop trackpads) report pinch as a
# single semantic gesture instead of two raw touch streams. factor > 1
# means the gesture is spreading outward, which zooms in - and since
# Camera2D.zoom is a magnification, that multiplies.
_set_zoom(camera.zoom.x * event.factor)
if debug_input_logging:
print("DEBUG magnify gesture factor=%.3f zoom=%.3f" % [event.factor, camera.zoom.x])
func _start_pinch() -> void:
pinch_active = true
var positions: Array = touches.values()
pinch_start_distance = positions[0].distance_to(positions[1])
pinch_start_zoom = camera.zoom.x
func _set_zoom(raw_zoom: float) -> void:
var z: float = clamp(raw_zoom, min_zoom, max_zoom)
camera.zoom = Vector2(z, z)

1
GameController.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://5tvbknhs0afu

194
GameState.gd Normal file
View file

@ -0,0 +1,194 @@
extends Node
# Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface
# (PlanetSurface.tscn). Everything else in either scene gets torn down
# and rebuilt from scratch on each swap, so anything that needs to
# survive the trip has to live here instead.
var has_return_position: bool = false
var surviving_ship_count: int = 5
var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_type: String = ""
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
# every scene swap) so exploration progress isn't lost every time the
# player lands on and leaves a planet.
var explored_chunks: Dictionary = {}
# --- Resources -------------------------------------------------------------
# Rocks mined from asteroid-planet surfaces, spent on buildings and ships.
# Starts stocked for play-testing so buildings can be tried without mining
# first - drop this back to 0 for a real playthrough.
var rocks: int = 500
const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12
const SHIP_COST: int = 6
# --- Buildings -------------------------------------------------------------
# Player-placed structures, as [{"type": String, "position": Vector2}].
# Buildings exist only in space (Main.tscn) but are stored here so they
# survive the scene swap to a planet's surface and back.
const BUILDING_MOTHERSHIP: String = "mothership"
const BUILDING_SHIP_CRAFTER: String = "ship_crafter"
const BUILDING_COMMAND_CENTER: String = "command_center"
# Extractors are not placed in space like the others - they are built ON a
# planet's surface and tracked separately (see `extractors`).
const BUILDING_EXTRACTOR: String = "extractor"
const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25
var buildings: Array = []
func add_building(type: String, at_position: Vector2) -> void:
buildings.append({"type": type, "position": at_position})
func has_mothership() -> bool:
return get_mothership_position() != null
func remove_buildings_of_type(type: String) -> int:
var kept: Array = []
var removed: int = 0
for b in buildings:
if b.type == type:
removed += 1
else:
kept.append(b)
buildings = kept
return removed
func get_mothership_position() -> Variant:
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership
# has been built yet, so the caller can fall back to the world origin.
for b in buildings:
if b.type == BUILDING_MOTHERSHIP:
return b.position
return null
# --- Fleet formation -------------------------------------------------------
# Where each vessel sits relative to the fleet centre, editable at a command
# 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.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
]
var formation: Array = DEFAULT_FORMATION.duplicate()
func reset_formation() -> void:
formation = DEFAULT_FORMATION.duplicate()
func rotate_formation(radians: float) -> void:
# Pivots every slot about the fleet centre, keeping the shape intact
# and only changing which way it faces.
for i in range(formation.size()):
formation[i] = formation[i].rotated(radians)
# --- Extractors and their haulers -----------------------------------------
# An extractor sits on a planet and runs a single automated hauler back and
# forth to the mothership, delivering ore on each arrival.
#
# The round trip is SIMULATED HERE, in the autoload, rather than by any
# node: it advances whether the player is in space, on a planet's surface,
# or far enough away that nothing about the route is loaded. `progress` is
# a fraction of one full there-and-back cycle, so a hauler's drawn position
# is derived from the same number that decides when ore is delivered - the
# visual arrival and the payout cannot drift apart, because they are the
# same event.
#
# Entries are {"position": Vector2 (the planet's position in SPACE),
# "planet_name": String, "progress": float}.
const HAULER_SPEED: float = 260.0
const HAULER_YIELD: int = 4
# Progress value at which the hauler reaches the mothership: exactly half
# way through the cycle, the outbound leg being the first half.
const HAULER_ARRIVAL: float = 0.5
var extractors: Array = []
func has_extractor_at(planet_name: String) -> bool:
for e in extractors:
if e.planet_name == planet_name:
return true
return false
func add_extractor(planet_name: String, at_position: Vector2) -> void:
extractors.append({
"position": at_position,
"planet_name": planet_name,
"progress": 0.0,
})
func get_hauler_route_time(extractor: Dictionary) -> float:
# Full cycle: out to the mothership and back again.
var mothership: Variant = get_mothership_position()
if mothership == null:
return 0.0
var distance: float = extractor.position.distance_to(mothership)
if distance < 1.0:
return 0.0
return (distance * 2.0) / HAULER_SPEED
func get_hauler_position(extractor: Dictionary) -> Variant:
# Where the hauler is right now, derived purely from `progress`. Returns
# null when there is no route (no mothership built yet).
var mothership: Variant = get_mothership_position()
if mothership == null:
return null
var t: float = extractor.progress
if t <= HAULER_ARRIVAL:
return extractor.position.lerp(mothership, t / HAULER_ARRIVAL)
return mothership.lerp(extractor.position, (t - HAULER_ARRIVAL) / (1.0 - HAULER_ARRIVAL))
func _process(delta: float) -> void:
# Runs in the autoload, so extraction keeps earning while the player is
# on a planet's surface or nowhere near the route.
if extractors.is_empty():
return
for e in extractors:
var cycle: float = get_hauler_route_time(e)
if cycle <= 0.0:
continue # nothing to deliver to yet
var before: float = e.progress
e.progress += delta / cycle
# Ore lands the instant the hauler reaches the mothership - the same
# instant get_hauler_position() puts it there.
if before < HAULER_ARRIVAL and e.progress >= HAULER_ARRIVAL:
rocks += HAULER_YIELD
while e.progress >= 1.0:
e.progress -= 1.0
func can_afford(cost: int) -> bool:
return rocks >= cost
func spend(cost: int) -> bool:
if not can_afford(cost):
return false
rocks -= cost
return true

1
GameState.gd.uid Normal file
View file

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

39
Hauler.gd Normal file
View file

@ -0,0 +1,39 @@
extends Node2D
# Purely visual: an automated ore hauler running an extractor's route. It
# carries no state of its own - GameState owns the simulation and this node
# is created, positioned and freed to match it. See HaulerField.
const HULL_COLOR: Color = Color(0.55, 0.85, 0.4)
var laden: bool = false
func _ready() -> void:
add_to_group("haulers")
z_index = -1
queue_redraw()
func set_laden(value: bool) -> void:
if laden == value:
return
laden = value
queue_redraw()
func _draw() -> void:
# Blunt freighter silhouette - deliberately unlike the player's sharp
# triangles, so it is obvious at a glance this is not a fleet vessel.
var hull: PackedVector2Array = PackedVector2Array([
Vector2(0, -20), Vector2(11, -6), Vector2(11, 16), Vector2(-11, 16), Vector2(-11, -6),
])
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0)
if laden:
# Ore in the hold on the outbound leg, so a glance tells you which
# way it is going.
var cargo: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU - PI / 2.0
cargo.append(Vector2(cos(a), sin(a)) * 6.0 + Vector2(0, 4))
NeonDraw.draw_glowing_polygon(self, cargo, Color(0.0, 0.62, 0.7), 1.0)

1
Hauler.gd.uid Normal file
View file

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

6
Hauler.tscn Normal file
View file

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

57
HaulerField.gd Normal file
View file

@ -0,0 +1,57 @@
extends Node2D
# Draws each extractor's hauler ONLY while it is somewhere the player could
# see it, and rebuilds it from GameState every frame.
#
# The point is that the visuals are optional. GameState simulates the round
# trip and pays out on arrival regardless of what exists in the scene, so a
# route far from the player costs nothing but still earns - while flying
# between a planet and the mothership shows the hauler making the run,
# positioned from the very same `progress` value that triggers the payout.
@export var visible_radius: float = 2600.0
@onready var fleet: Node2D = get_node("../Fleet")
var hauler_scene: PackedScene = preload("res://Hauler.tscn")
# extractor index -> live Hauler node
var active: Dictionary = {}
func _process(_delta: float) -> void:
var centre: Vector2 = fleet.get_centroid()
for i in range(GameState.extractors.size()):
var extractor: Dictionary = GameState.extractors[i]
var pos: Variant = GameState.get_hauler_position(extractor)
# No mothership means no route, so nothing to show.
if pos == null or pos.distance_to(centre) > visible_radius:
_despawn(i)
continue
var hauler: Node2D = active.get(i)
if hauler == null or not is_instance_valid(hauler):
hauler = hauler_scene.instantiate()
get_parent().add_child(hauler)
active[i] = hauler
var previous: Vector2 = hauler.position
hauler.position = pos
# Laden on the outbound leg, empty on the way back.
hauler.set_laden(extractor.progress <= GameState.HAULER_ARRIVAL)
var travel: Vector2 = pos - previous
if travel.length() > 0.5:
hauler.rotation = travel.angle() + PI / 2.0
# Drop visuals for extractors that no longer exist.
for key in active.keys():
if key >= GameState.extractors.size():
_despawn(key)
func _despawn(index: int) -> void:
var hauler: Node2D = active.get(index)
if hauler != null and is_instance_valid(hauler):
hauler.queue_free()
active.erase(index)

1
HaulerField.gd.uid Normal file
View file

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

277
IconButton.gd Normal file
View file

@ -0,0 +1,277 @@
class_name IconButton
extends Control
# Square neon-outlined button, hand-drawn to match the game's existing
# no-texture glowing look (same NeonDraw technique as ships/enemies) -
# no icon image assets, just a glyph drawn inside the frame.
signal pressed
# Scenes store `icon` as a plain int, so NEW ENTRIES MUST BE APPENDED -
# inserting one would silently repoint every button after it.
enum Icon {
MAP, CLOSE, SHIP, DOWN_ARROW, UP_ARROW, BUILD, ROTATE_CW, ROTATE_CCW,
# One per building, each a miniature of what actually gets placed. The
# build menu used to pick from the generic set, which left the mothership
# and the command centre sharing a folded-map glyph and neither of them
# looking anything like the structure they build.
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
# scaled by (actual size / this), so the button can be resized in the scene
# without the icon inside it staying small.
const REFERENCE_SIZE: float = 56.0
@export var icon: int = Icon.MAP
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
# Colour of the bright core stroke. Defaults to near-white, which reads well
# on the dark space background; scenes drawing over a bright backdrop (the
# lunar surface) override this with a dark colour so the button doesn't wash
# out. See NeonDraw.
@export var core_color: Color = Color(1.0, 1.0, 1.0)
# When false the button is drawn greyed out, but STILL emits `pressed` -
# the caller decides what to do, which is what lets an unavailable option
# explain itself instead of silently doing nothing.
@export var enabled: bool = true: set = set_enabled
@export var disabled_color: Color = Color(0.42, 0.45, 0.5)
var pressed_down: bool = false
func set_enabled(value: bool) -> void:
if enabled == value:
return
enabled = value
queue_redraw()
func _ready() -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
_handle_press(event.pressed, event.position)
accept_event()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_handle_press(event.pressed, event.position)
accept_event()
func _handle_press(is_pressed: bool, at_position: Vector2) -> void:
if is_pressed:
pressed_down = true
queue_redraw()
elif pressed_down:
pressed_down = false
queue_redraw()
# Only counts as a real press if the finger/cursor is still over
# the button when it lifts - dragging off before releasing (a
# common way to "cancel" a touch) must not still trigger it.
if Rect2(Vector2.ZERO, size).has_point(at_position):
pressed.emit()
func _active_color() -> Color:
return glow_color if enabled else disabled_color
func _draw() -> void:
var rect_size: Vector2 = size
var s: float = min(rect_size.x, rect_size.y) / REFERENCE_SIZE
# Constant glow, brighter while held. Only the press state changes it,
# so the button redraws on press/release instead of every frame.
var pulse: float = 1.0 if pressed_down else 0.85
var margin: float = 4.0 * s
var square: PackedVector2Array = PackedVector2Array([
Vector2(margin, margin),
Vector2(rect_size.x - margin, margin),
Vector2(rect_size.x - margin, rect_size.y - margin),
Vector2(margin, rect_size.y - margin),
])
NeonDraw.draw_glowing_polygon(self, square, _active_color(), pulse, core_color)
var center: Vector2 = rect_size / 2.0
match icon:
Icon.CLOSE:
_draw_close(center, pulse, s)
Icon.SHIP:
_draw_ship(center, pulse, s)
Icon.MAP:
_draw_map(center, pulse, s)
Icon.DOWN_ARROW:
_draw_arrow(center, pulse, 1.0, s)
Icon.UP_ARROW:
_draw_arrow(center, pulse, -1.0, s)
Icon.BUILD:
_draw_build(center, pulse, s)
Icon.ROTATE_CW:
_draw_rotate(center, pulse, s, 1.0)
Icon.ROTATE_CCW:
_draw_rotate(center, pulse, s, -1.0)
Icon.MOTHERSHIP:
_draw_mothership_icon(center, pulse, s)
Icon.CRAFTER:
_draw_crafter_icon(center, pulse, s)
Icon.COMMAND_CENTER:
_draw_command_icon(center, pulse, s)
Icon.EXTRACTOR:
_draw_extractor_icon(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
var r: float = 10.0 * s
NeonDraw.draw_glowing_line(self, center + Vector2(-r, -r), center + Vector2(r, r), _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-r, r), center + Vector2(r, -r), _active_color(), pulse, 0.5 * s, core_color)
func _draw_ship(center: Vector2, pulse: float, s: float) -> void:
var nose: Vector2 = center + Vector2(0, -11) * s
var left: Vector2 = center + Vector2(-7, 8) * s
var right: Vector2 = center + Vector2(7, 8) * s
var points: PackedVector2Array = PackedVector2Array([nose, left, right])
NeonDraw.draw_glowing_polygon(self, points, _active_color(), pulse, core_color)
func _draw_arrow(center: Vector2, pulse: float, direction: float, s: float) -> void:
var r: float = 9.0 * s
var tip: Vector2 = center + Vector2(0, r * direction)
var shaft_top: Vector2 = center + Vector2(0, -r * direction)
NeonDraw.draw_glowing_line(self, shaft_top, tip, _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip + Vector2(r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color)
func _draw_build(center: Vector2, pulse: float, s: float) -> void:
# A plus inside a frame - "add a structure".
var r: float = 11.0 * s
var frame: PackedVector2Array = PackedVector2Array([
center + Vector2(-r, -r), center + Vector2(r, -r),
center + Vector2(r, r), center + Vector2(-r, r),
])
NeonDraw.draw_glowing_polygon(self, frame, _active_color(), pulse, core_color)
var arm: float = r * 0.55
NeonDraw.draw_glowing_line(self, center + Vector2(-arm, 0), center + Vector2(arm, 0), _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(0, -arm), center + Vector2(0, arm), _active_color(), pulse, 0.4 * s, core_color)
func _draw_rotate(center: Vector2, pulse: float, s: float, direction: float) -> void:
# Three-quarter arc with an arrowhead on the leading end, mirrored for
# the counter-clockwise variant.
var r: float = 11.0 * s
var segments: int = 14
var sweep: float = TAU * 0.75
var start: float = -PI * 0.5
var arc: PackedVector2Array = PackedVector2Array()
for i in range(segments + 1):
var t: float = float(i) / segments
var a: float = start + sweep * t * direction
arc.append(center + Vector2(cos(a), sin(a)) * r)
for i in range(segments):
NeonDraw.draw_glowing_line(self, arc[i], arc[i + 1], _active_color(), pulse, 0.34 * s, core_color)
var tip: Vector2 = arc[arc.size() - 1]
var tangent: Vector2 = (tip - arc[arc.size() - 2]).normalized()
var normal: Vector2 = Vector2(-tangent.y, tangent.x)
var head: float = 5.0 * s
NeonDraw.draw_glowing_line(self, tip, tip - tangent * head + normal * head, _active_color(), pulse, 0.34 * s, core_color)
NeonDraw.draw_glowing_line(self, tip, tip - tangent * head - normal * head, _active_color(), pulse, 0.34 * s, core_color)
# --- building glyphs, each echoing the placed structure's silhouette ------
func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void:
# Radial flagship: a reactor core with six swept talons around it, the
# same silhouette as the placed structure.
for i in range(6):
var base: float = (float(i) / 6.0) * TAU
var talon: PackedVector2Array = PackedVector2Array()
for step in [0.0, 0.5, 1.0]:
var a: float = base + 0.72 * pow(step, 1.3)
talon.append(center + Vector2(cos(a), sin(a)) * lerpf(7.0, 16.0, step) * s)
for step in [1.0, 0.5, 0.0]:
var a: float = base + 0.72 * pow(step, 1.3) - (0.34 * (1.0 - step) + 0.05)
talon.append(center + Vector2(cos(a), sin(a)) * lerpf(7.0, 16.0, step) * s)
NeonDraw.draw_glowing_polygon(self, talon, _active_color(), pulse, core_color)
for radius in [6.5, 3.0]:
var ring: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
ring.append(center + Vector2(cos(a), sin(a)) * radius * s)
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
# Reactor core, truss spine, and an open cradle holding a hull.
var core_centre: Vector2 = center + Vector2(-11, 0) * s
var core: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
core.append(core_centre + Vector2(cos(a), sin(a)) * 5.0 * s)
NeonDraw.draw_glowing_polygon(self, core, _active_color(), pulse, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-6, 0) * s,
center + Vector2(-2, 0) * s, _active_color(), pulse, 0.35 * s, core_color)
var bay: Vector2 = center + Vector2(6, 0) * s
var cradle: PackedVector2Array = PackedVector2Array()
for i in range(13):
var a: float = lerpf(-0.25 * PI, 1.25 * PI, float(i) / 12.0)
cradle.append(bay + Vector2(cos(a), sin(a)) * 10.0 * s)
NeonDraw.draw_glowing_path(self, cradle, _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
bay + Vector2(0, -6) * s, bay + Vector2(-5, 4) * s, bay + Vector2(5, 4) * s,
]), _active_color(), pulse, core_color)
func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void:
# Diamond with three formation pips - "arrangement".
var d: float = 14.0 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(0, -d), center + Vector2(d, 0),
center + Vector2(0, d), center + Vector2(-d, 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:
# A derrick standing on a planet's limb, with ore lifting off it - the
# one building that only works over a planet, so the planet is the point.
var limb: PackedVector2Array = PackedVector2Array()
for i in range(11):
var a: float = lerpf(-0.82 * PI, -0.18 * PI, float(i) / 10.0)
limb.append(center + (Vector2(0, 22) + Vector2(cos(a), sin(a)) * 17.0) * s)
NeonDraw.draw_glowing_path(self, limb, _active_color(), pulse, 0.45 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-6, 5) * s, center + Vector2(0, -9) * s, _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(6, 5) * s, center + Vector2(0, -9) * s, _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-3.5, -1) * s, center + Vector2(3.5, -1) * s, _active_color(), pulse, 0.35 * s, core_color)
# Shaft driven down through the limb - without it the derrick reads as a
# tower standing on a hill rather than as something mining the planet.
NeonDraw.draw_glowing_line(self, center + Vector2(0, -1) * s, center + Vector2(0, 14) * s, _active_color(), pulse, 0.3 * s, core_color)
var ore_centre: Vector2 = center + Vector2(0, -15) * s
var ore: PackedVector2Array = PackedVector2Array()
for i in range(6):
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
ore.append(ore_centre + Vector2(cos(a), sin(a)) * 4.0 * s)
NeonDraw.draw_glowing_polygon(self, ore, _active_color(), pulse, core_color)
func _draw_map(center: Vector2, pulse: float, s: float) -> void:
var w: float = 11.0 * s
var h: float = 9.0 * s
var outline: PackedVector2Array = PackedVector2Array([
center + Vector2(-w, -h), center + Vector2(w, -h * 0.7),
center + Vector2(w, h), center + Vector2(-w, h * 0.7),
])
NeonDraw.draw_glowing_polygon(self, outline, _active_color(), pulse, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-w * 0.34, -h * 0.9), center + Vector2(-w * 0.34, h * 0.77), _active_color(), pulse, 0.4 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(w * 0.34, -h * 0.77), center + Vector2(w * 0.34, h * 0.9), _active_color(), pulse, 0.4 * s, core_color)

1
IconButton.gd.uid Normal file
View file

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

15
LightBurstVFX.gd Normal file
View file

@ -0,0 +1,15 @@
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 _draw_particle(p: Dictionary, alpha: float) -> void:
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)

1
LightBurstVFX.gd.uid Normal file
View file

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

19
LunarBackground.gd Normal file
View file

@ -0,0 +1,19 @@
extends ColorRect
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
# a fraction within that cell, rather than a raw world position. See the
# long note in Starfield.gd for why - identical reasoning, identical split.
@onready var camera: Camera2D = get_node("../../Camera2D")
func _process(_delta: float) -> void:
var viewport_size: Vector2 = get_viewport_rect().size
var cell_size: float = material.get_shader_parameter("cell_size")
var p: Vector2 = camera.global_position / cell_size
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
material.set_shader_parameter("camera_cell", cell)
material.set_shader_parameter("camera_frac", p - cell)
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
material.set_shader_parameter("viewport_size", viewport_size)

1
LunarBackground.gd.uid Normal file
View file

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

698
Main.tscn Normal file
View file

@ -0,0 +1,698 @@
[gd_scene load_steps=20 format=3]
[ext_resource type="Script" path="res://Starfield.gd" id="1"]
[ext_resource type="Script" path="res://Fleet.gd" id="2"]
[ext_resource type="Script" path="res://GameController.gd" id="3"]
[ext_resource type="Script" path="res://EnemySpawner.gd" id="4"]
[ext_resource type="Shader" path="res://starfield.gdshader" id="5"]
[ext_resource type="Script" path="res://ExplorationTracker.gd" id="6"]
[ext_resource type="Script" path="res://AsteroidField.gd" id="7"]
[ext_resource type="Script" path="res://WorldMap.gd" id="8"]
[ext_resource type="Script" path="res://WorldMapContent.gd" id="9"]
[ext_resource type="Script" path="res://IconButton.gd" id="10"]
[ext_resource type="Script" path="res://PlanetField.gd" id="11"]
[ext_resource type="Script" path="res://PlanetAtmosphereController.gd" id="12"]
[ext_resource type="Script" path="res://TouchJoystick.gd" id="13"]
[ext_resource type="Script" path="res://BuildingField.gd" id="14"]
[ext_resource type="Script" path="res://BuildController.gd" id="15"]
[ext_resource type="Script" path="res://ResourceCounter.gd" id="16"]
[ext_resource type="Script" path="res://FormationEditor.gd" id="17"]
[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"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5")
shader_parameter/camera_pos = Vector2(0, 0)
shader_parameter/camera_zoom = 1.0
shader_parameter/viewport_size = Vector2(720, 1280)
shader_parameter/cell_size = 60.0
shader_parameter/bg_color = Color(0.02, 0.02, 0.08, 1)
[node name="Main" type="Node2D"]
script = ExtResource("3")
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
layer = -10
[node name="Starfield" type="ColorRect" parent="BackgroundLayer"]
material = SubResource("1")
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
[node name="Fleet" type="Node2D" parent="."]
script = ExtResource("2")
[node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(0, 0)
zoom = Vector2(1, 1)
[node name="EnemySpawner" type="Node2D" parent="."]
script = ExtResource("4")
[node name="ExplorationTracker" type="Node2D" parent="."]
script = ExtResource("6")
[node name="AsteroidField" type="Node2D" parent="."]
script = ExtResource("7")
[node name="PlanetField" type="Node2D" parent="."]
script = ExtResource("11")
[node name="BuildingField" type="Node2D" parent="."]
script = ExtResource("14")
[node name="HaulerField" type="Node2D" parent="."]
script = ExtResource("18")
[node name="BuildController" type="Node2D" parent="."]
script = ExtResource("15")
[node name="LandingController" type="Node2D" parent="."]
script = ExtResource("12")
[node name="UI" type="CanvasLayer" parent="."]
layer = 1
[node name="ResourceCounter" type="Control" parent="UI"]
mouse_filter = 2
offset_left = 28
offset_top = 24
offset_right = 520
offset_bottom = 140
script = ExtResource("16")
digit_height = 100.0
[node name="PlanetNameLabel" type="Label" parent="UI"]
visible = false
offset_left = 24
offset_top = 156
offset_right = 696
offset_bottom = 282
text = ""
horizontal_alignment = 1
[node name="EnterAtmosphereButton" type="Control" parent="UI"]
visible = false
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 172
offset_top = -720
offset_bottom = -572
script = ExtResource("10")
icon = 3
[node name="BuildButton" type="Control" parent="UI"]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 172
offset_top = -890
offset_bottom = -742
script = ExtResource("10")
icon = 5
[node name="BuildActionButton" type="Control" parent="UI"]
visible = false
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 172
offset_top = -720
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
offset_left = -172
offset_right = -24
offset_top = 24
offset_bottom = 172
script = ExtResource("10")
icon = 0
[node name="KeyboardHintLabel" type="Label" parent="UI"]
visible = false
mouse_filter = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 28
offset_right = 360
offset_top = -300
offset_bottom = -120
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = "WASD / arrows move Q / E pivot wheel zoom"
[node name="Joystick" type="Control" parent="UI"]
mouse_filter = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 424
offset_top = -424
offset_bottom = -24
script = ExtResource("13")
[node name="PivotCCWButton" type="Control" 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 = -196
offset_top = -172
offset_bottom = -24
script = ExtResource("10")
icon = 7
[node name="PivotCWButton" type="Control" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -172
offset_right = -24
offset_top = -172
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
anchor_bottom = 1.0
script = ExtResource("8")
[node name="Backdrop" type="ColorRect" parent="UI/WorldMap"]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.96)
[node name="MapContent" type="Node2D" parent="UI/WorldMap"]
script = ExtResource("9")
[node name="RecenterButton" type="Control" parent="UI/WorldMap"]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 172
offset_top = -620
offset_bottom = -472
script = ExtResource("10")
icon = 2
[node name="CloseButton" type="Control" parent="UI/WorldMap"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172
offset_right = -24
offset_top = 24
offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="BuildMenu" type="Control" parent="UI"]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/BuildMenu"]
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/BuildMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 70
offset_right = -24
offset_bottom = 196
text = "Build"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 206
offset_right = -24
offset_bottom = 330
theme_override_font_sizes/font_size = 40
autowrap_mode = 2
text = ""
horizontal_alignment = 1
[node name="MothershipButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -214
offset_right = -66
offset_top = -224
offset_bottom = -76
script = ExtResource("10")
icon = 8
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="MothershipLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
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
autowrap_mode = 2
text = "Mothership"
horizontal_alignment = 1
[node name="MothershipCost" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
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
script = ExtResource("19")
amount = 20
digit_height = 66.0
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 66
offset_right = 214
offset_top = -224
offset_bottom = -76
script = ExtResource("10")
icon = 9
glow_color = Color(1, 0.72, 0.25, 1)
[node name="CrafterLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
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
autowrap_mode = 2
text = "Ship Crafter"
horizontal_alignment = 1
[node name="CrafterCost" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
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
script = ExtResource("19")
amount = 12
digit_height = 66.0
[node name="CommandButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -214
offset_right = -66
offset_top = 175
offset_bottom = 323
script = ExtResource("10")
icon = 10
glow_color = Color(0.55, 0.6, 1, 1)
[node name="CommandLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
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
autowrap_mode = 2
text = "Command Center"
horizontal_alignment = 1
[node name="CommandCost" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
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
script = ExtResource("19")
amount = 15
digit_height = 66.0
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 66
offset_right = 214
offset_top = 175
offset_bottom = 323
script = ExtResource("10")
icon = 11
glow_color = Color(0, 0.62, 0.7, 1)
[node name="ExtractorLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
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
autowrap_mode = 2
text = "Extractor"
horizontal_alignment = 1
[node name="ExtractorCost" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
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
script = ExtResource("19")
amount = 25
digit_height = 66.0
[node name="CloseButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172
offset_right = -24
offset_top = 24
offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="InfoPopup" type="Control" parent="UI/BuildMenu"]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Dim" type="ColorRect" parent="UI/BuildMenu/InfoPopup"]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.01, 0.01, 0.03, 0.55)
[node name="Panel" type="Control" parent="UI/BuildMenu/InfoPopup"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -352
offset_right = 352
offset_top = -600
offset_bottom = 600
script = ExtResource("20")
glow_color = Color(0, 0.62, 0.7, 1)
[node name="TitleLabel" type="Label" parent="UI/BuildMenu/InfoPopup/Panel"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 44
offset_right = -44
offset_top = 30
offset_bottom = 150
text = ""
horizontal_alignment = 1
[node name="MessageLabel" type="Label" parent="UI/BuildMenu/InfoPopup/Panel"]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 44
offset_right = -44
offset_top = 160
offset_bottom = -440
theme_override_font_sizes/font_size = 48
text = ""
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="CostNumber" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -70
offset_right = 90
offset_top = -424
offset_bottom = -334
script = ExtResource("19")
amount = 0
digit_height = 78.0
[node name="BuildButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -200
offset_right = -40
offset_top = -300
offset_bottom = -140
script = ExtResource("10")
icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="CloseButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 40
offset_right = 200
offset_top = -300
offset_bottom = -140
script = ExtResource("10")
icon = 1
glow_color = Color(0, 0.62, 0.7, 1)
[node name="CraftMenu" type="Control" parent="UI"]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/CraftMenu"]
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/CraftMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 70
offset_right = -24
offset_bottom = 196
text = "Build a ship"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 206
offset_right = -24
offset_bottom = 360
theme_override_font_sizes/font_size = 40
autowrap_mode = 2
text = ""
horizontal_alignment = 1
[node name="InterceptorButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -74
offset_right = 74
offset_top = -74
offset_bottom = 74
script = ExtResource("10")
icon = 2
[node name="InterceptorLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -260
offset_right = 260
offset_top = 100
offset_bottom = 200
theme_override_font_sizes/font_size = 60
text = "Interceptor"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172
offset_right = -24
offset_top = 24
offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="FormationMenu" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/FormationMenu"]
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/FormationMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 70
offset_right = -24
offset_bottom = 220
theme_override_font_sizes/font_size = 44
autowrap_mode = 2
text = "Fleet formation - drag to arrange"
horizontal_alignment = 1
[node name="Editor" type="Control" parent="UI/FormationMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -300
offset_right = 300
offset_top = -300
offset_bottom = 300
script = ExtResource("17")
[node name="ResetButton" type="Control" parent="UI/FormationMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -74
offset_right = 74
offset_top = -270
offset_bottom = -122
script = ExtResource("10")
icon = 3
[node name="ResetLabel" type="Label" parent="UI/FormationMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -180
offset_right = 180
offset_top = -114
offset_bottom = -8
theme_override_font_sizes/font_size = 60
text = "Reset"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="UI/FormationMenu"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -172
offset_right = -24
offset_top = 24
offset_bottom = 172
script = ExtResource("10")
icon = 1
[node name="FadeOverlay" type="CanvasLayer" parent="."]
layer = 10
[node name="FadeRect" type="ColorRect" parent="FadeOverlay"]
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0)

42
NeonDraw.gd Normal file
View file

@ -0,0 +1,42 @@
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) -> void:
var closed: PackedVector2Array = points.duplicate()
closed.append(points[0])
_draw_layers(item, closed, glow_color, pulse, 1.0, 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)
# 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
NeonDraw.gd.uid Normal file
View file

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

84
NeonNumber.gd Normal file
View file

@ -0,0 +1,84 @@
class_name NeonNumber
extends Control
# An ore amount drawn in the game's own hand-stroked style: the resource
# hexagon followed by segment-drawn digits. Godot's default Label font has
# no place in this look, which is why the digits are strokes rather than
# text.
#
# Used both for the live resource readout (see ResourceCounter, which feeds
# it from GameState) and for the fixed prices under each build option.
@export var amount: int = 0: set = set_amount
@export var show_icon: bool = true
@export var glow_color: Color = Color(0.0, 0.62, 0.7)
@export var core_color: Color = Color(0.0, 0.12, 0.15)
@export var digit_height: float = 30.0
@export var digit_gap: float = 9.0
# 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 = [
[true, true, true, true, true, true, false], # 0
[false, true, true, false, false, false, false], # 1
[true, true, false, true, true, false, true], # 2
[true, true, true, true, false, false, true], # 3
[false, true, true, false, false, true, true], # 4
[true, false, true, true, false, true, true], # 5
[true, false, true, true, true, true, true], # 6
[true, true, true, false, false, false, false], # 7
[true, true, true, true, true, true, true], # 8
[true, true, true, true, false, true, true], # 9
]
func set_amount(value: int) -> void:
if amount == value:
return
amount = value
queue_redraw()
func _draw() -> void:
var h: float = digit_height
var w: float = h * 0.56
var x: float = 0.0
if show_icon:
_draw_hexagon(Vector2(h * 0.42, h * 0.5), h * 0.62)
x = h * 1.15
for ch in str(maxi(amount, 0)):
_draw_digit(int(ch), Vector2(x, 0.0), w, h)
x += w + digit_gap
func _draw_hexagon(centre: Vector2, radius: float) -> void:
# Same silhouette as the ore pickup itself, so a price and the thing it
# is priced in are unmistakably the same resource.
var pts: PackedVector2Array = PackedVector2Array()
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)
func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
if value < 0 or value > 9:
return
var on: Array = DIGIT_SEGMENTS[value]
var mid: float = h * 0.5
var seg: Array = [
[Vector2(0.0, 0.0), Vector2(w, 0.0)], # top
[Vector2(w, 0.0), Vector2(w, mid)], # top-right
[Vector2(w, mid), Vector2(w, h)], # bottom-right
[Vector2(0.0, h), Vector2(w, h)], # bottom
[Vector2(0.0, mid), Vector2(0.0, h)], # bottom-left
[Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left
[Vector2(0.0, mid), Vector2(w, mid)], # middle
]
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)

1
NeonNumber.gd.uid Normal file
View file

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

47
NeonPanel.gd Normal file
View file

@ -0,0 +1,47 @@
class_name NeonPanel
extends Control
# A bounded dialog card drawn in the game's own style: a dark chamfered
# body with a glowing border and bright corner brackets. Chamfered rather
# than square because cut corners read as "console panel" and match the
# hard-edged neon of the buttons and HUD.
#
# Draws itself from its own rect, so it can be sized freely in the scene.
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var core_color: Color = Color(1.0, 1.0, 1.0)
# Fully opaque: at even 97% the bright neon of whatever sits behind the
# panel ghosts through and makes the text harder to read.
@export var fill_color: Color = Color(0.02, 0.03, 0.07, 1.0)
@export var corner_cut: float = 26.0
@export var bracket_length: float = 46.0
func _draw() -> void:
var w: float = size.x
var h: float = size.y
var c: float = corner_cut
var body: PackedVector2Array = PackedVector2Array([
Vector2(c, 0.0), Vector2(w - c, 0.0),
Vector2(w, c), Vector2(w, h - c),
Vector2(w - c, h), Vector2(c, h),
Vector2(0.0, h - c), Vector2(0.0, c),
])
draw_colored_polygon(body, fill_color)
NeonDraw.draw_glowing_polygon(self, body, glow_color, 1.0, core_color)
# Corner brackets: short doubled strokes just inside each cut corner,
# which is what gives it the heads-up-display feel rather than looking
# like a plain outlined box.
var b: float = bracket_length
var inset: float = 10.0
var corners: Array = [
[Vector2(inset, c + inset), Vector2(inset, c + inset + b), Vector2(c + inset, inset), Vector2(c + inset + b, inset)],
[Vector2(w - inset, c + inset), Vector2(w - inset, c + inset + b), Vector2(w - c - inset, inset), Vector2(w - c - inset - b, inset)],
[Vector2(inset, h - c - inset), Vector2(inset, h - c - inset - b), Vector2(c + inset, h - inset), Vector2(c + inset + b, h - inset)],
[Vector2(w - inset, h - c - inset), Vector2(w - inset, h - c - inset - b), Vector2(w - c - inset, h - inset), Vector2(w - c - inset - b, h - inset)],
]
for corner in corners:
NeonDraw.draw_glowing_line(self, corner[0], corner[1], glow_color, 0.9, 0.3, core_color)
NeonDraw.draw_glowing_line(self, corner[2], corner[3], glow_color, 0.9, 0.3, core_color)

1
NeonPanel.gd.uid Normal file
View file

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

39
Planet.gd Normal file
View file

@ -0,0 +1,39 @@
extends Node2D
# A landable world-object - unlike regular Asteroid.gd, planets are
# stationary ("Big Stable Asteroid" - stable means it doesn't drift or
# destroy ships on touch). Whether the fleet counts as "landed" is decided
# by PlanetField comparing the fleet's CENTROID distance to landing_radius,
# not per-ship Area2D collision - a single stable number, immune to the
# constant minor jostling of individual ships holding formation, which
# used to make the "enter atmosphere" prompt flicker on/off as ships
# drifted in and out of the boundary independently of each other even
# while the fleet as a whole was clearly sitting on the planet.
@export var planet_name: String = ""
@export var planet_type: String = "big_stable_asteroid"
@export var landing_radius: float = 140.0
# Set by whoever spawns this planet (PlanetField, seeded from the chunk's
# own coordinates) so the outline shape is stable across respawns of the
# same chunk, instead of reading as a "different" planet every time.
@export var shape_seed: int = 0
var shape_points: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
add_to_group("planets")
z_index = -1
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = shape_seed
var point_count: int = 10
for i in range(point_count):
var angle: float = (float(i) / point_count) * TAU
var radius: float = rng.randf_range(70.0, 100.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
queue_redraw()
func _draw() -> void:
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)

1
Planet.gd.uid Normal file
View file

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

6
Planet.tscn Normal file
View file

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

View file

@ -0,0 +1,124 @@
extends Node2D
# Space-side: bootstraps the fleet on Main.tscn startup (a fresh 5-ship
# fleet at the origin on first launch, or GameState's surviving ship
# count/position if we just returned from a planet's surface), shows an
# "enter atmosphere" prompt (name + button) when the fleet is standing on
# a landable planet, and - on press - fades out and swaps to a completely
# separate PlanetSurface.tscn scene (not an overlay toggled in place), so
# asteroids/enemies genuinely cannot exist there rather than merely being
# hidden. Fleet size and the world position/zoom to return to are handed
# off via the GameState autoload, since change_scene_to_file() tears down
# this whole scene tree.
@export var fade_duration: float = 0.4
@export var prompt_debounce_time: float = 0.15
@onready var planet_field: Node2D = get_node("../PlanetField")
@onready var asteroid_field: Node2D = get_node("../AsteroidField")
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel")
@onready var enter_button: IconButton = get_node("../UI/EnterAtmosphereButton")
@onready var world_map: Control = get_node("../UI/WorldMap")
@onready var build_controller: Node2D = get_node("../BuildController")
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
var current_planet: Node2D = null
var transitioning: bool = false
var pending_planet: Node2D = null
var pending_time: float = 0.0
func _ready() -> void:
enter_button.pressed.connect(_on_enter_pressed)
if GameState.has_return_position:
fleet.spawn_ships(GameState.surviving_ship_count, GameState.return_world_position)
camera.position = GameState.return_world_position
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
GameState.has_return_position = false
else:
fleet.spawn_ships(5, Vector2.ZERO)
# AsteroidField/PlanetField normally only populate nearby chunks once
# their own check_interval timer elapses (~1s) - fine during normal
# continuous play (things appear far off-screen, gradually), but
# right after a fresh scene load it means an empty-looking world for
# a second before asteroids/the planet visibly pop in. Force an
# immediate synchronous populate now that the fleet is correctly
# positioned, so nothing is missing on the very first visible frame.
asteroid_field.update_chunks()
planet_field.update_chunks()
func _process(delta: float) -> void:
if transitioning:
return
if world_map.visible or build_controller.is_showing_building_prompt():
# EnterAtmosphereButton shares one "action" slot with the map's
# RecenterButton and with BuildController's building prompt - it doesn't make sense to enter
# a planet's atmosphere while looking at the star map anyway, so
# suppress the prompt entirely rather than let it sit hidden
# behind the map's backdrop only to pop back in the instant the
# map closes.
current_planet = null
pending_planet = null
planet_name_label.visible = false
enter_button.visible = false
return
# Debounced: only commit a visible/hidden change once the raw
# landing-detection result has stayed the same for prompt_debounce_time
# in a row, instead of reacting to every single frame's reading. Belt
# and suspenders on top of get_planet_fleet_is_on() already using the
# fleet's centroid (a single stable number - see Planet.gd) rather
# than any individual ship's position, so this only has to absorb
# genuine sub-debounce-window boundary crossings, not constant noise.
var detected_planet: Node2D = planet_field.get_planet_fleet_is_on()
if detected_planet != pending_planet:
pending_planet = detected_planet
pending_time = 0.0
else:
pending_time += delta
if pending_time < prompt_debounce_time or pending_planet == current_planet:
return
current_planet = pending_planet
if current_planet != null:
planet_name_label.text = current_planet.planet_name
planet_name_label.visible = true
enter_button.visible = true
else:
planet_name_label.visible = false
enter_button.visible = false
func _on_enter_pressed() -> void:
if transitioning or current_planet == null:
return
transitioning = true
planet_name_label.visible = false
enter_button.visible = false
GameState.has_return_position = true
GameState.surviving_ship_count = fleet.ships.size()
# The planet's own position, not fleet.get_centroid() - the fleet only
# needs to be somewhere within the planet's landing radius (not
# centered exactly on it) for the prompt to show, so the centroid can
# be tens of units off from the planet. Returning should always place
# the fleet back at the planet itself.
GameState.return_world_position = current_planet.position
GameState.return_camera_zoom = camera.zoom.x
GameState.planet_name = current_planet.planet_name
GameState.planet_type = current_planet.planet_type
var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)
tween.tween_callback(_enter_surface)
func _enter_surface() -> void:
get_tree().change_scene_to_file("res://PlanetSurface.tscn")

View file

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

93
PlanetField.gd Normal file
View file

@ -0,0 +1,93 @@
extends Node2D
# Spawns "Big Stable Asteroid" planets in ASTEROID_FIELD biome chunks near
# the fleet where WorldGen says one belongs, and despawns them once the
# fleet has moved far enough away - mirrors AsteroidField's chunk-lifecycle
# pattern, but at a sparser, larger-radius scale since planets are rare
# landmarks rather than common decoration.
@export var check_interval: float = 1.0
@export var spawn_radius_chunks: int = 3
@export var despawn_radius_chunks: int = 6
@onready var fleet: Node2D = get_node("../Fleet")
var planet_scene: PackedScene = preload("res://Planet.tscn")
var populated_chunks: Dictionary = {}
var timer: float = 0.0
func _process(delta: float) -> void:
timer += delta
if timer < check_interval:
return
timer = 0.0
update_chunks()
func get_planet_fleet_is_on() -> Node2D:
# Fleet centroid distance, not per-ship Area2D overlap - a single
# stable number instead of many independently-noisy ship positions.
# See Planet.gd for why this matters.
var fleet_centroid: Vector2 = fleet.get_centroid()
for chunk in populated_chunks.keys():
for planet in populated_chunks[chunk]:
if is_instance_valid(planet) and planet.position.distance_to(fleet_centroid) <= planet.landing_radius:
return planet
return null
func update_chunks() -> void:
# Public (not just called from _process's own timer): also invoked
# once synchronously right after the fleet is positioned on a fresh
# Main.tscn load, so a nearby planet exists immediately instead of
# only appearing ~check_interval seconds later - most noticeable as a
# jarring pop-in right after returning from a planet's surface.
var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid())
for dx in range(-spawn_radius_chunks, spawn_radius_chunks + 1):
for dy in range(-spawn_radius_chunks, spawn_radius_chunks + 1):
var chunk: Vector2i = center_chunk + Vector2i(dx, dy)
if populated_chunks.has(chunk):
continue
if not WorldGen.has_planet_at_chunk(chunk):
continue
_spawn_chunk_planet(chunk)
var to_remove: Array = []
for chunk in populated_chunks.keys():
if _chebyshev_chunk_distance(chunk, center_chunk) > despawn_radius_chunks:
for planet in populated_chunks[chunk]:
if is_instance_valid(planet):
planet.queue_free()
to_remove.append(chunk)
for chunk in to_remove:
populated_chunks.erase(chunk)
func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int:
return max(abs(a.x - b.x), abs(a.y - b.y))
func _spawn_chunk_planet(chunk: Vector2i) -> void:
# WorldGen.planet_data_at_chunk() is seeded purely from the chunk's own
# coordinates, so a given chunk always produces the exact same planet -
# name, position within the chunk, and shape - no matter how many times
# it's despawned and respawned across a scene reload. It's also the
# same function the world map uses to place a marker for a
# not-currently-loaded explored chunk, so the two never disagree.
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var origin: Vector2 = WorldGen.chunk_origin(chunk)
var planet: Node2D = planet_scene.instantiate()
planet.position = origin + data.local_pos
planet.planet_name = data.name
planet.shape_seed = data.shape_seed
# Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet is
# positioned, to avoid a pop-in on scene load) - Godot refuses a
# direct add_child() while the tree is still "busy setting up
# children" in that situation, so this must be deferred either way.
get_parent().add_child.call_deferred(planet)
populated_chunks[chunk] = [planet]

1
PlanetField.gd.uid Normal file
View file

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

172
PlanetSurface.tscn Normal file
View file

@ -0,0 +1,172 @@
[gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://GameController.gd" id="1"]
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
[ext_resource type="Shader" path="res://lunar_surface.gdshader" id="3"]
[ext_resource type="Script" path="res://Fleet.gd" id="4"]
[ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"]
[ext_resource type="Script" path="res://IconButton.gd" id="6"]
[ext_resource type="Script" path="res://TouchJoystick.gd" id="7"]
[ext_resource type="Script" path="res://RockField.gd" id="8"]
[ext_resource type="Script" path="res://ResourceCounter.gd" id="9"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("3")
shader_parameter/camera_pos = Vector2(0, 0)
shader_parameter/camera_zoom = 1.0
shader_parameter/viewport_size = Vector2(720, 1280)
shader_parameter/cell_size = 220.0
shader_parameter/base_color = Color(0.75, 0.75, 0.76, 1)
shader_parameter/crater_color = Color(0.44, 0.44, 0.45, 1)
shader_parameter/highlight_color = Color(0.89, 0.89, 0.9, 1)
[node name="PlanetSurface" type="Node2D"]
script = ExtResource("1")
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
layer = -10
[node name="LunarBackground" type="ColorRect" parent="BackgroundLayer"]
material = SubResource("1")
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("2")
[node name="Fleet" type="Node2D" parent="."]
script = ExtResource("4")
ship_glow_color = Color(0.06, 0.32, 0.5, 1)
ship_core_color = Color(0.02, 0.09, 0.15, 1)
[node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(0, 0)
zoom = Vector2(1, 1)
[node name="RockField" type="Node2D" parent="."]
script = ExtResource("8")
[node name="SurfaceController" type="Node2D" parent="."]
script = ExtResource("5")
[node name="UI" type="CanvasLayer" parent="."]
layer = 1
[node name="ResourceCounter" type="Control" parent="UI"]
mouse_filter = 2
offset_left = 28
offset_top = 24
offset_right = 520
offset_bottom = 140
script = ExtResource("9")
digit_height = 100.0
glow_color = Color(0, 0.42, 0.48, 1)
core_color = Color(0, 0.08, 0.1, 1)
[node name="PlanetNameLabel" type="Label" parent="UI"]
offset_left = 24
offset_top = 156
offset_right = 696
offset_bottom = 282
text = ""
horizontal_alignment = 1
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
[node name="ExitButton" type="Control" parent="UI"]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 172
offset_top = -720
offset_bottom = -572
script = ExtResource("6")
icon = 4
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="KeyboardHintLabel" type="Label" parent="UI"]
visible = false
mouse_filter = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 28
offset_right = 360
offset_top = -300
offset_bottom = -120
theme_override_font_sizes/font_size = 36
autowrap_mode = 2
text = "WASD / arrows move Q / E pivot wheel zoom"
[node name="Joystick" type="Control" parent="UI"]
mouse_filter = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 24
offset_right = 424
offset_top = -424
offset_bottom = -24
script = ExtResource("7")
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="PivotCCWButton" type="Control" 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 = -196
offset_top = -172
offset_bottom = -24
script = ExtResource("6")
icon = 7
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="PivotCWButton" type="Control" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -172
offset_right = -24
offset_top = -172
offset_bottom = -24
script = ExtResource("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
[node name="FadeRect" type="ColorRect" parent="FadeOverlay"]
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 1)

View file

@ -0,0 +1,45 @@
extends Node2D
# Bootstraps the surviving fleet on this scene's own startup (from
# GameState, since this is a fresh scene tree - nothing here carries
# over automatically) and handles the always-available exit prompt that
# fades back out to Main.tscn, restoring the fleet at wherever it left
# space from.
@export var fade_duration: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel")
@onready var exit_button: IconButton = get_node("../UI/ExitButton")
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
var transitioning: bool = false
func _ready() -> void:
fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO)
camera.position = Vector2.ZERO
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
planet_name_label.text = GameState.planet_name
exit_button.pressed.connect(_on_exit_pressed)
fade_rect.color.a = 1.0
var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 0.0, fade_duration)
func _on_exit_pressed() -> void:
if transitioning:
return
transitioning = true
GameState.surviving_ship_count = fleet.ships.size()
var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)
tween.tween_callback(_return_to_space)
func _return_to_space() -> void:
get_tree().change_scene_to_file("res://Main.tscn")

View file

@ -0,0 +1 @@
uid://17thrioymdls

47
Projectile.gd Normal file
View file

@ -0,0 +1,47 @@
extends Area2D
@export var speed: float = 500.0
@export var lifetime: float = 1.5
@export var damage: int = 1
@export var bolt_length: float = 20.0
var velocity: Vector2 = Vector2.ZERO
var age: float = 0.0
func _ready() -> void:
add_to_group("player_projectiles")
area_entered.connect(_on_area_entered)
func launch(direction: Vector2) -> void:
velocity = direction.normalized() * speed
rotation = direction.angle() + PI / 2.0
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()
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("enemies"):
area.take_damage(damage)
queue_free()
func _draw() -> void:
# Fixed-length, constant-brightness bolt. Drawn once and left alone.
var tip: Vector2 = Vector2(0, -bolt_length * 0.6)
var tail: Vector2 = Vector2(0, bolt_length * 0.4)
NeonDraw.draw_glowing_line(self, tail, tip, Color(1.0, 0.85, 0.3), 1.0, 0.5)

1
Projectile.gd.uid Normal file
View file

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

12
Projectile.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://Projectile.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 4.0
[node name="Projectile" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

10
ResourceCounter.gd Normal file
View file

@ -0,0 +1,10 @@
extends NeonNumber
# Top-of-screen ore readout. All the drawing lives in NeonNumber; this only
# keeps the displayed amount in step with GameState, which it reads directly
# so it stays correct across the space <-> planet-surface scene swap without
# anyone having to push updates into it.
func _process(_delta: float) -> void:
set_amount(GameState.rocks)

1
ResourceCounter.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://5u8a3kx3gsh1

102
Rock.gd Normal file
View file

@ -0,0 +1,102 @@
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"):
GameState.rocks += 1
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)

1
Rock.gd.uid Normal file
View file

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

12
Rock.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://Rock.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 26.0
[node name="Rock" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

71
RockField.gd Normal file
View file

@ -0,0 +1,71 @@
extends Node2D
# Scatters gatherable rocks across a planet's surface around the fleet, and
# tops them back up as they are collected, so there is always something to
# mine wherever the player flies.
#
# Unlike AsteroidField/PlanetField this is deliberately NOT deterministic
# per chunk: rocks are consumable, and a chunk-seeded layout would respawn
# the very same rock in the very same spot the moment the player flew back,
# making them infinite and free. Tracking every collected rock forever would
# grow without bound, so they are simply scattered at random within a ring
# around the fleet and despawned once left far behind.
@export var target_count: int = 14
@export var spawn_radius: float = 1400.0
@export var min_spawn_distance: float = 320.0
@export var despawn_radius: float = 2600.0
@export var check_interval: float = 0.5
@onready var fleet: Node2D = get_node("../Fleet")
var rock_scene: PackedScene = preload("res://Rock.tscn")
var rocks: Array = []
var timer: float = 0.0
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
rng.randomize()
# Populate immediately rather than waiting out the first interval, so
# the surface isn't visibly empty for half a second on arrival.
update_rocks()
func _process(delta: float) -> void:
timer += delta
if timer < check_interval:
return
timer = 0.0
update_rocks()
func update_rocks() -> void:
var centre: Vector2 = fleet.get_centroid()
var surviving: Array = []
for rock in rocks:
if not is_instance_valid(rock):
continue
if rock.position.distance_to(centre) > despawn_radius:
rock.queue_free()
continue
surviving.append(rock)
rocks = surviving
while rocks.size() < target_count:
_spawn_rock(centre)
func _spawn_rock(centre: Vector2) -> void:
# Ring, not disc: spawning right on top of the fleet would hand the
# player free rocks without flying anywhere.
var angle: float = rng.randf() * TAU
var distance: float = rng.randf_range(min_spawn_distance, spawn_radius)
var rock: Area2D = rock_scene.instantiate()
rock.position = centre + Vector2(cos(angle), sin(angle)) * distance
rock.build_shape(rng.randi())
# Deferred: update_rocks() runs from _ready(), and Godot refuses a
# direct add_child() while the tree is still setting up children.
get_parent().add_child.call_deferred(rock)
rocks.append(rock)

1
RockField.gd.uid Normal file
View file

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

139
Ship.gd Normal file
View file

@ -0,0 +1,139 @@
extends Area2D
signal destroyed
# Deliberately faster than Fleet.cruise_speed: the fleet as a whole travels
# at the cruise rate, and this headroom is what lets an individual ship
# close on its formation slot - so a pivot mid-flight resolves quickly
# instead of the ship having no speed budget left over for it.
@export var max_speed: float = 460.0
@export var acceleration: float = 450.0
@export var rotation_speed: float = 8.0
@export var arrive_radius: float = 6.0
@export var fire_range: float = 320.0
@export var fire_cooldown: float = 0.5
# How long to wait before looking for targets again after finding none.
@export var idle_rescan_interval: float = 0.25
@export var projectile_damage: int = 1
# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet.
# The lunar surface is a bright grey, where the default near-white core all
# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw.
var glow_color: Color = Color(0.25, 0.85, 1.0)
var core_color: Color = Color(1.0, 1.0, 1.0)
var velocity: Vector2 = Vector2.ZERO
# The fleet's own velocity, supplied by Fleet as CONTEXT rather than as a
# facing command - the ship works out where to point from it.
#
# It is needed because `velocity` below is not the ship's true motion while
# it is holding formation: the slot it chases sits inside arrive_radius, so
# the ship snaps onto it and reports zero velocity even though it is being
# carried along at cruise speed. Facing derived from `velocity` alone
# therefore froze at whatever heading the ship last happened to travel.
# Adding the two gives the ship's ACTUAL motion through the world, which is
# the honest thing to point along in every case: it equals the fleet's
# heading when station-keeping, the ship's own heading when manoeuvring
# alone, and the true blend of both while strafing into a new slot.
var fleet_velocity: Vector2 = Vector2.ZERO
var move_target: Vector2
var fire_timer: float = 0.0
var projectile_scene: PackedScene = preload("res://Projectile.tscn")
func _ready() -> void:
move_target = position
add_to_group("player_ships")
area_entered.connect(_on_area_entered)
queue_redraw()
func set_move_target(world_pos: Vector2) -> void:
move_target = world_pos
func _physics_process(delta: float) -> void:
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
if distance <= arrive_radius:
# Snap and stop dead - prevents residual float-level jitter that
# would otherwise keep nudging back and forth around the target.
position = move_target
velocity = Vector2.ZERO
else:
# Braking-distance formula: the fastest speed we can be going right
# now and still be able to decelerate to 0 exactly at the target,
# given our acceleration. Without this the ship approaches at full
# speed, overshoots (stopping distance = max_speed^2 / (2*accel)),
# and oscillates back and forth forever instead of settling.
var braking_speed: float = sqrt(2.0 * acceleration * distance)
var desired_speed: float = min(max_speed, braking_speed)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
position += velocity * delta
# One owner, one rule: face the way this ship is actually travelling.
var motion: Vector2 = velocity + fleet_velocity
if motion.length() > 8.0:
var target_angle: float = motion.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
_update_weapon(delta)
func _update_weapon(delta: float) -> void:
fire_timer -= delta
if fire_timer > 0.0:
return
var target: Node2D = _find_nearest_enemy()
if target == null:
# Back off before scanning again. Without this, fire_timer stays at
# or below zero and every ship queries the whole "enemies" group and
# loops it on EVERY physics frame whenever nothing is in range -
# which is most of the time.
fire_timer = idle_rescan_interval
return
fire_timer = fire_cooldown
var proj: Node2D = projectile_scene.instantiate()
proj.position = position
proj.damage = projectile_damage
get_tree().root.add_child(proj)
proj.launch(target.position - position)
func _find_nearest_enemy() -> Node2D:
var enemies: Array = get_tree().get_nodes_in_group("enemies")
var nearest: Node2D = null
var best_dist: float = fire_range
for e in enemies:
if not is_instance_valid(e):
continue
var d: float = position.distance_to(e.position)
if d <= best_dist:
best_dist = d
nearest = e
return nearest
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("enemies") or area.is_in_group("asteroids"):
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _draw() -> void:
var nose: Vector2 = Vector2(0, -22)
var left: Vector2 = Vector2(-14, 16)
var right: Vector2 = Vector2(14, 16)
var points: PackedVector2Array = PackedVector2Array([nose, left, right])
# Constant glow, drawn once. No pulse means nothing changes frame to
# frame, so the ship never needs redrawing after _ready() - which also
# removes four antialiased polyline rebuilds per ship per frame.
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)

1
Ship.gd.uid Normal file
View file

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

12
Ship.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://Ship.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 16.0
[node name="Ship" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

12
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)

1
ShipExplosionVFX.gd.uid Normal file
View file

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

28
Starfield.gd Normal file
View file

@ -0,0 +1,28 @@
extends ColorRect
# Feeds the starfield shader the camera's position split into a CELL INDEX
# plus a fraction within that cell, rather than a raw world position.
#
# Why: the fleet routinely reaches world coordinates in the tens of
# thousands. Handing those straight to the shader forces every downstream
# calculation (the division by cell_size, floor/fract, and especially the
# hash's internal multiplies) to work at large magnitudes, where a mobile
# GPU's float precision starts to break down - and no precision qualifier
# can fix that, since `highp` widens the type but the magnitudes stay huge.
# GDScript computes in double precision, so doing the split here and passing
# only small numbers (cell indices in the hundreds, fractions in [0,1])
# keeps the shader's arithmetic in a range it can represent exactly.
@onready var camera: Camera2D = get_node("../../Camera2D")
func _process(_delta: float) -> void:
var viewport_size: Vector2 = get_viewport_rect().size
var cell_size: float = material.get_shader_parameter("cell_size")
var p: Vector2 = camera.global_position / cell_size
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
material.set_shader_parameter("camera_cell", cell)
material.set_shader_parameter("camera_frac", p - cell)
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
material.set_shader_parameter("viewport_size", viewport_size)

1
Starfield.gd.uid Normal file
View file

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

110
TouchJoystick.gd Normal file
View file

@ -0,0 +1,110 @@
class_name TouchJoystick
extends Control
# Touch joystick for steering the fleet. Lives in the bottom-left corner.
#
# Behaves like a "floating" stick within its own rect: the base re-centres
# on wherever the finger first lands, so the player doesn't have to hit an
# exact spot on a phone screen they can't feel. The knob then follows the
# finger out to max_radius, and `direction` reports the offset normalised
# against that radius (so magnitude is 0..1, usable for throttle later).
#
# Only claims touches that start inside its own rect - anything else falls
# through untouched, which is what keeps pinch-to-zoom working everywhere
# else on screen.
signal direction_changed(direction: Vector2)
signal released
# How far the knob travels from centre, as a fraction of the control's
# shorter side. Derived rather than a fixed number of pixels so that
# enlarging the stick's rect enlarges the ring the player actually sees -
# otherwise the touch area grows and the visible control stays small.
const RADIUS_RATIO: float = 0.28
@export var max_radius: float = 90.0
@export var deadzone: float = 0.12
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var core_color: Color = Color(1.0, 1.0, 1.0)
var direction: Vector2 = Vector2.ZERO
var active_touch_index: int = -1
var base_pos: Vector2 = Vector2.ZERO
var knob_pos: Vector2 = Vector2.ZERO
func _ready() -> void:
resized.connect(_relayout)
_relayout()
func _relayout() -> void:
base_pos = size / 2.0
knob_pos = base_pos
max_radius = minf(size.x, size.y) * RADIUS_RATIO
queue_redraw()
func is_active() -> bool:
return active_touch_index != -1
# Returns true if the event was consumed, so the caller knows not to treat
# it as a world gesture (a fleet move, or one finger of a pinch).
func handle_touch(event: InputEventScreenTouch) -> bool:
var local: Vector2 = event.position - global_position
if event.pressed:
if active_touch_index != -1:
return false
if not Rect2(Vector2.ZERO, size).has_point(local):
return false
active_touch_index = event.index
base_pos = local
knob_pos = local
_update_direction()
return true
if event.index != active_touch_index:
return false
active_touch_index = -1
knob_pos = base_pos
direction = Vector2.ZERO
queue_redraw()
direction_changed.emit(direction)
released.emit()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if event.index != active_touch_index:
return false
knob_pos = event.position - global_position
_update_direction()
return true
func _update_direction() -> void:
var offset: Vector2 = knob_pos - base_pos
if offset.length() > max_radius:
offset = offset.normalized() * max_radius
knob_pos = base_pos + offset
var raw: Vector2 = offset / max_radius
direction = Vector2.ZERO if raw.length() < deadzone else raw
queue_redraw()
direction_changed.emit(direction)
func _draw() -> void:
var ring: PackedVector2Array = PackedVector2Array()
var segments: int = 28
for i in range(segments):
var a: float = (float(i) / segments) * TAU
ring.append(base_pos + Vector2(cos(a), sin(a)) * max_radius)
NeonDraw.draw_glowing_polygon(self, ring, glow_color, 0.55, core_color)
var knob: PackedVector2Array = PackedVector2Array()
var knob_radius: float = max_radius * 0.34
for i in range(segments):
var a: float = (float(i) / segments) * TAU
knob.append(knob_pos + Vector2(cos(a), sin(a)) * knob_radius)
NeonDraw.draw_glowing_polygon(self, knob, glow_color, 1.0 if is_active() else 0.75, core_color)

1
TouchJoystick.gd.uid Normal file
View file

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

31
ViewUtils.gd Normal file
View file

@ -0,0 +1,31 @@
class_name ViewUtils
extends RefCounted
# Computes the exact world-space rect currently visible on screen by
# transforming the viewport's real screen corners through the actual canvas
# transform, instead of approximating with viewport_size * camera.zoom
# (which assumes a simple centered mapping - stretch/aspect="expand" doesn't
# necessarily expand symmetrically around center, which was making that
# approximation under/over-shoot on one side and cull content that was
# actually on screen).
static func get_visible_world_rect(node: Node) -> Rect2:
var canvas_transform: Transform2D = node.get_viewport().get_canvas_transform()
var inverse: Transform2D = canvas_transform.affine_inverse()
var size: Vector2 = node.get_viewport_rect().size
var corners: Array = [
inverse * Vector2(0, 0),
inverse * Vector2(size.x, 0),
inverse * Vector2(0, size.y),
inverse * Vector2(size.x, size.y),
]
var min_pt: Vector2 = corners[0]
var max_pt: Vector2 = corners[0]
for c in corners:
min_pt.x = min(min_pt.x, c.x)
min_pt.y = min(min_pt.y, c.y)
max_pt.x = max(max_pt.x, c.x)
max_pt.y = max(max_pt.y, c.y)
return Rect2(min_pt, max_pt - min_pt)

1
ViewUtils.gd.uid Normal file
View file

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

90
WorldGen.gd Normal file
View file

@ -0,0 +1,90 @@
class_name WorldGen
extends RefCounted
# Deterministic procedural world generation: the world is divided into
# square chunks, and each chunk's biome is derived from a seeded
# low-frequency noise field sampled at the chunk's own coordinates - so
# revisiting the same chunk always yields the same biome, and biomes form
# smooth multi-chunk regions instead of a per-chunk random checkerboard.
enum Biome { SPACE, ASTEROID_FIELD }
const CHUNK_SIZE: float = 1200.0
const NOISE_SEED: int = 1337
const ASTEROID_FIELD_THRESHOLD: float = 0.15
const PLANET_THRESHOLD: float = 0.33
const PLANET_NAMES: Array = [
"Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold",
"Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry",
"Baros Rock", "Umbra Point",
]
static var _noise: FastNoiseLite
static var _planet_noise: FastNoiseLite
static func _get_noise() -> FastNoiseLite:
if _noise == null:
_noise = FastNoiseLite.new()
_noise.seed = NOISE_SEED
_noise.frequency = 0.06
return _noise
static func _get_planet_noise() -> FastNoiseLite:
if _planet_noise == null:
_planet_noise = FastNoiseLite.new()
_planet_noise.seed = NOISE_SEED + 777
# Higher frequency than the biome noise so it spikes on individual
# chunks rather than forming its own smooth multi-chunk regions -
# "once or twice per asteroid biome [region]" needs a sparse, mostly
# independent chance per chunk, not another smooth blob.
_planet_noise.frequency = 0.35
return _planet_noise
static func chunk_coords(world_pos: Vector2) -> Vector2i:
return Vector2i(int(floor(world_pos.x / CHUNK_SIZE)), int(floor(world_pos.y / CHUNK_SIZE)))
static func chunk_origin(chunk: Vector2i) -> Vector2:
return Vector2(chunk.x * CHUNK_SIZE, chunk.y * CHUNK_SIZE)
static func biome_at_chunk(chunk: Vector2i) -> int:
var n: float = _get_noise().get_noise_2d(float(chunk.x), float(chunk.y))
if n > ASTEROID_FIELD_THRESHOLD:
return Biome.ASTEROID_FIELD
return Biome.SPACE
static func has_planet_at_chunk(chunk: Vector2i) -> bool:
if biome_at_chunk(chunk) != Biome.ASTEROID_FIELD:
return false
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
return n > PLANET_THRESHOLD
static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
# Single source of truth for a planet's identity within its chunk -
# seeded purely from the chunk's own coordinates (same pattern as the
# biome/planet noise fields above) so it's reproducible from anywhere,
# without needing an actual spawned Planet node. Both PlanetField (to
# actually spawn the planet) and the world map (to draw a marker for
# an explored-but-not-currently-loaded chunk) call this, so the two
# can never drift out of sync with each other.
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = hash(chunk)
var local_pos: Vector2 = Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()]
var shape_seed: int = rng.randi()
return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed}
static func biome_color(biome: int) -> Color:
match biome:
Biome.ASTEROID_FIELD:
return Color(0.55, 0.36, 0.18, 1.0)
_:
return Color(0.10, 0.13, 0.28, 1.0)

1
WorldGen.gd.uid Normal file
View file

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

93
WorldMap.gd Normal file
View file

@ -0,0 +1,93 @@
extends Control
@export var min_zoom: float = 0.3
@export var max_zoom: float = 3.0
@onready var content: Node2D = $MapContent
@onready var open_button: IconButton = get_node("../MapButton")
@onready var recenter_button: IconButton = $RecenterButton
@onready var close_button: IconButton = $CloseButton
@onready var fleet: Node2D = get_node("../../Fleet")
var touches: Dictionary = {}
var pinch_active: bool = false
var pinch_start_distance: float = 0.0
var pinch_start_zoom: float = 1.0
func _ready() -> void:
open_button.pressed.connect(open_map)
recenter_button.pressed.connect(recenter_view)
close_button.pressed.connect(close_map)
func open_map() -> void:
visible = true
open_button.visible = false
recenter_view()
func close_map() -> void:
visible = false
open_button.visible = true
touches.clear()
pinch_active = false
func recenter_view() -> void:
var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL
var viewport_center: Vector2 = get_viewport_rect().size / 2.0
content.position = viewport_center - fleet_local * content.scale.x
func _input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventScreenTouch:
_handle_touch(event)
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag:
_handle_drag(event)
get_viewport().set_input_as_handled()
elif event is InputEventMagnifyGesture:
_handle_magnify(event)
get_viewport().set_input_as_handled()
func _handle_touch(event: InputEventScreenTouch) -> void:
if event.pressed:
touches[event.index] = event.position
if touches.size() == 2:
pinch_active = true
var positions: Array = touches.values()
pinch_start_distance = positions[0].distance_to(positions[1])
pinch_start_zoom = content.scale.x
else:
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
func _handle_drag(event: InputEventScreenDrag) -> void:
if not touches.has(event.index):
return
var previous_pos: Vector2 = touches[event.index]
touches[event.index] = event.position
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
var positions: Array = touches.values()
var current_distance: float = positions[0].distance_to(positions[1])
var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0))
_set_zoom(new_zoom)
elif touches.size() == 1:
content.position += event.position - previous_pos
func _handle_magnify(event: InputEventMagnifyGesture) -> void:
_set_zoom(content.scale.x * event.factor)
func _set_zoom(z: float) -> void:
var clamped: float = clamp(z, min_zoom, max_zoom)
content.scale = Vector2(clamped, clamped)

1
WorldMap.gd.uid Normal file
View file

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

42
WorldMapContent.gd Normal file
View file

@ -0,0 +1,42 @@
extends Node2D
const WORLD_TO_LOCAL: float = 0.04
@onready var tracker: Node2D = get_node("../../../ExplorationTracker")
@onready var fleet: Node2D = get_node("../../../Fleet")
func _process(_delta: float) -> void:
if is_visible_in_tree():
queue_redraw()
func _draw() -> void:
var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL
for chunk in tracker.get_explored_chunks():
var origin_local: Vector2 = WorldGen.chunk_origin(chunk) * WORLD_TO_LOCAL
var biome: int = WorldGen.biome_at_chunk(chunk)
var color: Color = WorldGen.biome_color(biome)
var rect: Rect2 = Rect2(origin_local, Vector2(chunk_size_local, chunk_size_local))
draw_rect(rect, color, true)
draw_rect(rect, Color(0.0, 0.0, 0.0, 0.4), false, 1.5)
if WorldGen.has_planet_at_chunk(chunk):
# Same deterministic per-chunk data PlanetField spawns the
# actual Planet from - the marker stays put on the map even
# for a chunk whose Planet node isn't currently loaded (out of
# spawn_radius_chunks of the fleet).
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var planet_local: Vector2 = origin_local + data.local_pos * WORLD_TO_LOCAL
draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55))
draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true)
var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL
var marker_points: PackedVector2Array = PackedVector2Array([
fleet_local + Vector2(0, -10),
fleet_local + Vector2(7, 8),
fleet_local + Vector2(-7, 8),
])
draw_colored_polygon(marker_points, Color(0.3, 0.9, 1.0))
draw_polyline(PackedVector2Array([marker_points[0], marker_points[1], marker_points[2], marker_points[0]]), Color(1, 1, 1), 1.5, true)

1
WorldMapContent.gd.uid Normal file
View file

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

1
android/.build_version Normal file
View file

@ -0,0 +1 @@
4.7.1.stable

0
android/.gdignore Normal file
View file

271
export_presets.cfg Normal file
View file

@ -0,0 +1,271 @@
[runnable_presets]
Android="Android"
[preset.0]
name="Android"
platform="Android"
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="builds/SpaceshipTest.apk"
patches=PackedStringArray()
patch_delta_encoding=true
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
gradle_build/use_gradle_build=true
gradle_build/gradle_build_directory=""
gradle_build/android_source_template=""
gradle_build/compress_native_libraries=false
gradle_build/export_format=0
gradle_build/min_sdk=""
gradle_build/target_sdk=""
gradle_build/custom_theme_attributes={}
architectures/armeabi-v7a=false
architectures/arm64-v8a=true
architectures/x86=false
architectures/x86_64=false
version/code=1
version/name="1.0"
package/unique_name="com.spaceshiptest.game"
package/name="Spaceship Test"
package/signed=true
package/app_category=2
package/retain_data_on_uninstall=false
package/exclude_from_recents=false
package/show_in_android_tv=false
package/show_in_app_library=true
package/show_as_launcher_app=false
launcher_icons/main_192x192=""
launcher_icons/adaptive_foreground_432x432=""
launcher_icons/adaptive_background_432x432=""
launcher_icons/adaptive_monochrome_432x432=""
graphics/opengl_debug=false
shader_baker/enabled=false
xr_features/xr_mode=0
gesture/swipe_to_dismiss=false
screen/immersive_mode=true
screen/edge_to_edge=false
screen/support_small=true
screen/support_normal=true
screen/support_large=true
screen/support_xlarge=true
screen/background_color=Color(0, 0, 0, 1)
splash_screen/disable_godot_boot_splash=false
splash_screen/icon=""
splash_screen/branding_image=""
splash_screen/background_color=Color(0, 0, 0, 1)
user_data_backup/allow=false
command_line/extra_args=""
permissions/custom_permissions=PackedStringArray()
permissions/access_checkin_properties=false
permissions/access_coarse_location=false
permissions/access_fine_location=false
permissions/access_location_extra_commands=false
permissions/access_media_location=false
permissions/access_mock_location=false
permissions/access_network_state=false
permissions/access_surface_flinger=false
permissions/access_wifi_state=false
permissions/account_manager=false
permissions/add_voicemail=false
permissions/authenticate_accounts=false
permissions/battery_stats=false
permissions/bind_accessibility_service=false
permissions/bind_appwidget=false
permissions/bind_device_admin=false
permissions/bind_input_method=false
permissions/bind_nfc_service=false
permissions/bind_notification_listener_service=false
permissions/bind_print_service=false
permissions/bind_remoteviews=false
permissions/bind_text_service=false
permissions/bind_vpn_service=false
permissions/bind_wallpaper=false
permissions/bluetooth=false
permissions/bluetooth_admin=false
permissions/bluetooth_privileged=false
permissions/brick=false
permissions/broadcast_package_removed=false
permissions/broadcast_sms=false
permissions/broadcast_sticky=false
permissions/broadcast_wap_push=false
permissions/call_phone=false
permissions/call_privileged=false
permissions/camera=false
permissions/capture_audio_output=false
permissions/capture_secure_video_output=false
permissions/capture_video_output=false
permissions/change_component_enabled_state=false
permissions/change_configuration=false
permissions/change_network_state=false
permissions/change_wifi_multicast_state=false
permissions/change_wifi_state=false
permissions/clear_app_cache=false
permissions/clear_app_user_data=false
permissions/control_location_updates=false
permissions/delete_cache_files=false
permissions/delete_packages=false
permissions/device_power=false
permissions/diagnostic=false
permissions/disable_keyguard=false
permissions/dump=false
permissions/expand_status_bar=false
permissions/factory_test=false
permissions/flashlight=false
permissions/force_back=false
permissions/get_accounts=false
permissions/get_package_size=false
permissions/get_tasks=false
permissions/get_top_activity_info=false
permissions/global_search=false
permissions/hardware_test=false
permissions/inject_events=false
permissions/install_location_provider=false
permissions/install_packages=false
permissions/install_shortcut=false
permissions/internal_system_window=false
permissions/internet=false
permissions/kill_background_processes=false
permissions/location_hardware=false
permissions/manage_accounts=false
permissions/manage_app_tokens=false
permissions/manage_documents=false
permissions/manage_external_storage=false
permissions/manage_media=false
permissions/master_clear=false
permissions/media_content_control=false
permissions/modify_audio_settings=false
permissions/modify_phone_state=false
permissions/mount_format_filesystems=false
permissions/mount_unmount_filesystems=false
permissions/nfc=false
permissions/persistent_activity=false
permissions/post_notifications=false
permissions/process_outgoing_calls=false
permissions/read_calendar=false
permissions/read_call_log=false
permissions/read_contacts=false
permissions/read_external_storage=false
permissions/read_frame_buffer=false
permissions/read_history_bookmarks=false
permissions/read_input_state=false
permissions/read_logs=false
permissions/read_media_audio=false
permissions/read_media_images=false
permissions/read_media_video=false
permissions/read_media_visual_user_selected=false
permissions/read_phone_state=false
permissions/read_profile=false
permissions/read_sms=false
permissions/read_social_stream=false
permissions/read_sync_settings=false
permissions/read_sync_stats=false
permissions/read_user_dictionary=false
permissions/reboot=false
permissions/receive_boot_completed=false
permissions/receive_mms=false
permissions/receive_sms=false
permissions/receive_wap_push=false
permissions/record_audio=false
permissions/reorder_tasks=false
permissions/restart_packages=false
permissions/send_respond_via_message=false
permissions/send_sms=false
permissions/set_activity_watcher=false
permissions/set_alarm=false
permissions/set_always_finish=false
permissions/set_animation_scale=false
permissions/set_debug_app=false
permissions/set_orientation=false
permissions/set_pointer_speed=false
permissions/set_preferred_applications=false
permissions/set_process_limit=false
permissions/set_time=false
permissions/set_time_zone=false
permissions/set_wallpaper=false
permissions/set_wallpaper_hints=false
permissions/signal_persistent_processes=false
permissions/status_bar=false
permissions/subscribed_feeds_read=false
permissions/subscribed_feeds_write=false
permissions/system_alert_window=false
permissions/transmit_ir=false
permissions/uninstall_shortcut=false
permissions/update_device_stats=false
permissions/use_credentials=false
permissions/use_sip=false
permissions/vibrate=false
permissions/wake_lock=false
permissions/write_apn_settings=false
permissions/write_calendar=false
permissions/write_call_log=false
permissions/write_contacts=false
permissions/write_external_storage=false
permissions/write_gservices=false
permissions/write_history_bookmarks=false
permissions/write_profile=false
permissions/write_secure_settings=false
permissions/write_settings=false
permissions/write_sms=false
permissions/write_social_stream=false
permissions/write_sync_settings=false
permissions/write_user_dictionary=false
[preset.1]
name="Web"
platform="Web"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="builds/web/index.html"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=true
html/export_icon=true
html/custom_html_shell=""
html/head_include=""
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=true
html/progressive_web_app=false
html/progressive_web_app/display=1
html/progressive_web_app/offline_page=""
html/progressive_web_app/icon_144x144=""
html/progressive_web_app/icon_180x180=""
html/progressive_web_app/icon_512x512=""
html/progressive_web_app/orientation=0

4
icon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="20" fill="#05050f"/>
<polygon points="64,20 92,100 64,84 36,100" fill="none" stroke="#40d9ff" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

43
icon.svg.import Normal file
View file

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bwonxwqysm3es"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

212
lunar_surface.gdshader Normal file
View file

@ -0,0 +1,212 @@
shader_type canvas_item;
// Lunar/crater backdrop for the "Big Stable Asteroid" atmosphere. Reacts
// to camera position/zoom exactly like starfield.gdshader (world-space
// sampling from camera uniforms), so it parallaxes correctly as the fleet
// flies around instead of being a static picture.
//
// Structured as a Worley / cellular distance field, following the design
// of the "Floating Bubbles" shader on godotshaders.com: a neighbor scan
// reduces everything to ONE scalar - the normalized distance to the
// nearest crater - and every visual (floor darkness, rim light, rim
// shadow) is then derived from that single value.
//
// This single-field structure matters, and is the whole reason for the
// rewrite. The previous version accumulated three fields independently
// (floor via max() over all neighbors, rim light/shadow via a separate
// "strongest crater wins" test). Those could disagree: one crater's dark
// rim band could be composited over a different, nearer crater's floor,
// cutting a hard wrongly-shaped bite into it. Deriving everything from
// the nearest crater makes that class of bug structurally impossible -
// the floor and the rim always describe the same crater.
//
// Where two craters overlap, the nearest-crater field has a crease (normal
// for Worley noise) and the lighting direction flips across it, which drew
// a thin bright rim wedge through the middle of the merged basin. The
// second-nearest distance is tracked purely to detect that case and fade
// the rim out there - see the note further down.
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
uniform float camera_zoom = 1.0;
uniform vec2 viewport_size = vec2(720.0, 1280.0);
uniform float cell_size = 220.0;
// Sampled from a NASA Artemis lunar photograph: the regolith is a
// bright neutral grey with a very faint cool cast (blue a touch above
// red), craters read as mid-grey floors rather than black, and the lit
// rims are only slightly brighter than the surface.
uniform vec4 base_color : source_color = vec4(0.75, 0.75, 0.76, 1.0);
uniform vec4 crater_color : source_color = vec4(0.44, 0.44, 0.45, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.89, 0.89, 0.90, 1.0);
// DIAGNOSTIC ONLY (set in PlanetSurface.tscn for a debug build; 0 = off).
// 1 = tint alternate cells, to show where cell boundaries fall.
// 2 = output the raw normalized distance field as grayscale, with NO
// rim, lighting, dither or tint. This bisects the shader: the field
// and the shading built on top of it are independent halves, so if
// cuts appear in this grayscale output the FIELD is at fault, and if
// it is smooth the field is fine and the SHADING is at fault.
uniform float debug_mode = 0.0;
const vec2 LIGHT_DIR = vec2(-0.6, -0.8);
const float MIN_RADIUS = 0.16;
const float MAX_RADIUS = 0.38;
// Sentinel for "this cell holds no crater". Must stay well inside mediump
// range (max ~65504) so it can never become +Inf - see the note in the
// loop. Only needs to exceed the largest real normalized distance (~18).
const float MISS_DISTANCE = 1000.0;
// Hash pair chosen for precision safety on mobile GPUs. The previous
// version multiplied the cell coordinate by ~123/456 before fract(), which
// lands the intermediate in the thousands where a mobile float loses its
// fractional bits entirely. These multipliers are all < 1, so every
// intermediate stays small no matter how large the cell index gets.
// (Standard "hash without sine" formulation, Dave Hoskins.)
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash22(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
// Position is expressed RELATIVE TO THE CAMERA'S CELL, never as an
// absolute world coordinate. camera_frac is the camera's offset inside
// its own cell (always in [0,1]) and `centered` spans at most a few
// cells, so every value here stays small and exactly representable even
// on a mobile GPU - whereas an absolute world position reaches the tens
// of thousands, where float precision degrades and no `highp` qualifier
// can help (it widens the type, but the magnitude is the problem).
// GDScript does the large-number split in double precision; see
// LunarBackground.gd / Starfield.gd.
//
// cell and local also MUST come from one shared value, with local
// derived as (rel - cell_rel) rather than an independent fract():
// calling floor(x) and fract(x) separately lets the GPU round the two
// evaluations differently, which right at a cell boundary makes them
// disagree by a full cell and snaps the sampled point one cell sideways.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
// Absolute cell index, used only for hashing. Built from exact integers.
vec2 cell = camera_cell + cell_rel;
// Single scalar field: normalized distance to the nearest crater.
// < 1.0 means this pixel is inside that crater; 1.0 is exactly on its
// edge. Normalizing by each crater's own radius is what lets craters
// vary in size while still collapsing to one comparable number.
//
// The SECOND-nearest crater is tracked alongside the nearest, purely so
// the rim can be suppressed inside overlaps. Where two craters overlap,
// the nearest-crater test flips along the line between them; the
// lighting direction flips with it and paints a thin bright rim wedge
// through the middle of what should read as one merged depression.
// A pixel in that region is inside BOTH craters, i.e. second_nearest is
// also < 1.0 - which is precisely the condition to fade the rim out.
float nearest = MISS_DISTANCE;
float second_nearest = MISS_DISTANCE;
vec2 nearest_dir = vec2(0.0, 1.0);
// 5x5 rather than 3x3. A crater's radius is at most MAX_RADIUS cells,
// so in theory 3x3 covers every crater that could possibly reach this
// pixel - but on-device, craters were still being clipped exactly at
// cell boundaries, which means some crater's influence was reaching
// further than the scan did (e.g. a hash returning a value outside its
// nominal range on that GPU, inflating a radius). Scanning two cells
// out costs more samples but makes the clip impossible to reach even
// if a radius comes out several times larger than intended.
for (int dx = -2; dx <= 2; dx++) {
for (int dy = -2; dy <= 2; dy++) {
vec2 offset = vec2(float(dx), float(dy));
highp vec2 neighbor_cell = cell + offset;
// Cells without a crater are pushed far away rather than
// branched around, keeping the loop body uniform. NOTE the
// sentinel is deliberately a small number, not something like
// 1e9: mediump floats top out around 65504, so a huge literal
// becomes +Inf there, and (1.0 - has_crater) * Inf evaluates to
// 0 * Inf = NaN for every cell that DOES hold a crater - which
// compares false against everything and would silently discard
// every crater. MISS_DISTANCE only has to beat the largest real
// normalized distance (~2.9 cells / 0.16 min radius ~= 18).
float has_crater = step(0.35, hash21(neighbor_cell + vec2(41.0, 5.0)));
vec2 crater_center = offset + hash22(neighbor_cell);
vec2 to_sample = local - crater_center;
float dist = length(to_sample);
// clamp() guards against a hash landing outside [0,1] on some
// driver and extrapolating mix() into a huge radius.
float radius = clamp(mix(MIN_RADIUS, MAX_RADIUS, hash21(neighbor_cell + vec2(9.0, 2.0))), MIN_RADIUS, MAX_RADIUS);
float normalized = dist / radius + (1.0 - has_crater) * MISS_DISTANCE;
if (normalized < nearest) {
second_nearest = nearest;
nearest = normalized;
// Direction from the winning crater's centre to this pixel,
// used for the rim's light/shadow split. Taken from the SAME
// crater that won the distance test, so the shading can never
// describe one crater while the floor describes another.
nearest_dir = dist > 0.0001 ? to_sample / dist : vec2(0.0, 1.0);
} else if (normalized < second_nearest) {
second_nearest = normalized;
}
}
}
vec2 light_dir = normalize(LIGHT_DIR);
float facing = dot(nearest_dir, light_dir);
// Everything below is a pure function of `nearest` (plus the winning
// crater's direction), so all three visuals stay mutually consistent.
float floor_amount = 1.0 - smoothstep(0.45, 0.9, nearest);
// Thin raised-rim band right at the crater's edge, split into a lit
// half (facing LIGHT_DIR) and a shadowed half (facing away), giving a
// sense of a raised lip catching light.
float rim_band = smoothstep(0.55, 0.85, nearest) * (1.0 - smoothstep(0.85, 1.0, nearest));
// Fade the rim out where this pixel also lies inside a second crater:
// that is the overlap region, where a rim would be an internal crease
// rather than a real outer lip. Craters there merge into one basin.
rim_band *= smoothstep(0.85, 1.15, second_nearest);
float rim_light = rim_band * clamp(facing, 0.0, 1.0);
float rim_shadow = rim_band * clamp(-facing, 0.0, 1.0);
vec3 color = base_color.rgb;
color = mix(color, crater_color.rgb, floor_amount * 0.85);
color = mix(color, highlight_color.rgb, rim_light * 0.6);
color = mix(color, crater_color.rgb * 0.7, rim_shadow * 0.5);
// Tiny per-pixel dither to mask 8-bit colour-quantization banding: the
// crater shading is a smooth gradient over a fairly large area, which
// can visibly "step" into faint bands on real hardware. A small random
// jitter well under one visible step breaks the stepping up without
// being perceptible as noise.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
// Diagnostic checkerboard: alternate cells tinted, plus a bright line
// right at each cell edge. Makes the grid unmistakable in a screenshot.
if (debug_mode > 0.5 && debug_mode < 1.5) {
float checker = mod(cell.x + cell.y, 2.0);
color = mix(color, vec3(0.10, 0.30, 0.55), checker * 0.35);
vec2 edge = min(local, 1.0 - local);
float on_edge = 1.0 - smoothstep(0.0, 0.012, min(edge.x, edge.y));
color = mix(color, vec3(1.0, 0.25, 0.25), on_edge * 0.9);
}
// Bisection output: the field alone, with none of the shading above
// applied. Godot forbids `return` inside fragment(), so this overrides
// the computed colour rather than exiting early.
if (debug_mode > 1.5) {
color = vec3(clamp(nearest / 2.0, 0.0, 1.0));
}
COLOR = vec4(color, 1.0);
}

View file

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

42
project.godot Normal file
View file

@ -0,0 +1,42 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Spaceship Test"
run/main_scene="res://Main.tscn"
config/features=PackedStringArray("4.7")
config/icon="res://icon.svg"
[autoload]
GameState="*res://GameState.gd"
[display]
window/size/viewport_width=720
window/size/viewport_height=1280
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
window/handheld/orientation="portrait"
[gui]
theme/custom="res://ui_theme.tres"
theme/default_font_size=78
[input_devices]
pointing/emulate_touch_from_mouse=true
[rendering]
renderer/rendering_method="gl_compatibility"
environment/defaults/default_clear_color=Color(0.02, 0.02, 0.08, 1)

125
starfield.gdshader Normal file
View file

@ -0,0 +1,125 @@
shader_type canvas_item;
// Procedural infinite starfield. Reconstructs a world-space coordinate for
// every screen pixel from the camera's current position/zoom (the exact
// same mapping used for every other object in the scene - ships, enemies,
// etc.), then hashes a grid cell in world space to decide whether that
// cell holds a star, and how big/bright it is. Cost is one fragment
// shader pass over the screen - constant regardless of zoom level or how
// many stars are "visible", with zero per-star CPU bookkeeping.
//
// Star size is a fixed WORLD-space radius (not compensated for zoom), so
// stars shrink/grow exactly in sync with ships and everything else when
// the camera zooms - zooming out reveals more of the field (unavoidable -
// a wider view always shows more of a spatially spread-out field) but
// each star also gets visibly smaller, so it still reads as "zoomed out"
// consistently with the foreground instead of feeling mismatched.
// Stars are found with a 3x3-neighbor cellular lookup: each pixel checks
// its own grid cell AND all 8 neighbors for a nearby star center. Checking
// only the pixel's own cell (which this shader did originally) hard-clips
// any star whose glow crosses a cell boundary, leaving a star with sharp
// straight edges on one or two sides and normal soft falloff on the others
// - which is exactly the artifact that was reported on-device here, and
// the reason the same technique was already used in lunar_surface.gdshader.
//
// The camera position arrives pre-split into a cell index plus a fraction
// within that cell, so this shader never handles an absolute world
// coordinate (which reaches the tens of thousands once the fleet has
// explored, where mobile float precision degrades). See Starfield.gd.
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
uniform float camera_zoom = 1.0;
uniform vec2 viewport_size = vec2(720.0, 1280.0);
uniform float cell_size = 60.0;
uniform vec4 bg_color : source_color = vec4(0.02, 0.02, 0.08, 1.0);
// DIAGNOSTIC ONLY - see the same uniform in lunar_surface.gdshader.
uniform float debug_mode = 0.0;
// Hash pair chosen for precision safety on mobile GPUs. The previous
// version multiplied the cell coordinate by ~123/456 before fract(), which
// lands the intermediate in the thousands where a mobile float loses its
// fractional bits entirely. These multipliers are all < 1, so every
// intermediate stays small no matter how large the cell index gets.
// (Standard "hash without sine" formulation, Dave Hoskins.)
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash22(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
// Position is expressed RELATIVE TO THE CAMERA'S CELL, never as an
// absolute world coordinate. camera_frac is the camera's offset inside
// its own cell (always in [0,1]) and `centered` spans at most a few
// cells, so every value here stays small and exactly representable even
// on a mobile GPU - whereas an absolute world position reaches the tens
// of thousands, where float precision degrades and no `highp` qualifier
// can help (it widens the type, but the magnitude is the problem).
// GDScript does the large-number split in double precision; see
// LunarBackground.gd / Starfield.gd.
//
// cell and local also MUST come from one shared value, with local
// derived as (rel - cell_rel) rather than an independent fract():
// calling floor(x) and fract(x) separately lets the GPU round the two
// evaluations differently, which right at a cell boundary makes them
// disagree by a full cell and snaps the sampled point one cell sideways.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
// Absolute cell index, used only for hashing. Built from exact integers.
vec2 cell = camera_cell + cell_rel;
float star = 0.0;
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
vec2 offset = vec2(float(dx), float(dy));
highp vec2 neighbor_cell = cell + offset;
float has_star = step(0.86, hash21(neighbor_cell + vec2(99.0, 7.0)));
// Star center expressed in THIS pixel's cell-local space, so a
// star living in a neighboring cell is still measured correctly
// from here instead of being clipped away at the cell edge.
vec2 star_center = offset + hash22(neighbor_cell);
float dist = length(local - star_center);
// Fixed world-space size (fraction of cell) - no zoom
// compensation, so it scales exactly like any other world object
// when the camera zooms.
float size = mix(0.12, 0.22, hash21(neighbor_cell + vec2(3.0, 5.0)));
float brightness = mix(0.35, 1.0, hash21(neighbor_cell + vec2(11.0, 13.0)));
// smoothstep() edges must be given with edge0 < edge1 - GLSL
// leaves the reversed-edge case undefined, and different GPUs are
// free to compute something else for it. Use correctly-ordered
// edges and invert the result arithmetically instead of swapping
// the edges.
float s = has_star * (1.0 - smoothstep(0.0, size, dist)) * brightness;
star = max(star, s);
}
}
vec3 out_color = bg_color.rgb + vec3(star);
// Diagnostic checkerboard - makes cell boundaries directly visible so a
// screenshot can settle whether the reported cuts lie on them.
if (debug_mode > 0.5 && debug_mode < 1.5) {
float checker = mod(cell.x + cell.y, 2.0);
out_color = mix(out_color, vec3(0.10, 0.30, 0.55), checker * 0.35);
vec2 edge = min(local, 1.0 - local);
float on_edge = 1.0 - smoothstep(0.0, 0.012, min(edge.x, edge.y));
out_color = mix(out_color, vec3(1.0, 0.25, 0.25), on_edge * 0.9);
}
COLOR = vec4(out_color, 1.0);
}

1
starfield.gdshader.uid Normal file
View file

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

View file

@ -0,0 +1,31 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
# build menu and shows a building's info popup, so the panel layout can be
# checked with real text in it. Uses the mothership-already-exists case on
# purpose: that is the longest message any option produces, so if it fits,
# the others do.
#
# bash tests/screenshot.sh res://tests/_build_popup_preview.tscn /tmp/out.png 40
const SHOW_POPUP: bool = true
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.rocks = 500
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-900.0, 0.0))
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._on_build_pressed()
# Flip SHOW_POPUP to check the menu underneath
# instead of the popup that covers it.
if SHOW_POPUP:
controller._on_place_mothership()

Some files were not shown because too many files have changed in this diff Show more