Compare commits

..
18 changed files with 106 additions and 971 deletions

View file

@ -73,35 +73,6 @@ const LEVELS: Dictionary = {
BLACK_HOLE: -11.0,
}
# Forgejo #229: how long a sound blocks another of its own kind.
#
# Every hull fires its own gun and asks for its own shot sound, so a fleet
# firing a volley started one voice per ship - the same sample, in the same
# frame, at the same level. Those amplitudes ADD, so the volley got louder
# for every ship added, and a full fleet was the loudest thing in the game
# by a wide margin.
#
# A volley is one event to the ear, so it gets one voice. The window is
# shorter than any weapon's cooldown, so consecutive volleys are still heard
# individually - it collapses "six guns at once", never "six shots in a row".
#
# Deaths and the boss scream are absent deliberately: several things dying
# together IS several events, and each is rare enough not to stack.
const COALESCE_MSEC: Dictionary = {
SHOT: 70,
# Ore comes in bursts when a rock breaks, and a burst is one pickup to
# the ear. Quieter than a shot but far more frequent (see LEVELS).
PICKUP: 60,
# Rocks break in twos and threes - that is in the pitch spread comment
# below, and it is the same stacking problem.
ASTEROID_BREAK: 60,
}
# When each sound last started, keyed by id. Static because play() is, and
# the pool outlives every scene that uses it.
static var _last_started_msec: Dictionary = {}
# Shots vary a little in pitch so a stream of them does not turn into one
# flat machine-gun note. The deaths do not: they are events, and a wobbling
# explosion sounds broken rather than varied.
@ -157,13 +128,6 @@ static func play(tree: SceneTree, id: String) -> void:
var stream: AudioStream = SOUNDS[id] as AudioStream
if stream == null:
return
var window: int = int(COALESCE_MSEC.get(id, 0))
if window > 0:
var now: int = Time.get_ticks_msec()
var last: int = int(_last_started_msec.get(id, -1000000))
if now - last < window:
return
_last_started_msec[id] = now
var pool: Node = _pool(tree)
var voice: AudioStreamPlayer = null
for child in pool.get_children():

View file

@ -92,11 +92,8 @@ const KEY_DIRECTIONS: Dictionary = {
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
}
# Z and C rather than Q and E: #231 gives Q, E and space to the abilities, so
# that each one is triggered directly instead of being selected first. Pivot
# stays under the same hand, one row down from the movement keys.
const KEY_PIVOT_CW: int = KEY_C
const KEY_PIVOT_CCW: int = KEY_Z
const KEY_PIVOT_CW: int = KEY_E
const KEY_PIVOT_CCW: int = KEY_Q
const WHEEL_ZOOM_STEP: float = 1.12

View file

@ -25,18 +25,6 @@ extends Node2D
# as "nonexistent function" on a node that looks fine in the scene.
const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController", "DashController"]
# Forgejo #231. Selecting a weapon and then clicking is two acts for one
# shot, and on a keyboard the selection is invisible until you look at the
# buttons - which is what made the abilities "hard to use". Each one now has
# a key that fires it outright, at wherever the mouse already is.
#
# Indices into ABILITY_ORDER above, so these stay in step with it.
const ABILITY_KEYS: Dictionary = {
KEY_Q: 0,
KEY_E: 1,
KEY_SPACE: 2,
}
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var fire_stick_controller: Node = get_node_or_null("../FireStickController")
@ -109,19 +97,6 @@ func _unhandled_input(event: InputEvent) -> void:
aim_world = get_global_mouse_position()
return
if event is InputEventKey and event.pressed and not event.echo:
# Fired where the mouse already points, so pressing the key is the
# whole action. fire_at() carries every guard the click path uses -
# no hull ready, cannot afford it, a panel covering the button.
if ABILITY_KEYS.has(event.keycode):
var slot: int = int(ABILITY_KEYS[event.keycode])
if slot < abilities.size():
aim_world = get_global_mouse_position()
abilities[slot].fire_at(aim_world)
get_viewport().set_input_as_handled()
return
# The number keys still choose what a CLICK does. They are not what
# #231 is about, and removing them would take the click path's
# abilities with them.
var index: int = event.keycode - KEY_1
if index >= 0 and index < weapon_count() and select_weapon(index):
get_viewport().set_input_as_handled()

View file

@ -3275,30 +3275,14 @@ func wave_strength(fleet_size: int) -> int:
# roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE
# the player meets chasers and nothing else, however long they take, and each
# vessel past the starting three opens exactly one new kind of trouble.
#
# min_wave is the third gate (#233). min_fleet alone still let a player who
# had grown early meet two kinds of trouble in the FIRST wave they ever saw -
# a fleet of four opened on two lancers and three chasers. Whatever the fleet
# looks like, the opening waves teach one enemy at a time.
const WAVE_ROSTER: Array = [
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6, "min_wave": 4},
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5, "min_wave": 3},
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4, "min_wave": 2},
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0, "min_wave": 0},
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6},
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5},
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4},
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0},
]
const WAVE_MAX_ENEMIES: int = 18
# How many can arrive in one wave, early on (#233). The variety gate above
# fixed "too complex" but made the other half of that ticket WORSE: budget
# that used to buy a lancer now buys two more chasers, so a grown fleet's
# FIRST wave went from a mixed six to seven chasers at once.
#
# Strength still rises with the fleet and the wave number - it simply cannot
# all be spent at once yet. What the budget cannot buy is not carried over;
# an early wave is meant to be smaller, not delayed.
func wave_max_enemies() -> int:
return mini(WAVE_MAX_ENEMIES, 2 + wave_number * 2)
func wave_composition(fleet_size: int) -> PackedStringArray:
var budget: int = wave_strength(fleet_size)
@ -3306,15 +3290,13 @@ func wave_composition(fleet_size: int) -> PackedStringArray:
var out: PackedStringArray = PackedStringArray()
for entry in WAVE_ROSTER:
if strength < int(entry.min_strength) \
or fleet_size < int(entry.get("min_fleet", 0)) \
or wave_number < int(entry.get("min_wave", 0)):
or fleet_size < int(entry.get("min_fleet", 0)):
continue
var cost: int = int(entry.cost)
# The cheapest tier is last and takes whatever is left, so the budget
# is always spent rather than rounded away.
var share: int = budget if String(entry.id) == "chaser" else budget / 2
var ceiling: int = wave_max_enemies()
while share >= cost and budget >= cost and out.size() < ceiling:
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
out.append(String(entry.id))
budget -= cost
share -= cost

View file

@ -27,59 +27,15 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
var wave_enemies: Array = []
# Seconds between one hull of a wave landing and the next.
const ARRIVAL_INTERVAL: float = 0.45
# Hulls of the current wave that have not landed yet, with the position each
# was assigned when the wave was aimed.
var arriving: Array = []
var arrival_timer: float = 0.0
func _process(delta: float) -> void:
func _process(_delta: float) -> void:
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
if GameState.wave_pending:
_launch_wave()
return
_land_arrivals(delta)
# `arriving` as well as `wave_enemies`: between the first hull landing and
# the last, the field can legitimately be empty of live enemies, and
# clearing the wave there would end it before it had finished arriving.
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE \
and wave_enemies.is_empty() and arriving.is_empty():
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty():
GameState.wave_cleared()
# Forgejo #233: a wave used to appear entirely within one frame, as a closed
# ring around the fleet. That reads as a wall rather than as an attack, and
# it was the "arrive at the same time" half of the complaint - the other half
# being how many there were, which wave_composition now limits.
#
# The first hull still lands immediately, so a wave never feels delayed; the
# rest follow at ARRIVAL_INTERVAL. Same enemies, same bearing, same total -
# they just do not all exist at once.
func _land_arrivals(delta: float) -> void:
if arriving.is_empty():
return
arrival_timer -= delta
while arrival_timer <= 0.0 and not arriving.is_empty():
_spawn_arrival(arriving.pop_front())
arrival_timer += ARRIVAL_INTERVAL
func _spawn_arrival(entry: Dictionary) -> void:
var enemy: Node2D = enemy_scene.instantiate()
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
enemy.enemy_id = String(entry.id)
# #113: a wave is an attack on what the player has built. It converges
# on the nearest structure from wherever it spawned rather than
# waiting to be flown near.
enemy.relentless = true
enemy.position = entry.at
get_parent().add_child(enemy)
wave_enemies.append(enemy)
# Public so a test can put a wave in the world without waiting out the peace.
func _launch_wave() -> void:
if fleet == null or fleet.ships.is_empty():
@ -93,20 +49,18 @@ func _launch_wave() -> void:
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
var centre: Vector2 = fleet.get_centroid()
var bearing: float = randf() * TAU
# Positions are settled now, while the fleet is where the wave was aimed
# at - not as each hull lands, or a moving fleet would drag the later
# arrivals around behind it and the ring would smear.
arriving.clear()
for i in range(composition.size()):
# Spread around one bearing rather than scattered at random: a wave
# should read as an attack arriving, not as the map filling up.
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
arriving.append({
"id": composition[i],
"at": centre + Vector2(cos(angle), sin(angle)) * radius,
})
# The first lands at once, so a wave is never felt as late.
if not arriving.is_empty():
_spawn_arrival(arriving.pop_front())
arrival_timer = ARRIVAL_INTERVAL
var enemy: Node2D = enemy_scene.instantiate()
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
enemy.enemy_id = composition[i]
# #113: a wave is an attack on what the player has built. It converges
# on the nearest structure from wherever it spawned rather than
# waiting to be flown near.
enemy.relentless = true
enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
get_parent().add_child(enemy)
wave_enemies.append(enemy)

View file

@ -9,23 +9,7 @@ const RESOURCE_PICKUP_VFX = preload("res://vfx/ResourcePickupVFX.gd")
@export var fleet_path: NodePath = NodePath("../Fleet")
@onready var fleet: Node2D = get_node(fleet_path)
const SOURCE_COLOUR: Color = Color(0.85, 0.52, 1.0)
const CORE_COLOUR: Color = Color(0.15, 0.03, 0.22)
# Offsets from a vent's centre, and each bubble's radius.
const SOURCE_BUBBLES: Array = [
[Vector2(-20.0, 8.0), 20.0],
[Vector2(18.0, 14.0), 27.0],
[Vector2(17.0, -22.0), 15.0],
]
const BUBBLES_PER_VENT: int = 4
const BUBBLE_MIN_RADIUS: float = 5.0
const BUBBLE_MAX_RADIUS: float = 14.0
const EMISSION_COLOUR: Color = Color(0.92, 0.68, 1.0)
var vents: Array[Vector2] = []
# One row of rising-bubble pieces per vent.
var bubbles: Array = []
var progress: float = 0.0
var emission_phase: float = 0.0
func _ready() -> void:
@ -40,73 +24,10 @@ func _ready() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name)
for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650)))
_build_sources()
_build_bubbles()
_place_bubbles()
# The sources never move, never change colour and never fade - they were
# simply being re-tessellated every frame alongside the bubbles rising out of
# them, because one _draw() covered both. Eighteen glowing polygons a frame,
# each of which NeonDraw expands into four antialiased polylines. Rasterised
# once into a piece each now, per the rule issues #6 and #10 set.
func _build_sources() -> void:
for vent in vents:
var piece: NeonPiece = NeonPiece.create(self, vent, SOURCE_COLOUR)
piece.core_color = CORE_COLOUR
piece.z_index = -1
for bubble in SOURCE_BUBBLES:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(Vector2(bubble[0]) + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
piece.add_polygon(ring, 0.9, 0.6)
# One piece per rising bubble, built as a UNIT circle and thereafter only
# moved, scaled and faded. The bubble genuinely changes size and opacity as
# it climbs, which is why this used to redraw - but size is scale and opacity
# is modulate, and neither needs the geometry rebuilding.
func _build_bubbles() -> void:
for vent in vents:
var row: Array = []
for i in range(BUBBLES_PER_VENT):
var piece: NeonPiece = NeonPiece.create(self, vent, EMISSION_COLOUR)
piece.core_color = CORE_COLOUR
piece.z_index = -1
# Built at the LARGEST size a bubble reaches and scaled DOWN,
# never up. Scaling a piece scales its stroke with it, and a unit
# circle blown up to radius 14 took NeonDraw's widths with it -
# a hundred pixels of glow around a fourteen pixel bubble, which
# rendered as a bright blob rather than a bubble. Downward, the
# stroke only ever thins, which is what a smaller bubble wants.
var ring := PackedVector2Array()
for point in range(10):
var angle: float = TAU * float(point) / 10.0
ring.append(Vector2(cos(angle), sin(angle)) * BUBBLE_MAX_RADIUS)
piece.add_polygon(ring, 0.75, 0.45)
row.append(piece)
bubbles.append(row)
# Where one bubble sits, how big it is and how faded, for a point in its
# climb. Unchanged arithmetic - only the thing it is applied to changed.
func _place_bubbles() -> void:
for v in range(vents.size()):
for i in range(BUBBLES_PER_VENT):
var travel: float = fmod(emission_phase + float(i) * 0.25, 1.0)
var piece: NeonPiece = bubbles[v][i]
piece.position = vents[v] + Vector2(
sin((travel + float(i)) * TAU) * 11.0,
-48.0 - travel * 92.0)
var radius: float = lerpf(BUBBLE_MIN_RADIUS, BUBBLE_MAX_RADIUS, travel)
var k: float = radius / BUBBLE_MAX_RADIUS
piece.scale = Vector2(k, k)
piece.modulate.a = 1.0 - travel * 0.72
queue_redraw()
func _process(delta: float) -> void:
emission_phase = fmod(emission_phase + delta * 0.55, 1.0)
# Node properties only. Nothing is rasterised after the first frame.
_place_bubbles()
queue_redraw()
for vent in vents:
if fleet.get_centroid().distance_to(vent) < 90.0:
progress += delta
@ -121,3 +42,43 @@ func _process(delta: float) -> void:
to_global(vent), GameState.RESOURCE_GAS, taken)
return
progress = 0.0
func _draw() -> void:
for vent in vents:
_draw_gas_source(vent)
_draw_emission(vent)
func _draw_gas_source(centre: Vector2) -> void:
var colour := Color(0.85, 0.52, 1.0)
var core := Color(0.15, 0.03, 0.22)
var bubbles: Array = [
[centre + Vector2(-20.0, 8.0), 20.0],
[centre + Vector2(18.0, 14.0), 27.0],
[centre + Vector2(17.0, -22.0), 15.0],
]
for bubble in bubbles:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
NeonDraw.draw_glowing_polygon(self, ring, colour, 0.9, core, 0.6)
func _draw_emission(centre: Vector2) -> void:
# Small bubbles continuously rise out of every source. This remains
# animated even when the fleet is not collecting, so the vent reads as
# an active gas generator rather than a static decoration.
var core := Color(0.15, 0.03, 0.22)
for i in range(4):
var travel: float = fmod(emission_phase + float(i) * 0.25, 1.0)
var bubble_centre := centre + Vector2(
sin((travel + float(i)) * TAU) * 11.0,
-48.0 - travel * 92.0
)
var radius: float = lerpf(5.0, 14.0, travel)
var colour := Color(0.92, 0.68, 1.0, 1.0 - travel * 0.72)
var ring := PackedVector2Array()
for point in range(10):
var angle: float = TAU * float(point) / 10.0
ring.append(bubble_centre + Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_polygon(self, ring, colour, 0.75, core, 0.45)

View file

@ -1,121 +0,0 @@
extends SceneTree
# Forgejo #231: on a computer each ability has a key that fires it outright.
#
# Selecting a weapon with a number key and then clicking is two acts for one
# shot, and the selection is invisible until you look at the buttons - which
# is what made the abilities hard to use with a keyboard. Q, E and space now
# fire the gas bomb, the laser and the dash where the mouse already points.
#
# Covers:
# A. the three keys map to the three abilities, in the documented order
# B. pressing one fires THAT ability and no other
# C. it fires at the mouse, so pointing is the aim
# D. Q and E no longer pivot, which is what they used to do
# E. the number keys still choose what a CLICK does - #231 replaces the
# selection as a way to FIRE, not the click path itself
# F. touch is untouched: on a phone these keys do nothing
var scene: Node
var mouse: Node
var controller: Node
var game_state: Node
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.reset_new_game()
game_state.intro_active = false
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
mouse = scene.get_node("MouseCombatController")
# GameController is the ROOT of Main.tscn, not a child of it - the same
# script roots the planet surface too.
controller = scene
func _key(code: int) -> InputEventKey:
var event := InputEventKey.new()
event.keycode = code
event.pressed = true
return event
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.4:
return false
# --- A. the mapping is the documented one ----------------------------
if mouse.ABILITY_ORDER.size() != 3:
return _fail("expected 3 abilities, found %d" % mouse.ABILITY_ORDER.size())
var expected: Dictionary = {KEY_Q: "GasBombController", KEY_E: "LaserController",
KEY_SPACE: "DashController"}
for code in expected:
if not mouse.ABILITY_KEYS.has(code):
return _fail("key %d is not bound to an ability" % code)
var slot: int = int(mouse.ABILITY_KEYS[code])
if String(mouse.ABILITY_ORDER[slot]) != String(expected[code]):
return _fail("key %d fires %s, expected %s" \
% [code, mouse.ABILITY_ORDER[slot], expected[code]])
print("Q, E and space map to the bomb, the laser and the dash")
# --- D. and they no longer pivot --------------------------------------
if controller.KEY_PIVOT_CW in [KEY_Q, KEY_E, KEY_SPACE] \
or controller.KEY_PIVOT_CCW in [KEY_Q, KEY_E, KEY_SPACE]:
return _fail("pivot still uses an ability key (cw=%d ccw=%d)" \
% [controller.KEY_PIVOT_CW, controller.KEY_PIVOT_CCW])
print("pivot moved to %d/%d, clear of the ability keys" \
% [controller.KEY_PIVOT_CCW, controller.KEY_PIVOT_CW])
# --- B/C. the KEY itself reaches that ability, aimed at the mouse -----
# Driven through _unhandled_input rather than by calling fire_at(), or
# this would prove the abilities work and nothing about the binding.
#
# Observed through aim_world: the ability branch overwrites it with the
# live cursor before firing, so a value that survives means the branch
# never ran. The abilities themselves are locked in a fresh game, so
# fire_at() correctly refuses - the wiring is what is under test here.
mouse.pointer_mode = 1 # PointerMode.ON - a headless display reports no mouse
var sentinel := Vector2(98765.0, -4321.0)
for code in [KEY_Q, KEY_E, KEY_SPACE]:
mouse.aim_world = sentinel
mouse._unhandled_input(_key(code))
if mouse.aim_world == sentinel:
var slot: int = int(mouse.ABILITY_KEYS[code])
return _fail("key %d never reached %s" % [code, mouse.ABILITY_ORDER[slot]])
print("Q, E and space each reach their ability through the input path")
# A key bound to nothing must fall through to the number-key branch
# rather than being swallowed by the ability one.
mouse.aim_world = sentinel
mouse._unhandled_input(_key(KEY_K))
if mouse.aim_world != sentinel:
return _fail("an unbound key was treated as an ability trigger")
print("an unbound key is left alone")
# --- E. the number keys still select for the click path ---------------
if not mouse.select_weapon(1):
return _fail("the number keys no longer select a weapon for a click")
if mouse.selected_weapon != 1:
return _fail("selecting weapon 1 left selected_weapon at %d" % mouse.selected_weapon)
print("the number keys still choose what a click fires")
# --- F. and none of this reaches a phone ------------------------------
mouse.pointer_mode = 2 # PointerMode.OFF
if mouse.is_enabled():
return _fail("mouse combat is live with the pointer mode off")
print("with a touch pointer the keys are inert")
print("TEST_ABILITY_KEYS: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_ABILITY_KEYS: FAIL (%s)" % message)
quit(1)
return true

View file

@ -1,150 +0,0 @@
extends SceneTree
# Forgejo #230: the upkeep menu shows what the fleet costs IN TOTAL, laid out
# as a table.
#
# It listed each berth and never added them up, so the bill had to be done in
# the player's head. The rows were also one Label padded with %-16s, which
# lines up only in a monospace font - the theme's is CarroisGothicSC, which
# is proportional, so the "columns" never actually aligned. Worse, that Label
# had autowrap on, so a long row folded and the table lost its shape.
#
# Covers:
# A. a row per paying berth, in a real grid
# B. a TOTAL row, and it equals the sum of the rows
# C. the headline states both the per-charge bill and the per-minute rate
# D. an unpaid berth is marked
# E. with nothing to charge, the table gives way to an explanation
# F. the sheet is not rebuilt on every frame - the menu refreshes at frame
# rate and these numbers move every 25 seconds
var game_state: Node
var scene: Node
var menu: Control
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_new_game()
game_state.reset_quests()
game_state.intro_active = false
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
menu = scene.get_node("UI/FleetUpkeepMenu")
func _rows() -> Array:
# Grid cells come out in reading order; COLUMNS wide makes one row.
var width: int = menu.sheet.columns
var cells: Array = []
for child in menu.sheet.get_children():
cells.append(String((child as Label).text))
var rows: Array = []
for i in range(0, cells.size(), width):
rows.append(cells.slice(i, i + width))
return rows
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.4:
return false
# --- E. nothing to charge -------------------------------------------
menu.open_menu()
menu.refresh()
if not menu.empty_label.visible or menu.sheet.visible:
return _fail("with no berths the table is shown instead of an explanation")
if not menu.total_label.text.contains("NOTHING"):
return _fail("with no berths the headline reads '%s'" % menu.total_label.text)
print("with nothing to charge: '%s'" % menu.total_label.text)
# Two offices, two crewed berths - the second costs more than the first.
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2.ZERO)
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(600.0, 0.0))
game_state.fleet_vessel_types = PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR,
GameState.VESSEL_INTERCEPTOR])
menu.refresh()
var breakdown: Dictionary = game_state.fleet_upkeep_breakdown()
if breakdown.berths.size() < 2:
return _fail("only %d berth(s) are being charged - that proves nothing about a total" \
% breakdown.berths.size())
# --- A. a row per berth, plus header and total ------------------------
var rows: Array = _rows()
if not menu.sheet.visible or menu.empty_label.visible:
return _fail("the table is hidden while berths are being charged")
if rows.size() != breakdown.berths.size() + 2:
return _fail("%d rows for %d berths - expected a header and a total too" \
% [rows.size(), breakdown.berths.size()])
if String(rows[0][0]) != "BERTH":
return _fail("the first row is not a header: %s" % str(rows[0]))
print("%d berths -> %d rows (header, %d berths, total)" \
% [breakdown.berths.size(), rows.size(), breakdown.berths.size()])
# --- B. and the total is the sum of the rows --------------------------
var total_row: Array = rows[rows.size() - 1]
if String(total_row[0]) != "TOTAL":
return _fail("the last row is '%s', not a total" % total_row[0])
var summed: int = 0
for berth in breakdown.berths:
for resource_type in berth.costs:
summed += int(berth.costs[resource_type])
var shown: int = 0
for word in String(total_row[2]).split(" "):
if word.is_valid_int():
shown += int(word)
if shown != summed:
return _fail("the total row reads '%s' (%d), but the berths add to %d" \
% [total_row[2], shown, summed])
if summed <= 0:
return _fail("the berths cost nothing, so a total proves nothing")
print("total row '%s' matches the %d the berths add to" % [total_row[2], summed])
# --- C. the headline carries both figures -----------------------------
var headline: String = menu.total_label.text
if not headline.contains("PER MINUTE") or not headline.contains("EVERY"):
return _fail("the headline does not state both the bill and the rate: '%s'" % headline)
print("headline: %s" % headline)
# --- F. and it is not rebuilt every frame -----------------------------
var first: Array = menu.sheet.get_children()
menu.refresh()
menu.refresh()
var again: Array = menu.sheet.get_children()
if first.size() != again.size():
return _fail("the row count changed across a refresh with no change behind it")
for i in range(first.size()):
if first[i] != again[i]:
return _fail("refreshing rebuilt the table though nothing changed")
print("refreshing twice reuses the same %d cells" % again.size())
# --- D. an unpaid berth is called out ---------------------------------
for building in game_state.buildings:
if String(building.type) == GameState.BUILDING_FLEET_OFFICE:
building["starved"] = true
menu.refresh()
var marked: bool = false
for row in _rows():
if String(row[4]) == "UNPAID":
marked = true
if not marked:
return _fail("a starved berth is not marked unpaid")
print("a starved berth is marked UNPAID")
print("TEST_FLEET_UPKEEP_SHEET: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_FLEET_UPKEEP_SHEET: FAIL (%s)" % message)
quit(1)
return true

View file

@ -4,7 +4,7 @@ extends SceneTree
# godot --headless -s res://tests/test_keyboard_input.gd
# Desktop/web controls. The phone build is driven entirely by touch, but the
# web build is played with a keyboard and mouse, so WASD (and the arrows)
# must steer the fleet, the pivot pair must turn the formation, and releasing
# must steer the fleet, Q/E must pivot the formation, and releasing the keys
# must stop - the stick has a centred-again signal to hook, the keyboard has
# nothing equivalent, so a missed release would leave the fleet flying off
# forever.
@ -127,32 +127,21 @@ func _process(delta: float) -> bool:
quit(1)
return true
# --- the pivot pair -------------------------------------------
# Read off the controller's own constants rather than naming the
# keys again here. #231 moved this pair from Q/E to Z/C because
# the abilities took Q, E and space, and a test that spelled the
# old keys out reported a broken pivot when the pivot was fine.
# --- Q/E pivot ------------------------------------------------
var game_state: Node = root.get_node("GameState")
var cw_key: int = controller.KEY_PIVOT_CW
var ccw_key: int = controller.KEY_PIVOT_CCW
if cw_key in [KEY_Q, KEY_E, KEY_SPACE] or ccw_key in [KEY_Q, KEY_E, KEY_SPACE]:
print("TEST_KEYBOARD_INPUT: FAIL (pivot is bound to an ability key)")
quit(1)
return true
var before: float = game_state.formation[1].angle()
_key(cw_key, true)
_key(cw_key, false)
_key(KEY_E, true)
_key(KEY_E, false)
var after_cw: float = game_state.formation[1].angle()
_key(ccw_key, true)
_key(ccw_key, false)
_key(KEY_Q, true)
_key(KEY_Q, false)
var after_ccw: float = game_state.formation[1].angle()
var cw_turn: float = angle_difference(before, after_cw)
var ccw_turn: float = angle_difference(after_cw, after_ccw)
print("key %d turned %.3f rad, key %d turned %.3f rad" \
% [cw_key, cw_turn, ccw_key, ccw_turn])
print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn])
if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(ccw_turn):
print("TEST_KEYBOARD_INPUT: FAIL (the pivot pair did not turn in opposite directions)")
print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)")
quit(1)
return true

View file

@ -262,12 +262,6 @@ func _process(delta: float) -> bool:
# the player now is a wave, so that is what has to be able to
# produce one - and it must not have displaced the chaser while
# doing it.
# Read past the openings: #233 gates each kind behind a minimum
# WAVE as well as a minimum fleet, so the first waves are chasers
# only however large the fleet is. This case is about whether a
# wave can field a lancer at all, not about when it starts to.
var settled_wave: int = game_state.wave_number
game_state.wave_number = 8
var kinds: Dictionary = {}
var lancer_at: int = 0
for size in range(1, 12):
@ -275,7 +269,6 @@ func _process(delta: float) -> bool:
kinds[id] = true
if id == "lancer" and lancer_at == 0:
lancer_at = size
game_state.wave_number = settled_wave
if lancer_at == 0:
return _fail("no wave against a fleet of 1..11 contains a lancer")
if not kinds.has("chaser"):

View file

@ -193,39 +193,23 @@ func _process(delta: float) -> bool:
if _pool() != null and _sounding() > 0:
return _fail("something was already sounding before anything happened")
# --- B. more at once than there are voices ---------------
# --- B. a shot, and then far more than there are voices --
Sfx.play(self, Sfx.SHOT)
if _sounding() != 1:
return _fail("firing played %d sounds, expected 1" % _sounding())
if _pool().get_parent() != root:
return _fail("the sound pool hangs off the scene - a death would take it with it")
# Driven by a DEATH, not a shot. #229 coalesces shots - a volley
# is one event to the ear and now gets one voice - so shots can
# no longer saturate anything. Deaths carry no window, because
# several things dying together really is several events, and
# they are what the pool has to absorb now.
for i in range(Sfx.VOICES * 3):
Sfx.play(self, Sfx.SHIP_DEATH)
Sfx.play(self, Sfx.SHOT)
var busy: int = _sounding()
if busy != Sfx.VOICES:
return _fail("%d voices sounding after %d deaths, the pool holds %d" \
return _fail("%d voices sounding after %d shots, the pool holds %d" \
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
if _pool().get_child_count() != Sfx.VOICES:
return _fail("the pool grew to %d voices" % _pool().get_child_count())
print("%d deaths into a %d-voice pool: %d sounding, none leaked" \
print("%d shots into a %d-voice pool: %d sounding, none leaked" \
% [Sfx.VOICES * 3, Sfx.VOICES, busy])
# --- G. and a volley does not stack (#229) ----------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
Sfx._last_started_msec.clear()
for i in range(Sfx.VOICES * 3):
Sfx.play(self, Sfx.SHOT)
if _sounding() != 1:
return _fail("%d simultaneous shots took %d voices - that is the volley getting louder per ship" \
% [Sfx.VOICES * 3, _sounding()])
print("%d simultaneous shots still take 1 voice" % (Sfx.VOICES * 3))
# --- F. and a dialog does not silence it -----------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()

View file

@ -1,134 +0,0 @@
extends SceneTree
# Forgejo #229: a volley does not get louder for every ship in the fleet.
#
# Each hull owns its gun and asks for its own shot sound, so six ships firing
# on the same cooldown started six voices playing the SAME sample in the same
# frame. Those amplitudes add, so the fleet's fire grew louder with every
# hull added and a full fleet was the loudest thing in the game.
#
# Covers:
# A. a whole fleet's simultaneous shots produce ONE voice, not one each
# B. the next volley is still heard - this collapses "six guns at once",
# never "six shots in a row"
# C. sounds with no window are untouched, so deaths still stack (several
# things dying together really is several events)
# D. the fleet size makes no difference to how many voices start
const VOLLEY: int = 8
func _initialize() -> void:
pass
func _voices_playing() -> int:
var pool: Node = root.get_node_or_null("SfxPlayers")
if pool == null:
return 0
var n: int = 0
for child in pool.get_children():
if (child as AudioStreamPlayer).playing:
n += 1
return n
func _process(_delta: float) -> bool:
# The headless build has a Dummy audio driver, so `playing` is not a
# reliable readout. Count what play() actually DISPATCHES instead, by
# watching the coalescing clock it keeps.
Sfx.warm(self)
# --- A. one volley, one voice ---------------------------------------
Sfx._last_started_msec.clear()
var started: int = 0
for i in range(VOLLEY):
if _dispatches(Sfx.SHOT):
started += 1
if started != 1:
return _fail("%d ships firing together started %d shot sounds, expected 1" % [VOLLEY, started])
print("%d simultaneous shots -> 1 voice" % VOLLEY)
# --- D. and that holds however big the fleet is ----------------------
for size in [2, 4, 16, 32]:
Sfx._last_started_msec.clear()
var n: int = 0
for i in range(size):
if _dispatches(Sfx.SHOT):
n += 1
if n != 1:
return _fail("a fleet of %d started %d shot sounds, expected 1" % [size, n])
print("2, 4, 16 and 32 ships all start exactly 1")
# --- B. the next volley is still heard -------------------------------
Sfx._last_started_msec.clear()
if not _dispatches(Sfx.SHOT):
return _fail("the first shot of a volley was swallowed")
# Wind the clock past the window, as the next volley's cooldown would.
var window: int = int(Sfx.COALESCE_MSEC[Sfx.SHOT])
Sfx._last_started_msec[Sfx.SHOT] = Time.get_ticks_msec() - window - 1
if not _dispatches(Sfx.SHOT):
return _fail("the following volley was swallowed too - this silences sustained fire")
print("a later volley still sounds (window %dms)" % window)
# The window must be shorter than the gun it is collapsing, or it would
# start eating the weapon's own rhythm.
var fastest: float = _fastest_fire_cooldown()
if fastest <= 0.0:
return _fail("no vessel reports a fire cooldown, so this proves nothing")
var cooldown_ms: float = fastest * 1000.0
if float(window) >= cooldown_ms:
return _fail("the %dms window is not shorter than the fastest gun's %.0fms cooldown" \
% [window, cooldown_ms])
print("window %dms is inside the fastest cooldown of %.0fms" % [window, cooldown_ms])
# --- C. unwindowed sounds are untouched -------------------------------
Sfx._last_started_msec.clear()
var deaths: int = 0
for i in range(4):
if _dispatches(Sfx.SHIP_DEATH):
deaths += 1
if deaths != 4:
return _fail("4 deaths together produced %d sounds - deaths must still stack" % deaths)
print("4 simultaneous deaths still produce 4 sounds")
print("TEST_SHOT_VOLLEY_LOUDNESS: PASS")
quit(0)
return true
# Would play() actually start a voice for this id right now? Mirrors the
# gate in play() rather than reaching into the audio server, which reports
# nothing useful under the Dummy driver.
func _dispatches(id: String) -> bool:
var window: int = int(Sfx.COALESCE_MSEC.get(id, 0))
if window <= 0:
Sfx.play(self, id)
return true
var now: int = Time.get_ticks_msec()
var last: int = int(Sfx._last_started_msec.get(id, -1000000))
Sfx.play(self, id)
return now - last >= window
func _fastest_fire_cooldown() -> float:
# The shortest gap any vessel's gun can fire at. ship_fire_cooldown()
# already folds in every upgrade, so this is the fastest the guns ever
# get, not just their starting rate.
var best: float = INF
var gs: Node = root.get_node("GameState")
for vessel_type in [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_CARRIER,
GameState.VESSEL_LASER, GameState.VESSEL_DISTORDER]:
var cooldown: float = gs.ship_fire_cooldown(vessel_type)
if cooldown > 0.0:
best = minf(best, cooldown)
if is_inf(best):
return -1.0
return best
func _fail(message: String) -> bool:
print("TEST_SHOT_VOLLEY_LOUDNESS: FAIL (%s)" % message)
quit(1)
return true

View file

@ -1,102 +0,0 @@
extends SceneTree
# Forgejo #234: gas is always in reach of where the game starts.
#
# Gas giants are gated behind two independent noise fields - the SPACE biome
# AND the planet roll - so whether any sat near the spawn was luck. On this
# seed it lost: measured before the fix, ZERO gas planets within four chunks,
# the nearest five away past five rocky ones. Gas is an early need (the
# burner, ammunition), so the opening cannot depend on the noise being kind.
#
# Covers:
# A. a gas planet exists within four chunks of the spawn
# B. it is a real planet - it spawns, and it actually yields gas
# C. it is not sitting on top of the spawn itself
# D. forcing it does not reshuffle any other chunk, which is the property
# the whole of WorldGen is built on
const SPAWN_CHUNK: Vector2i = Vector2i(0, 0)
const REACH: int = 4
var WG
var PD
func _initialize() -> void:
WG = load("res://world/WorldGen.gd")
PD = load("res://planets/PlanetDefinitions.gd")
func _process(_delta: float) -> bool:
# --- A. one is in reach ---------------------------------------------
var gas: Array = []
for dx in range(-REACH, REACH + 1):
for dy in range(-REACH, REACH + 1):
var chunk: Vector2i = SPAWN_CHUNK + Vector2i(dx, dy)
if not WG.has_planet_at_chunk(chunk):
continue
if String(WG.planet_data_at_chunk(chunk).get("definition_id", "")) == PD.GAS_GIANT_ID:
gas.append(chunk)
if gas.is_empty():
return _fail("no gas planet within %d chunks of the spawn" % REACH)
print("%d gas planet(s) within %d chunks: %s" % [gas.size(), REACH, gas])
# --- C. and not underneath the fleet ---------------------------------
if gas.has(SPAWN_CHUNK):
return _fail("the guaranteed gas planet is on the spawn chunk itself")
# --- B. it is a real, generated, gas-bearing planet -------------------
var chunk: Vector2i = WG.SPAWN_GAS_GIANT_CHUNK
var chebyshev: int = maxi(absi(chunk.x - SPAWN_CHUNK.x), absi(chunk.y - SPAWN_CHUNK.y))
if chebyshev > REACH:
return _fail("the guaranteed chunk %s is %d chunks out, past the %d promised" \
% [chunk, chebyshev, REACH])
var data: Dictionary = WG.planet_data_at_chunk(chunk)
if String(data.get("definition_id", "")) != PD.GAS_GIANT_ID:
return _fail("the guaranteed chunk is '%s', not a gas giant" % data.get("definition_id", ""))
var definition: Resource = PD.get_definition(PD.GAS_GIANT_ID)
if String(definition.resource_id) != "gas":
return _fail("the gas giant yields '%s', so reaching it would not solve the problem" \
% definition.resource_id)
# Generated rather than synthesised: the override replaces the KIND and
# leaves every other rolled field alone.
if String(data.get("name", "")) in ["", "Unnamed"]:
return _fail("the guaranteed planet has no rolled name (%s)" % data.get("name", ""))
var local: Vector2 = data.get("local_pos", Vector2(-1, -1))
if local.x < 0.0 or local.y < 0.0 or local.x > WG.CHUNK_SIZE or local.y > WG.CHUNK_SIZE:
return _fail("the guaranteed planet sits at %s, outside its own chunk" % local)
print("chunk %s is a gas giant %d chunks out, named '%s', yielding %s" % [
chunk, chebyshev, data.get("name", ""), definition.resource_id])
# --- D. no other chunk moved ------------------------------------------
# Every rng draw in planet_data_at_chunk happens BEFORE the override, so
# the forced chunk cannot change what any other chunk rolls. Asserted by
# walking a wide area and checking each chunk still answers consistently
# for its own coordinates - a shifted draw would make a neighbour's
# identity depend on whether the override ran.
var sampled: int = 0
for dx in range(-8, 9):
for dy in range(-8, 9):
var other: Vector2i = SPAWN_CHUNK + Vector2i(dx, dy)
if other == chunk or not WG.has_planet_at_chunk(other):
continue
var a: Dictionary = WG.planet_data_at_chunk(other)
var b: Dictionary = WG.planet_data_at_chunk(other)
if a.get("definition_id") != b.get("definition_id") \
or a.get("shape_seed") != b.get("shape_seed") \
or a.get("name") != b.get("name"):
return _fail("chunk %s generates differently on two calls" % other)
sampled += 1
if sampled < 10:
return _fail("only %d other planets sampled - that proves nothing about the rest of the map" % sampled)
print("%d other planets nearby still generate deterministically" % sampled)
print("TEST_SPAWN_GAS_PLANET: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_SPAWN_GAS_PLANET: FAIL (%s)" % message)
quit(1)
return true

View file

@ -108,34 +108,12 @@ func _state_cases() -> bool:
game_state.wave_number = before_number
if later.size() <= small.size():
return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation")
# A big wave must not be the same swarm at greater length - but read at a
# wave number where variety is ALLOWED. #233 gates each enemy kind behind
# a minimum wave as well as a minimum fleet, so that the openings teach
# one enemy at a time however big the fleet already is; a fleet of nine
# meeting only chasers in wave 1 is now the intent, not the swarm this
# case is guarding against.
game_state.wave_number = before_number + 6
var varied: PackedStringArray = game_state.wave_composition(9)
game_state.wave_number = before_number
# A big wave must not be the same swarm at greater length.
var heavy_kinds: Dictionary = {}
for id in varied:
for id in large:
heavy_kinds[id] = true
if heavy_kinds.size() < 2:
return _fail("a large wave at wave %d is %d copies of one enemy" \
% [before_number + 6, varied.size()])
# And the openings really are single-kind, whatever the fleet looks like.
game_state.wave_number = 1
var opener: PackedStringArray = game_state.wave_composition(9)
game_state.wave_number = before_number
var opening_kinds: Dictionary = {}
for id in opener:
opening_kinds[id] = true
if opening_kinds.size() != 1:
return _fail("wave 1 against a fleet of 9 already mixes %d kinds" % opening_kinds.size())
if opener.size() > game_state.wave_max_enemies():
return _fail("wave 1 sends %d enemies, past the %d an opening wave may" \
% [opener.size(), game_state.wave_max_enemies()])
return _fail("a large wave is %d copies of one enemy" % large.size())
if large.size() > GameState.WAVE_MAX_ENEMIES:
return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES])
print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \
@ -162,23 +140,15 @@ func _state_cases() -> bool:
# Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting
# capacity to 1, and this is about the roster's own thresholds (4, 5, 6),
# not about where the fleet happens to begin.
# Read past the opening waves: #233 also gates each kind behind a minimum
# WAVE, so that the first waves teach one enemy at a time whatever the
# fleet looks like. This case is about the roster's FLEET thresholds, so
# it asks at a wave number where every kind is already permitted.
var settled: int = game_state.wave_number
game_state.wave_number = 8
var previous_kinds: int = 1
for size in range(3, 8):
var kinds_here: Dictionary = {}
for id in game_state.wave_composition(size):
kinds_here[id] = true
if kinds_here.size() < previous_kinds:
game_state.wave_number = settled
return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \
% [size, kinds_here.size(), previous_kinds])
previous_kinds = kinds_here.size()
game_state.wave_number = settled
if previous_kinds < 3:
return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds)
print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds)
@ -274,22 +244,6 @@ func _process(delta: float) -> bool:
print("wave %d arrived: %d enemies, nearest %.0fpx away" \
% [game_state.wave_number, arrived, nearest])
# #233 lands a wave over several seconds rather than in one
# frame, so "the whole wave" is not on the field yet. Run the
# arrivals out first - otherwise this kills the one hull that
# has landed and calls that the wave cleared.
for i in range(40):
spawner._process(GameState.WAVE_MAX_SECONDS)
if spawner.arriving.is_empty():
break
if not spawner.arriving.is_empty():
return _fail("%d hulls never finished arriving" % spawner.arriving.size())
var landed: int = _enemy_count()
if landed <= arrived:
return _fail("running the arrivals out added nothing (%d then %d) - the wave is not staggered" \
% [arrived, landed])
print("the rest of the wave followed: %d -> %d enemies" % [arrived, landed])
for enemy in get_nodes_in_group("enemies"):
if is_instance_valid(enemy):
enemy.queue_free()

