Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross

DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.

TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.

HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.

LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.

RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.

ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.

Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().

ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".

MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.

Full suite green at 99/99.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-18 16:54:46 +02:00
commit 7817d1ffc6
48 changed files with 897 additions and 159 deletions

View file

@ -11,6 +11,8 @@ max_hp = 8
speed = 60.0 speed = 60.0
acceleration = 120.0 acceleration = 120.0
rotation_speed = 3.0 rotation_speed = 3.0
hull_points = PackedVector2Array(0, -26, 18.4, -18.4, 26, 0, 18.4, 18.4, 0, 26, -18.4, 18.4, -26, 0, -18.4, -18.4) hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22)
glow_color = Color(1.0, 0.5, 0.2, 1) glow_color = Color(1.0, 0.5, 0.2, 1)
detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6)
detail_rings = PackedVector2Array(0, -20, 8, 0)
component_scenes = Array[PackedScene]([ExtResource("2")]) component_scenes = Array[PackedScene]([ExtResource("2")])

View file

@ -1,11 +1,11 @@
class_name ArtilleryShell class_name ArtilleryShell
extends Node2D extends Node2D
# Self-contained telegraph -> travel -> explode sequence for the artillery # Self-contained travel -> explode sequence for the artillery enemy's attack
# enemy's attack (see EnemyArtilleryComponent). Lives independently of the # (see EnemyArtilleryComponent). Lives independently of the
# enemy that fired it once launched - added to the tree root, exactly like # enemy that fired it once launched - added to the tree root, exactly like
# EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't # EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't
# cancel a shot already telegraphed to the player. # cancel a shell already in the air.
# #
# Shield interaction: a shield charge absorbs this hit entirely, same # Shield interaction: a shield charge absorbs this hit entirely, same
# all-or-nothing semantics as every other damage source in this game # all-or-nothing semantics as every other damage source in this game
@ -14,20 +14,26 @@ extends Node2D
signal finished signal finished
@export var telegraph_time: float = 1.8 # The tower fires as soon as it decides to; the warning is the shell you can
# SEE coming and the cross marking where it will land. There used to be a
# 1.8s charge-up before anything left the barrel, which arrived long before
# the threat was legible and mostly read as the tower being stuck.
@export var shell_speed: float = 180.0 @export var shell_speed: float = 180.0
@export var aoe_radius: float = 220.0 @export var aoe_radius: float = 220.0
@export var damage: int = 3 @export var damage: int = 3
@export var color: Color = Color(1.0, 0.5, 0.2) @export var color: Color = Color(1.0, 0.5, 0.2)
enum Phase { TELEGRAPHING, TRAVELING, EXPLODING } enum Phase { TRAVELING, EXPLODING }
const EXPLOSION_DURATION: float = 0.8 const EXPLOSION_DURATION: float = 0.8
# The impact cross. Small on purpose: it says WHERE, and the shell in flight
# already says that something is coming. A full blast-radius ring here read
# as an attack that had already happened.
const IMPACT_CROSS_RADIUS: float = 20.0
var phase: int = Phase.TELEGRAPHING var phase: int = Phase.TRAVELING
var target: Vector2 = Vector2.ZERO var target: Vector2 = Vector2.ZERO
var shell_pos: Vector2 = Vector2.ZERO var shell_pos: Vector2 = Vector2.ZERO
var telegraph_left: float = 0.0
var explosion_time: float = 0.0 var explosion_time: float = 0.0
var trail: PackedVector2Array = PackedVector2Array() var trail: PackedVector2Array = PackedVector2Array()
@ -39,20 +45,14 @@ func _ready() -> void:
func launch(from: Vector2, at: Vector2) -> void: func launch(from: Vector2, at: Vector2) -> void:
target = at target = at
shell_pos = from shell_pos = from
telegraph_left = telegraph_time phase = Phase.TRAVELING
phase = Phase.TELEGRAPHING trail = PackedVector2Array([shell_pos])
position = target position = shell_pos
queue_redraw() queue_redraw()
func _process(delta: float) -> void: func _process(delta: float) -> void:
match phase: match phase:
Phase.TELEGRAPHING:
telegraph_left -= delta
if telegraph_left <= 0.0:
phase = Phase.TRAVELING
trail = PackedVector2Array([shell_pos])
queue_redraw()
Phase.TRAVELING: Phase.TRAVELING:
var to_target: Vector2 = target - shell_pos var to_target: Vector2 = target - shell_pos
var step: float = shell_speed * delta var step: float = shell_speed * delta
@ -85,15 +85,10 @@ func _explode() -> void:
func _draw() -> void: func _draw() -> void:
match phase: match phase:
Phase.TELEGRAPHING:
var progress: float = clampf(1.0 - telegraph_left / maxf(telegraph_time, 0.001), 0.0, 1.0)
# Growing ring plus a filling countdown arc: the warning reads
# clearly without needing a number on screen.
var radius: float = lerpf(24.0, aoe_radius, progress)
_draw_ring(radius, 0.7)
draw_arc(Vector2.ZERO, aoe_radius + 14.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 48,
Color(color, 0.85), 8.0, true)
Phase.TRAVELING: Phase.TRAVELING:
# Where it will land, drawn on the ground. Local coordinates,
# because this node rides along with the shell while it flies.
_draw_impact_cross(target - shell_pos)
for i in range(1, trail.size()): for i in range(1, trail.size()):
var alpha: float = float(i) / trail.size() var alpha: float = float(i) / trail.size()
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true) draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true)
@ -106,6 +101,18 @@ func _draw() -> void:
_draw_ring(eradius, 1.0 - eprogress) _draw_ring(eradius, 1.0 - eprogress)
# A small cross where the shell will land, plus a dot at the exact point.
# Deliberately not a filled circle the size of the blast: the player needs to
# read "move off this spot" at a glance, and a large translucent area was
# easy to mistake for the explosion itself.
func _draw_impact_cross(at: Vector2) -> void:
var r: float = IMPACT_CROSS_RADIUS
for offset in [Vector2(r, r), Vector2(r, -r)]:
draw_line(at - offset, at + offset, Color(color, 0.35), 7.0, true)
draw_line(at - offset, at + offset, Color(1.0, 0.92, 0.86, 0.85), 2.5, true)
draw_arc(at, r * 0.32, 0.0, TAU, 20, Color(color, 0.9), 2.5, true)
func _draw_ring(radius: float, alpha: float) -> void: func _draw_ring(radius: float, alpha: float) -> void:
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true)
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true)

View file

@ -62,6 +62,8 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
@onready var hangar_button: IconButton = get_node("../UI/HangarButton") @onready var hangar_button: IconButton = get_node("../UI/HangarButton")
@onready var hangar_menu: Control = get_node("../UI/HangarMenu") @onready var hangar_menu: Control = get_node("../UI/HangarMenu")
@onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel") @onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel")
@onready var hangar_available_title: Label = get_node("../UI/HangarMenu/AvailableTitle")
@onready var hangar_fleet_title: Label = get_node("../UI/HangarMenu/FleetTitle")
@onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton") @onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton")
@onready var hangar_available_slots: Array = [ @onready var hangar_available_slots: Array = [
get_node("../UI/HangarMenu/AvailableInterceptor"), get_node("../UI/HangarMenu/AvailableInterceptor"),
@ -247,6 +249,7 @@ func _seat_hud_columns() -> void:
func _ready() -> void: func _ready() -> void:
_seat_hud_columns() _seat_hud_columns()
_layout_hangar()
build_button.pressed.connect(_on_build_pressed) build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu) build_close_button.pressed.connect(_close_build_menu)
_configure_build_menu_entries() _configure_build_menu_entries()
@ -1223,6 +1226,75 @@ func _hangar_types() -> Array:
return types return types
# How many vessel types sit side by side in the hangar. Six in a single row
# gave each one about 100px while its two-line label needs 155, so the names
# ran into each other - "InterceptSheld VessGas Bomber". Three columns give
# each cell room, and the second row costs nothing: the lower half of the
# menu was empty.
const HANGAR_COLUMNS: int = 3
# Arranges both hangar sections as grids sized from the space actually
# available, rather than from the single row the scene authored. A seventh
# vessel type now wraps instead of squeezing the other six.
func _layout_hangar() -> void:
var count: int = hangar_available_slots.size()
if count == 0:
return
var width: float = hangar_menu.size.x
var columns: int = mini(HANGAR_COLUMNS, count)
var rows: int = int(ceil(float(count) / float(columns)))
# The band between the first section's heading and the status line under
# both of them. Derived, so moving either in the scene moves the grids.
var top: float = hangar_available_title.position.y + hangar_available_title.size.y + 8.0
var bottom: float = hangar_status.position.y - 12.0
var title_height: float = hangar_fleet_title.size.y
var section_height: float = (bottom - top - title_height - 16.0) * 0.5
if section_height <= 0.0:
return
var cell_width: float = width / float(columns)
var cell_height: float = section_height / float(rows)
# Squarer than the authored 75x120: a grid cell is wider than it is tall
# here, so height is what constrains the icon.
var slot_size: float = minf(cell_height * 0.58, cell_width * 0.42)
var label_height: float = maxf(cell_height - slot_size - 6.0, 1.0)
_layout_hangar_section(hangar_available_slots, hangar_available_labels,
top, columns, cell_width, cell_height, slot_size, label_height, width)
# AFTER the first section's last row, not before it. Placing the heading
# by subtracting from the second section's top put it back inside the
# first one, straight over its bottom row.
var available_bottom: float = top + section_height
hangar_fleet_title.position.y = available_bottom + 4.0
var fleet_top: float = available_bottom + title_height + 8.0
_layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels,
fleet_top, columns, cell_width, cell_height, slot_size, label_height, width)
func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int,
cell_width: float, cell_height: float, slot_size: float,
label_height: float, width: float) -> void:
for i in range(slots.size()):
var column: int = i % columns
var row: int = i / columns
# Control.position is in PARENT space, whatever the node's anchors
# are - the authored offsets being negative (anchored to the menu's
# centre) does not make position centre-relative too.
var cell_x: float = float(column) * cell_width
var cell_y: float = top + float(row) * cell_height
var slot: Control = slots[i]
slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y)
slot.size = Vector2(slot_size, slot_size)
var label: Label = labels[i]
label.position = Vector2(cell_x, cell_y + slot_size + 4.0)
label.size = Vector2(cell_width, label_height)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
func _refresh_hangar() -> void: func _refresh_hangar() -> void:
var types: Array = _hangar_types() var types: Array = _hangar_types()
for i in range(types.size()): for i in range(types.size()):

View file

@ -60,9 +60,13 @@ func take_damage(amount: int) -> void:
func _draw() -> void: func _draw() -> void:
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([ # Drawn through EnemyDefinitions.draw_definition, the same routine the
# map editor's markers and palette use, so a definition cannot end up
# looking one way where it is placed and another where it is fought.
if definition == null:
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
]) ]), Color(1.0, 0.15, 0.2), 1.0)
var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2) return
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw. # Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
NeonDraw.draw_glowing_polygon(self, points, glow, 1.0) ENEMY_DEFINITIONS.draw_definition(self, definition, definition.glow_color)

View file

@ -3,7 +3,7 @@ extends Node
# Stationary siege piece: drifts slowly toward the fleet (deliberately no # Stationary siege piece: drifts slowly toward the fleet (deliberately no
# pursuit component - closing distance is incidental, not its purpose) and # pursuit component - closing distance is incidental, not its purpose) and
# fires a slow, heavily telegraphed AOE shell on a long cooldown via a # fires a slow, clearly visible AOE shell on a long cooldown via a
# standalone ArtilleryShell, so an already-launched shot survives this # standalone ArtilleryShell, so an already-launched shot survives this
# enemy dying mid-flight, same as EnemyProjectile/Projectile already do. # enemy dying mid-flight, same as EnemyProjectile/Projectile already do.
@ -11,7 +11,6 @@ var capability_id: String = "artillery"
@export var fire_cooldown: float = 6.0 @export var fire_cooldown: float = 6.0
@export var fire_range: float = 900.0 @export var fire_range: float = 900.0
@export var telegraph_time: float = 1.8
@export var shell_speed: float = 180.0 @export var shell_speed: float = 180.0
@export var aoe_radius: float = 220.0 @export var aoe_radius: float = 220.0
@export var damage: int = 3 @export var damage: int = 3
@ -38,7 +37,6 @@ func tick(delta: float) -> void:
return return
var shell: Node2D = shell_scene.instantiate() var shell: Node2D = shell_scene.instantiate()
shell.telegraph_time = telegraph_time
shell.shell_speed = shell_speed shell.shell_speed = shell_speed
shell.aoe_radius = aoe_radius shell.aoe_radius = aoe_radius
shell.damage = damage shell.damage = damage

View file

@ -16,4 +16,15 @@ extends Resource
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
]) ])
@export var glow_color: Color = Color(1.0, 0.15, 0.2) @export var glow_color: Color = Color(1.0, 0.15, 0.2)
# Interior detail drawn on top of the hull, as consecutive PAIRS of points
# (a, b, c, d -> segments a-b and c-d). Every enemy used to be a single flat
# outline, which left the artillery and the turret spawner as the same
# octagon at two sizes - the silhouette carried no information about what a
# thing did. Kept as DATA rather than a per-type draw branch, for the same
# reason hull_points is: adding an enemy must not mean editing Enemy.gd.
@export var detail_lines: PackedVector2Array = PackedVector2Array()
# Rings, as triples of (centre x, centre y, radius). Sensor domes, muzzles
# and bay openings all read better as circles than as polygon chords.
@export var detail_rings: PackedVector2Array = PackedVector2Array()
@export var core_color: Color = Color(0.06, 0.02, 0.03)
@export var component_scenes: Array[PackedScene] = [] @export var component_scenes: Array[PackedScene] = []

View file

@ -12,6 +12,31 @@ const DEFINITIONS: Dictionary = {
} }
# The single place that knows what an enemy LOOKS like. Three things draw
# enemies - the enemy itself, the map editor's markers, and the editor's
# palette buttons - and each used to reimplement it from hull_points alone.
# Adding interior detail to the definitions therefore showed up in the game
# and nowhere else, so an editor marker no longer matched the ship it placed.
#
# `at` and `scale` exist because the palette draws into a small button
# centred on itself, while the other two draw around their own origin at
# full size.
static func draw_definition(item: CanvasItem, definition: Resource, glow: Color,
scale: float = 1.0, at: Vector2 = Vector2.ZERO) -> void:
if definition == null:
return
var core: Color = definition.core_color
item.draw_set_transform(at, 0.0, Vector2(scale, scale))
NeonDraw.draw_glowing_polygon(item, definition.hull_points, glow, 1.0, core)
var lines: PackedVector2Array = definition.detail_lines
for i in range(0, lines.size() - 1, 2):
NeonDraw.draw_glowing_line(item, lines[i], lines[i + 1], glow, 0.85, 0.55, core)
var rings: PackedVector2Array = definition.detail_rings
for i in range(0, rings.size() - 1, 2):
NeonDraw.draw_glowing_circle(item, rings[i], rings[i + 1].x, glow, 0.85, core)
item.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
static func get_definition(enemy_id: String) -> Resource: static func get_definition(enemy_id: String) -> Resource:
return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID]) return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID])

19
EnemyMarker.gd Normal file
View file

@ -0,0 +1,19 @@
class_name EnemyMarker
extends Node2D
# An enemy as the map editor shows it. Exists so the editor can draw a
# spawn through EnemyDefinitions.draw_definition - the same routine the
# real enemy uses - instead of reimplementing it from hull_points, which is
# how the two drifted apart in the first place.
#
# glow_color is separate from the definition's own so the editor can tint a
# selected marker without touching the definition.
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
var definition: Resource
var glow_color: Color = Color(1.0, 0.15, 0.2)
func _draw() -> void:
ENEMY_DEFINITIONS.draw_definition(self, definition, glow_color)

1
EnemyMarker.gd.uid Normal file
View file

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

View file

@ -96,6 +96,9 @@ func _sync_composition_state() -> void:
# Composition changes are authoritative; scene teardown is not. Keeping # Composition changes are authoritative; scene teardown is not. Keeping
# this out of SpeedFader prevents an outgoing scene's empty fleet from # this out of SpeedFader prevents an outgoing scene's empty fleet from
# resetting a boost while the same vessels are being restored on a surface. # resetting a boost while the same vessels are being restored on a surface.
# Each distorder vessel past the first widens the speed ceiling, so the
# COUNT matters, not merely whether one is present.
GameState.set_distorder_vessel_count(count_ships_with_capability("distorder"))
if not has_capability("distorder"): if not has_capability("distorder"):
GameState.set_fleet_speed_multiplier(1.0) GameState.set_fleet_speed_multiplier(1.0)
@ -118,6 +121,14 @@ func has_capability(capability: String) -> bool:
return first_ship_with_capability(capability) != null return first_ship_with_capability(capability) != null
func count_ships_with_capability(capability: String) -> int:
var total: int = 0
for ship in ships:
if is_instance_valid(ship) and ship.has_capability(capability):
total += 1
return total
func first_ship_with_capability(capability: String) -> Node2D: func first_ship_with_capability(capability: String) -> Node2D:
for ship in ships: for ship in ships:
if is_instance_valid(ship) and ship.has_capability(capability): if is_instance_valid(ship) and ship.has_capability(capability):

View file

@ -31,7 +31,16 @@ var planet_definition_id: String = "rocky"
# anchor, Ship's own max_speed/acceleration) rather than baked into stored # anchor, Ship's own max_speed/acceleration) rather than baked into stored
# stats, so a speed upgrade researched while boosted can't corrupt the base # stats, so a speed upgrade researched while boosted can't corrupt the base
# value - see Fleet.gd/Ship.gd for the multiply sites. # value - see Fleet.gd/Ship.gd for the multiply sites.
# The ceiling ONE distorder vessel reaches. Each ADDITIONAL one raises it by
# another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel
# figure rather than an absolute cap so the fader's meaning at a single
# vessel is unchanged.
const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0
# How far the fader can actually go right now, given the fleet's current
# composition. Pushed by Fleet whenever that composition changes, the same
# direction as every other fleet-derived figure here - GameState does not
# reach into the scene tree to count ships.
var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER
var fleet_speed_multiplier: float = 1.0 var fleet_speed_multiplier: float = 1.0
# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the # #62: energy cost of sustaining a boost, scaled by how far above 1.0 the
@ -47,7 +56,20 @@ var boost_depleted_notice: bool = false
func set_fleet_speed_multiplier(value: float) -> void: func set_fleet_speed_multiplier(value: float) -> void:
fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier)
# Every distorder vessel past the first widens the fleet's speed ceiling by
# 1.0, so stacking them is worth doing rather than the second one being dead
# weight. Zero vessels returns the ceiling to its one-vessel value; the
# multiplier itself is reset separately by Fleet when the last one is lost.
#
# Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost
# pulls the fleet down to the new ceiling instead of leaving it travelling
# faster than its remaining crew can sustain.
func set_distorder_vessel_count(count: int) -> void:
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER + maxf(float(count) - 1.0, 0.0)
set_fleet_speed_multiplier(fleet_speed_multiplier)
# #63: currency gated behind conquering a research_lab planet - required by # #63: currency gated behind conquering a research_lab planet - required by
@ -1335,7 +1357,10 @@ func _advance_distorder_drain(delta: float) -> void:
# carry over into a LATER, unrelated boost. # carry over into a LATER, unrelated boost.
_distorder_drain_progress = 0.0 _distorder_drain_progress = 0.0
return return
var rate: float = (fleet_speed_multiplier - 1.0) / (MAX_FLEET_SPEED_MULTIPLIER - 1.0) \ # Normalized against the CURRENT ceiling, so "full boost" always costs
# ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it -
# a bigger fleet buys more speed, not cheaper speed.
var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \
* ENERGY_DRAIN_PER_SECOND * ENERGY_DRAIN_PER_SECOND
_distorder_drain_progress += rate * delta _distorder_drain_progress += rate * delta
var whole: int = int(floor(_distorder_drain_progress)) var whole: int = int(floor(_distorder_drain_progress))
@ -1365,7 +1390,8 @@ var planet_capture: Dictionary = {}
signal planet_conquered(planet_id: String) signal planet_conquered(planet_id: String)
func register_capture_points(planet_key: String, total: int, definition_id: String = "") -> void: func register_capture_points(planet_key: String, total: int, definition_id: String = "",
reward_override: int = 0) -> void:
# Idempotent: landing again on a planet must not wipe points already # Idempotent: landing again on a planet must not wipe points already
# captured on a previous visit. # captured on a previous visit.
if planet_capture.has(planet_key): if planet_capture.has(planet_key):
@ -1381,6 +1407,11 @@ func register_capture_points(planet_key: String, total: int, definition_id: Stri
# the capture system. # the capture system.
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points) var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
# A map may be worth more than its planet type's flat rate - a harder
# lab should pay better, and only the authored layout knows how hard it
# is. Zero means the setup did not express an opinion.
if reward_override > 0:
point_reward = reward_override
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0, planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
"research_point_reward": point_reward, "research_awarded": false} "research_point_reward": point_reward, "research_awarded": false}

View file

@ -1903,6 +1903,9 @@ offset_right = -200
offset_bottom = 166 offset_bottom = 166
text = "Transfer cargo" text = "Transfer cargo"
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
theme_override_font_sizes/font_size = 44
[node name="StatusLabel" type="Label" parent="UI/TransferMenu"] [node name="StatusLabel" type="Label" parent="UI/TransferMenu"]
mouse_filter = 2 mouse_filter = 2

View file

@ -3,8 +3,10 @@ extends Node2D
# The menu stacks whatever entries are actually available, rather than # The menu stacks whatever entries are actually available, rather than
# trusting the positions authored in the scene. # trusting the positions authored in the scene.
# #
# Two of the four entries only exist when the game is run from Godot, so a # Two of the three entries only exist when the game is run from Godot, so a
# shipped build shows a two-entry menu and an editor run shows four. Fixed # shipped build shows one entry and an editor run shows three. There is no
# Quit: a web build has nothing to quit TO, and on Android the system back
# gesture already does it. Fixed
# offsets would leave holes where the hidden entries used to be - the same # offsets would leave holes where the hidden entries used to be - the same
# failure the HUD's button columns had, where a literal y meant "below the # failure the HUD's button columns had, where a literal y meant "below the
# thing above me" and stopped being true when that thing moved. Here the # thing above me" and stopped being true when that thing moved. Here the
@ -28,8 +30,6 @@ const STATUS_HEIGHT: float = 70.0
@onready var editor_label: Label = $UI/EditorLabel @onready var editor_label: Label = $UI/EditorLabel
@onready var dev_button: IconButton = $UI/DevModeButton @onready var dev_button: IconButton = $UI/DevModeButton
@onready var dev_label: Label = $UI/DevModeLabel @onready var dev_label: Label = $UI/DevModeLabel
@onready var quit_button: IconButton = $UI/QuitButton
@onready var quit_label: Label = $UI/QuitLabel
@onready var status_label: Label = $UI/StatusLabel @onready var status_label: Label = $UI/StatusLabel
@ -39,8 +39,6 @@ func _ready() -> void:
editor_button.enabled = true editor_button.enabled = true
dev_button.pressed.connect(_on_dev_mode_pressed) dev_button.pressed.connect(_on_dev_mode_pressed)
dev_button.enabled = true dev_button.enabled = true
quit_button.pressed.connect(_on_quit_pressed)
quit_button.visible = not OS.has_feature("web")
# Dev mode and the map editor are development tools, not features of the # Dev mode and the map editor are development tools, not features of the
# game: they are for building and play-testing it, and a player who # game: they are for building and play-testing it, and a player who
@ -65,7 +63,7 @@ func is_developer_build() -> bool:
func _layout_stack() -> void: func _layout_stack() -> void:
var rows: Array = [] var rows: Array = []
for row in [[play_button, play_label], [editor_button, editor_label], for row in [[play_button, play_label], [editor_button, editor_label],
[dev_button, dev_label], [quit_button, quit_label]]: [dev_button, dev_label]]:
if row[0].visible: if row[0].visible:
rows.append(row) rows.append(row)
if rows.is_empty(): if rows.is_empty():
@ -93,7 +91,17 @@ func _layout_stack() -> void:
func _on_play_pressed() -> void: func _on_play_pressed() -> void:
GameState.reset_new_game() GameState.reset_new_game()
get_tree().change_scene_to_file("res://Main.tscn") _start("res://Main.tscn")
# change_scene_to_file() returns an error and otherwise fails SILENTLY - the
# menu simply stays up, which is indistinguishable from the button not having
# been pressed at all. Reporting it turns "nothing happens" into something a
# player can read back.
func _start(scene_path: String) -> void:
var result: int = get_tree().change_scene_to_file(scene_path)
if result != OK:
status_label.text = "Could not start (%s, error %d)" % [scene_path.get_file(), result]
func _on_dev_mode_pressed() -> void: func _on_dev_mode_pressed() -> void:
@ -110,10 +118,8 @@ func _on_editor_pressed() -> void:
status_label.text = "Map editor only runs from Godot" status_label.text = "Map editor only runs from Godot"
return return
if editor_button.enabled: if editor_button.enabled:
get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn") _start("res://PlanetSetupEditor.tscn")
return return
status_label.text = "Map editor coming soon" status_label.text = "Map editor coming soon"
func _on_quit_pressed() -> void:
get_tree().quit()

View file

@ -1,18 +1,7 @@
[gd_scene load_steps=4 format=3] [gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://MainMenu.gd" id="1"] [ext_resource type="Script" path="res://MainMenu.gd" id="1"]
[ext_resource type="Script" path="res://IconButton.gd" id="2"] [ext_resource type="Script" path="res://IconButton.gd" id="2"]
[ext_resource type="Shader" path="res://starfield.gdshader" id="3"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("3")
shader_parameter/camera_cell = Vector2(0, 0)
shader_parameter/camera_frac = 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="MainMenu" type="Node2D"] [node name="MainMenu" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@ -20,7 +9,7 @@ script = ExtResource("1")
layer = -10 layer = -10
[node name="Starfield" type="ColorRect" parent="BackgroundLayer"] [node name="Starfield" type="ColorRect" parent="BackgroundLayer"]
material = SubResource("1") color = Color(0, 0, 0, 1)
mouse_filter = 2 mouse_filter = 2
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
@ -41,7 +30,7 @@ offset_top = -560
offset_bottom = -460 offset_bottom = -460
theme_override_font_sizes/font_size = 60 theme_override_font_sizes/font_size = 60
horizontal_alignment = 1 horizontal_alignment = 1
text = "SPACESHIP TEST" text = "SPACESHIP"
[node name="PlayButton" type="Control" parent="UI"] [node name="PlayButton" type="Control" parent="UI"]
anchor_left = 0.5 anchor_left = 0.5
@ -137,30 +126,3 @@ theme_override_font_sizes/font_size = 26
horizontal_alignment = 1 horizontal_alignment = 1
autowrap_mode = 2 autowrap_mode = 2
text = "" text = ""
[node name="QuitButton" type="Control" parent="UI"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = 320
offset_bottom = 448
script = ExtResource("2")
icon = 1
glow_color = Color(0.9, 0.35, 0.35, 1)
[node name="QuitLabel" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -160
offset_right = 160
offset_top = 460
offset_bottom = 526
theme_override_font_sizes/font_size = 32
horizontal_alignment = 1
text = "Quit"

View file

@ -16,4 +16,6 @@ acceleration = 45.0
rotation_speed = 1.5 rotation_speed = 1.5
hull_points = PackedVector2Array(0, -50, 35, -25, 48, 10, 20, 48, -20, 48, -48, 10, -35, -25) hull_points = PackedVector2Array(0, -50, 35, -25, 48, 10, 20, 48, -20, 48, -48, 10, -35, -25)
glow_color = Color(0.55, 0.05, 0.85, 1) glow_color = Color(0.55, 0.05, 0.85, 1)
detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40)
detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0)
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")]) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")])

View file

@ -9,6 +9,8 @@ extends Control
signal pressed signal pressed
@export var is_capture_point: bool = false @export var is_capture_point: bool = false
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
@export var enemy_definition: Resource @export var enemy_definition: Resource
@export var selected: bool = false: set = set_selected @export var selected: bool = false: set = set_selected
@ -49,8 +51,8 @@ func _draw() -> void:
points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28)) points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28))
NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8) NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8)
elif enemy_definition != null: elif enemy_definition != null:
var scale_factor: float = (size.x * 0.5) / 28.0 # Same routine as the marker and the enemy, so the button previews
var points: PackedVector2Array = PackedVector2Array() # what will actually be placed.
for p in enemy_definition.hull_points: var scale_factor: float = (size.x * 0.5) / 28.0 * 0.6
points.append(center + p * scale_factor * 0.6) ENEMY_DEFINITIONS.draw_definition(self, enemy_definition,
NeonDraw.draw_glowing_polygon(self, points, enemy_definition.glow_color, 0.9) enemy_definition.glow_color, scale_factor, center)

View file

@ -14,3 +14,10 @@ extends Resource
# Surface-local positions (the fleet lands at Vector2.ZERO). # Surface-local positions (the fleet lands at Vector2.ZERO).
@export var capture_points: PackedVector2Array = PackedVector2Array() @export var capture_points: PackedVector2Array = PackedVector2Array()
@export var enemy_spawns: Array[Resource] = [] @export var enemy_spawns: Array[Resource] = []
# Research points awarded for conquering this map, set in the map editor.
# A harder lab is worth more, and how hard a lab IS depends on the layout
# authored here - how many capture points, how many and what kind of
# defenders - which the planet type cannot know. 0 means "use whatever the
# planet type awards", so every setup written before this existed keeps its
# old value rather than silently dropping to nothing.
@export var research_point_reward: int = 0

View file

@ -17,6 +17,10 @@ extends Node2D
# load-order notes) - a bare class-name reference needs the global # load-order notes) - a bare class-name reference needs the global
# class_name cache already warm, which an explicit script path never does. # class_name cache already warm, which an explicit script path never does.
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
# By path, not by its `EnemyMarker` global name: the class_name cache is not
# guaranteed warm before an import pass has run, the same hazard PlanetSetups
# and PlanetSurfaceController already work around.
const ENEMY_MARKER = preload("res://EnemyMarker.gd")
const PLANET_SETUP_SCRIPT = preload("res://PlanetSetup.gd") const PLANET_SETUP_SCRIPT = preload("res://PlanetSetup.gd")
const PLANET_SETUP_SPAWN_SCRIPT = preload("res://PlanetSetupSpawn.gd") const PLANET_SETUP_SPAWN_SCRIPT = preload("res://PlanetSetupSpawn.gd")
const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://PaletteEntryButton.gd") const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://PaletteEntryButton.gd")
@ -43,6 +47,8 @@ const PALETTE_STEP: float = 92.0
@onready var back_button: IconButton = $UI/BackButton @onready var back_button: IconButton = $UI/BackButton
@onready var profile_button: IconButton = $UI/ProfileButton @onready var profile_button: IconButton = $UI/ProfileButton
@onready var profile_value_label: Label = $UI/ProfileValueLabel @onready var profile_value_label: Label = $UI/ProfileValueLabel
@onready var reward_button: IconButton = $UI/RewardButton
@onready var reward_value_label: Label = $UI/RewardValueLabel
@onready var name_input: LineEdit = $UI/NameInput @onready var name_input: LineEdit = $UI/NameInput
@onready var new_button: IconButton = $UI/NewButton @onready var new_button: IconButton = $UI/NewButton
@onready var save_button: IconButton = $UI/SaveButton @onready var save_button: IconButton = $UI/SaveButton
@ -83,6 +89,7 @@ func _ready() -> void:
_profile_ids = PlanetSurfaceProfiles.PROFILES.keys() _profile_ids = PlanetSurfaceProfiles.PROFILES.keys()
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://MainMenu.tscn")) back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://MainMenu.tscn"))
profile_button.pressed.connect(_on_profile_pressed) profile_button.pressed.connect(_on_profile_pressed)
reward_button.pressed.connect(_on_reward_pressed)
new_button.pressed.connect(_on_new_pressed) new_button.pressed.connect(_on_new_pressed)
save_button.pressed.connect(_on_save_pressed) save_button.pressed.connect(_on_save_pressed)
load_button.pressed.connect(_on_load_pressed) load_button.pressed.connect(_on_load_pressed)
@ -224,6 +231,7 @@ func _refresh_background() -> void:
material.set_shader_parameter("highlight_color", profile.highlight_color) material.set_shader_parameter("highlight_color", profile.highlight_color)
if profile_value_label != null: if profile_value_label != null:
profile_value_label.text = current_setup.surface_profile_id.to_upper() profile_value_label.text = current_setup.surface_profile_id.to_upper()
_refresh_reward_label()
func _rebuild_markers() -> void: func _rebuild_markers() -> void:
@ -256,9 +264,14 @@ func _add_enemy_marker(index: int, spawn: Resource) -> void:
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id) var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index
var color: Color = Color(1.0, 0.95, 0.9) if is_selected else definition.glow_color var color: Color = Color(1.0, 0.95, 0.9) if is_selected else definition.glow_color
var marker: NeonPiece = NeonPiece.create(item_host, spawn.position, color) # Drawn through the same routine the enemy itself uses, rather than from
marker.add_polygon(definition.hull_points, 0.9) # hull_points alone - a marker that does not look like what it places is
marker.queue_redraw() # worse than no picture at all.
var marker: Node2D = ENEMY_MARKER.new()
marker.position = spawn.position
marker.definition = definition
marker.glow_color = color
item_host.add_child(marker)
# --- palette --------------------------------------------------------------- # --- palette ---------------------------------------------------------------
@ -295,6 +308,27 @@ func _refresh_palette_selection() -> void:
# --- UI handlers ------------------------------------------------------- # --- UI handlers -------------------------------------------------------
# What conquering this map is worth in research points. 0 keeps the planet
# type's own flat rate, so a setup that says nothing behaves as it always
# did; the rest let an author price a hard lab above an easy one.
const REWARD_OPTIONS: Array[int] = [0, 1, 2, 3, 5, 8]
func _on_reward_pressed() -> void:
if current_setup == null:
return
var index: int = REWARD_OPTIONS.find(int(current_setup.research_point_reward))
current_setup.research_point_reward = REWARD_OPTIONS[(index + 1) % REWARD_OPTIONS.size()]
_refresh_reward_label()
func _refresh_reward_label() -> void:
if reward_value_label == null or current_setup == null:
return
var reward: int = int(current_setup.research_point_reward)
reward_value_label.text = "DEFAULT RP" if reward <= 0 else "%d RESEARCH PT" % reward
func _on_profile_pressed() -> void: func _on_profile_pressed() -> void:
if _profile_ids.is_empty(): if _profile_ids.is_empty():
return return

View file

@ -170,6 +170,25 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 2 autowrap_mode = 2
text = "" text = ""
[node name="RewardButton" type="Control" parent="UI"]
offset_left = 350
offset_top = 20
offset_right = 422
offset_bottom = 92
script = ExtResource("4")
icon = 29
glow_color = Color(0.55, 0.9, 0.65, 1)
[node name="RewardValueLabel" type="Label" parent="UI"]
mouse_filter = 2
offset_left = 430
offset_top = 36
offset_right = 690
offset_bottom = 76
theme_override_font_sizes/font_size = 26
theme_override_colors/font_color = Color(0.55, 0.9, 0.65, 1)
text = "DEFAULT RP"
[node name="SelectedLabel" type="Label" parent="UI"] [node name="SelectedLabel" type="Label" parent="UI"]
mouse_filter = 2 mouse_filter = 2
offset_left = 20 offset_left = 20

View file

@ -20,7 +20,7 @@ func apply_setup(new_setup: Resource) -> void:
return return
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(), GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(),
GameState.planet_definition_id) GameState.planet_definition_id, int(setup.research_point_reward))
for i in range(setup.capture_points.size()): for i in range(setup.capture_points.size()):
_add_capture_point(i, setup.capture_points[i]) _add_capture_point(i, setup.capture_points[i])
for spawn in setup.enemy_spawns: for spawn in setup.enemy_spawns:

View file

@ -1,12 +1,14 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3] [gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] [ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="Shader" path="res://research_lab_surface.gdshader" id="2"]
[resource] [resource]
script = ExtResource("1") script = ExtResource("1")
profile_id = "research_lab" profile_id = "research_lab"
base_color = Color(0.13, 0.1, 0.1, 1) background_shader = ExtResource("2")
crater_color = Color(0.05, 0.03, 0.03, 1) base_color = Color(0.17, 0.13, 0.13, 1)
highlight_color = Color(0.55, 0.15, 0.1, 1) crater_color = Color(0.045, 0.028, 0.03, 1)
highlight_color = Color(0.85, 0.24, 0.16, 1)
caption_color = Color(0.9, 0.6, 0.55, 1) caption_color = Color(0.9, 0.6, 0.55, 1)
caption_outline_color = Color(0.05, 0.02, 0.02, 0.9) caption_outline_color = Color(0.05, 0.02, 0.02, 0.9)

View file

@ -78,17 +78,13 @@ func _ready() -> void:
increase.glow_color = colour increase.glow_color = colour
increase.pressed.connect(_change_amount.bind(resource_type, 1)) increase.pressed.connect(_change_amount.bind(resource_type, 1))
add_child(increase) add_child(increase)
var amount_caption := _make_label("AMOUNT", 18, Color(0.62, 0.78, 0.86))
amount_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(amount_caption)
_rows[resource_type] = { _rows[resource_type] = {
"glyph": glyph, "name": name_label, "glyph": glyph, "name": name_label,
"fleet_amount": fleet_amount, "local_amount": local_amount, "fleet_amount": fleet_amount, "local_amount": local_amount,
"unload": unload, "unload_label": unload_label, "unload": unload, "unload_label": unload_label,
"load": load, "load_label": load_label, "load": load, "load_label": load_label,
"decrease": decrease, "amount_label": amount_label, "decrease": decrease, "amount_label": amount_label,
"increase": increase, "amount_caption": amount_caption, "increase": increase,
"amount_index": AMOUNT_OPTIONS.size() - 1, "amount_index": AMOUNT_OPTIONS.size() - 1,
} }
@ -183,39 +179,61 @@ func _layout() -> void:
for resource_type in GameState.resource_types(): for resource_type in GameState.resource_types():
var row: Dictionary = _rows[resource_type] var row: Dictionary = _rows[resource_type]
var y: float = HEADER_HEIGHT + float(row_index) * row_height var y: float = HEADER_HEIGHT + float(row_index) * row_height
var glyph_size: float = minf(66.0, row_height * 0.23)
row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, y + 10.0) # EVERY offset below is a fraction of row_height. It used to be a
# fixed pixel ladder - glyph at +10, buttons at +88, selector at
# +142, captions under that - which needed about 244px inside a row
# whose height is divided from the available space. At three
# resources that fit; the fourth cut the row to 180 and every row
# spilled 64px into the one below it, and the last one into the hint
# text under the list. Proportions cannot overflow: the bands always
# sum to the row.
var pad: float = row_height * 0.04
var content: float = row_height - pad * 2.0
var top: float = y + pad
# amounts, with the resource's glyph and name between them
var band_totals: float = content * 0.32
# the arrows, and the amount selector between them
var band_controls: float = content * 0.48
# the arrow captions
var band_captions: float = content * 0.20
row.fleet_amount.position = Vector2(0.0, top)
row.fleet_amount.size = Vector2(side_width, band_totals)
row.local_amount.position = Vector2(right_x, top)
row.local_amount.size = Vector2(side_width, band_totals)
# Glyph and name sit at the TOP of their row rather than under its
# controls, so a row reads as one thing. Previously the name hung
# low enough to look like a heading for the row beneath it.
var glyph_size: float = minf(band_totals * 0.52, 44.0)
row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, top)
row.glyph.size = Vector2(glyph_size, glyph_size) row.glyph.size = Vector2(glyph_size, glyph_size)
row.name.position = Vector2(side_width, y + 10.0 + glyph_size) row.name.position = Vector2(side_width, top + glyph_size)
row.name.size = Vector2(centre_width, 44.0) row.name.size = Vector2(centre_width, maxf(band_totals - glyph_size, 1.0))
row.fleet_amount.position = Vector2(0.0, y + 10.0) var controls_y: float = top + band_totals
row.fleet_amount.size = Vector2(side_width, 70.0) var button_size: float = minf(band_controls, side_width * 0.5)
row.local_amount.position = Vector2(right_x, y + 10.0) var button_y: float = controls_y + (band_controls - button_size) * 0.5
row.local_amount.size = Vector2(side_width, 70.0)
var button_size: float = minf(132.0, row_height * 0.44)
var button_y: float = y + 88.0
row.unload.position = Vector2(side_width * 0.5 - button_size * 0.5, button_y) row.unload.position = Vector2(side_width * 0.5 - button_size * 0.5, button_y)
row.unload.size = Vector2(button_size, button_size) row.unload.size = Vector2(button_size, button_size)
row.load.position = Vector2(right_x + side_width * 0.5 - button_size * 0.5, button_y) row.load.position = Vector2(right_x + side_width * 0.5 - button_size * 0.5, button_y)
row.load.size = Vector2(button_size, button_size) row.load.size = Vector2(button_size, button_size)
var caption_y: float = button_y + button_size + 2.0
row.unload_label.position = Vector2(0.0, caption_y)
row.unload_label.size = Vector2(side_width, 42.0)
row.load_label.position = Vector2(right_x, caption_y)
row.load_label.size = Vector2(side_width, 42.0)
var selector_size: float = minf(66.0, centre_width * 0.33) var selector_size: float = minf(band_controls * 0.62, centre_width * 0.30)
var selector_y: float = y + 142.0 var selector_y: float = controls_y + (band_controls - selector_size) * 0.5
row.decrease.position = Vector2(side_width + 2.0, selector_y) row.decrease.position = Vector2(side_width + 2.0, selector_y)
row.decrease.size = Vector2(selector_size, selector_size) row.decrease.size = Vector2(selector_size, selector_size)
row.increase.position = Vector2(right_x - selector_size - 2.0, selector_y) row.increase.position = Vector2(right_x - selector_size - 2.0, selector_y)
row.increase.size = Vector2(selector_size, selector_size) row.increase.size = Vector2(selector_size, selector_size)
row.amount_label.position = Vector2(side_width + selector_size + 2.0, selector_y) row.amount_label.position = Vector2(side_width + selector_size + 2.0, selector_y)
row.amount_label.size = Vector2(centre_width - selector_size * 2.0 - 4.0, selector_size) row.amount_label.size = Vector2(centre_width - selector_size * 2.0 - 4.0, selector_size)
row.amount_caption.position = Vector2(side_width, selector_y + selector_size + 2.0)
row.amount_caption.size = Vector2(centre_width, 34.0) var caption_y: float = controls_y + band_controls
row.unload_label.position = Vector2(0.0, caption_y)
row.unload_label.size = Vector2(side_width, band_captions)
row.load_label.position = Vector2(right_x, caption_y)
row.load_label.size = Vector2(side_width, band_captions)
row_index += 1 row_index += 1
queue_redraw() queue_redraw()

View file

@ -12,6 +12,8 @@ max_hp = 4
speed = 90.0 speed = 90.0
acceleration = 180.0 acceleration = 180.0
rotation_speed = 5.0 rotation_speed = 5.0
hull_points = PackedVector2Array(0, -28, 7, -6, 7, 22, -7, 22, -7, -6) hull_points = PackedVector2Array(0, -34, 5, -20, 9, 4, 16, 20, 6, 18, 4, 26, -4, 26, -6, 18, -16, 20, -9, 4, -5, -20)
glow_color = Color(0.42, 0.72, 1.0, 1) glow_color = Color(0.42, 0.72, 1.0, 1)
detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12)
detail_rings = PackedVector2Array(0, -2, 5, 0)
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")]) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])

View file

@ -20,7 +20,9 @@ extends Control
@onready var label: Label = get_node("Label") @onready var label: Label = get_node("Label")
var active_touch_index: int = -1 var active_touch_index: int = -1
# 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER). # 0 = bottom of the track (1.0x), 1 = top. The top is
# GameState.max_fleet_speed_multiplier, which widens with each distorder
# vessel past the first - so the track always spans the whole usable range.
var knob_t: float = 0.0 var knob_t: float = 0.0
var status_message_until: int = 0 var status_message_until: int = 0
@ -42,7 +44,7 @@ func _process(_delta: float) -> void:
knob_t = 0.0 knob_t = 0.0
return return
knob_t = (GameState.fleet_speed_multiplier - 1.0) \ knob_t = (GameState.fleet_speed_multiplier - 1.0) \
/ maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) / maxf(GameState.max_fleet_speed_multiplier - 1.0, 0.001)
# #62: GameState._process() (not this node's own _process()) is what # #62: GameState._process() (not this node's own _process()) is what
# drains energy and force-resets the multiplier, since it has to keep # drains energy and force-resets the multiplier, since it has to keep
# running across the space<->surface swap and while this control might # running across the space<->surface swap and while this control might
@ -104,7 +106,7 @@ func _update_from_local(local: Vector2) -> void:
# Up is 1.0 (top of the track), matching how a real fader/throttle reads. # Up is 1.0 (top of the track), matching how a real fader/throttle reads.
var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0) var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0)
knob_t = t knob_t = t
GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0)) GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.max_fleet_speed_multiplier - 1.0))
label.text = "x%.1f" % GameState.fleet_speed_multiplier label.text = "x%.1f" % GameState.fleet_speed_multiplier
queue_redraw() queue_redraw()

View file

@ -13,4 +13,6 @@ acceleration = 0.0
rotation_speed = 0.0 rotation_speed = 0.0
hull_points = PackedVector2Array(0, -32, 22.6, -22.6, 32, 0, 22.6, 22.6, 0, 32, -22.6, 22.6, -32, 0, -22.6, -22.6) hull_points = PackedVector2Array(0, -32, 22.6, -22.6, 32, 0, 22.6, 22.6, 0, 32, -22.6, 22.6, -32, 0, -22.6, -22.6)
glow_color = Color(0.68, 0.92, 0.18, 1) glow_color = Color(0.68, 0.92, 0.18, 1)
detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16)
detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0)
component_scenes = Array[PackedScene]([ExtResource("2")]) component_scenes = Array[PackedScene]([ExtResource("2")])

View file

@ -10,7 +10,7 @@ config_version=5
[application] [application]
config/name="Spaceship Test" config/name="Spaceship"
run/main_scene="res://MainMenu.tscn" run/main_scene="res://MainMenu.tscn"
config/features=PackedStringArray("4.7") config/features=PackedStringArray("4.7")
config/icon="res://icon.svg" config/icon="res://icon.svg"

View file

@ -0,0 +1,117 @@
shader_type canvas_item;
// Backdrop for a landed enemy research lab.
//
// The other surfaces are NATURAL: lunar_surface.gdshader erodes craters,
// gas_surface.gdshader bands clouds. A research lab is the one place in the
// game that is BUILT, and it was previously borrowing the crater field with
// a dark red palette - which read as "rocky planet, tinted", not as walking
// onto someone's facility. So this draws a plated floor instead: panel
// seams, service conduits running through them, and lit junctions where two
// conduits cross.
//
// Deliberately loop-free. lunar_surface.gdshader needs a neighbour scan
// because a crater overlaps its cell's borders; nothing here does, so every
// feature is a function of this pixel's own cell and the shader stays a
// handful of hashes per pixel (#68's lesson, applied up front rather than
// after the fact).
//
// The conduits still have to run CONTINUOUSLY across cell borders, which is
// what a neighbour scan would normally buy. The trick is to make a
// horizontal run's existence and height depend only on cell.y, and a
// vertical run's on cell.x: every cell in a row then independently agrees on
// the same answer, so the line joins up without any cell ever looking at
// its neighbours.
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;
// Shared with the lunar background so the same camera feed drives both, and
// so the field repeats on the same period. See LunarBackground.gd.
uniform float tile_cells = 32.0;
uniform vec4 base_color : source_color = vec4(0.16, 0.12, 0.12, 1.0);
uniform vec4 crater_color : source_color = vec4(0.05, 0.03, 0.03, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.75, 0.20, 0.15, 1.0);
// Plates per cell, across and down.
const float SUBPANELS = 3.0;
const float SEAM_WIDTH = 0.05;
const float CONDUIT_WIDTH = 0.055;
// How many rows/columns carry a conduit. Low enough that the floor stays
// mostly plating, high enough that a screen usually shows a junction.
const float CONDUIT_CHANCE = 0.38;
float hash11(float p) {
vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
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);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
// Same camera-relative construction as every other background here: an
// absolute world position reaches the tens of thousands, where a mobile
// float has already lost the fractional bits this needs. camera_cell
// arrives pre-wrapped from GDScript's double precision.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
vec2 cell = mod(camera_cell + cell_rel, tile_cells);
// --- plating ---------------------------------------------------------
vec2 sub = local * SUBPANELS;
vec2 sub_cell = floor(sub);
vec2 sub_local = sub - sub_cell;
vec2 edge = min(sub_local, 1.0 - sub_local);
float seam = 1.0 - smoothstep(0.0, SEAM_WIDTH, min(edge.x, edge.y));
// A little per-plate variation, so a large floor does not read as one
// flat fill: plates are stamped, not poured.
float plate = hash21(cell * SUBPANELS + sub_cell);
vec3 color = mix(base_color.rgb, crater_color.rgb, plate * 0.40);
color = mix(color, crater_color.rgb, seam * 0.85);
// --- conduits --------------------------------------------------------
// Existence and offset keyed to a SINGLE axis of the cell index, which
// is what makes a run continuous across every cell it passes through.
float has_h = step(1.0 - CONDUIT_CHANCE, hash11(cell.y + 0.5));
float has_v = step(1.0 - CONDUIT_CHANCE, hash11(cell.x + 13.7));
float run_y = 0.25 + 0.5 * hash11(cell.y + 7.3);
float run_x = 0.25 + 0.5 * hash11(cell.x + 3.1);
float h_band = has_h * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.y - run_y)));
float v_band = has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.x - run_x)));
// Charge travelling along the run. Phase is built from cell + local, a
// continuous coordinate, so the pulse crosses cell borders unbroken
// rather than restarting inside every cell.
float pulse_h = 0.55 + 0.45 * sin((cell.x + local.x) * 2.4 - TIME * 1.6);
float pulse_v = 0.55 + 0.45 * sin((cell.y + local.y) * 2.4 - TIME * 1.6);
float conduit = max(h_band * pulse_h, v_band * pulse_v);
// A dark channel under the glow, so a conduit reads as recessed into the
// floor rather than painted on top of it.
color = mix(color, crater_color.rgb, max(h_band, v_band) * 0.55);
color = mix(color, highlight_color.rgb, conduit * 0.75);
// Junction box wherever a horizontal and a vertical run cross.
float junction = has_h * has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH * 2.4,
length(vec2(local.x - run_x, local.y - run_y))));
color = mix(color, highlight_color.rgb, junction * 0.9);
// Same dither as the other backgrounds: the gradients here are shallow
// and dark, which is exactly where 8-bit quantization bands visibly.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
COLOR = vec4(color, 1.0);
}

View file

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

View file

@ -1,5 +1,9 @@
extends Node2D extends Node2D
# Preview, not a test - an artillery shell in flight with the cross marking
# where it will land. Named "telegraph" from when the tower charged up
# before firing; the warning is now the shell itself plus that cross.
func _ready() -> void: func _ready() -> void:
GameState.planet_name = "Violet Tempest" GameState.planet_name = "Violet Tempest"
@ -11,10 +15,15 @@ func _ready() -> void:
add_child(surface) add_child(surface)
var shell: Node = load("res://ArtilleryShell.tscn").instantiate() var shell: Node = load("res://ArtilleryShell.tscn").instantiate()
shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot
shell.aoe_radius = 220.0 shell.aoe_radius = 220.0
surface.add_child(shell) surface.add_child(shell)
# Frozen with the shell part-way to its target, which is the moment the
# warning has to work: the shell is visibly in the air and the cross
# marks where it will land. There is no charge-up phase to capture any
# more - the tower fires straight away.
shell.launch(Vector2(-600, -600), Vector2.ZERO) shell.launch(Vector2(-600, -600), Vector2.ZERO)
shell.telegraph_left = shell.telegraph_time * 0.45 shell.shell_pos = Vector2(-260, -260)
shell.position = shell.shell_pos
shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos])
shell.set_process(false) shell.set_process(false)
shell.queue_redraw() shell.queue_redraw()

View file

@ -1,5 +1,9 @@
extends Node2D extends Node2D
# Preview, not a test - an artillery shell in flight with the cross marking
# where it will land. Named "telegraph" from when the tower charged up
# before firing; the warning is now the shell itself plus that cross.
func _ready() -> void: func _ready() -> void:
GameState.planet_name = "Kepler's Rest" GameState.planet_name = "Kepler's Rest"
@ -11,10 +15,15 @@ func _ready() -> void:
add_child(surface) add_child(surface)
var shell: Node = load("res://ArtilleryShell.tscn").instantiate() var shell: Node = load("res://ArtilleryShell.tscn").instantiate()
shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot
shell.aoe_radius = 220.0 shell.aoe_radius = 220.0
surface.add_child(shell) surface.add_child(shell)
# Frozen with the shell part-way to its target, which is the moment the
# warning has to work: the shell is visibly in the air and the cross
# marks where it will land. There is no charge-up phase to capture any
# more - the tower fires straight away.
shell.launch(Vector2(-600, -600), Vector2.ZERO) shell.launch(Vector2(-600, -600), Vector2.ZERO)
shell.telegraph_left = shell.telegraph_time * 0.45 shell.shell_pos = Vector2(-260, -260)
shell.position = shell.shell_pos
shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos])
shell.set_process(false) shell.set_process(false)
shell.queue_redraw() shell.queue_redraw()

View file

@ -0,0 +1,29 @@
extends Node2D
# Preview, not a test - every enemy hull side by side, so their silhouettes
# can be compared at a glance. The chaser is included as the reference the
# others are meant to be more distinctive than.
# bash tests/screenshot.sh res://tests/_enemy_gallery_preview.tscn /tmp/enemies.png 15 720x1280
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
func _ready() -> void:
var enemy_scene: PackedScene = load("res://Enemy.tscn")
var ids: Array = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
var y: float = 200.0
for id in ids:
var definition: Resource = ENEMY_DEFINITIONS.get_definition(id)
if definition == null:
continue
var enemy: Node2D = enemy_scene.instantiate()
enemy.enemy_id = id
enemy.position = Vector2(230.0, y)
add_child(enemy)
var label := Label.new()
label.text = definition.display_name
label.position = Vector2(330.0, y - 20.0)
label.add_theme_font_size_override("font_size", 30)
label.add_theme_color_override("font_color", definition.glow_color)
add_child(label)
y += 200.0

View file

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

View file

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

View file

@ -0,0 +1,14 @@
extends Node2D
# Preview, not a test - the enemy research lab's own backdrop.
# bash tests/screenshot.sh res://tests/_research_lab_surface_preview.tscn /tmp/lab.png 20 720x1280
func _ready() -> void:
GameState.planet_name = "Vault Nine"
GameState.planet_definition_id = "research_lab"
GameState.surviving_ship_count = 3
GameState.rocks = 128
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
surface.get_node("SurfaceController").fade_duration = 0.0
add_child(surface)

View file

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

View file

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

29
tests/_web_play_probe.gd Normal file
View file

@ -0,0 +1,29 @@
extends Node2D
# Probe, not a test - presses Play on the main menu under the REAL
# gl_compatibility renderer, which is the same path the web export takes
# (WebGL2 == GLES3 compatibility). --headless uses a dummy renderer and so
# cannot surface a failure that only happens once something actually has to
# be rendered or a shader has to compile.
#
# bash tests/screenshot.sh res://tests/_web_play_probe.tscn /tmp/play.png 45 720x1280
var menu: Node
var frames: int = 0
var pressed: bool = false
func _ready() -> void:
menu = load("res://MainMenu.tscn").instantiate()
add_child(menu)
func _process(_delta: float) -> void:
frames += 1
if frames == 8 and not pressed:
pressed = true
print("PLAY_PROBE: pressing Play")
menu._on_play_pressed()
if frames == 30:
var scene: Node = get_tree().current_scene
print("PLAY_PROBE: current_scene=%s" % [scene.name if scene != null else "<null>"])

View file

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

View file

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

View file

@ -3,8 +3,9 @@ extends SceneTree
# Headless gameplay test: run with # Headless gameplay test: run with
# godot --headless -s res://tests/test_artillery_enemy.gd # godot --headless -s res://tests/test_artillery_enemy.gd
# Verifies: # Verifies:
# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole # 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it
# window before any damage lands # is launched - and the warning is the shell's flight plus the impact
# cross drawn at its target, not a charge-up before anything happens
# 2. a ship that leaves the blast radius before impact takes no damage; # 2. a ship that leaves the blast radius before impact takes no damage;
# one that stays takes the full hit # one that stays takes the full hit
# 3. a shielded ship inside the blast survives, losing exactly one charge # 3. a shielded ship inside the blast survives, losing exactly one charge
@ -34,6 +35,7 @@ var live_shell: Node
var recorded_target: Vector2 = Vector2.ZERO var recorded_target: Vector2 = Vector2.ZERO
var impact_point: Vector2 = Vector2.ZERO var impact_point: Vector2 = Vector2.ZERO
var travel_time: float = 0.0 var travel_time: float = 0.0
var origin: Vector2 = Vector2.ZERO
var stage2_wait: float = 0.0 var stage2_wait: float = 0.0
var stage5_wait: float = 0.0 var stage5_wait: float = 0.0
var stage: int = 0 var stage: int = 0
@ -79,10 +81,9 @@ func _process(delta: float) -> bool:
shield_ship = fleet.ships[fleet.ships.size() - 1] shield_ship = fleet.ships[fleet.ships.size() - 1]
shield_ship.position = impact_point + Vector2(-100, 0) shield_ship.position = impact_point + Vector2(-100, 0)
var origin: Vector2 = impact_point + Vector2(400, 400) origin = impact_point + Vector2(400, 400)
travel_time = origin.distance_to(impact_point) / 300.0 travel_time = origin.distance_to(impact_point) / 300.0
shell = load("res://ArtilleryShell.tscn").instantiate() shell = load("res://ArtilleryShell.tscn").instantiate()
shell.telegraph_time = 1.0
shell.shell_speed = 300.0 shell.shell_speed = 300.0
shell.aoe_radius = 220.0 shell.aoe_radius = 220.0
shell.damage = 3 shell.damage = 3
@ -93,28 +94,40 @@ func _process(delta: float) -> bool:
stage = 1 stage = 1
return false return false
1: 1:
# Mid-telegraph: nobody hurt yet, still visibly telegraphing. # The shell must already be travelling - there is no charge-up
if elapsed < shell.telegraph_time * 0.8: # phase to sit in any more - and it must have moved off the
# tower under its own speed.
if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING:
return _fail("the shell is not in the air on the frame after firing")
if elapsed < travel_time * 0.4:
return false return false
if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING: if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point):
return _fail("shell left the telegraph phase too early") return _fail("the shell has not closed any distance on its target")
# The warning the player actually gets: a cross on the ground at
# the point of impact. Drawing cannot be asserted headlessly, so
# this checks what it is drawn FROM - the aim point is fixed and
# the marker has a real size.
if not shell.target.is_equal_approx(impact_point):
return _fail("the impact marker would not be drawn at the point the shell was aimed at")
if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0:
return _fail("the impact cross has no size, so nothing marks the landing point")
if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \ if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \
or not is_instance_valid(shield_ship): or not is_instance_valid(shield_ship):
return _fail("damage landed before the telegraph window finished") return _fail("damage landed before the shell arrived")
# Ship B clears the blast radius now, well before impact. # Ship B clears the blast radius now, well before impact.
ship_leaves.position = impact_point + Vector2(2000, 0) ship_leaves.position = impact_point + Vector2(2000, 0)
# Captured once, as a plain float: the shell frees itself once # Captured once, as a plain float: the shell frees itself once
# it resolves, so re-reading shell.telegraph_time from inside # it resolves, so reading off it from inside the wait condition
# the wait condition below (evaluated every frame) would touch # below (evaluated every frame) would touch a freed object on
# a freed object on any frame after that. # any frame after that.
stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5 stage2_wait = travel_time + 1.5
elapsed = 0.0 elapsed = 0.0
stage = 2 stage = 2
return false return false
2: 2:
# Enough time for the remaining telegraph, travel, and the full # Enough time for the remaining travel and the full explosion
# explosion visual to resolve. # visual to resolve.
if elapsed < stage2_wait: if elapsed < stage2_wait:
return false return false
if is_instance_valid(ship_inside): if is_instance_valid(ship_inside):
@ -138,7 +151,6 @@ func _process(delta: float) -> bool:
main.add_child(artillery) main.add_child(artillery)
art_component = artillery.get_component("artillery") art_component = artillery.get_component("artillery")
art_component.cooldown_left = 0.0 art_component.cooldown_left = 0.0
art_component.telegraph_time = 0.3
art_component.fire_cooldown = 999.0 # only the one shot for this check art_component.fire_cooldown = 999.0 # only the one shot for this check
elapsed = 0.0 elapsed = 0.0
stage = 3 stage = 3
@ -173,7 +185,9 @@ func _process(delta: float) -> bool:
# destroy the artillery enemy (and with it art_component) well # destroy the artillery enemy (and with it art_component) well
# before this wait ends, so the condition below must not keep # before this wait ends, so the condition below must not keep
# re-reading a property off a node that might already be freed. # re-reading a property off a node that might already be freed.
stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5 # No charge-up to wait out any more - just flight time plus the
# explosion visual.
stage5_wait = (300.0 / 180.0) + 1.5
elapsed = 0.0 elapsed = 0.0
stage = 5 stage = 5
return false return false

View file

@ -131,8 +131,8 @@ func _process(delta: float) -> bool:
return _fail("the dev mode button's visibility did not follow is_developer_build()") return _fail("the dev mode button's visibility did not follow is_developer_build()")
if menu.editor_button.visible != developing: if menu.editor_button.visible != developing:
return _fail("the map editor button's visibility did not follow is_developer_build()") return _fail("the map editor button's visibility did not follow is_developer_build()")
if menu.play_button.visible != true or menu.quit_button.visible != (not OS.has_feature("web")): if not menu.play_button.visible:
return _fail("gating the development entries also hid an entry a player needs") return _fail("gating the development entries also hid Play")
# Hiding is presentation; pressing it anyway must still be refused. # Hiding is presentation; pressing it anyway must still be refused.
if not developing: if not developing:
menu._on_dev_mode_pressed() menu._on_dev_mode_pressed()

View file

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

View file

@ -0,0 +1,135 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_distorder_stacking.gd
# Each space-time distorder vessel PAST THE FIRST raises the fleet's speed
# ceiling by 1.0, so a second one is worth crewing rather than dead weight.
#
# Covers:
# A. one vessel gives the original ceiling, so nothing changed for a fleet
# that carries a single distorder
# B. each additional vessel adds exactly 1.0
# C. the fader's full travel reaches the WIDENED ceiling, not the old one
# D. losing a vessel mid-boost pulls the fleet back down to the new
# ceiling rather than leaving it faster than its crew can sustain
# E. full boost still costs the same energy per second however wide the
# ceiling is - more vessels buy more speed, not cheaper speed
var game_state: Node
var main: Node
var fleet: Node
var fader: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.reset_progression()
game_state.vessel_levels["unlock_distorder"] = 1
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500)
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
fader = main.get_node("UI/SpeedFader")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
# Room for a fleet of distorders.
game_state.unlocked.append_array(["wing", "squadron", "armada"])
# --- A. one vessel is unchanged ------------------------------
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER]))
if not is_equal_approx(game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER):
return _fail("one distorder gives a ceiling of %.1f, expected the unchanged %.1f" \
% [game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER])
print("1 distorder -> ceiling %.1fx" % game_state.max_fleet_speed_multiplier)
# --- B. each additional vessel adds 1.0 ----------------------
for extra in range(1, 4):
fleet.add_ship(GameState.VESSEL_DISTORDER)
var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER + float(extra)
if not is_equal_approx(game_state.max_fleet_speed_multiplier, expected):
return _fail("%d distorders give a ceiling of %.1f, expected %.1f" \
% [extra + 1, game_state.max_fleet_speed_multiplier, expected])
print("%d distorders -> ceiling %.1fx" % [extra + 1, game_state.max_fleet_speed_multiplier])
# --- C. the fader reaches the widened ceiling ----------------
# Dragging the track to its top must mean the CURRENT ceiling, or
# the extra vessels would be unreachable through the only control
# that exists for them.
fader._process(0.0)
if not fader.visible:
return _fail("the fader is hidden with four distorder vessels crewed")
var press := InputEventScreenTouch.new()
press.index = 3
press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0)
press.pressed = true
main._handle_touch(press)
var drag := InputEventScreenDrag.new()
drag.index = 3
drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0)
main._handle_drag(drag)
if not is_equal_approx(game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier):
return _fail("dragging the fader to the top reached %.2fx, not the ceiling %.2fx" \
% [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier])
print("fader top reaches %.1fx" % game_state.fleet_speed_multiplier)
# --- E. full boost costs the same per second -----------------
# The drain is normalized against the current ceiling, so sitting
# at full boost is the same price whatever the ceiling is.
var wide_energy: int = _energy_spent_over(1.0)
# --- D. losing vessels pulls the boost back down -------------
var boosted_before: float = game_state.fleet_speed_multiplier
for i in range(fleet.ships.size() - 1, -1, -1):
if is_instance_valid(fleet.ships[i]) and fleet.ships[i].vessel_type == GameState.VESSEL_DISTORDER:
fleet.store_ship(i)
break
if game_state.max_fleet_speed_multiplier >= boosted_before:
return _fail("losing a distorder left the ceiling at %.1f, no lower than the %.1f the fleet was already doing" \
% [game_state.max_fleet_speed_multiplier, boosted_before])
if game_state.fleet_speed_multiplier > game_state.max_fleet_speed_multiplier + 0.001:
return _fail("the fleet is still doing %.2fx after its ceiling dropped to %.2fx" \
% [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier])
print("losing one pulled %.1fx down to the new %.1fx ceiling" \
% [boosted_before, game_state.fleet_speed_multiplier])
# Same measurement at the narrower ceiling, also at full boost.
game_state.set_fleet_speed_multiplier(game_state.max_fleet_speed_multiplier)
var narrow_energy: int = _energy_spent_over(1.0)
if absi(wide_energy - narrow_energy) > 1:
return _fail("full boost cost %d energy with a wide ceiling and %d with a narrow one - extra vessels changed the PRICE, not just the speed" \
% [wide_energy, narrow_energy])
print("full boost costs %d/%d energy per second at either ceiling" % [wide_energy, narrow_energy])
print("TEST_DISTORDER_STACKING: PASS")
quit(0)
return true
return false
# Energy actually consumed by holding the current multiplier for `seconds`,
# driven directly so the measurement does not depend on frame pacing.
func _energy_spent_over(seconds: float) -> int:
var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
var step: float = 0.05
var t: float = 0.0
while t < seconds:
game_state._advance_distorder_drain(step)
t += step
return before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
func _fail(message: String) -> bool:
print("TEST_DISTORDER_STACKING: FAIL (%s)" % message)
quit(1)
return true

View file

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

View file

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

View file

@ -0,0 +1,115 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_lab_research_reward.gd
# A harder enemy research lab should be worth more research points, and how
# hard a lab is depends on the layout authored for it - how many capture
# points, how many defenders - which the planet TYPE cannot know. So the
# reward is a per-map value set in the map editor, falling back to the
# planet type's flat rate when a setup does not express an opinion.
#
# Covers:
# A. a setup carries its own reward and it survives a save/load round trip
# B. a map's reward overrides the planet type's flat rate on conquest
# C. a setup that says nothing (0) still gets the type's rate, so every
# map authored before this existed keeps its old value
# D. the editor's control cycles the value and reports it
var game_state: Node
var editor: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.reset_new_game()
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. the field exists and round-trips ---------------------
var setup: Resource = load("res://PlanetSetup.gd").new()
setup.setup_id = "reward_probe"
setup.display_name = "Reward Probe"
setup.surface_profile_id = "rocky"
setup.capture_points = PackedVector2Array([Vector2(10, 10), Vector2(-10, -10)])
setup.research_point_reward = 5
var path: String = "user://test_reward_probe.tres"
if ResourceSaver.save(setup, path) != OK:
return _fail("could not save a setup carrying a research reward")
var reloaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
if reloaded == null or int(reloaded.research_point_reward) != 5:
return _fail("the reward did not survive a save/load round trip")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
print("a setup carries its own reward (%d) across a save/load" % reloaded.research_point_reward)
# --- B. the map's reward beats the planet type's rate --------
# A research_lab planet's own definition pays a flat 1.
var flat_rate: int = int(load("res://ResearchLabPlanetDefinition.tres").conquest_research_points)
game_state.research_points = 0
game_state.register_capture_points("hard_lab", 2, "research_lab", 6)
game_state.mark_point_captured("hard_lab", 0)
game_state.mark_point_captured("hard_lab", 1)
if game_state.research_points != 6:
return _fail("a hard lab authored at 6 points awarded %d" % game_state.research_points)
if flat_rate >= 6:
return _fail("the planet type's flat rate (%d) is not below the authored one - this test proves nothing" % flat_rate)
print("a hard lab authored at 6 pays 6, over the planet type's flat %d" % flat_rate)
# --- C. silence still means the type's rate ------------------
game_state.research_points = 0
game_state.register_capture_points("plain_lab", 1, "research_lab", 0)
game_state.mark_point_captured("plain_lab", 0)
if game_state.research_points != flat_rate:
return _fail("a setup with no authored reward paid %d, expected the type's %d" \
% [game_state.research_points, flat_rate])
print("a setup with no opinion still pays the planet type's %d" % flat_rate)
# A harder map must never be worth LESS than an easier one.
if 6 <= flat_rate:
return _fail("authoring a harder lab did not raise its worth")
editor = load("res://PlanetSetupEditor.tscn").instantiate()
root.add_child(editor)
current_scene = editor
elapsed = 0.0
stage = 1
return false
1:
# --- D. the editor can set it --------------------------------
if elapsed < 0.2:
return false
editor.new_setup("reward_edit", "Reward Edit", "rocky")
var seen: Array = []
for i in range(editor.REWARD_OPTIONS.size()):
seen.append(int(editor.current_setup.research_point_reward))
editor._on_reward_pressed()
# Cycling the whole list must come back round to where it began.
if int(editor.current_setup.research_point_reward) != seen[0]:
return _fail("cycling every reward option did not return to the start")
if seen.size() != editor.REWARD_OPTIONS.size() or seen.max() <= 0:
return _fail("the reward control did not offer a range of values: %s" % [seen])
editor.current_setup.research_point_reward = 3
editor._refresh_reward_label()
if not "3" in editor.reward_value_label.text:
return _fail("the editor does not report the chosen reward: '%s'" % editor.reward_value_label.text)
editor.current_setup.research_point_reward = 0
editor._refresh_reward_label()
if not "DEFAULT" in editor.reward_value_label.text:
return _fail("the editor does not distinguish 'use the planet type's rate': '%s'" \
% editor.reward_value_label.text)
print("the map editor cycles %s and reports the choice" % [seen])
print("TEST_LAB_RESEARCH_REWARD: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_LAB_RESEARCH_REWARD: FAIL (%s)" % message)
quit(1)
return true

View file

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

View file

@ -5,9 +5,8 @@ extends SceneTree
# Verifies: # Verifies:
# 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and # 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and
# reaches PlanetSetupEditor.tscn (#48) # reaches PlanetSetupEditor.tscn (#48)
# 2. Quit's visibility follows OS.has_feature("web") (this platform is # 2. there is no Quit entry in any build - a web build has nothing to quit
# not a web export, so this run exercises the "visible" branch; the # to, and Android's back gesture already does it
# condition itself is what's under test, not the concrete boolean)
# 3. Play resets GameState to a fresh game AND reaches Main.tscn # 3. Play resets GameState to a fresh game AND reaches Main.tscn
var menu: Node var menu: Node
@ -43,8 +42,8 @@ func _process(delta: float) -> bool:
return _fail("map editor entry visibility did not follow is_developer_build()") return _fail("map editor entry visibility did not follow is_developer_build()")
if menu.dev_button.visible != developing or menu.dev_label.visible != developing: if menu.dev_button.visible != developing or menu.dev_label.visible != developing:
return _fail("dev mode entry visibility did not follow is_developer_build()") return _fail("dev mode entry visibility did not follow is_developer_build()")
if menu.quit_button.visible != (not OS.has_feature("web")): if menu.has_node("UI/QuitButton") or menu.has_node("UI/QuitLabel"):
return _fail("quit button visibility did not follow OS.has_feature('web')") return _fail("the menu still carries a Quit entry")
_press(menu.editor_button) _press(menu.editor_button)
elapsed = 0.0 elapsed = 0.0