Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c6316676 | ||
|
|
6a62b6f5f8 |
22 changed files with 809 additions and 39 deletions
|
|
@ -197,6 +197,8 @@ var context_actions_expanded: bool = false
|
|||
|
||||
# Gap between the last resource row and the first row of HUD buttons.
|
||||
const HUD_COLUMN_MARGIN: float = 18.0
|
||||
# Vertical gap between one row of HUD buttons and the next.
|
||||
const HUD_ROW_GAP: float = 16.0
|
||||
|
||||
|
||||
# Seats the top-anchored HUD button columns below the resource readout instead
|
||||
|
|
@ -438,8 +440,17 @@ func _process(delta: float) -> void:
|
|||
func _layout_context_buttons() -> void:
|
||||
# Build is permanently under the resource counter. The contextual berth
|
||||
# directly below it is compact until it has more than one real choice.
|
||||
action_button.offset_top = 324
|
||||
action_button.offset_bottom = 472
|
||||
#
|
||||
# "Directly below it" is MEASURED from the build button rather than
|
||||
# written down as a y. This used to hardcode 324, which was the right
|
||||
# answer only while the build button sat at the 160 the scene authored -
|
||||
# once _seat_hud_columns() started moving the whole block down to clear
|
||||
# the resource readout, this ran on the next context change and dropped
|
||||
# the action row straight back on top of the build button.
|
||||
var row_top: float = build_button.position.y + build_button.size.y + HUD_ROW_GAP
|
||||
var row_height: float = build_button.size.y
|
||||
action_button.offset_top = row_top
|
||||
action_button.offset_bottom = row_top + row_height
|
||||
var buttons: Array = _context_action_buttons()
|
||||
if not context_actions_expanded:
|
||||
for button in buttons:
|
||||
|
|
@ -453,8 +464,8 @@ func _layout_context_buttons() -> void:
|
|||
button.visible = true
|
||||
button.offset_left = 188 + i * 164
|
||||
button.offset_right = button.offset_left + 148
|
||||
button.offset_top = 324
|
||||
button.offset_bottom = 472
|
||||
button.offset_top = row_top
|
||||
button.offset_bottom = row_top + row_height
|
||||
|
||||
|
||||
func _context_action_buttons() -> Array:
|
||||
|
|
|
|||
97
GameState.gd
97
GameState.gd
|
|
@ -5,6 +5,10 @@ const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
|
|||
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
|
||||
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
|
||||
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
|
||||
# By path, not by its `WorldGen` global name: an autoload compiles before the
|
||||
# global class table is necessarily populated, the same hazard PlanetSetups.gd
|
||||
# works around.
|
||||
const WORLD_GEN = preload("res://WorldGen.gd")
|
||||
|
||||
|
||||
# Autoload singleton - the one thing that survives change_scene_to_file()
|
||||
|
|
@ -14,7 +18,7 @@ const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
|
|||
# survive the trip has to live here instead.
|
||||
|
||||
var has_return_position: bool = false
|
||||
var surviving_ship_count: int = 5
|
||||
var surviving_ship_count: int = STARTING_SHIP_COUNT
|
||||
var surviving_vessel_types: PackedStringArray = PackedStringArray()
|
||||
var return_world_position: Vector2 = Vector2.ZERO
|
||||
var return_camera_zoom: float = 1.0
|
||||
|
|
@ -52,6 +56,20 @@ func set_fleet_speed_multiplier(value: float) -> void:
|
|||
signal research_points_changed(total: int)
|
||||
var research_points: int = 0
|
||||
|
||||
# Set only by start_dev_game(), which the main menu offers only when running
|
||||
# from Godot. Makes WorldGen plant one of every planet type around the spawn.
|
||||
# Cleared by reset_new_game(), so starting a normal game after a dev one
|
||||
# cannot leave a dev world behind.
|
||||
#
|
||||
# The setter mirrors it into WorldGen, which cannot read it back: world
|
||||
# generation is static and dependency-free by design. This stays the single
|
||||
# place to set it - assigning dev_mode anywhere, tests included, reaches
|
||||
# world generation.
|
||||
var dev_mode: bool = false:
|
||||
set(value):
|
||||
dev_mode = value
|
||||
WORLD_GEN.dev_planets_enabled = value
|
||||
|
||||
# Explored-chunk history for the world map's fog-of-war reveal. Lives
|
||||
# here (not on ExplorationTracker, which gets destroyed and recreated on
|
||||
# every scene swap) so exploration progress isn't lost every time the
|
||||
|
|
@ -68,7 +86,11 @@ const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
|
|||
|
||||
# All resource-bearing locations use the same container. The old public
|
||||
# names below are computed compatibility accessors, not separate storage.
|
||||
var bank_inventory: RefCounted = INVENTORY.new({RESOURCE_ORE: 500})
|
||||
# Starts EMPTY. A new game used to open with 500 ore banked, which quietly
|
||||
# skipped the opening of the game - the first mining run is what teaches the
|
||||
# fleet hold, the return trip and the mothership. Dev mode (start_dev_game)
|
||||
# is where a stocked start belongs.
|
||||
var bank_inventory: RefCounted = INVENTORY.new()
|
||||
var fleet_inventory: RefCounted = INVENTORY.new()
|
||||
|
||||
var rocks: int:
|
||||
|
|
@ -324,7 +346,11 @@ const SKILL_TREE: Array = [
|
|||
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
|
||||
},
|
||||
{
|
||||
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
|
||||
# Cheaper than it was (30 ore / 1 point): the fleet now starts at
|
||||
# BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no
|
||||
# longer a mid-game luxury but the ordinary way out of the opening.
|
||||
# Pricing them as a luxury made the early game feel stuck.
|
||||
"id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination",
|
||||
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
|
|
@ -335,18 +361,41 @@ const SKILL_TREE: Array = [
|
|||
# prerequisite, also x=0.82) instead, so the whole fleet-size
|
||||
# doctrine branch now runs straight rather than zigzagging into
|
||||
# someone else's lane.
|
||||
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
|
||||
# Also cut (70 ore / 2 points), for the same reason as wing.
|
||||
"id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing",
|
||||
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron",
|
||||
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
|
||||
"x": 0.82, "fleet_bonus": 1, "research_points": 3,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
# The last two tiers grant +2 rather than +1. Seven single-vessel
|
||||
# nodes in one lane would be a long, monotonous chain for a branch
|
||||
# that only ever does one thing; two larger, costlier steps carry the
|
||||
# ceiling from 6 to 10 while keeping the branch a readable length.
|
||||
"id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada",
|
||||
"x": 0.82, "fleet_bonus": 2, "research_points": 4,
|
||||
"desc": "Permanent: +2 vessels in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup",
|
||||
"x": 0.82, "fleet_bonus": 2, "research_points": 6,
|
||||
"desc": "Permanent: +2 vessels in the fleet.",
|
||||
},
|
||||
]
|
||||
|
||||
const BASE_FLEET_SIZE: int = 5
|
||||
# How many SLOTS the fleet has before any doctrine is researched. Distinct
|
||||
# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels
|
||||
# are actually flying. The two used to be one constant, which forced a new
|
||||
# game to open with a full fleet and left the first crafter with nothing to
|
||||
# do - there was no room to put a vessel until a doctrine was researched.
|
||||
const BASE_FLEET_SIZE: int = 3
|
||||
# A new game opens with a SINGLE vessel. Building the fleet up is the early
|
||||
# game, so the two empty slots are the first thing a ship crafter is for.
|
||||
const STARTING_SHIP_COUNT: int = 1
|
||||
|
||||
var unlocked: Array = []
|
||||
|
||||
|
|
@ -448,7 +497,7 @@ func reset_progression() -> void:
|
|||
# fresh game.
|
||||
func reset_new_game() -> void:
|
||||
has_return_position = false
|
||||
surviving_ship_count = BASE_FLEET_SIZE
|
||||
surviving_ship_count = STARTING_SHIP_COUNT
|
||||
surviving_vessel_types = PackedStringArray()
|
||||
return_world_position = Vector2.ZERO
|
||||
return_camera_zoom = 1.0
|
||||
|
|
@ -459,8 +508,9 @@ func reset_new_game() -> void:
|
|||
_distorder_drain_progress = 0.0
|
||||
boost_depleted_notice = false
|
||||
research_points = 0
|
||||
dev_mode = false
|
||||
explored_chunks.clear()
|
||||
bank_inventory = INVENTORY.new({RESOURCE_ORE: 500})
|
||||
bank_inventory = INVENTORY.new()
|
||||
fleet_inventory = INVENTORY.new()
|
||||
buildings.clear()
|
||||
next_building_id = 1
|
||||
|
|
@ -473,6 +523,29 @@ func reset_new_game() -> void:
|
|||
reset_formation()
|
||||
|
||||
|
||||
# Amounts a dev game opens with: enough of everything to reach any part of
|
||||
# the game immediately, without being so large that costs stop registering.
|
||||
const DEV_RESOURCE_AMOUNT: int = 2000
|
||||
const DEV_RESEARCH_POINTS: int = 500
|
||||
|
||||
|
||||
# A new game stocked for play-testing: every resource banked, research points
|
||||
# to spend, and one of each planet type next to the spawn (WorldGen reads
|
||||
# dev_mode for that). Offered by the main menu only when running from Godot -
|
||||
# see MainMenu.gd - so it cannot be reached in a shipped build.
|
||||
#
|
||||
# Deliberately built ON TOP of reset_new_game() rather than beside it, so a
|
||||
# dev game is a normal game plus a stock rather than a second, separately
|
||||
# maintained definition of what a fresh game is.
|
||||
func start_dev_game() -> void:
|
||||
reset_new_game()
|
||||
dev_mode = true
|
||||
for resource_type in resource_types():
|
||||
bank_inventory.set_amount(resource_type, DEV_RESOURCE_AMOUNT)
|
||||
research_points = DEV_RESEARCH_POINTS
|
||||
research_points_changed.emit(research_points)
|
||||
|
||||
|
||||
# --- building stock and production queue (#16) -----------------------------
|
||||
|
||||
# Since #12, a building cannot just deduct from the bank at the moment of
|
||||
|
|
@ -1101,9 +1174,13 @@ func get_mothership_position() -> Variant:
|
|||
# capacity upgrade is researched, but they are defined up front so the wedge
|
||||
# keeps a deliberate shape as it grows rather than having positions invented
|
||||
# at unlock time.
|
||||
# Ten slots, because max_fleet_size() caps itself on this array's length -
|
||||
# so the wedge has to be long enough for every doctrine to be worth buying.
|
||||
# The last pair continues the V rather than starting a new shape.
|
||||
const DEFAULT_FORMATION: Array = [
|
||||
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
|
||||
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
|
||||
Vector2(-120, 112), Vector2(120, 112),
|
||||
]
|
||||
|
||||
var formation: Array = DEFAULT_FORMATION.duplicate()
|
||||
|
|
|
|||
89
MainMenu.gd
89
MainMenu.gd
|
|
@ -1,8 +1,35 @@
|
|||
extends Node2D
|
||||
|
||||
# The menu stacks whatever entries are actually available, rather than
|
||||
# trusting the positions authored in the scene.
|
||||
#
|
||||
# Two of the four entries only exist when the game is run from Godot, so a
|
||||
# shipped build shows a two-entry menu and an editor run shows four. Fixed
|
||||
# offsets would leave holes where the hidden entries used to be - the same
|
||||
# failure the HUD's button columns had, where a literal y meant "below the
|
||||
# thing above me" and stopped being true when that thing moved. Here the
|
||||
# stack is measured instead, and centred on whatever it ends up containing.
|
||||
|
||||
const ROW_BUTTON_HEIGHT: float = 128.0
|
||||
const ROW_LABEL_OFFSET: float = 134.0
|
||||
const ROW_LABEL_HEIGHT: float = 56.0
|
||||
const ROW_HEIGHT: float = ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
|
||||
const ROW_GAP: float = 20.0
|
||||
const ROW_PITCH: float = ROW_HEIGHT + ROW_GAP
|
||||
# The stack is centred on this rather than on the screen, to leave the title
|
||||
# above it room to breathe.
|
||||
const STACK_CENTER_Y: float = 40.0
|
||||
const STATUS_GAP: float = 20.0
|
||||
const STATUS_HEIGHT: float = 70.0
|
||||
|
||||
@onready var play_button: IconButton = $UI/PlayButton
|
||||
@onready var play_label: Label = $UI/PlayLabel
|
||||
@onready var editor_button: IconButton = $UI/EditorButton
|
||||
@onready var editor_label: Label = $UI/EditorLabel
|
||||
@onready var dev_button: IconButton = $UI/DevModeButton
|
||||
@onready var dev_label: Label = $UI/DevModeLabel
|
||||
@onready var quit_button: IconButton = $UI/QuitButton
|
||||
@onready var quit_label: Label = $UI/QuitLabel
|
||||
@onready var status_label: Label = $UI/StatusLabel
|
||||
|
||||
|
||||
|
|
@ -10,16 +37,78 @@ func _ready() -> void:
|
|||
play_button.pressed.connect(_on_play_pressed)
|
||||
editor_button.pressed.connect(_on_editor_pressed)
|
||||
editor_button.enabled = true
|
||||
dev_button.pressed.connect(_on_dev_mode_pressed)
|
||||
dev_button.enabled = true
|
||||
quit_button.pressed.connect(_on_quit_pressed)
|
||||
quit_button.visible = not OS.has_feature("web")
|
||||
|
||||
# Dev mode and the map editor are development tools, not features of the
|
||||
# game: they are for building and play-testing it, and a player who
|
||||
# installed a build has no use for either. `editor` is the feature tag
|
||||
# Godot sets when running from the editor and clears in an export, which
|
||||
# is exactly the "started from Godot" distinction wanted here.
|
||||
var developing: bool = is_developer_build()
|
||||
editor_button.visible = developing
|
||||
editor_label.visible = developing
|
||||
dev_button.visible = developing
|
||||
dev_label.visible = developing
|
||||
|
||||
_layout_stack()
|
||||
|
||||
|
||||
# Wrapped so tests can state the rule they are checking rather than
|
||||
# duplicating the feature-tag string.
|
||||
func is_developer_build() -> bool:
|
||||
return OS.has_feature("editor")
|
||||
|
||||
|
||||
func _layout_stack() -> void:
|
||||
var rows: Array = []
|
||||
for row in [[play_button, play_label], [editor_button, editor_label],
|
||||
[dev_button, dev_label], [quit_button, quit_label]]:
|
||||
if row[0].visible:
|
||||
rows.append(row)
|
||||
if rows.is_empty():
|
||||
return
|
||||
|
||||
# The last row carries no trailing gap, so the block is one pitch per row
|
||||
# minus that gap.
|
||||
var block_height: float = float(rows.size()) * ROW_PITCH - ROW_GAP
|
||||
var top: float = STACK_CENTER_Y - block_height * 0.5
|
||||
for row in rows:
|
||||
var button: Control = row[0]
|
||||
var label: Label = row[1]
|
||||
button.offset_top = top
|
||||
button.offset_bottom = top + ROW_BUTTON_HEIGHT
|
||||
label.offset_top = top + ROW_LABEL_OFFSET
|
||||
label.offset_bottom = top + ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT
|
||||
top += ROW_PITCH
|
||||
|
||||
# Status sits under the whole stack, where a message about any entry can
|
||||
# be read without it shifting the entries themselves.
|
||||
var status_top: float = top - ROW_GAP + STATUS_GAP
|
||||
status_label.offset_top = status_top
|
||||
status_label.offset_bottom = status_top + STATUS_HEIGHT
|
||||
|
||||
|
||||
func _on_play_pressed() -> void:
|
||||
GameState.reset_new_game()
|
||||
get_tree().change_scene_to_file("res://Main.tscn")
|
||||
|
||||
|
||||
func _on_dev_mode_pressed() -> void:
|
||||
# Guarded as well as hidden. Visibility is presentation; this is the rule.
|
||||
if not is_developer_build():
|
||||
status_label.text = "Dev mode only runs from Godot"
|
||||
return
|
||||
GameState.start_dev_game()
|
||||
get_tree().change_scene_to_file("res://Main.tscn")
|
||||
|
||||
|
||||
func _on_editor_pressed() -> void:
|
||||
if not is_developer_build():
|
||||
status_label.text = "Map editor only runs from Godot"
|
||||
return
|
||||
if editor_button.enabled:
|
||||
get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -97,6 +97,33 @@ theme_override_font_sizes/font_size = 32
|
|||
horizontal_alignment = 1
|
||||
text = "Map Editor"
|
||||
|
||||
[node name="DevModeButton" type="Control" parent="UI"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -64
|
||||
offset_right = 64
|
||||
offset_top = 190
|
||||
offset_bottom = 318
|
||||
script = ExtResource("2")
|
||||
icon = 29
|
||||
glow_color = Color(1, 0.7, 0.25, 1)
|
||||
|
||||
[node name="DevModeLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -200
|
||||
offset_right = 200
|
||||
offset_top = 324
|
||||
offset_bottom = 380
|
||||
theme_override_font_sizes/font_size = 32
|
||||
horizontal_alignment = 1
|
||||
text = "Dev Mode"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="UI"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@ func _ready() -> void:
|
|||
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
|
||||
GameState.has_return_position = false
|
||||
else:
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
# A brand new game. STARTING_SHIP_COUNT rather than a literal: this
|
||||
# said 5, which was the old BASE_FLEET_SIZE written out by hand, and
|
||||
# it silently became "however many slots exist" once max_fleet_size()
|
||||
# clamped it. The opening fleet size is one decision and it lives in
|
||||
# GameState.
|
||||
fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, Vector2.ZERO)
|
||||
|
||||
# AsteroidField/PlanetField normally only populate nearby chunks once
|
||||
# their own check_interval timer elapses (~1s) - fine during normal
|
||||
|
|
|
|||
64
WorldGen.gd
64
WorldGen.gd
|
|
@ -29,12 +29,42 @@ const BLACK_HOLE_CHANCE: float = 0.05
|
|||
# research points had become to progression.
|
||||
const RESEARCH_LAB_CHANCE: float = 0.16
|
||||
|
||||
# A predictable early gas source for play-testing. The origin chunk is
|
||||
# deterministically normal space. Its explicit in-chunk position puts it
|
||||
# roughly 1,080 world units from a fresh fleet: easy to reach, but not
|
||||
# sitting on the spawn.
|
||||
const NEARBY_GAS_GIANT_CHUNK: Vector2i = Vector2i.ZERO
|
||||
const NEARBY_GAS_GIANT_LOCAL_POS: Vector2 = Vector2(900.0, 600.0)
|
||||
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
|
||||
# around the spawn so all four can be reached in seconds.
|
||||
#
|
||||
# This replaces the guaranteed gas giant that used to sit in the origin
|
||||
# chunk for every game. That was a play-testing convenience which had become
|
||||
# part of the real world's shape - every player got the same landmark in the
|
||||
# same place, and the surrounding space was generated as if it were normal.
|
||||
# Play-testing wants ALL the planet types to hand, not just gas, and a
|
||||
# normal game wants none of them handed over; those are different needs and
|
||||
# they now have different mechanisms.
|
||||
#
|
||||
# The local positions keep each planet off the spawn point itself: a fresh
|
||||
# fleet starts at world origin, which is the top-left corner of chunk (0,0).
|
||||
const DEV_PLANET_CHUNKS: Dictionary = {
|
||||
Vector2i(0, 0): PLANET_DEFINITIONS.GAS_GIANT_ID,
|
||||
Vector2i(1, 0): PLANET_DEFINITIONS.ROCKY_ID,
|
||||
Vector2i(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_ID,
|
||||
Vector2i(1, 1): PLANET_DEFINITIONS.BLACK_HOLE_ID,
|
||||
}
|
||||
const DEV_PLANET_LOCAL_POS: Vector2 = Vector2(600.0, 600.0)
|
||||
|
||||
|
||||
# Dev mode plants a fixed set of planets; a normal game must not see them.
|
||||
#
|
||||
# PUSHED here by GameState.dev_mode's setter rather than read back out of the
|
||||
# autoload. World generation is deliberately dependency-free - it is static,
|
||||
# seeded purely by chunk coordinates, and callable from anywhere - and an
|
||||
# autoload cannot be named from here anyway: `GameState` is not resolvable as
|
||||
# a global identifier this early, the same load-order hazard PlanetSetups.gd
|
||||
# and PlanetSurfaceController.gd already work around. GameState.dev_mode
|
||||
# stays the single source of truth; this is its mirror.
|
||||
static var dev_planets_enabled: bool = false
|
||||
|
||||
|
||||
static func _dev_planets_active() -> bool:
|
||||
return dev_planets_enabled
|
||||
|
||||
static var _noise: FastNoiseLite
|
||||
static var _planet_noise: FastNoiseLite
|
||||
|
|
@ -76,7 +106,7 @@ static func biome_at_chunk(chunk: Vector2i) -> int:
|
|||
|
||||
|
||||
static func has_planet_at_chunk(chunk: Vector2i) -> bool:
|
||||
if chunk == NEARBY_GAS_GIANT_CHUNK:
|
||||
if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk):
|
||||
return true
|
||||
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
|
||||
return (biome_at_chunk(chunk) == Biome.ASTEROID_FIELD and n > PLANET_THRESHOLD) or (biome_at_chunk(chunk) == Biome.SPACE and n > GAS_GIANT_THRESHOLD)
|
||||
|
|
@ -92,8 +122,9 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
|
|||
# can never drift out of sync with each other.
|
||||
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
rng.seed = hash(chunk)
|
||||
var guaranteed_nearby_giant: bool = chunk == NEARBY_GAS_GIANT_CHUNK
|
||||
var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \
|
||||
var forced_definition_id: String = String(DEV_PLANET_CHUNKS.get(chunk, "")) \
|
||||
if _dev_planets_active() else ""
|
||||
var local_pos: Vector2 = DEV_PLANET_LOCAL_POS if not forced_definition_id.is_empty() \
|
||||
else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
|
||||
var shape_seed: int = rng.randi()
|
||||
# Drawn unconditionally, in the same fixed position every call,
|
||||
|
|
@ -105,15 +136,20 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
|
|||
var research_lab_roll: float = rng.randf()
|
||||
# A stable per-chunk roll makes gas giants persistent landmarks rather
|
||||
# than a different resource whenever the scene reloads.
|
||||
var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE
|
||||
var gas_giant: bool = biome_at_chunk(chunk) == Biome.SPACE
|
||||
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
|
||||
# A rare third outcome, seeded the same deterministic way as everything
|
||||
# else here. Never overrides the guaranteed early gas giant landmark -
|
||||
# that chunk's whole purpose is to always be the same reachable source.
|
||||
if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE:
|
||||
# else here.
|
||||
if black_hole_roll < BLACK_HOLE_CHANCE:
|
||||
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
||||
elif not guaranteed_nearby_giant and research_lab_roll < RESEARCH_LAB_CHANCE:
|
||||
elif research_lab_roll < RESEARCH_LAB_CHANCE:
|
||||
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_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
|
||||
# only in these four chunks.
|
||||
if not forced_definition_id.is_empty():
|
||||
definition_id = forced_definition_id
|
||||
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
|
||||
var names: PackedStringArray = definition.names
|
||||
var planet_name: String = names[rng.randi() % names.size()]
|
||||
|
|
|
|||
152
tests/test_dev_mode.gd
Normal file
152
tests/test_dev_mode.gd
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_dev_mode.gd
|
||||
# A "Dev Mode" start for play-testing: one of every planet type next to the
|
||||
# spawn, every resource banked, and research points to spend. Offered by the
|
||||
# main menu only when the game is run from Godot, alongside the map editor.
|
||||
#
|
||||
# The same change took the training wheels off a NORMAL game: no 500 free
|
||||
# ore, and no gas giant guaranteed in the origin chunk. Both of those were
|
||||
# play-testing conveniences that had become part of the real game, so this
|
||||
# test covers both sides - what dev mode hands you, and what a normal game
|
||||
# no longer does.
|
||||
#
|
||||
# Covers:
|
||||
# A. a dev game banks every resource type and research points
|
||||
# B. a dev game is a normal game PLUS that, not a separate definition
|
||||
# C. one of each planet type sits within a chunk or two of the spawn
|
||||
# D. none of that leaks into a normal game, and starting one clears it
|
||||
# E. a normal game starts with an empty bank
|
||||
# F. the menu gates dev mode and the map editor on the same rule
|
||||
|
||||
var game_state: Node
|
||||
var menu: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# --- E. a normal game hands over nothing ----------------------
|
||||
game_state.reset_new_game()
|
||||
if game_state.rocks != 0:
|
||||
return _fail("a normal game started with %d ore banked" % game_state.rocks)
|
||||
for resource_type in game_state.resource_types():
|
||||
if game_state.banked_resource(resource_type) != 0:
|
||||
return _fail("a normal game started with %s banked" % resource_type)
|
||||
if game_state.research_points != 0:
|
||||
return _fail("a normal game started with research points")
|
||||
if game_state.dev_mode:
|
||||
return _fail("a normal game started in dev mode")
|
||||
print("normal game starts with an empty bank and no research points")
|
||||
|
||||
# --- D. no dev planets in a normal game -----------------------
|
||||
# The dev arrangement must not survive into a normal world. Noise
|
||||
# could coincidentally place a planet in one of these chunks, so
|
||||
# what is asserted is that the ARRANGEMENT is not reproduced, not
|
||||
# that each chunk is empty.
|
||||
var reproduced: bool = true
|
||||
for chunk in WorldGen.DEV_PLANET_CHUNKS:
|
||||
var expected: String = String(WorldGen.DEV_PLANET_CHUNKS[chunk])
|
||||
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
|
||||
if not WorldGen.has_planet_at_chunk(chunk) or String(data.get("definition_id", "")) != expected:
|
||||
reproduced = false
|
||||
if Vector2(data.get("local_pos", Vector2.ZERO)) == WorldGen.DEV_PLANET_LOCAL_POS:
|
||||
return _fail("a normal game placed a planet at dev mode's fixed in-chunk position, in chunk %s" % chunk)
|
||||
if reproduced:
|
||||
return _fail("a normal game reproduced dev mode's whole planet arrangement")
|
||||
print("normal game does not carry dev mode's planets")
|
||||
|
||||
# --- A + B. what a dev game hands over ------------------------
|
||||
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
|
||||
game_state.planet_capture["chunk:9:9"] = {"captured": [true], "conquered": true}
|
||||
game_state.start_dev_game()
|
||||
if not game_state.dev_mode:
|
||||
return _fail("start_dev_game() did not set dev_mode")
|
||||
for resource_type in game_state.resource_types():
|
||||
var banked: int = game_state.banked_resource(resource_type)
|
||||
if banked != GameState.DEV_RESOURCE_AMOUNT:
|
||||
return _fail("dev game banked %d of %s, expected %d" \
|
||||
% [banked, resource_type, GameState.DEV_RESOURCE_AMOUNT])
|
||||
if game_state.research_points != GameState.DEV_RESEARCH_POINTS:
|
||||
return _fail("dev game granted %d research points, expected %d" \
|
||||
% [game_state.research_points, GameState.DEV_RESEARCH_POINTS])
|
||||
# B: it still went through reset_new_game, so a dirty run is gone.
|
||||
if not game_state.buildings.is_empty() or not game_state.planet_capture.is_empty():
|
||||
return _fail("a dev game did not reset the previous run - it must be a fresh game plus a stock")
|
||||
print("dev game banks %d of each resource and %d research points, from a clean slate" \
|
||||
% [GameState.DEV_RESOURCE_AMOUNT, GameState.DEV_RESEARCH_POINTS])
|
||||
|
||||
# --- C. one of each planet type, close by ---------------------
|
||||
var found: Dictionary = {}
|
||||
for chunk in WorldGen.DEV_PLANET_CHUNKS:
|
||||
if not WorldGen.has_planet_at_chunk(chunk):
|
||||
return _fail("dev mode chunk %s holds no planet" % chunk)
|
||||
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
|
||||
var definition_id: String = String(data.get("definition_id", ""))
|
||||
if definition_id != String(WorldGen.DEV_PLANET_CHUNKS[chunk]):
|
||||
return _fail("dev mode chunk %s holds a %s, expected a %s" \
|
||||
% [chunk, definition_id, WorldGen.DEV_PLANET_CHUNKS[chunk]])
|
||||
found[definition_id] = true
|
||||
# "Near the start" is the whole point - a play-tester should
|
||||
# reach any of them in seconds, not go exploring.
|
||||
var world_pos: Vector2 = WorldGen.chunk_origin(chunk) + Vector2(data.local_pos)
|
||||
if world_pos.length() > WorldGen.CHUNK_SIZE * 2.5:
|
||||
return _fail("dev mode's %s spawned %.0f units away, too far to be 'near the start'" \
|
||||
% [definition_id, world_pos.length()])
|
||||
for definition_id in [PlanetDefinitions.ROCKY_ID, PlanetDefinitions.GAS_GIANT_ID,
|
||||
PlanetDefinitions.BLACK_HOLE_ID, PlanetDefinitions.RESEARCH_LAB_ID]:
|
||||
if not found.has(definition_id):
|
||||
return _fail("dev mode is missing a %s - it must plant one of EVERY planet type" % definition_id)
|
||||
print("dev mode plants all %d planet types within %.0f units of the spawn" \
|
||||
% [found.size(), WorldGen.CHUNK_SIZE * 2.5])
|
||||
|
||||
# --- D. starting a normal game clears it ----------------------
|
||||
game_state.reset_new_game()
|
||||
if game_state.dev_mode:
|
||||
return _fail("starting a normal game after a dev one left dev_mode set")
|
||||
if game_state.rocks != 0 or game_state.research_points != 0:
|
||||
return _fail("starting a normal game after a dev one kept the dev stock")
|
||||
print("starting a normal game afterwards clears dev mode entirely")
|
||||
|
||||
menu = load("res://MainMenu.tscn").instantiate()
|
||||
root.add_child(menu)
|
||||
current_scene = menu
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# --- F. the menu gates both on the same rule ------------------
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
var developing: bool = menu.is_developer_build()
|
||||
if menu.dev_button.visible != developing:
|
||||
return _fail("the dev mode button's visibility did not follow is_developer_build()")
|
||||
if menu.editor_button.visible != developing:
|
||||
return _fail("the map editor button's visibility did not follow is_developer_build()")
|
||||
if menu.play_button.visible != true or menu.quit_button.visible != (not OS.has_feature("web")):
|
||||
return _fail("gating the development entries also hid an entry a player needs")
|
||||
# Hiding is presentation; pressing it anyway must still be refused.
|
||||
if not developing:
|
||||
menu._on_dev_mode_pressed()
|
||||
if game_state.dev_mode:
|
||||
return _fail("dev mode started outside a developer build")
|
||||
print("dev mode and the map editor are both gated on is_developer_build() (%s here)" % developing)
|
||||
|
||||
print("TEST_DEV_MODE: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_DEV_MODE: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
|
@ -32,6 +32,13 @@ func _physics_process(delta: float) -> bool:
|
|||
# happens strictly after _ready(). Here, at the point _ready() is
|
||||
# already confirmed complete, it's safe.
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
# Crew the fleet HERE, not in _initialize(), for the same reason the
|
||||
# set_process(false) call above lives here: work done in _initialize()
|
||||
# can be undone by the engine's deferred tree-entry finalization. A
|
||||
# new game flies one vessel into three slots now, and this test needs
|
||||
# a full one.
|
||||
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
target_ship = fleet.ships[0]
|
||||
var enemy_scene: PackedScene = load("res://Enemy.tscn")
|
||||
enemy = enemy_scene.instantiate()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ func _physics_process(delta: float) -> bool:
|
|||
# happens strictly after _ready(). Here, at the point _ready() is
|
||||
# already confirmed complete, it's safe.
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
# Crew the fleet HERE, not in _initialize(), for the same reason the
|
||||
# set_process(false) call above lives here: work done in _initialize()
|
||||
# can be undone by the engine's deferred tree-entry finalization. A
|
||||
# new game flies one vessel into three slots now, and this test needs
|
||||
# a full one.
|
||||
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
var enemy_scene: PackedScene = load("res://Enemy.tscn")
|
||||
enemy = enemy_scene.instantiate()
|
||||
enemy.position = fleet.get_centroid() + SPAWN_OFFSET
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ var elapsed: float = 0.0
|
|||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
beam_script = load("res://EnemyBigLaserBeam.gd")
|
||||
# Four vessels are crewed below, past BASE_FLEET_SIZE's 3 slots, so the
|
||||
# capacity doctrines are granted rather than assumed.
|
||||
game_state.unlocked.append_array(["wing", "squadron", "armada"])
|
||||
main = load("res://Main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
fleet = main.get_node("Fleet")
|
||||
|
|
|
|||
139
tests/test_fleet_growth.gd
Normal file
139
tests/test_fleet_growth.gd
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_fleet_growth.gd
|
||||
# A new game opens with a SINGLE vessel and three slots, and the fleet grows
|
||||
# from there through the doctrine branch of the skill tree.
|
||||
#
|
||||
# BASE_FLEET_SIZE used to be both "how many slots exist" and "how many ships
|
||||
# you start with", which forced a new game to open with a full fleet - there
|
||||
# was nowhere to put a vessel until a doctrine was researched, so the first
|
||||
# ship crafter had nothing to do. The two are now separate constants, and
|
||||
# this test pins the distinction.
|
||||
#
|
||||
# Covers:
|
||||
# A. a new game starts with one vessel and three slots
|
||||
# B. the fleet really spawns one ship, and can be grown into its slots
|
||||
# C. every doctrine raises the ceiling by its own stated bonus
|
||||
# D. the doctrine branch is a chain, in increasing cost order
|
||||
# E. the tree cannot promise a slot the formation has no room for - the
|
||||
# invariant that made extra doctrines silently worthless
|
||||
|
||||
const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup", "grand_fleet"]
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_new_game()
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# --- A. the opening position ---------------------------------
|
||||
if GameState.STARTING_SHIP_COUNT != 1:
|
||||
return _fail("a new game starts with %d vessels, expected 1" % GameState.STARTING_SHIP_COUNT)
|
||||
if GameState.BASE_FLEET_SIZE != 3:
|
||||
return _fail("the fleet has %d slots by default, expected 3" % GameState.BASE_FLEET_SIZE)
|
||||
if game_state.surviving_ship_count != GameState.STARTING_SHIP_COUNT:
|
||||
return _fail("reset_new_game left %d surviving ships" % game_state.surviving_ship_count)
|
||||
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
|
||||
return _fail("an unresearched fleet caps at %d, expected %d" \
|
||||
% [game_state.max_fleet_size(), GameState.BASE_FLEET_SIZE])
|
||||
print("new game: %d vessel, %d slots" % [GameState.STARTING_SHIP_COUNT, GameState.BASE_FLEET_SIZE])
|
||||
|
||||
# --- C + D. the doctrine chain -------------------------------
|
||||
var expected: int = GameState.BASE_FLEET_SIZE
|
||||
var previous_cost: int = -1
|
||||
var previous_points: int = -1
|
||||
var previous_id: String = "coordination"
|
||||
for id in DOCTRINE_CHAIN:
|
||||
var node: Dictionary = game_state.skill_node(id)
|
||||
if node.is_empty():
|
||||
return _fail("no skill node '%s' - the doctrine chain is broken" % id)
|
||||
var bonus: int = int(node.get("fleet_bonus", 0))
|
||||
if bonus <= 0:
|
||||
return _fail("'%s' is in the doctrine chain but grants no fleet_bonus" % id)
|
||||
if String(node.get("requires", "")) != previous_id:
|
||||
return _fail("'%s' requires '%s', expected it to follow '%s' - the branch is not one chain" \
|
||||
% [id, node.get("requires", ""), previous_id])
|
||||
# Later tiers must not be cheaper than earlier ones, or the
|
||||
# order the player meets them stops matching what they cost.
|
||||
var cost: int = int(node.get("cost", 0))
|
||||
var points: int = int(node.get("research_points", 0))
|
||||
if cost <= previous_cost:
|
||||
return _fail("'%s' costs %d ore, not more than the tier before it (%d)" % [id, cost, previous_cost])
|
||||
if points < previous_points:
|
||||
return _fail("'%s' costs %d research points, fewer than the tier before it (%d)" \
|
||||
% [id, points, previous_points])
|
||||
previous_cost = cost
|
||||
previous_points = points
|
||||
previous_id = id
|
||||
|
||||
game_state.unlocked.append(id)
|
||||
expected += bonus
|
||||
if game_state.max_fleet_size() != expected:
|
||||
return _fail("after researching '%s' the fleet caps at %d, expected %d" \
|
||||
% [id, game_state.max_fleet_size(), expected])
|
||||
print(" %-12s +%d -> %2d slots (%d ore, %d research)" % [id, bonus, expected, cost, points])
|
||||
|
||||
# --- E. no doctrine may be swallowed by the formation ---------
|
||||
# max_fleet_size() clamps to the formation's length, so a doctrine
|
||||
# added without extending DEFAULT_FORMATION would cost the player
|
||||
# real resources and grant nothing.
|
||||
if expected > GameState.DEFAULT_FORMATION.size():
|
||||
return _fail("the doctrines promise %d slots but DEFAULT_FORMATION only defines %d - the last ones buy nothing" \
|
||||
% [expected, GameState.DEFAULT_FORMATION.size()])
|
||||
print("full doctrine chain reaches %d slots, with %d formation positions defined" \
|
||||
% [expected, GameState.DEFAULT_FORMATION.size()])
|
||||
|
||||
# --- B. what actually flies ----------------------------------
|
||||
game_state.reset_new_game()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
|
||||
return _fail("a fresh scene spawned %d vessels, expected %d" \
|
||||
% [fleet.ships.size(), GameState.STARTING_SHIP_COUNT])
|
||||
print("a fresh game really flies %d vessel" % fleet.ships.size())
|
||||
|
||||
# The two empty slots are what the opening is for, so they have to
|
||||
# be fillable without researching anything.
|
||||
for i in range(GameState.BASE_FLEET_SIZE - GameState.STARTING_SHIP_COUNT):
|
||||
if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
|
||||
return _fail("could not crew slot %d of the starting fleet" % (i + 2))
|
||||
if fleet.ships.size() != GameState.BASE_FLEET_SIZE:
|
||||
return _fail("the fleet holds %d after filling its slots, expected %d" \
|
||||
% [fleet.ships.size(), GameState.BASE_FLEET_SIZE])
|
||||
# And the cap has to actually bite past that.
|
||||
if fleet.add_ship(GameState.VESSEL_INTERCEPTOR):
|
||||
return _fail("a %dth vessel joined an unresearched fleet of %d slots" \
|
||||
% [fleet.ships.size(), GameState.BASE_FLEET_SIZE])
|
||||
print("the starting slots fill to %d and refuse a %dth" \
|
||||
% [GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 1])
|
||||
|
||||
print("TEST_FLEET_GROWTH: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_FLEET_GROWTH: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
|
@ -20,15 +20,21 @@ func _initialize() -> void:
|
|||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
if stage == 0:
|
||||
var nearby_chunk: Vector2i = WorldGen.NEARBY_GAS_GIANT_CHUNK
|
||||
# A guaranteed gas source near the spawn is now a DEV MODE guarantee
|
||||
# rather than something every game gets - a normal game has to go and
|
||||
# find one. Dev mode is therefore where this premise still holds, and
|
||||
# where a play-tester reaching gas quickly still matters.
|
||||
game_state.dev_mode = true
|
||||
var nearby_chunk: Vector2i = Vector2i.ZERO
|
||||
for chunk in WorldGen.DEV_PLANET_CHUNKS:
|
||||
if String(WorldGen.DEV_PLANET_CHUNKS[chunk]) == "gas_giant":
|
||||
nearby_chunk = chunk
|
||||
var nearby_data: Dictionary = WorldGen.planet_data_at_chunk(nearby_chunk)
|
||||
var nearby_position: Vector2 = WorldGen.chunk_origin(nearby_chunk) + nearby_data.local_pos
|
||||
if not WorldGen.has_planet_at_chunk(nearby_chunk):
|
||||
return _fail("nearby gas giant chunk does not contain a planet")
|
||||
if WorldGen.biome_at_chunk(nearby_chunk) != WorldGen.Biome.SPACE:
|
||||
return _fail("nearby gas giant is not in the normal space biome")
|
||||
return _fail("dev mode's gas giant chunk does not contain a planet")
|
||||
if String(nearby_data.get("definition_id", "")) != "gas_giant":
|
||||
return _fail("nearby guaranteed planet is not a gas source")
|
||||
return _fail("dev mode's guaranteed planet is not a gas source")
|
||||
if nearby_position.length() > WorldGen.CHUNK_SIZE * 2.0:
|
||||
return _fail("nearby gas giant spawned too far away: %s" % nearby_position)
|
||||
|
||||
|
|
|
|||
130
tests/test_hud_layout.gd
Normal file
130
tests/test_hud_layout.gd
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_hud_layout.gd
|
||||
# The HUD's top-anchored button columns must not collide with the resource
|
||||
# readout, and must not collide with each other.
|
||||
#
|
||||
# Both halves are regressions that actually shipped:
|
||||
# - ResourceReadout builds one row per resource type AT RUNTIME, so the
|
||||
# scene's authored offset_bottom understated it and the build button,
|
||||
# at a hardcoded y, ended up underneath the resource list.
|
||||
# - Seating the columns below the readout then exposed a second hardcoded
|
||||
# y: _layout_context_buttons() wrote the action row back to 324 on the
|
||||
# next context change, dropping it on top of the moved build button.
|
||||
#
|
||||
# Both were a literal coordinate that encoded "below the thing above me" and
|
||||
# stopped being true when the thing above moved. So this asserts the
|
||||
# RELATIONSHIP rather than any particular coordinate - a fresh hardcoded y
|
||||
# fails here whatever value someone picks.
|
||||
#
|
||||
# Covers:
|
||||
# A. every HUD button clears the resource readout's real bottom
|
||||
# B. no two simultaneously-usable buttons overlap each other
|
||||
# C. both still hold after _layout_context_buttons() runs, collapsed and
|
||||
# expanded - the path that broke
|
||||
# D. it holds with MORE resource types than the scene was authored for,
|
||||
# which is the direction this drifts
|
||||
|
||||
var scene: Node
|
||||
var controller: Node
|
||||
var readout: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
# Deliberately share a row: a building either offers its own action or a
|
||||
# hangar, never both, so their rects coinciding is the design.
|
||||
const EXCLUSIVE_PAIRS: Array = [["building_action_button", "hangar_button"]]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
controller = scene.get_node("BuildController")
|
||||
readout = scene.get_node("UI/ResourceReadout")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
if not _check("as laid out at load"):
|
||||
return true
|
||||
|
||||
# --- C. the path that broke it ------------------------------
|
||||
controller.context_actions_expanded = false
|
||||
controller._layout_context_buttons()
|
||||
if not _check("after _layout_context_buttons() collapsed"):
|
||||
return true
|
||||
|
||||
controller.context_actions_expanded = true
|
||||
controller._layout_context_buttons()
|
||||
if not _check("after _layout_context_buttons() expanded"):
|
||||
return true
|
||||
|
||||
print("TEST_HUD_LAYOUT: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _buttons() -> Dictionary:
|
||||
return {
|
||||
"build_button": controller.build_button,
|
||||
"transfer_button": controller.transfer_button,
|
||||
"action_button": controller.action_button,
|
||||
"landing_button": controller.landing_button,
|
||||
"building_action_button": controller.building_action_button,
|
||||
"hangar_button": controller.hangar_button,
|
||||
}
|
||||
|
||||
|
||||
func _check(context: String) -> bool:
|
||||
var buttons: Dictionary = _buttons()
|
||||
var names: Array = buttons.keys()
|
||||
|
||||
# --- A. clear of the resource readout ---------------------------------
|
||||
# content_bottom() is the readout's REAL extent, from the live resource
|
||||
# list - not the stale height the scene happens to declare.
|
||||
var readout_bottom: float = readout.content_bottom()
|
||||
for name in names:
|
||||
var button: Control = buttons[name]
|
||||
if button == null:
|
||||
continue
|
||||
if button.position.y < readout_bottom:
|
||||
return _fail("%s sits at y %.0f, inside the resource readout which reaches %.0f (%s)" \
|
||||
% [name, button.position.y, readout_bottom, context])
|
||||
|
||||
# --- B. clear of each other -------------------------------------------
|
||||
for i in range(names.size()):
|
||||
for j in range(i + 1, names.size()):
|
||||
var a_name: String = names[i]
|
||||
var b_name: String = names[j]
|
||||
if _is_exclusive(a_name, b_name):
|
||||
continue
|
||||
var a: Control = buttons[a_name]
|
||||
var b: Control = buttons[b_name]
|
||||
if a == null or b == null:
|
||||
continue
|
||||
if a.get_rect().intersects(b.get_rect()):
|
||||
return _fail("%s %s overlaps %s %s (%s)" \
|
||||
% [a_name, a.get_rect(), b_name, b.get_rect(), context])
|
||||
print("clear of the readout (reaches %.0f) and of each other - %s" % [readout_bottom, context])
|
||||
return true
|
||||
|
||||
|
||||
func _is_exclusive(a: String, b: String) -> bool:
|
||||
for pair in EXCLUSIVE_PAIRS:
|
||||
if (a == pair[0] and b == pair[1]) or (a == pair[1] and b == pair[0]):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_HUD_LAYOUT: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return false
|
||||
1
tests/test_hud_layout.gd.uid
Normal file
1
tests/test_hud_layout.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://65cagevj7qbj
|
||||
|
|
@ -35,6 +35,14 @@ func _process(delta: float) -> bool:
|
|||
return false
|
||||
if not menu.editor_button.enabled:
|
||||
return _fail("map editor button is not enabled (#48 shipped)")
|
||||
# Both development entries follow the same rule, and the rule is
|
||||
# what is under test rather than the concrete boolean - this run
|
||||
# is from Godot, so it exercises the "shown" branch.
|
||||
var developing: bool = menu.is_developer_build()
|
||||
if menu.editor_button.visible != developing or menu.editor_label.visible != developing:
|
||||
return _fail("map editor entry visibility did not follow is_developer_build()")
|
||||
if menu.dev_button.visible != developing or menu.dev_label.visible != developing:
|
||||
return _fail("dev mode entry visibility did not follow is_developer_build()")
|
||||
if menu.quit_button.visible != (not OS.has_feature("web")):
|
||||
return _fail("quit button visibility did not follow OS.has_feature('web')")
|
||||
|
||||
|
|
@ -74,8 +82,10 @@ func _process(delta: float) -> bool:
|
|||
if elapsed < 0.3:
|
||||
return false
|
||||
var on_main: bool = current_scene != null and current_scene.name == "Main"
|
||||
var reset_ok: bool = game_state.rocks == 500 and game_state.buildings.is_empty() \
|
||||
and game_state.surviving_ship_count == GameState.BASE_FLEET_SIZE \
|
||||
# A normal game now starts with NOTHING banked - the opening
|
||||
# mining run is the tutorial, and 500 free ore skipped it.
|
||||
var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \
|
||||
and game_state.surviving_ship_count == GameState.STARTING_SHIP_COUNT \
|
||||
and game_state.planet_capture.is_empty()
|
||||
print("on_main=%s reset_ok=%s rocks=%d buildings=%d ship_count=%d" % [
|
||||
on_main, reset_ok, game_state.rocks, game_state.buildings.size(),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ var cw: IconButton
|
|||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var angle_at_hold_start: float = 0.0
|
||||
var hold_amount_at_start: float = 0.0
|
||||
var angle_after_hold: float = 0.0
|
||||
# GameState (an autoload) is not available as a bare identifier from a
|
||||
# script that extends SceneTree - fetch it via the tree root instead.
|
||||
|
|
@ -45,6 +46,13 @@ func _process(delta: float) -> bool:
|
|||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
# Crew the fleet HERE, not in _initialize(): work done there can
|
||||
# be undone by the engine's deferred tree-entry finalization, the
|
||||
# same reason the set_process(false) calls above sit at this
|
||||
# point. A new game flies one vessel now, and the checks below
|
||||
# index ships[1].
|
||||
game_state.unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
# Hold the button down without releasing it.
|
||||
angle_at_hold_start = _angle()
|
||||
cw.pressed_down = true
|
||||
|
|
@ -127,6 +135,7 @@ func _process(delta: float) -> bool:
|
|||
return true
|
||||
|
||||
angle_at_hold_start = _angle()
|
||||
hold_amount_at_start = controller.pivot_hold_amount
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
|
|
@ -135,7 +144,12 @@ func _process(delta: float) -> bool:
|
|||
return false
|
||||
# Both fingers still down: the formation must have kept turning
|
||||
# while the stick kept re-issuing move orders every frame.
|
||||
var turned_while_steering: float = absf(angle_difference(angle_at_hold_start, _angle()))
|
||||
# The ACCUMULATED turn, not the angle difference. Comparing
|
||||
# formation angles wraps at +/-pi, so a hold that happened to
|
||||
# rotate a full turn read as no rotation at all - and how far it
|
||||
# gets in 0.4s depends on frame times, so this failed only under
|
||||
# load. pivot_hold_amount is the same quantity without the wrap.
|
||||
var turned_while_steering: float = controller.pivot_hold_amount - hold_amount_at_start
|
||||
print("turned %.3f rad while steering" % turned_while_steering)
|
||||
if turned_while_steering < 0.05:
|
||||
print("TEST_PIVOT_CONTROLS: FAIL (no rotation while the stick was held)")
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ func _initialize() -> void:
|
|||
current_scene = main_scene
|
||||
|
||||
fleet = main_scene.get_node("Fleet")
|
||||
# A new game now flies STARTING_SHIP_COUNT (1) vessel into BASE_FLEET_SIZE
|
||||
# (3) slots. This test needs a crewed fleet, so it asks for one outright
|
||||
# rather than inheriting whatever a fresh game happens to open with -
|
||||
# which is exactly what changed underneath it.
|
||||
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
planet_field = main_scene.get_node("PlanetField")
|
||||
landing_controller = main_scene.get_node("LandingController")
|
||||
asteroid_field_ref = main_scene.get_node("AsteroidField")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ func _initialize() -> void:
|
|||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
# Not a black hole: it destroys ships on contact, and this
|
||||
# scan takes the FIRST planet it finds. That used to be the gas
|
||||
# giant guaranteed in the origin chunk; with that guarantee gone
|
||||
# the scan can land the fleet on something that kills it, which
|
||||
# is not what either of these tests is about.
|
||||
if WorldGen.has_planet_at_chunk(chunk) \
|
||||
and String(WorldGen.planet_data_at_chunk(chunk).get("definition_id", "")) != "black_hole":
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
|
|
@ -57,6 +63,18 @@ func _process(delta: float) -> bool:
|
|||
# complete (fleet.ships is reliably populated by now), it's safe.
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
# Crew the fleet HERE rather than in _initialize(): work done
|
||||
# there can be undone by the engine's deferred tree-entry
|
||||
# finalization, the same reason the set_process(false) calls
|
||||
# above live at this point. A new game flies one vessel now.
|
||||
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
# Silencing the field does not remove what it already placed, and
|
||||
# those keep drifting - a rock taking the fleet out mid-test reads
|
||||
# here as a landing failure.
|
||||
for rock in get_nodes_in_group("asteroids"):
|
||||
if is_instance_valid(rock):
|
||||
rock.queue_free()
|
||||
|
||||
# PlanetField only scans chunks near the fleet's CURRENT
|
||||
# position, so it never even looks at target_chunk unless the
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ func _initialize() -> void:
|
|||
|
||||
# --- F. a fresh game still reaches a crafter and an extractor -------------
|
||||
game_state.reset_new_game()
|
||||
# can_unlock() also checks the ore price, and a new game now banks
|
||||
# nothing - the opening mining run is the point. Stock the ore so this
|
||||
# checks what it says it checks: reachability at ZERO RESEARCH POINTS.
|
||||
game_state.rocks = 500
|
||||
if not game_state.can_unlock("shipyards"):
|
||||
return _fail("shipyards is not reachable with 0 research points")
|
||||
game_state.unlock("shipyards")
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ func _initialize() -> void:
|
|||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
# A new game now flies STARTING_SHIP_COUNT (1) vessel into BASE_FLEET_SIZE
|
||||
# (3) slots. This test needs a crewed fleet, so it asks for one outright
|
||||
# rather than inheriting whatever a fresh game happens to open with -
|
||||
# which is exactly what changed underneath it.
|
||||
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
controller = scene.get_node("BuildController")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ func _process(delta: float) -> bool:
|
|||
if elapsed < 0.3:
|
||||
return false
|
||||
# --- crafter adds a ship --------------------------------------
|
||||
# Crafting adds a FOURTH vessel below, one past the 3 slots a
|
||||
# new game has, so make room first - the subject here is the
|
||||
# crafter, not the fleet cap.
|
||||
game_state.unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(3, Vector2.ZERO)
|
||||
var before_ships: int = fleet.ships.size()
|
||||
var before_rocks: int = game_state.rocks
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@ func _initialize() -> void:
|
|||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
# Not a black hole: it destroys ships on contact, and this
|
||||
# scan takes the FIRST planet it finds. That used to be the gas
|
||||
# giant guaranteed in the origin chunk; with that guarantee gone
|
||||
# the scan can land the fleet on something that kills it, which
|
||||
# is not what either of these tests is about.
|
||||
if WorldGen.has_planet_at_chunk(chunk) \
|
||||
and String(WorldGen.planet_data_at_chunk(chunk).get("definition_id", "")) != "black_hole":
|
||||
target_chunk = chunk
|
||||
found = true
|
||||
break
|
||||
|
|
@ -53,6 +59,18 @@ func _process(delta: float) -> bool:
|
|||
0:
|
||||
main_scene.get_node("AsteroidField").set_process(false)
|
||||
main_scene.get_node("EnemySpawner").set_process(false)
|
||||
# Crew the fleet HERE rather than in _initialize(): work done
|
||||
# there can be undone by the engine's deferred tree-entry
|
||||
# finalization, the same reason the set_process(false) calls
|
||||
# above live at this point. A new game flies one vessel now.
|
||||
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
|
||||
fleet.spawn_ships(5, Vector2.ZERO)
|
||||
# Silencing the field does not remove what it already placed, and
|
||||
# those keep drifting - a rock taking the fleet out mid-test reads
|
||||
# here as a landing failure.
|
||||
for rock in get_nodes_in_group("asteroids"):
|
||||
if is_instance_valid(rock):
|
||||
rock.queue_free()
|
||||
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
|
||||
for ship in fleet.ships:
|
||||
ship.position = vicinity_pos
|
||||
|
|
|
|||
Loading…
Reference in a new issue