View file

@ -14,22 +14,10 @@ extends "res://ui/menus/ModalMenu.gd"
# of "the game is lying to me" the ticket is about.
@onready var total_label: Label = $TotalLabel
@onready var sheet: GridContainer = $Sheet
@onready var empty_label: Label = $EmptyLabel
@onready var detail_label: Label = $DetailLabel
@onready var formula_label: Label = $FormulaLabel
const HEADER_COLOR: Color = Color(0.55, 0.78, 0.86)
const CELL_COLOR: Color = Color(0.72, 0.88, 0.96)
const TOTAL_COLOR: Color = Color(1.0, 0.85, 0.35)
const UNPAID_COLOR: Color = Color(1.0, 0.45, 0.4)
const COLUMNS: Array = ["BERTH", "VESSEL", "PER CHARGE", "RATE", ""]
var building_id: int = 0
# What the sheet was last built from. The menu is refreshed every frame while
# it is open (see BuildController), and rebuilding a dozen Labels per frame to
# show numbers that change every 25 seconds is the thing issues #6 and #10
# are about.
var _built_from: String = ""
func open_for(next_building_id: int) -> void:
@ -50,99 +38,34 @@ func refresh() -> void:
var berths: Array = breakdown.berths
var per_minute: Dictionary = breakdown.per_minute
# The bill, both ways round: what leaves the stores at each charge, and
# what that adds up to over a minute. The per-charge figure is the one
# the player watches happen; the rate is the one they plan against.
var totals: Dictionary = {}
for berth in berths:
for resource_type in berth.costs:
totals[resource_type] = int(totals.get(resource_type, 0)) \
+ int(berth.costs[resource_type])
if per_minute.is_empty():
total_label.text = "NOTHING TO KEEP"
else:
total_label.text = "%s EVERY %dS · %s PER MINUTE" % [
_amounts(totals), int(breakdown.charge_seconds), _rates(per_minute)]
var parts: PackedStringArray = PackedStringArray()
for resource_type in per_minute:
parts.append("%.1f %s" % [float(per_minute[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
total_label.text = "%s PER MINUTE" % " · ".join(parts)
var signature: String = "%s|%s" % [str(berths), str(totals)]
if signature != _built_from:
_built_from = signature
_build_sheet(berths, totals)
# One line per paying berth: which hull, what it is charged, and the
# multiplier that got it there. An empty berth is charged nothing and is
# left out rather than listed at zero.
var lines: PackedStringArray = PackedStringArray()
for berth in berths:
var costs: PackedStringArray = PackedStringArray()
for resource_type in berth.costs:
costs.append("%d %s" % [int(berth.costs[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
lines.append("BERTH %d %-16s %s (x%.2f)%s" % [
int(berth.slot), GameState.vessel_name(String(berth.vessel_type)),
" ".join(costs), float(berth.scale),
" — UNPAID" if bool(berth.starved) else "",
])
detail_label.text = "\n".join(lines) if not lines.is_empty() \
else "No berth is being charged. Build a Fleet Office and put a vessel in it."
empty_label.visible = berths.is_empty()
sheet.visible = not berths.is_empty()
empty_label.text = "No berth is being charged. Build a Fleet Office and put a vessel in it."
formula_label.text = ("Each berth is charged every %ds: the vessel\'s build price "
formula_label.text = ("Each berth is charged every %ds: the vessel's build price "
+ "divided by %d, then multiplied by %.2f for every berth past the first "
+ "paid one. The first vessel is free.") % [
int(breakdown.charge_seconds), int(breakdown.divisor),
float(breakdown.growth)]
# One row per paying berth, plus a totals row - a berth standing empty is
# charged nothing and is left out rather than listed at zero.
func _build_sheet(berths: Array, totals: Dictionary) -> void:
for child in sheet.get_children():
child.free()
if berths.is_empty():
return
for column in COLUMNS:
_cell(String(column), HEADER_COLOR)
for berth in berths:
var starved: bool = bool(berth.starved)
var row_color: Color = UNPAID_COLOR if starved else CELL_COLOR
_cell(str(int(berth.slot)), row_color)
_cell(GameState.vessel_name(String(berth.vessel_type)), row_color)
_cell(_amounts(berth.costs), row_color)
_cell("x%.2f" % float(berth.scale), row_color)
_cell("UNPAID" if starved else "", UNPAID_COLOR)
# The totals row is what #230 asked for: the per-berth lines never added
# up to anything on screen, so the bill had to be done in the head.
_cell("TOTAL", TOTAL_COLOR)
_cell("", TOTAL_COLOR)
_cell(_amounts(totals), TOTAL_COLOR)
_cell("", TOTAL_COLOR)
_cell("", TOTAL_COLOR)
func _cell(text: String, colour: Color) -> void:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 28)
label.add_theme_color_override("font_color", colour)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
sheet.add_child(label)
# In the game's own resource order, not the dictionary's. Iterating the costs
# directly put "2 ore 1 energy" on one row and "2 gas 2 ore" on the next,
# which is most of what a column is for - you cannot read down a table whose
# entries are in a different order on every line.
func _ordered(costs: Dictionary) -> Array:
var out: Array = []
for resource_type in GameState.resource_types():
if costs.has(resource_type):
out.append(resource_type)
# Anything the catalog does not know about still gets shown rather than
# silently dropped.
for resource_type in costs:
if not out.has(resource_type):
out.append(resource_type)
return out
func _amounts(costs: Dictionary) -> String:
var parts: PackedStringArray = PackedStringArray()
for resource_type in _ordered(costs):
parts.append("%d %s" % [int(costs[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
return " ".join(parts) if not parts.is_empty() else "-"
func _rates(per_minute: Dictionary) -> String:
var parts: PackedStringArray = PackedStringArray()
for resource_type in _ordered(per_minute):
parts.append("%.1f %s" % [float(per_minute[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
return " ".join(parts)

View file

@ -50,28 +50,16 @@ theme_override_colors/font_color = Color(1, 0.85, 0.35, 1)
text = ""
horizontal_alignment = 1
[node name="Sheet" type="GridContainer" parent="."]
[node name="DetailLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 250.0
offset_right = -32.0
offset_bottom = 860.0
columns = 5
theme_override_constants/h_separation = 24
theme_override_constants/v_separation = 10
[node name="EmptyLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 250.0
offset_right = -32.0
offset_bottom = 400.0
theme_override_font_sizes/font_size = 30
theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1)
text = ""
horizontal_alignment = 1
autowrap_mode = 2
[node name="FormulaLabel" type="Label" parent="."]

View file

@ -34,9 +34,8 @@ static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0)
return piece
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0,
width: float = 1.0) -> void:
polygons.append({"points": points, "pulse": piece_pulse, "width": width})
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void:
polygons.append({"points": points, "pulse": piece_pulse})
func add_path(points: PackedVector2Array, piece_pulse: float = 1.0,
@ -50,8 +49,7 @@ func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0
func _draw() -> void:
for poly in polygons:
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color,
poly.pulse * pulse, core_color, float(poly.get("width", 1.0)))
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color)
for path in paths:
NeonDraw.draw_glowing_path(self, path.points, glow_color,
path.pulse * pulse, path.width, core_color)

View file

@ -43,19 +43,6 @@ const RESEARCH_LAB_CHANCE: float = 0.16
# anchor and one shape make the actual rule representable: one multi-chunk
# octopus biome, with its boss at the centre.
const OCTOPUS_BIOME_CENTER: Vector2i = Vector2i(12, -9)
# Forgejo #234: a gas giant the opening is guaranteed to be able to reach.
#
# Gas is gated behind two independent noise fields - the SPACE biome and the
# planet roll - so whether any is in reach of the spawn was luck, and on this
# seed it lost: measured, there were ZERO gas planets within four chunks and
# the nearest sat five away, past five rocky ones. Gas is needed early (the
# burner, ammunition), so the opening cannot depend on the noise being kind.
#
# Authored like OCTOPUS_BIOME_CENTER above rather than found by searching
# outward: a fixed chunk is reproducible from anywhere without walking the
# map, which is the property everything in this file is built on.
const SPAWN_GAS_GIANT_CHUNK: Vector2i = Vector2i(2, -2)
const OCTOPUS_BIOME_OFFSETS: Array[Vector2i] = [
Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0),
Vector2i(0, -1), Vector2i(0, 1),
@ -147,8 +134,6 @@ static func has_planet_at_chunk(chunk: Vector2i) -> bool:
return true
if chunk == OCTOPUS_BIOME_CENTER:
return true
if chunk == SPAWN_GAS_GIANT_CHUNK:
return true
if is_octopus_chunk(chunk):
return false
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
@ -188,11 +173,6 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
elif research_lab_roll < RESEARCH_LAB_CHANCE:
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
# #234, applied after the rolls for the same reason the dev planting is:
# forcing this chunk must not shift which random values any OTHER chunk
# consumes, or one guaranteed planet would reshuffle the whole map.
if chunk == SPAWN_GAS_GIANT_CHUNK:
definition_id = PLANET_DEFINITIONS.GAS_GIANT_ID
# Dev mode's fixed planting wins over every roll above. Applied last, and
# AFTER all the rng draws, so switching it on cannot shift which random
# values any other chunk consumes - a dev world and a normal one differ