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>
327 lines
12 KiB
GDScript
327 lines
12 KiB
GDScript
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. The optional vessel-type list preserves the exact
|
|
# mixed fleet through scene swaps; a fresh start still defaults each slot
|
|
# to an interceptor.
|
|
# 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 _ready() -> void:
|
|
# Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling
|
|
# path - enemies parented under different hosts (e.g. a planet surface's
|
|
# SurfaceFeatures) need this to find their target.
|
|
add_to_group("player_fleet")
|
|
# Researching a vessel upgrade improves the ships already flying, not
|
|
# just the next one built (#5).
|
|
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
|
|
|
|
|
|
func _on_vessel_upgraded(_id: String) -> void:
|
|
for ship in ships:
|
|
if is_instance_valid(ship):
|
|
ship.apply_vessel_stats()
|
|
|
|
|
|
func spawn_ships(count: int, center: Vector2 = Vector2.ZERO,
|
|
vessel_types: PackedStringArray = PackedStringArray()) -> void:
|
|
for ship in ships:
|
|
if is_instance_valid(ship):
|
|
ship.queue_free()
|
|
ships.clear()
|
|
formation_offsets.clear()
|
|
|
|
var spawn_types: PackedStringArray = PackedStringArray()
|
|
if vessel_types.is_empty():
|
|
for i in range(count):
|
|
spawn_types.append(GameState.VESSEL_INTERCEPTOR)
|
|
else:
|
|
for vessel_type in vessel_types:
|
|
if GameState.vessel_is_fleet_eligible(vessel_type):
|
|
spawn_types.append(vessel_type)
|
|
if spawn_types.is_empty():
|
|
spawn_types.append(GameState.VESSEL_INTERCEPTOR)
|
|
var n: int = clamp(spawn_types.size(), 1, GameState.max_fleet_size())
|
|
for i in range(n):
|
|
var offset: Vector2 = GameState.formation[i]
|
|
var ship: Area2D = ship_scene.instantiate()
|
|
ship.vessel_type = spawn_types[i]
|
|
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
|
|
_sync_composition_state()
|
|
|
|
|
|
func vessel_types() -> PackedStringArray:
|
|
var result: PackedStringArray = PackedStringArray()
|
|
for ship in ships:
|
|
if is_instance_valid(ship):
|
|
result.append(String(ship.vessel_type))
|
|
return result
|
|
|
|
|
|
func _sync_composition_state() -> void:
|
|
GameState.sync_fleet_cargo_capacity(vessel_types())
|
|
# Composition changes are authoritative; scene teardown is not. Keeping
|
|
# 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.
|
|
# 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"):
|
|
GameState.set_fleet_speed_multiplier(1.0)
|
|
|
|
|
|
func has_vessel_type(vessel_type: String) -> bool:
|
|
for ship in ships:
|
|
if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type:
|
|
return true
|
|
return false
|
|
|
|
|
|
func first_vessel_of_type(vessel_type: String) -> Node2D:
|
|
for ship in ships:
|
|
if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type:
|
|
return ship
|
|
return null
|
|
|
|
|
|
func has_capability(capability: String) -> bool:
|
|
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:
|
|
for ship in ships:
|
|
if is_instance_valid(ship) and ship.has_capability(capability):
|
|
return ship
|
|
return null
|
|
|
|
|
|
func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool:
|
|
# Adds one vessel in the next free formation slot (bought from a ship
|
|
# crafter). Returns false when the wedge is already full.
|
|
if not GameState.vessel_is_fleet_eligible(vessel_type) \
|
|
or ships.size() >= GameState.max_fleet_size():
|
|
return false
|
|
var offset: Vector2 = GameState.formation[ships.size()]
|
|
var ship: Area2D = ship_scene.instantiate()
|
|
# Before add_child, so Ship._ready() builds the right vessel - the shield
|
|
# ring is created there and only for a shield vessel.
|
|
ship.vessel_type = vessel_type
|
|
# 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)
|
|
_sync_composition_state()
|
|
return true
|
|
|
|
|
|
func store_ship(index: int) -> String:
|
|
# A fleet always keeps one ship deployed. Removing the last one would trip
|
|
# the wiped-out-fleet safety net on the next frame and immediately spawn a
|
|
# replacement, which is the opposite of storing it at the mothership.
|
|
if ships.size() <= 1 or index < 0 or index >= ships.size():
|
|
return ""
|
|
var ship: Node = ships[index]
|
|
if not is_instance_valid(ship):
|
|
return ""
|
|
var vessel_type: String = String(ship.vessel_type)
|
|
ships.remove_at(index)
|
|
formation_offsets.remove_at(index)
|
|
ship.queue_free()
|
|
apply_formation()
|
|
_sync_composition_state()
|
|
return vessel_type
|
|
|
|
|
|
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.max_fleet_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. #59: multiplied
|
|
# at the point of use, not baked into cruise_speed itself, so the base
|
|
# value stays stable regardless of the current boost.
|
|
anchor += steer_direction * _effective_cruise_speed() * delta
|
|
fleet_target = anchor
|
|
_assign_slot_targets()
|
|
|
|
|
|
func _effective_cruise_speed() -> float:
|
|
return cruise_speed * GameState.fleet_speed_multiplier
|
|
|
|
|
|
func _assign_slot_targets() -> void:
|
|
var effective_cruise_speed: float = _effective_cruise_speed()
|
|
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 * effective_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:
|
|
# A ship's `velocity` is already its true motion, including the
|
|
# fleet's travel, because Ship feeds the slot velocity forward into
|
|
# its own steering rather than carrying it in a separate field. So
|
|
# stopping is only a matter of telling it the slot has halted and
|
|
# aiming it at the point its own braking curve reaches.
|
|
#
|
|
# This used to do `ship.velocity += ship.fleet_velocity` here, to
|
|
# compensate for `velocity` reading zero while the ship was in fact
|
|
# cruising (Ship snapped to its slot every tick and zeroed it). That
|
|
# compensation was an unbounded ACCUMULATOR: every release added a
|
|
# whole cruise velocity, the next steer refilled fleet_velocity, and
|
|
# tapping a direction repeatedly stacked them up faster than
|
|
# `acceleration` could bleed them off - so a vessel could be flung
|
|
# along at many times its own top speed, and ships fell out of sync
|
|
# because each bled off at a rate set by its own distance to its
|
|
# slot. Ship now bounds its total speed every tick as well, so
|
|
# neither half of that can come back.
|
|
var motion: Vector2 = ship.velocity
|
|
# The fleet is not cruising any more, so the slot is standing still.
|
|
ship.fleet_velocity = Vector2.ZERO
|
|
var speed: float = motion.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 + motion.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)
|
|
_sync_composition_state()
|
|
|
|
|
|
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()
|