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,
|
||||
}
|
||||
|
||||
# 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.
|
||||
|
|
@ -128,6 +157,13 @@ 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():
|
||||
|
|
|
|||
|
|
@ -92,8 +92,11 @@ const KEY_DIRECTIONS: Dictionary = {
|
|||
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
|
||||
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
|
||||
}
|
||||
const KEY_PIVOT_CW: int = KEY_E
|
||||
const KEY_PIVOT_CCW: int = KEY_Q
|
||||
# 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 WHEEL_ZOOM_STEP: float = 1.12
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@ 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")
|
||||
|
|
@ -97,6 +109,19 @@ 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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 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},
|
||||
{"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},
|
||||
{"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},
|
||||
]
|
||||
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)
|
||||
|
|
@ -3290,13 +3306,15 @@ 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 fleet_size < int(entry.get("min_fleet", 0)) \
|
||||
or wave_number < int(entry.get("min_wave", 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
|
||||
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))
|
||||
budget -= cost
|
||||
share -= cost
|
||||
|
|
|
|||
|
|
@ -27,15 +27,59 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
|
|||
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))
|
||||
if GameState.wave_pending:
|
||||
_launch_wave()
|
||||
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()
|
||||
|
||||
|
||||
# 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():
|
||||
|
|
@ -49,18 +93,20 @@ 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
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,23 @@ 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:
|
||||
|
|
@ -24,10 +40,73 @@ 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)))
|
||||
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:
|
||||
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:
|
||||
if fleet.get_centroid().distance_to(vent) < 90.0:
|
||||
progress += delta
|
||||
|
|
@ -42,43 +121,3 @@ 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)
|
||||
|
|
|
|||
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
|
||||
# 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, 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
|
||||
# nothing equivalent, so a missed release would leave the fleet flying off
|
||||
# forever.
|
||||
|
|
@ -127,21 +127,32 @@ func _process(delta: float) -> bool:
|
|||
quit(1)
|
||||
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 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(KEY_E, true)
|
||||
_key(KEY_E, false)
|
||||
_key(cw_key, true)
|
||||
_key(cw_key, false)
|
||||
var after_cw: float = game_state.formation[1].angle()
|
||||
_key(KEY_Q, true)
|
||||
_key(KEY_Q, false)
|
||||
_key(ccw_key, true)
|
||||
_key(ccw_key, 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("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):
|
||||
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)
|
||||
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
|
||||
# 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):
|
||||
|
|
@ -269,6 +275,7 @@ 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"):
|
||||
|
|
|
|||
|
|
@ -193,23 +193,39 @@ func _process(delta: float) -> bool:
|
|||
if _pool() != null and _sounding() > 0:
|
||||
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)
|
||||
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.SHOT)
|
||||
Sfx.play(self, Sfx.SHIP_DEATH)
|
||||
var busy: int = _sounding()
|
||||
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])
|
||||
if _pool().get_child_count() != Sfx.VOICES:
|
||||
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])
|
||||
|
||||
# --- 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()
|
||||
|
|
|
|||
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
|
||||
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.
|
||||
# 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 = {}
|
||||
for id in large:
|
||||
for id in varied:
|
||||
heavy_kinds[id] = true
|
||||
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:
|
||||
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" \
|
||||
|
|
@ -140,15 +162,23 @@ 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)
|
||||
|
|
@ -244,6 +274,22 @@ 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()
|
||||
|
|
|
|||
|
|
@ -14,10 +14,22 @@ 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 detail_label: Label = $DetailLabel
|
||||
@onready var sheet: GridContainer = $Sheet
|
||||
@onready var empty_label: Label = $EmptyLabel
|
||||
@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:
|
||||
|
|
@ -38,34 +50,99 @@ 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:
|
||||
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)
|
||||
total_label.text = "%s EVERY %dS · %s PER MINUTE" % [
|
||||
_amounts(totals), int(breakdown.charge_seconds), _rates(per_minute)]
|
||||
|
||||
# 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."
|
||||
var signature: String = "%s|%s" % [str(berths), str(totals)]
|
||||
if signature != _built_from:
|
||||
_built_from = signature
|
||||
_build_sheet(berths, totals)
|
||||
|
||||
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 "
|
||||
+ "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)
|
||||
|
|
|
|||
|
|
@ -50,16 +50,28 @@ theme_override_colors/font_color = Color(1, 0.85, 0.35, 1)
|
|||
text = ""
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="DetailLabel" type="Label" parent="."]
|
||||
[node name="Sheet" type="GridContainer" 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="."]
|
||||
|
|
|
|||
|
|
@ -34,8 +34,9 @@ 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) -> void:
|
||||
polygons.append({"points": points, "pulse": piece_pulse})
|
||||
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_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:
|
||||
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:
|
||||
NeonDraw.draw_glowing_path(self, path.points, glow_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
|
||||
# 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),
|
||||
|
|
@ -134,6 +147,8 @@ 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))
|
||||
|
|
@ -173,6 +188,11 @@ 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue