Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e252037e | ||
|
|
4ebd832a70 | ||
|
|
cb3650e3a9 | ||
|
|
885f977768 |
18 changed files with 971 additions and 106 deletions
36
audio/Sfx.gd
36
audio/Sfx.gd
|
|
@ -73,6 +73,35 @@ const LEVELS: Dictionary = {
|
||||||
BLACK_HOLE: -11.0,
|
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
|
# 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
|
# flat machine-gun note. The deaths do not: they are events, and a wobbling
|
||||||
# explosion sounds broken rather than varied.
|
# explosion sounds broken rather than varied.
|
||||||
|
|
@ -128,6 +157,13 @@ static func play(tree: SceneTree, id: String) -> void:
|
||||||
var stream: AudioStream = SOUNDS[id] as AudioStream
|
var stream: AudioStream = SOUNDS[id] as AudioStream
|
||||||
if stream == null:
|
if stream == null:
|
||||||
return
|
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 pool: Node = _pool(tree)
|
||||||
var voice: AudioStreamPlayer = null
|
var voice: AudioStreamPlayer = null
|
||||||
for child in pool.get_children():
|
for child in pool.get_children():
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,11 @@ const KEY_DIRECTIONS: Dictionary = {
|
||||||
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
|
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
|
||||||
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
|
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
|
||||||
}
|
}
|
||||||
const KEY_PIVOT_CW: int = KEY_E
|
# Z and C rather than Q and E: #231 gives Q, E and space to the abilities, so
|
||||||
const KEY_PIVOT_CCW: int = KEY_Q
|
# 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 WHEEL_ZOOM_STEP: float = 1.12
|
const WHEEL_ZOOM_STEP: float = 1.12
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,18 @@ extends Node2D
|
||||||
# as "nonexistent function" on a node that looks fine in the scene.
|
# as "nonexistent function" on a node that looks fine in the scene.
|
||||||
const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController", "DashController"]
|
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 fleet: Node2D = get_node("../Fleet")
|
||||||
@onready var camera: Camera2D = get_node("../Camera2D")
|
@onready var camera: Camera2D = get_node("../Camera2D")
|
||||||
@onready var fire_stick_controller: Node = get_node_or_null("../FireStickController")
|
@onready var fire_stick_controller: Node = get_node_or_null("../FireStickController")
|
||||||
|
|
@ -97,6 +109,19 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||||
aim_world = get_global_mouse_position()
|
aim_world = get_global_mouse_position()
|
||||||
return
|
return
|
||||||
if event is InputEventKey and event.pressed and not event.echo:
|
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
|
var index: int = event.keycode - KEY_1
|
||||||
if index >= 0 and index < weapon_count() and select_weapon(index):
|
if index >= 0 and index < weapon_count() and select_weapon(index):
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
|
||||||
|
|
@ -3275,14 +3275,30 @@ func wave_strength(fleet_size: int) -> int:
|
||||||
# roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE
|
# 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
|
# 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.
|
# 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 = [
|
const WAVE_ROSTER: Array = [
|
||||||
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6},
|
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6, "min_wave": 4},
|
||||||
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5},
|
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5, "min_wave": 3},
|
||||||
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4},
|
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4, "min_wave": 2},
|
||||||
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0},
|
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0, "min_wave": 0},
|
||||||
]
|
]
|
||||||
const WAVE_MAX_ENEMIES: int = 18
|
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:
|
func wave_composition(fleet_size: int) -> PackedStringArray:
|
||||||
var budget: int = wave_strength(fleet_size)
|
var budget: int = wave_strength(fleet_size)
|
||||||
|
|
@ -3290,13 +3306,15 @@ func wave_composition(fleet_size: int) -> PackedStringArray:
|
||||||
var out: PackedStringArray = PackedStringArray()
|
var out: PackedStringArray = PackedStringArray()
|
||||||
for entry in WAVE_ROSTER:
|
for entry in WAVE_ROSTER:
|
||||||
if strength < int(entry.min_strength) \
|
if strength < int(entry.min_strength) \
|
||||||
or fleet_size < int(entry.get("min_fleet", 0)):
|
or fleet_size < int(entry.get("min_fleet", 0)) \
|
||||||
|
or wave_number < int(entry.get("min_wave", 0)):
|
||||||
continue
|
continue
|
||||||
var cost: int = int(entry.cost)
|
var cost: int = int(entry.cost)
|
||||||
# The cheapest tier is last and takes whatever is left, so the budget
|
# The cheapest tier is last and takes whatever is left, so the budget
|
||||||
# is always spent rather than rounded away.
|
# is always spent rather than rounded away.
|
||||||
var share: int = budget if String(entry.id) == "chaser" else budget / 2
|
var share: int = budget if String(entry.id) == "chaser" else budget / 2
|
||||||
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
|
var ceiling: int = wave_max_enemies()
|
||||||
|
while share >= cost and budget >= cost and out.size() < ceiling:
|
||||||
out.append(String(entry.id))
|
out.append(String(entry.id))
|
||||||
budget -= cost
|
budget -= cost
|
||||||
share -= cost
|
share -= cost
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,59 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
|
||||||
var wave_enemies: Array = []
|
var wave_enemies: Array = []
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
# 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:
|
||||||
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
|
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
|
||||||
if GameState.wave_pending:
|
if GameState.wave_pending:
|
||||||
_launch_wave()
|
_launch_wave()
|
||||||
return
|
return
|
||||||
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty():
|
_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():
|
||||||
GameState.wave_cleared()
|
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.
|
# Public so a test can put a wave in the world without waiting out the peace.
|
||||||
func _launch_wave() -> void:
|
func _launch_wave() -> void:
|
||||||
if fleet == null or fleet.ships.is_empty():
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
|
@ -49,18 +93,20 @@ func _launch_wave() -> void:
|
||||||
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
|
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
|
||||||
var centre: Vector2 = fleet.get_centroid()
|
var centre: Vector2 = fleet.get_centroid()
|
||||||
var bearing: float = randf() * TAU
|
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()):
|
for i in range(composition.size()):
|
||||||
# Spread around one bearing rather than scattered at random: a wave
|
# Spread around one bearing rather than scattered at random: a wave
|
||||||
# should read as an attack arriving, not as the map filling up.
|
# should read as an attack arriving, not as the map filling up.
|
||||||
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
|
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
|
||||||
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
|
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
|
||||||
var enemy: Node2D = enemy_scene.instantiate()
|
arriving.append({
|
||||||
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
|
"id": composition[i],
|
||||||
enemy.enemy_id = composition[i]
|
"at": centre + Vector2(cos(angle), sin(angle)) * radius,
|
||||||
# #113: a wave is an attack on what the player has built. It converges
|
})
|
||||||
# on the nearest structure from wherever it spawned rather than
|
# The first lands at once, so a wave is never felt as late.
|
||||||
# waiting to be flown near.
|
if not arriving.is_empty():
|
||||||
enemy.relentless = true
|
_spawn_arrival(arriving.pop_front())
|
||||||
enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
|
arrival_timer = ARRIVAL_INTERVAL
|
||||||
get_parent().add_child(enemy)
|
|
||||||
wave_enemies.append(enemy)
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,23 @@ const RESOURCE_PICKUP_VFX = preload("res://vfx/ResourcePickupVFX.gd")
|
||||||
|
|
||||||
@export var fleet_path: NodePath = NodePath("../Fleet")
|
@export var fleet_path: NodePath = NodePath("../Fleet")
|
||||||
@onready var fleet: Node2D = get_node(fleet_path)
|
@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] = []
|
var vents: Array[Vector2] = []
|
||||||
|
# One row of rising-bubble pieces per vent.
|
||||||
|
var bubbles: Array = []
|
||||||
var progress: float = 0.0
|
var progress: float = 0.0
|
||||||
var emission_phase: float = 0.0
|
var emission_phase: float = 0.0
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
|
@ -24,10 +40,73 @@ func _ready() -> void:
|
||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name)
|
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)))
|
for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650)))
|
||||||
queue_redraw()
|
_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
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
emission_phase = fmod(emission_phase + delta * 0.55, 1.0)
|
emission_phase = fmod(emission_phase + delta * 0.55, 1.0)
|
||||||
queue_redraw()
|
# Node properties only. Nothing is rasterised after the first frame.
|
||||||
|
_place_bubbles()
|
||||||
for vent in vents:
|
for vent in vents:
|
||||||
if fleet.get_centroid().distance_to(vent) < 90.0:
|
if fleet.get_centroid().distance_to(vent) < 90.0:
|
||||||
progress += delta
|
progress += delta
|
||||||
|
|
@ -42,43 +121,3 @@ func _process(delta: float) -> void:
|
||||||
to_global(vent), GameState.RESOURCE_GAS, taken)
|
to_global(vent), GameState.RESOURCE_GAS, taken)
|
||||||
return
|
return
|
||||||
progress = 0.0
|
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)
|
|
||||||
|
|
|
||||||
121
tests/test_ability_keys.gd
Normal file
121
tests/test_ability_keys.gd
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
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
|
||||||
150
tests/test_fleet_upkeep_sheet.gd
Normal file
150
tests/test_fleet_upkeep_sheet.gd
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
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
|
||||||
|
|
@ -4,7 +4,7 @@ extends SceneTree
|
||||||
# godot --headless -s res://tests/test_keyboard_input.gd
|
# godot --headless -s res://tests/test_keyboard_input.gd
|
||||||
# Desktop/web controls. The phone build is driven entirely by touch, but the
|
# 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)
|
# web build is played with a keyboard and mouse, so WASD (and the arrows)
|
||||||
# must steer the fleet, Q/E must pivot the formation, and releasing the keys
|
# must steer the fleet, the pivot pair must turn the formation, and releasing
|
||||||
# must stop - the stick has a centred-again signal to hook, the keyboard has
|
# 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
|
# nothing equivalent, so a missed release would leave the fleet flying off
|
||||||
# forever.
|
# forever.
|
||||||
|
|
@ -127,21 +127,32 @@ func _process(delta: float) -> bool:
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
# --- Q/E pivot ------------------------------------------------
|
# --- 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.
|
||||||
var game_state: Node = root.get_node("GameState")
|
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()
|
var before: float = game_state.formation[1].angle()
|
||||||
_key(KEY_E, true)
|
_key(cw_key, true)
|
||||||
_key(KEY_E, false)
|
_key(cw_key, false)
|
||||||
var after_cw: float = game_state.formation[1].angle()
|
var after_cw: float = game_state.formation[1].angle()
|
||||||
_key(KEY_Q, true)
|
_key(ccw_key, true)
|
||||||
_key(KEY_Q, false)
|
_key(ccw_key, false)
|
||||||
var after_ccw: float = game_state.formation[1].angle()
|
var after_ccw: float = game_state.formation[1].angle()
|
||||||
|
|
||||||
var cw_turn: float = angle_difference(before, after_cw)
|
var cw_turn: float = angle_difference(before, after_cw)
|
||||||
var ccw_turn: float = angle_difference(after_cw, after_ccw)
|
var ccw_turn: float = angle_difference(after_cw, after_ccw)
|
||||||
print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn])
|
print("key %d turned %.3f rad, key %d turned %.3f rad" \
|
||||||
|
% [cw_key, cw_turn, ccw_key, ccw_turn])
|
||||||
if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(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 (Q/E did not pivot in opposite directions)")
|
print("TEST_KEYBOARD_INPUT: FAIL (the pivot pair did not turn in opposite directions)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,12 @@ func _process(delta: float) -> bool:
|
||||||
# the player now is a wave, so that is what has to be able to
|
# 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
|
# produce one - and it must not have displaced the chaser while
|
||||||
# doing it.
|
# 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 kinds: Dictionary = {}
|
||||||
var lancer_at: int = 0
|
var lancer_at: int = 0
|
||||||
for size in range(1, 12):
|
for size in range(1, 12):
|
||||||
|
|
@ -269,6 +275,7 @@ func _process(delta: float) -> bool:
|
||||||
kinds[id] = true
|
kinds[id] = true
|
||||||
if id == "lancer" and lancer_at == 0:
|
if id == "lancer" and lancer_at == 0:
|
||||||
lancer_at = size
|
lancer_at = size
|
||||||
|
game_state.wave_number = settled_wave
|
||||||
if lancer_at == 0:
|
if lancer_at == 0:
|
||||||
return _fail("no wave against a fleet of 1..11 contains a lancer")
|
return _fail("no wave against a fleet of 1..11 contains a lancer")
|
||||||
if not kinds.has("chaser"):
|
if not kinds.has("chaser"):
|
||||||
|
|
|
||||||
|
|
@ -193,23 +193,39 @@ func _process(delta: float) -> bool:
|
||||||
if _pool() != null and _sounding() > 0:
|
if _pool() != null and _sounding() > 0:
|
||||||
return _fail("something was already sounding before anything happened")
|
return _fail("something was already sounding before anything happened")
|
||||||
|
|
||||||
# --- B. a shot, and then far more than there are voices --
|
# --- B. more at once than there are voices ---------------
|
||||||
Sfx.play(self, Sfx.SHOT)
|
Sfx.play(self, Sfx.SHOT)
|
||||||
if _sounding() != 1:
|
if _sounding() != 1:
|
||||||
return _fail("firing played %d sounds, expected 1" % _sounding())
|
return _fail("firing played %d sounds, expected 1" % _sounding())
|
||||||
if _pool().get_parent() != root:
|
if _pool().get_parent() != root:
|
||||||
return _fail("the sound pool hangs off the scene - a death would take it with it")
|
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):
|
for i in range(Sfx.VOICES * 3):
|
||||||
Sfx.play(self, Sfx.SHOT)
|
Sfx.play(self, Sfx.SHIP_DEATH)
|
||||||
var busy: int = _sounding()
|
var busy: int = _sounding()
|
||||||
if busy != Sfx.VOICES:
|
if busy != Sfx.VOICES:
|
||||||
return _fail("%d voices sounding after %d shots, the pool holds %d" \
|
return _fail("%d voices sounding after %d deaths, the pool holds %d" \
|
||||||
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
|
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
|
||||||
if _pool().get_child_count() != Sfx.VOICES:
|
if _pool().get_child_count() != Sfx.VOICES:
|
||||||
return _fail("the pool grew to %d voices" % _pool().get_child_count())
|
return _fail("the pool grew to %d voices" % _pool().get_child_count())
|
||||||
print("%d shots into a %d-voice pool: %d sounding, none leaked" \
|
print("%d deaths into a %d-voice pool: %d sounding, none leaked" \
|
||||||
% [Sfx.VOICES * 3, Sfx.VOICES, busy])
|
% [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 -----------------
|
# --- F. and a dialog does not silence it -----------------
|
||||||
for voice in _pool().get_children():
|
for voice in _pool().get_children():
|
||||||
(voice as AudioStreamPlayer).stop()
|
(voice as AudioStreamPlayer).stop()
|
||||||
|
|
|
||||||
134
tests/test_shot_volley_loudness.gd
Normal file
134
tests/test_shot_volley_loudness.gd
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
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
|
||||||
102
tests/test_spawn_gas_planet.gd
Normal file
102
tests/test_spawn_gas_planet.gd
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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
|
||||||
|
|
@ -108,12 +108,34 @@ func _state_cases() -> bool:
|
||||||
game_state.wave_number = before_number
|
game_state.wave_number = before_number
|
||||||
if later.size() <= small.size():
|
if later.size() <= small.size():
|
||||||
return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation")
|
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.
|
# 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
|
||||||
var heavy_kinds: Dictionary = {}
|
var heavy_kinds: Dictionary = {}
|
||||||
for id in large:
|
for id in varied:
|
||||||
heavy_kinds[id] = true
|
heavy_kinds[id] = true
|
||||||
if heavy_kinds.size() < 2:
|
if heavy_kinds.size() < 2:
|
||||||
return _fail("a large wave is %d copies of one enemy" % large.size())
|
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()])
|
||||||
if large.size() > GameState.WAVE_MAX_ENEMIES:
|
if large.size() > GameState.WAVE_MAX_ENEMIES:
|
||||||
return _fail("a wave of %d exceeds the %d cap" % [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" \
|
print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \
|
||||||
|
|
@ -140,15 +162,23 @@ func _state_cases() -> bool:
|
||||||
# Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting
|
# 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),
|
# capacity to 1, and this is about the roster's own thresholds (4, 5, 6),
|
||||||
# not about where the fleet happens to begin.
|
# 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
|
var previous_kinds: int = 1
|
||||||
for size in range(3, 8):
|
for size in range(3, 8):
|
||||||
var kinds_here: Dictionary = {}
|
var kinds_here: Dictionary = {}
|
||||||
for id in game_state.wave_composition(size):
|
for id in game_state.wave_composition(size):
|
||||||
kinds_here[id] = true
|
kinds_here[id] = true
|
||||||
if kinds_here.size() < previous_kinds:
|
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" \
|
return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \
|
||||||
% [size, kinds_here.size(), previous_kinds])
|
% [size, kinds_here.size(), previous_kinds])
|
||||||
previous_kinds = kinds_here.size()
|
previous_kinds = kinds_here.size()
|
||||||
|
game_state.wave_number = settled
|
||||||
if previous_kinds < 3:
|
if previous_kinds < 3:
|
||||||
return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds)
|
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)
|
print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds)
|
||||||
|
|
@ -244,6 +274,22 @@ func _process(delta: float) -> bool:
|
||||||
print("wave %d arrived: %d enemies, nearest %.0fpx away" \
|
print("wave %d arrived: %d enemies, nearest %.0fpx away" \
|
||||||
% [game_state.wave_number, arrived, nearest])
|
% [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"):
|
for enemy in get_nodes_in_group("enemies"):
|
||||||
if is_instance_valid(enemy):
|
if is_instance_valid(enemy):
|
||||||
enemy.queue_free()
|
enemy.queue_free()
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,22 @@ extends "res://ui/menus/ModalMenu.gd"
|
||||||
# of "the game is lying to me" the ticket is about.
|
# of "the game is lying to me" the ticket is about.
|
||||||
|
|
||||||
@onready var total_label: Label = $TotalLabel
|
@onready var total_label: Label = $TotalLabel
|
||||||
@onready var detail_label: Label = $DetailLabel
|
@onready var sheet: GridContainer = $Sheet
|
||||||
|
@onready var empty_label: Label = $EmptyLabel
|
||||||
@onready var formula_label: Label = $FormulaLabel
|
@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
|
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:
|
func open_for(next_building_id: int) -> void:
|
||||||
|
|
@ -38,34 +50,99 @@ func refresh() -> void:
|
||||||
var berths: Array = breakdown.berths
|
var berths: Array = breakdown.berths
|
||||||
var per_minute: Dictionary = breakdown.per_minute
|
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():
|
if per_minute.is_empty():
|
||||||
total_label.text = "NOTHING TO KEEP"
|
total_label.text = "NOTHING TO KEEP"
|
||||||
else:
|
else:
|
||||||
var parts: PackedStringArray = PackedStringArray()
|
total_label.text = "%s EVERY %dS · %s PER MINUTE" % [
|
||||||
for resource_type in per_minute:
|
_amounts(totals), int(breakdown.charge_seconds), _rates(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)
|
|
||||||
|
|
||||||
# One line per paying berth: which hull, what it is charged, and the
|
var signature: String = "%s|%s" % [str(berths), str(totals)]
|
||||||
# multiplier that got it there. An empty berth is charged nothing and is
|
if signature != _built_from:
|
||||||
# left out rather than listed at zero.
|
_built_from = signature
|
||||||
var lines: PackedStringArray = PackedStringArray()
|
_build_sheet(berths, totals)
|
||||||
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."
|
|
||||||
|
|
||||||
formula_label.text = ("Each berth is charged every %ds: the vessel's build price "
|
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 "
|
||||||
+ "divided by %d, then multiplied by %.2f for every berth past the first "
|
+ "divided by %d, then multiplied by %.2f for every berth past the first "
|
||||||
+ "paid one. The first vessel is free.") % [
|
+ "paid one. The first vessel is free.") % [
|
||||||
int(breakdown.charge_seconds), int(breakdown.divisor),
|
int(breakdown.charge_seconds), int(breakdown.divisor),
|
||||||
float(breakdown.growth)]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -50,16 +50,28 @@ theme_override_colors/font_color = Color(1, 0.85, 0.35, 1)
|
||||||
text = ""
|
text = ""
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="DetailLabel" type="Label" parent="."]
|
[node name="Sheet" type="GridContainer" parent="."]
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
offset_left = 32.0
|
offset_left = 32.0
|
||||||
offset_top = 250.0
|
offset_top = 250.0
|
||||||
offset_right = -32.0
|
offset_right = -32.0
|
||||||
offset_bottom = 860.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_font_sizes/font_size = 30
|
||||||
theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1)
|
theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1)
|
||||||
text = ""
|
text = ""
|
||||||
|
horizontal_alignment = 1
|
||||||
autowrap_mode = 2
|
autowrap_mode = 2
|
||||||
|
|
||||||
[node name="FormulaLabel" type="Label" parent="."]
|
[node name="FormulaLabel" type="Label" parent="."]
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,9 @@ static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0)
|
||||||
return piece
|
return piece
|
||||||
|
|
||||||
|
|
||||||
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void:
|
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0,
|
||||||
polygons.append({"points": points, "pulse": piece_pulse})
|
width: float = 1.0) -> void:
|
||||||
|
polygons.append({"points": points, "pulse": piece_pulse, "width": width})
|
||||||
|
|
||||||
|
|
||||||
func add_path(points: PackedVector2Array, piece_pulse: float = 1.0,
|
func add_path(points: PackedVector2Array, piece_pulse: float = 1.0,
|
||||||
|
|
@ -49,7 +50,8 @@ func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
for poly in polygons:
|
for poly in polygons:
|
||||||
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color)
|
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color,
|
||||||
|
poly.pulse * pulse, core_color, float(poly.get("width", 1.0)))
|
||||||
for path in paths:
|
for path in paths:
|
||||||
NeonDraw.draw_glowing_path(self, path.points, glow_color,
|
NeonDraw.draw_glowing_path(self, path.points, glow_color,
|
||||||
path.pulse * pulse, path.width, core_color)
|
path.pulse * pulse, path.width, core_color)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,19 @@ const RESEARCH_LAB_CHANCE: float = 0.16
|
||||||
# anchor and one shape make the actual rule representable: one multi-chunk
|
# anchor and one shape make the actual rule representable: one multi-chunk
|
||||||
# octopus biome, with its boss at the centre.
|
# octopus biome, with its boss at the centre.
|
||||||
const OCTOPUS_BIOME_CENTER: Vector2i = Vector2i(12, -9)
|
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] = [
|
const OCTOPUS_BIOME_OFFSETS: Array[Vector2i] = [
|
||||||
Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0),
|
Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0),
|
||||||
Vector2i(0, -1), Vector2i(0, 1),
|
Vector2i(0, -1), Vector2i(0, 1),
|
||||||
|
|
@ -134,6 +147,8 @@ static func has_planet_at_chunk(chunk: Vector2i) -> bool:
|
||||||
return true
|
return true
|
||||||
if chunk == OCTOPUS_BIOME_CENTER:
|
if chunk == OCTOPUS_BIOME_CENTER:
|
||||||
return true
|
return true
|
||||||
|
if chunk == SPAWN_GAS_GIANT_CHUNK:
|
||||||
|
return true
|
||||||
if is_octopus_chunk(chunk):
|
if is_octopus_chunk(chunk):
|
||||||
return false
|
return false
|
||||||
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
|
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
|
||||||
|
|
@ -173,6 +188,11 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
|
||||||
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
||||||
elif research_lab_roll < RESEARCH_LAB_CHANCE:
|
elif research_lab_roll < RESEARCH_LAB_CHANCE:
|
||||||
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
|
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
|
# 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
|
# 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
|
# values any other chunk consumes - a dev world and a normal one differ
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue