From 7817d1ffc6f59e9821824cb8a1bb114d4e540f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 18 Aug 2026 16:54:46 +0200 Subject: [PATCH] Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 --- ArtilleryEnemyDefinition.tres | 4 +- ArtilleryShell.gd | 55 ++++---- BuildController.gd | 72 +++++++++++ Enemy.gd | 14 +- EnemyArtilleryComponent.gd | 4 +- EnemyDefinition.gd | 11 ++ EnemyDefinitions.gd | 25 ++++ EnemyMarker.gd | 19 +++ EnemyMarker.gd.uid | 1 + Fleet.gd | 11 ++ GameState.gd | 37 +++++- Main.tscn | 3 + MainMenu.gd | 28 ++-- MainMenu.tscn | 44 +------ MothershipEnemyDefinition.tres | 2 + PaletteEntryButton.gd | 12 +- PlanetSetup.gd | 7 + PlanetSetupEditor.gd | 40 +++++- PlanetSetupEditor.tscn | 19 +++ PlanetSetupHost.gd | 2 +- ResearchLabSurfaceProfile.tres | 10 +- ResourceTransferList.gd | 68 ++++++---- SniperEnemyDefinition.tres | 4 +- SpeedFader.gd | 8 +- TurretSpawnerEnemyDefinition.tres | 2 + project.godot | 2 +- research_lab_surface.gdshader | 117 +++++++++++++++++ research_lab_surface.gdshader.uid | 1 + tests/_artillery_telegraph_gas_preview.gd | 13 +- tests/_artillery_telegraph_rocky_preview.gd | 13 +- tests/_enemy_gallery_preview.gd | 29 +++++ tests/_enemy_gallery_preview.gd.uid | 1 + tests/_enemy_gallery_preview.tscn | 6 + tests/_research_lab_surface_preview.gd | 14 ++ tests/_research_lab_surface_preview.gd.uid | 1 + tests/_research_lab_surface_preview.tscn | 6 + tests/_web_play_probe.gd | 29 +++++ tests/_web_play_probe.gd.uid | 1 + tests/_web_play_probe.tscn | 6 + tests/test_artillery_enemy.gd | 48 ++++--- tests/test_dev_mode.gd | 4 +- tests/test_dev_mode.gd.uid | 1 + tests/test_distorder_stacking.gd | 135 ++++++++++++++++++++ tests/test_distorder_stacking.gd.uid | 1 + tests/test_fleet_growth.gd.uid | 1 + tests/test_lab_research_reward.gd | 115 +++++++++++++++++ tests/test_lab_research_reward.gd.uid | 1 + tests/test_main_menu.gd | 9 +- 48 files changed, 897 insertions(+), 159 deletions(-) create mode 100644 EnemyMarker.gd create mode 100644 EnemyMarker.gd.uid create mode 100644 research_lab_surface.gdshader create mode 100644 research_lab_surface.gdshader.uid create mode 100644 tests/_enemy_gallery_preview.gd create mode 100644 tests/_enemy_gallery_preview.gd.uid create mode 100644 tests/_enemy_gallery_preview.tscn create mode 100644 tests/_research_lab_surface_preview.gd create mode 100644 tests/_research_lab_surface_preview.gd.uid create mode 100644 tests/_research_lab_surface_preview.tscn create mode 100644 tests/_web_play_probe.gd create mode 100644 tests/_web_play_probe.gd.uid create mode 100644 tests/_web_play_probe.tscn create mode 100644 tests/test_dev_mode.gd.uid create mode 100644 tests/test_distorder_stacking.gd create mode 100644 tests/test_distorder_stacking.gd.uid create mode 100644 tests/test_fleet_growth.gd.uid create mode 100644 tests/test_lab_research_reward.gd create mode 100644 tests/test_lab_research_reward.gd.uid diff --git a/ArtilleryEnemyDefinition.tres b/ArtilleryEnemyDefinition.tres index f47c597..938e573 100644 --- a/ArtilleryEnemyDefinition.tres +++ b/ArtilleryEnemyDefinition.tres @@ -11,6 +11,8 @@ max_hp = 8 speed = 60.0 acceleration = 120.0 rotation_speed = 3.0 -hull_points = PackedVector2Array(0, -26, 18.4, -18.4, 26, 0, 18.4, 18.4, 0, 26, -18.4, 18.4, -26, 0, -18.4, -18.4) +hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22) glow_color = Color(1.0, 0.5, 0.2, 1) +detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6) +detail_rings = PackedVector2Array(0, -20, 8, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/ArtilleryShell.gd b/ArtilleryShell.gd index 2c8b1f9..ce7e46e 100644 --- a/ArtilleryShell.gd +++ b/ArtilleryShell.gd @@ -1,11 +1,11 @@ class_name ArtilleryShell extends Node2D -# Self-contained telegraph -> travel -> explode sequence for the artillery -# enemy's attack (see EnemyArtilleryComponent). Lives independently of the +# Self-contained travel -> explode sequence for the artillery enemy's attack +# (see EnemyArtilleryComponent). Lives independently of the # enemy that fired it once launched - added to the tree root, exactly like # EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't -# cancel a shot already telegraphed to the player. +# cancel a shell already in the air. # # Shield interaction: a shield charge absorbs this hit entirely, same # all-or-nothing semantics as every other damage source in this game @@ -14,20 +14,26 @@ extends Node2D signal finished -@export var telegraph_time: float = 1.8 +# The tower fires as soon as it decides to; the warning is the shell you can +# SEE coming and the cross marking where it will land. There used to be a +# 1.8s charge-up before anything left the barrel, which arrived long before +# the threat was legible and mostly read as the tower being stuck. @export var shell_speed: float = 180.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 @export var color: Color = Color(1.0, 0.5, 0.2) -enum Phase { TELEGRAPHING, TRAVELING, EXPLODING } +enum Phase { TRAVELING, EXPLODING } const EXPLOSION_DURATION: float = 0.8 +# The impact cross. Small on purpose: it says WHERE, and the shell in flight +# already says that something is coming. A full blast-radius ring here read +# as an attack that had already happened. +const IMPACT_CROSS_RADIUS: float = 20.0 -var phase: int = Phase.TELEGRAPHING +var phase: int = Phase.TRAVELING var target: Vector2 = Vector2.ZERO var shell_pos: Vector2 = Vector2.ZERO -var telegraph_left: float = 0.0 var explosion_time: float = 0.0 var trail: PackedVector2Array = PackedVector2Array() @@ -39,20 +45,14 @@ func _ready() -> void: func launch(from: Vector2, at: Vector2) -> void: target = at shell_pos = from - telegraph_left = telegraph_time - phase = Phase.TELEGRAPHING - position = target + phase = Phase.TRAVELING + trail = PackedVector2Array([shell_pos]) + position = shell_pos queue_redraw() func _process(delta: float) -> void: match phase: - Phase.TELEGRAPHING: - telegraph_left -= delta - if telegraph_left <= 0.0: - phase = Phase.TRAVELING - trail = PackedVector2Array([shell_pos]) - queue_redraw() Phase.TRAVELING: var to_target: Vector2 = target - shell_pos var step: float = shell_speed * delta @@ -85,15 +85,10 @@ func _explode() -> void: func _draw() -> void: match phase: - Phase.TELEGRAPHING: - var progress: float = clampf(1.0 - telegraph_left / maxf(telegraph_time, 0.001), 0.0, 1.0) - # Growing ring plus a filling countdown arc: the warning reads - # clearly without needing a number on screen. - var radius: float = lerpf(24.0, aoe_radius, progress) - _draw_ring(radius, 0.7) - draw_arc(Vector2.ZERO, aoe_radius + 14.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, - Color(color, 0.85), 8.0, true) Phase.TRAVELING: + # Where it will land, drawn on the ground. Local coordinates, + # because this node rides along with the shell while it flies. + _draw_impact_cross(target - shell_pos) for i in range(1, trail.size()): var alpha: float = float(i) / trail.size() draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true) @@ -106,6 +101,18 @@ func _draw() -> void: _draw_ring(eradius, 1.0 - eprogress) +# A small cross where the shell will land, plus a dot at the exact point. +# Deliberately not a filled circle the size of the blast: the player needs to +# read "move off this spot" at a glance, and a large translucent area was +# easy to mistake for the explosion itself. +func _draw_impact_cross(at: Vector2) -> void: + var r: float = IMPACT_CROSS_RADIUS + for offset in [Vector2(r, r), Vector2(r, -r)]: + draw_line(at - offset, at + offset, Color(color, 0.35), 7.0, true) + draw_line(at - offset, at + offset, Color(1.0, 0.92, 0.86, 0.85), 2.5, true) + draw_arc(at, r * 0.32, 0.0, TAU, 20, Color(color, 0.9), 2.5, true) + + func _draw_ring(radius: float, alpha: float) -> void: draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) diff --git a/BuildController.gd b/BuildController.gd index 3fd7f04..f976de6 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -62,6 +62,8 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var hangar_button: IconButton = get_node("../UI/HangarButton") @onready var hangar_menu: Control = get_node("../UI/HangarMenu") @onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel") +@onready var hangar_available_title: Label = get_node("../UI/HangarMenu/AvailableTitle") +@onready var hangar_fleet_title: Label = get_node("../UI/HangarMenu/FleetTitle") @onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton") @onready var hangar_available_slots: Array = [ get_node("../UI/HangarMenu/AvailableInterceptor"), @@ -247,6 +249,7 @@ func _seat_hud_columns() -> void: func _ready() -> void: _seat_hud_columns() + _layout_hangar() build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) _configure_build_menu_entries() @@ -1223,6 +1226,75 @@ func _hangar_types() -> Array: return types +# How many vessel types sit side by side in the hangar. Six in a single row +# gave each one about 100px while its two-line label needs 155, so the names +# ran into each other - "InterceptSheld VessGas Bomber". Three columns give +# each cell room, and the second row costs nothing: the lower half of the +# menu was empty. +const HANGAR_COLUMNS: int = 3 + + +# Arranges both hangar sections as grids sized from the space actually +# available, rather than from the single row the scene authored. A seventh +# vessel type now wraps instead of squeezing the other six. +func _layout_hangar() -> void: + var count: int = hangar_available_slots.size() + if count == 0: + return + var width: float = hangar_menu.size.x + var columns: int = mini(HANGAR_COLUMNS, count) + var rows: int = int(ceil(float(count) / float(columns))) + + # The band between the first section's heading and the status line under + # both of them. Derived, so moving either in the scene moves the grids. + var top: float = hangar_available_title.position.y + hangar_available_title.size.y + 8.0 + var bottom: float = hangar_status.position.y - 12.0 + var title_height: float = hangar_fleet_title.size.y + var section_height: float = (bottom - top - title_height - 16.0) * 0.5 + if section_height <= 0.0: + return + + var cell_width: float = width / float(columns) + var cell_height: float = section_height / float(rows) + # Squarer than the authored 75x120: a grid cell is wider than it is tall + # here, so height is what constrains the icon. + var slot_size: float = minf(cell_height * 0.58, cell_width * 0.42) + var label_height: float = maxf(cell_height - slot_size - 6.0, 1.0) + + _layout_hangar_section(hangar_available_slots, hangar_available_labels, + top, columns, cell_width, cell_height, slot_size, label_height, width) + # AFTER the first section's last row, not before it. Placing the heading + # by subtracting from the second section's top put it back inside the + # first one, straight over its bottom row. + var available_bottom: float = top + section_height + hangar_fleet_title.position.y = available_bottom + 4.0 + var fleet_top: float = available_bottom + title_height + 8.0 + _layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels, + fleet_top, columns, cell_width, cell_height, slot_size, label_height, width) + + +func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int, + cell_width: float, cell_height: float, slot_size: float, + label_height: float, width: float) -> void: + for i in range(slots.size()): + var column: int = i % columns + var row: int = i / columns + # Control.position is in PARENT space, whatever the node's anchors + # are - the authored offsets being negative (anchored to the menu's + # centre) does not make position centre-relative too. + var cell_x: float = float(column) * cell_width + var cell_y: float = top + float(row) * cell_height + var slot: Control = slots[i] + slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y) + slot.size = Vector2(slot_size, slot_size) + var label: Label = labels[i] + label.position = Vector2(cell_x, cell_y + slot_size + 4.0) + label.size = Vector2(cell_width, label_height) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_TOP + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + + func _refresh_hangar() -> void: var types: Array = _hangar_types() for i in range(types.size()): diff --git a/Enemy.gd b/Enemy.gd index 5d5e796..16335b6 100644 --- a/Enemy.gd +++ b/Enemy.gd @@ -60,9 +60,13 @@ func take_damage(amount: int) -> void: func _draw() -> void: - var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([ - Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), - ]) - var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2) + # Drawn through EnemyDefinitions.draw_definition, the same routine the + # map editor's markers and palette use, so a definition cannot end up + # looking one way where it is placed and another where it is fought. + if definition == null: + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), + ]), Color(1.0, 0.15, 0.2), 1.0) + return # Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw. - NeonDraw.draw_glowing_polygon(self, points, glow, 1.0) + ENEMY_DEFINITIONS.draw_definition(self, definition, definition.glow_color) diff --git a/EnemyArtilleryComponent.gd b/EnemyArtilleryComponent.gd index 9832905..cdf98ce 100644 --- a/EnemyArtilleryComponent.gd +++ b/EnemyArtilleryComponent.gd @@ -3,7 +3,7 @@ extends Node # Stationary siege piece: drifts slowly toward the fleet (deliberately no # pursuit component - closing distance is incidental, not its purpose) and -# fires a slow, heavily telegraphed AOE shell on a long cooldown via a +# fires a slow, clearly visible AOE shell on a long cooldown via a # standalone ArtilleryShell, so an already-launched shot survives this # enemy dying mid-flight, same as EnemyProjectile/Projectile already do. @@ -11,7 +11,6 @@ var capability_id: String = "artillery" @export var fire_cooldown: float = 6.0 @export var fire_range: float = 900.0 -@export var telegraph_time: float = 1.8 @export var shell_speed: float = 180.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 @@ -38,7 +37,6 @@ func tick(delta: float) -> void: return var shell: Node2D = shell_scene.instantiate() - shell.telegraph_time = telegraph_time shell.shell_speed = shell_speed shell.aoe_radius = aoe_radius shell.damage = damage diff --git a/EnemyDefinition.gd b/EnemyDefinition.gd index bb0927b..e214181 100644 --- a/EnemyDefinition.gd +++ b/EnemyDefinition.gd @@ -16,4 +16,15 @@ extends Resource Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), ]) @export var glow_color: Color = Color(1.0, 0.15, 0.2) +# Interior detail drawn on top of the hull, as consecutive PAIRS of points +# (a, b, c, d -> segments a-b and c-d). Every enemy used to be a single flat +# outline, which left the artillery and the turret spawner as the same +# octagon at two sizes - the silhouette carried no information about what a +# thing did. Kept as DATA rather than a per-type draw branch, for the same +# reason hull_points is: adding an enemy must not mean editing Enemy.gd. +@export var detail_lines: PackedVector2Array = PackedVector2Array() +# Rings, as triples of (centre x, centre y, radius). Sensor domes, muzzles +# and bay openings all read better as circles than as polygon chords. +@export var detail_rings: PackedVector2Array = PackedVector2Array() +@export var core_color: Color = Color(0.06, 0.02, 0.03) @export var component_scenes: Array[PackedScene] = [] diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index fb5fd15..20cf8b0 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -12,6 +12,31 @@ const DEFINITIONS: Dictionary = { } +# The single place that knows what an enemy LOOKS like. Three things draw +# enemies - the enemy itself, the map editor's markers, and the editor's +# palette buttons - and each used to reimplement it from hull_points alone. +# Adding interior detail to the definitions therefore showed up in the game +# and nowhere else, so an editor marker no longer matched the ship it placed. +# +# `at` and `scale` exist because the palette draws into a small button +# centred on itself, while the other two draw around their own origin at +# full size. +static func draw_definition(item: CanvasItem, definition: Resource, glow: Color, + scale: float = 1.0, at: Vector2 = Vector2.ZERO) -> void: + if definition == null: + return + var core: Color = definition.core_color + item.draw_set_transform(at, 0.0, Vector2(scale, scale)) + NeonDraw.draw_glowing_polygon(item, definition.hull_points, glow, 1.0, core) + var lines: PackedVector2Array = definition.detail_lines + for i in range(0, lines.size() - 1, 2): + NeonDraw.draw_glowing_line(item, lines[i], lines[i + 1], glow, 0.85, 0.55, core) + var rings: PackedVector2Array = definition.detail_rings + for i in range(0, rings.size() - 1, 2): + NeonDraw.draw_glowing_circle(item, rings[i], rings[i + 1].x, glow, 0.85, core) + item.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + static func get_definition(enemy_id: String) -> Resource: return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID]) diff --git a/EnemyMarker.gd b/EnemyMarker.gd new file mode 100644 index 0000000..de38c1f --- /dev/null +++ b/EnemyMarker.gd @@ -0,0 +1,19 @@ +class_name EnemyMarker +extends Node2D + +# An enemy as the map editor shows it. Exists so the editor can draw a +# spawn through EnemyDefinitions.draw_definition - the same routine the +# real enemy uses - instead of reimplementing it from hull_points, which is +# how the two drifted apart in the first place. +# +# glow_color is separate from the definition's own so the editor can tint a +# selected marker without touching the definition. + +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") + +var definition: Resource +var glow_color: Color = Color(1.0, 0.15, 0.2) + + +func _draw() -> void: + ENEMY_DEFINITIONS.draw_definition(self, definition, glow_color) diff --git a/EnemyMarker.gd.uid b/EnemyMarker.gd.uid new file mode 100644 index 0000000..380eed1 --- /dev/null +++ b/EnemyMarker.gd.uid @@ -0,0 +1 @@ +uid://c6ldnity1nxq7 diff --git a/Fleet.gd b/Fleet.gd index dae7f48..fda2244 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -96,6 +96,9 @@ func _sync_composition_state() -> void: # Composition changes are authoritative; scene teardown is not. Keeping # this out of SpeedFader prevents an outgoing scene's empty fleet from # resetting a boost while the same vessels are being restored on a surface. + # Each distorder vessel past the first widens the speed ceiling, so the + # COUNT matters, not merely whether one is present. + GameState.set_distorder_vessel_count(count_ships_with_capability("distorder")) if not has_capability("distorder"): GameState.set_fleet_speed_multiplier(1.0) @@ -118,6 +121,14 @@ func has_capability(capability: String) -> bool: return first_ship_with_capability(capability) != null +func count_ships_with_capability(capability: String) -> int: + var total: int = 0 + for ship in ships: + if is_instance_valid(ship) and ship.has_capability(capability): + total += 1 + return total + + func first_ship_with_capability(capability: String) -> Node2D: for ship in ships: if is_instance_valid(ship) and ship.has_capability(capability): diff --git a/GameState.gd b/GameState.gd index 5d514bf..1e46592 100644 --- a/GameState.gd +++ b/GameState.gd @@ -31,7 +31,16 @@ var planet_definition_id: String = "rocky" # anchor, Ship's own max_speed/acceleration) rather than baked into stored # stats, so a speed upgrade researched while boosted can't corrupt the base # value - see Fleet.gd/Ship.gd for the multiply sites. +# The ceiling ONE distorder vessel reaches. Each ADDITIONAL one raises it by +# another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel +# figure rather than an absolute cap so the fader's meaning at a single +# vessel is unchanged. const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 +# How far the fader can actually go right now, given the fleet's current +# composition. Pushed by Fleet whenever that composition changes, the same +# direction as every other fleet-derived figure here - GameState does not +# reach into the scene tree to count ships. +var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER var fleet_speed_multiplier: float = 1.0 # #62: energy cost of sustaining a boost, scaled by how far above 1.0 the @@ -47,7 +56,20 @@ var boost_depleted_notice: bool = false func set_fleet_speed_multiplier(value: float) -> void: - fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER) + fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier) + + +# Every distorder vessel past the first widens the fleet's speed ceiling by +# 1.0, so stacking them is worth doing rather than the second one being dead +# weight. Zero vessels returns the ceiling to its one-vessel value; the +# multiplier itself is reset separately by Fleet when the last one is lost. +# +# Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost +# pulls the fleet down to the new ceiling instead of leaving it travelling +# faster than its remaining crew can sustain. +func set_distorder_vessel_count(count: int) -> void: + max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER + maxf(float(count) - 1.0, 0.0) + set_fleet_speed_multiplier(fleet_speed_multiplier) # #63: currency gated behind conquering a research_lab planet - required by @@ -1335,7 +1357,10 @@ func _advance_distorder_drain(delta: float) -> void: # carry over into a LATER, unrelated boost. _distorder_drain_progress = 0.0 return - var rate: float = (fleet_speed_multiplier - 1.0) / (MAX_FLEET_SPEED_MULTIPLIER - 1.0) \ + # Normalized against the CURRENT ceiling, so "full boost" always costs + # ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it - + # a bigger fleet buys more speed, not cheaper speed. + var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \ * ENERGY_DRAIN_PER_SECOND _distorder_drain_progress += rate * delta var whole: int = int(floor(_distorder_drain_progress)) @@ -1365,7 +1390,8 @@ var planet_capture: Dictionary = {} signal planet_conquered(planet_id: String) -func register_capture_points(planet_key: String, total: int, definition_id: String = "") -> void: +func register_capture_points(planet_key: String, total: int, definition_id: String = "", + reward_override: int = 0) -> void: # Idempotent: landing again on a planet must not wipe points already # captured on a previous visit. if planet_capture.has(planet_key): @@ -1381,6 +1407,11 @@ func register_capture_points(planet_key: String, total: int, definition_id: Stri # the capture system. var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var point_reward: int = 0 if definition == null else int(definition.conquest_research_points) + # A map may be worth more than its planet type's flat rate - a harder + # lab should pay better, and only the authored layout knows how hard it + # is. Zero means the setup did not express an opinion. + if reward_override > 0: + point_reward = reward_override planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0, "research_point_reward": point_reward, "research_awarded": false} diff --git a/Main.tscn b/Main.tscn index 536b8f5..9fa7529 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1903,6 +1903,9 @@ offset_right = -200 offset_bottom = 166 text = "Transfer cargo" horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 +theme_override_font_sizes/font_size = 44 [node name="StatusLabel" type="Label" parent="UI/TransferMenu"] mouse_filter = 2 diff --git a/MainMenu.gd b/MainMenu.gd index bb44be4..fb07dce 100644 --- a/MainMenu.gd +++ b/MainMenu.gd @@ -3,8 +3,10 @@ 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 +# Two of the three entries only exist when the game is run from Godot, so a +# shipped build shows one entry and an editor run shows three. There is no +# Quit: a web build has nothing to quit TO, and on Android the system back +# gesture already does it. 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 @@ -28,8 +30,6 @@ const STATUS_HEIGHT: float = 70.0 @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 @@ -39,8 +39,6 @@ func _ready() -> void: 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 @@ -65,7 +63,7 @@ func is_developer_build() -> bool: 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]]: + [dev_button, dev_label]]: if row[0].visible: rows.append(row) if rows.is_empty(): @@ -93,7 +91,17 @@ func _layout_stack() -> void: func _on_play_pressed() -> void: GameState.reset_new_game() - get_tree().change_scene_to_file("res://Main.tscn") + _start("res://Main.tscn") + + +# change_scene_to_file() returns an error and otherwise fails SILENTLY - the +# menu simply stays up, which is indistinguishable from the button not having +# been pressed at all. Reporting it turns "nothing happens" into something a +# player can read back. +func _start(scene_path: String) -> void: + var result: int = get_tree().change_scene_to_file(scene_path) + if result != OK: + status_label.text = "Could not start (%s, error %d)" % [scene_path.get_file(), result] func _on_dev_mode_pressed() -> void: @@ -110,10 +118,8 @@ func _on_editor_pressed() -> void: status_label.text = "Map editor only runs from Godot" return if editor_button.enabled: - get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn") + _start("res://PlanetSetupEditor.tscn") return status_label.text = "Map editor coming soon" -func _on_quit_pressed() -> void: - get_tree().quit() diff --git a/MainMenu.tscn b/MainMenu.tscn index 20899a8..14e9ce3 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -1,18 +1,7 @@ -[gd_scene load_steps=4 format=3] +[gd_scene load_steps=3 format=3] [ext_resource type="Script" path="res://MainMenu.gd" id="1"] [ext_resource type="Script" path="res://IconButton.gd" id="2"] -[ext_resource type="Shader" path="res://starfield.gdshader" id="3"] - -[sub_resource type="ShaderMaterial" id="1"] -shader = ExtResource("3") -shader_parameter/camera_cell = Vector2(0, 0) -shader_parameter/camera_frac = Vector2(0, 0) -shader_parameter/camera_zoom = 1.0 -shader_parameter/viewport_size = Vector2(720, 1280) -shader_parameter/cell_size = 60.0 -shader_parameter/bg_color = Color(0.02, 0.02, 0.08, 1) - [node name="MainMenu" type="Node2D"] script = ExtResource("1") @@ -20,7 +9,7 @@ script = ExtResource("1") layer = -10 [node name="Starfield" type="ColorRect" parent="BackgroundLayer"] -material = SubResource("1") +color = Color(0, 0, 0, 1) mouse_filter = 2 anchors_preset = 15 anchor_right = 1.0 @@ -41,7 +30,7 @@ offset_top = -560 offset_bottom = -460 theme_override_font_sizes/font_size = 60 horizontal_alignment = 1 -text = "SPACESHIP TEST" +text = "SPACESHIP" [node name="PlayButton" type="Control" parent="UI"] anchor_left = 0.5 @@ -137,30 +126,3 @@ theme_override_font_sizes/font_size = 26 horizontal_alignment = 1 autowrap_mode = 2 text = "" - -[node name="QuitButton" 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 = 320 -offset_bottom = 448 -script = ExtResource("2") -icon = 1 -glow_color = Color(0.9, 0.35, 0.35, 1) - -[node name="QuitLabel" 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 = -160 -offset_right = 160 -offset_top = 460 -offset_bottom = 526 -theme_override_font_sizes/font_size = 32 -horizontal_alignment = 1 -text = "Quit" diff --git a/MothershipEnemyDefinition.tres b/MothershipEnemyDefinition.tres index f031799..d8e3603 100644 --- a/MothershipEnemyDefinition.tres +++ b/MothershipEnemyDefinition.tres @@ -16,4 +16,6 @@ acceleration = 45.0 rotation_speed = 1.5 hull_points = PackedVector2Array(0, -50, 35, -25, 48, 10, 20, 48, -20, 48, -48, 10, -35, -25) glow_color = Color(0.55, 0.05, 0.85, 1) +detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40) +detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")]) diff --git a/PaletteEntryButton.gd b/PaletteEntryButton.gd index ea479a2..c841907 100644 --- a/PaletteEntryButton.gd +++ b/PaletteEntryButton.gd @@ -9,6 +9,8 @@ extends Control signal pressed @export var is_capture_point: bool = false +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") + @export var enemy_definition: Resource @export var selected: bool = false: set = set_selected @@ -49,8 +51,8 @@ func _draw() -> void: points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28)) NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8) elif enemy_definition != null: - var scale_factor: float = (size.x * 0.5) / 28.0 - var points: PackedVector2Array = PackedVector2Array() - for p in enemy_definition.hull_points: - points.append(center + p * scale_factor * 0.6) - NeonDraw.draw_glowing_polygon(self, points, enemy_definition.glow_color, 0.9) + # Same routine as the marker and the enemy, so the button previews + # what will actually be placed. + var scale_factor: float = (size.x * 0.5) / 28.0 * 0.6 + ENEMY_DEFINITIONS.draw_definition(self, enemy_definition, + enemy_definition.glow_color, scale_factor, center) diff --git a/PlanetSetup.gd b/PlanetSetup.gd index fd81af1..ca5a6a6 100644 --- a/PlanetSetup.gd +++ b/PlanetSetup.gd @@ -14,3 +14,10 @@ extends Resource # Surface-local positions (the fleet lands at Vector2.ZERO). @export var capture_points: PackedVector2Array = PackedVector2Array() @export var enemy_spawns: Array[Resource] = [] +# Research points awarded for conquering this map, set in the map editor. +# A harder lab is worth more, and how hard a lab IS depends on the layout +# authored here - how many capture points, how many and what kind of +# defenders - which the planet type cannot know. 0 means "use whatever the +# planet type awards", so every setup written before this existed keeps its +# old value rather than silently dropping to nothing. +@export var research_point_reward: int = 0 diff --git a/PlanetSetupEditor.gd b/PlanetSetupEditor.gd index 7d0f9fd..0f31e15 100644 --- a/PlanetSetupEditor.gd +++ b/PlanetSetupEditor.gd @@ -17,6 +17,10 @@ extends Node2D # load-order notes) - a bare class-name reference needs the global # class_name cache already warm, which an explicit script path never does. const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") +# By path, not by its `EnemyMarker` global name: the class_name cache is not +# guaranteed warm before an import pass has run, the same hazard PlanetSetups +# and PlanetSurfaceController already work around. +const ENEMY_MARKER = preload("res://EnemyMarker.gd") const PLANET_SETUP_SCRIPT = preload("res://PlanetSetup.gd") const PLANET_SETUP_SPAWN_SCRIPT = preload("res://PlanetSetupSpawn.gd") const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://PaletteEntryButton.gd") @@ -43,6 +47,8 @@ const PALETTE_STEP: float = 92.0 @onready var back_button: IconButton = $UI/BackButton @onready var profile_button: IconButton = $UI/ProfileButton @onready var profile_value_label: Label = $UI/ProfileValueLabel +@onready var reward_button: IconButton = $UI/RewardButton +@onready var reward_value_label: Label = $UI/RewardValueLabel @onready var name_input: LineEdit = $UI/NameInput @onready var new_button: IconButton = $UI/NewButton @onready var save_button: IconButton = $UI/SaveButton @@ -83,6 +89,7 @@ func _ready() -> void: _profile_ids = PlanetSurfaceProfiles.PROFILES.keys() back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://MainMenu.tscn")) profile_button.pressed.connect(_on_profile_pressed) + reward_button.pressed.connect(_on_reward_pressed) new_button.pressed.connect(_on_new_pressed) save_button.pressed.connect(_on_save_pressed) load_button.pressed.connect(_on_load_pressed) @@ -224,6 +231,7 @@ func _refresh_background() -> void: material.set_shader_parameter("highlight_color", profile.highlight_color) if profile_value_label != null: profile_value_label.text = current_setup.surface_profile_id.to_upper() + _refresh_reward_label() func _rebuild_markers() -> void: @@ -256,9 +264,14 @@ func _add_enemy_marker(index: int, spawn: Resource) -> void: var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id) var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index var color: Color = Color(1.0, 0.95, 0.9) if is_selected else definition.glow_color - var marker: NeonPiece = NeonPiece.create(item_host, spawn.position, color) - marker.add_polygon(definition.hull_points, 0.9) - marker.queue_redraw() + # Drawn through the same routine the enemy itself uses, rather than from + # hull_points alone - a marker that does not look like what it places is + # worse than no picture at all. + var marker: Node2D = ENEMY_MARKER.new() + marker.position = spawn.position + marker.definition = definition + marker.glow_color = color + item_host.add_child(marker) # --- palette --------------------------------------------------------------- @@ -295,6 +308,27 @@ func _refresh_palette_selection() -> void: # --- UI handlers ------------------------------------------------------- +# What conquering this map is worth in research points. 0 keeps the planet +# type's own flat rate, so a setup that says nothing behaves as it always +# did; the rest let an author price a hard lab above an easy one. +const REWARD_OPTIONS: Array[int] = [0, 1, 2, 3, 5, 8] + + +func _on_reward_pressed() -> void: + if current_setup == null: + return + var index: int = REWARD_OPTIONS.find(int(current_setup.research_point_reward)) + current_setup.research_point_reward = REWARD_OPTIONS[(index + 1) % REWARD_OPTIONS.size()] + _refresh_reward_label() + + +func _refresh_reward_label() -> void: + if reward_value_label == null or current_setup == null: + return + var reward: int = int(current_setup.research_point_reward) + reward_value_label.text = "DEFAULT RP" if reward <= 0 else "%d RESEARCH PT" % reward + + func _on_profile_pressed() -> void: if _profile_ids.is_empty(): return diff --git a/PlanetSetupEditor.tscn b/PlanetSetupEditor.tscn index 0d41a64..e726a03 100644 --- a/PlanetSetupEditor.tscn +++ b/PlanetSetupEditor.tscn @@ -170,6 +170,25 @@ theme_override_font_sizes/font_size = 20 autowrap_mode = 2 text = "" +[node name="RewardButton" type="Control" parent="UI"] +offset_left = 350 +offset_top = 20 +offset_right = 422 +offset_bottom = 92 +script = ExtResource("4") +icon = 29 +glow_color = Color(0.55, 0.9, 0.65, 1) + +[node name="RewardValueLabel" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 430 +offset_top = 36 +offset_right = 690 +offset_bottom = 76 +theme_override_font_sizes/font_size = 26 +theme_override_colors/font_color = Color(0.55, 0.9, 0.65, 1) +text = "DEFAULT RP" + [node name="SelectedLabel" type="Label" parent="UI"] mouse_filter = 2 offset_left = 20 diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd index 99e145e..7624851 100644 --- a/PlanetSetupHost.gd +++ b/PlanetSetupHost.gd @@ -20,7 +20,7 @@ func apply_setup(new_setup: Resource) -> void: return GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(), - GameState.planet_definition_id) + GameState.planet_definition_id, int(setup.research_point_reward)) for i in range(setup.capture_points.size()): _add_capture_point(i, setup.capture_points[i]) for spawn in setup.enemy_spawns: diff --git a/ResearchLabSurfaceProfile.tres b/ResearchLabSurfaceProfile.tres index 98e83c8..d779e0f 100644 --- a/ResearchLabSurfaceProfile.tres +++ b/ResearchLabSurfaceProfile.tres @@ -1,12 +1,14 @@ -[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3] +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3] [ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] +[ext_resource type="Shader" path="res://research_lab_surface.gdshader" id="2"] [resource] script = ExtResource("1") profile_id = "research_lab" -base_color = Color(0.13, 0.1, 0.1, 1) -crater_color = Color(0.05, 0.03, 0.03, 1) -highlight_color = Color(0.55, 0.15, 0.1, 1) +background_shader = ExtResource("2") +base_color = Color(0.17, 0.13, 0.13, 1) +crater_color = Color(0.045, 0.028, 0.03, 1) +highlight_color = Color(0.85, 0.24, 0.16, 1) caption_color = Color(0.9, 0.6, 0.55, 1) caption_outline_color = Color(0.05, 0.02, 0.02, 0.9) diff --git a/ResourceTransferList.gd b/ResourceTransferList.gd index 3424191..de22bf2 100644 --- a/ResourceTransferList.gd +++ b/ResourceTransferList.gd @@ -78,17 +78,13 @@ func _ready() -> void: increase.glow_color = colour increase.pressed.connect(_change_amount.bind(resource_type, 1)) add_child(increase) - var amount_caption := _make_label("AMOUNT", 18, Color(0.62, 0.78, 0.86)) - amount_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - add_child(amount_caption) - _rows[resource_type] = { "glyph": glyph, "name": name_label, "fleet_amount": fleet_amount, "local_amount": local_amount, "unload": unload, "unload_label": unload_label, "load": load, "load_label": load_label, "decrease": decrease, "amount_label": amount_label, - "increase": increase, "amount_caption": amount_caption, + "increase": increase, "amount_index": AMOUNT_OPTIONS.size() - 1, } @@ -183,39 +179,61 @@ func _layout() -> void: for resource_type in GameState.resource_types(): var row: Dictionary = _rows[resource_type] var y: float = HEADER_HEIGHT + float(row_index) * row_height - var glyph_size: float = minf(66.0, row_height * 0.23) - row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, y + 10.0) + + # EVERY offset below is a fraction of row_height. It used to be a + # fixed pixel ladder - glyph at +10, buttons at +88, selector at + # +142, captions under that - which needed about 244px inside a row + # whose height is divided from the available space. At three + # resources that fit; the fourth cut the row to 180 and every row + # spilled 64px into the one below it, and the last one into the hint + # text under the list. Proportions cannot overflow: the bands always + # sum to the row. + var pad: float = row_height * 0.04 + var content: float = row_height - pad * 2.0 + var top: float = y + pad + # amounts, with the resource's glyph and name between them + var band_totals: float = content * 0.32 + # the arrows, and the amount selector between them + var band_controls: float = content * 0.48 + # the arrow captions + var band_captions: float = content * 0.20 + + row.fleet_amount.position = Vector2(0.0, top) + row.fleet_amount.size = Vector2(side_width, band_totals) + row.local_amount.position = Vector2(right_x, top) + row.local_amount.size = Vector2(side_width, band_totals) + + # Glyph and name sit at the TOP of their row rather than under its + # controls, so a row reads as one thing. Previously the name hung + # low enough to look like a heading for the row beneath it. + var glyph_size: float = minf(band_totals * 0.52, 44.0) + row.glyph.position = Vector2(size.x * 0.5 - glyph_size * 0.5, top) row.glyph.size = Vector2(glyph_size, glyph_size) - row.name.position = Vector2(side_width, y + 10.0 + glyph_size) - row.name.size = Vector2(centre_width, 44.0) + row.name.position = Vector2(side_width, top + glyph_size) + row.name.size = Vector2(centre_width, maxf(band_totals - glyph_size, 1.0)) - row.fleet_amount.position = Vector2(0.0, y + 10.0) - row.fleet_amount.size = Vector2(side_width, 70.0) - row.local_amount.position = Vector2(right_x, y + 10.0) - row.local_amount.size = Vector2(side_width, 70.0) - - var button_size: float = minf(132.0, row_height * 0.44) - var button_y: float = y + 88.0 + var controls_y: float = top + band_totals + var button_size: float = minf(band_controls, side_width * 0.5) + var button_y: float = controls_y + (band_controls - button_size) * 0.5 row.unload.position = Vector2(side_width * 0.5 - button_size * 0.5, button_y) row.unload.size = Vector2(button_size, button_size) row.load.position = Vector2(right_x + side_width * 0.5 - button_size * 0.5, button_y) row.load.size = Vector2(button_size, button_size) - var caption_y: float = button_y + button_size + 2.0 - row.unload_label.position = Vector2(0.0, caption_y) - row.unload_label.size = Vector2(side_width, 42.0) - row.load_label.position = Vector2(right_x, caption_y) - row.load_label.size = Vector2(side_width, 42.0) - var selector_size: float = minf(66.0, centre_width * 0.33) - var selector_y: float = y + 142.0 + var selector_size: float = minf(band_controls * 0.62, centre_width * 0.30) + var selector_y: float = controls_y + (band_controls - selector_size) * 0.5 row.decrease.position = Vector2(side_width + 2.0, selector_y) row.decrease.size = Vector2(selector_size, selector_size) row.increase.position = Vector2(right_x - selector_size - 2.0, selector_y) row.increase.size = Vector2(selector_size, selector_size) row.amount_label.position = Vector2(side_width + selector_size + 2.0, selector_y) row.amount_label.size = Vector2(centre_width - selector_size * 2.0 - 4.0, selector_size) - row.amount_caption.position = Vector2(side_width, selector_y + selector_size + 2.0) - row.amount_caption.size = Vector2(centre_width, 34.0) + + var caption_y: float = controls_y + band_controls + row.unload_label.position = Vector2(0.0, caption_y) + row.unload_label.size = Vector2(side_width, band_captions) + row.load_label.position = Vector2(right_x, caption_y) + row.load_label.size = Vector2(side_width, band_captions) row_index += 1 queue_redraw() diff --git a/SniperEnemyDefinition.tres b/SniperEnemyDefinition.tres index 102455f..75654f8 100644 --- a/SniperEnemyDefinition.tres +++ b/SniperEnemyDefinition.tres @@ -12,6 +12,8 @@ max_hp = 4 speed = 90.0 acceleration = 180.0 rotation_speed = 5.0 -hull_points = PackedVector2Array(0, -28, 7, -6, 7, 22, -7, 22, -7, -6) +hull_points = PackedVector2Array(0, -34, 5, -20, 9, 4, 16, 20, 6, 18, 4, 26, -4, 26, -6, 18, -16, 20, -9, 4, -5, -20) glow_color = Color(0.42, 0.72, 1.0, 1) +detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12) +detail_rings = PackedVector2Array(0, -2, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")]) diff --git a/SpeedFader.gd b/SpeedFader.gd index 46edf9e..69d9a0f 100644 --- a/SpeedFader.gd +++ b/SpeedFader.gd @@ -20,7 +20,9 @@ extends Control @onready var label: Label = get_node("Label") var active_touch_index: int = -1 -# 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER). +# 0 = bottom of the track (1.0x), 1 = top. The top is +# GameState.max_fleet_speed_multiplier, which widens with each distorder +# vessel past the first - so the track always spans the whole usable range. var knob_t: float = 0.0 var status_message_until: int = 0 @@ -42,7 +44,7 @@ func _process(_delta: float) -> void: knob_t = 0.0 return knob_t = (GameState.fleet_speed_multiplier - 1.0) \ - / maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001) + / maxf(GameState.max_fleet_speed_multiplier - 1.0, 0.001) # #62: GameState._process() (not this node's own _process()) is what # drains energy and force-resets the multiplier, since it has to keep # running across the space<->surface swap and while this control might @@ -104,7 +106,7 @@ func _update_from_local(local: Vector2) -> void: # Up is 1.0 (top of the track), matching how a real fader/throttle reads. var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0) knob_t = t - GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0)) + GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.max_fleet_speed_multiplier - 1.0)) label.text = "x%.1f" % GameState.fleet_speed_multiplier queue_redraw() diff --git a/TurretSpawnerEnemyDefinition.tres b/TurretSpawnerEnemyDefinition.tres index 59e000f..3e33fb3 100644 --- a/TurretSpawnerEnemyDefinition.tres +++ b/TurretSpawnerEnemyDefinition.tres @@ -13,4 +13,6 @@ acceleration = 0.0 rotation_speed = 0.0 hull_points = PackedVector2Array(0, -32, 22.6, -22.6, 32, 0, 22.6, 22.6, 0, 32, -22.6, 22.6, -32, 0, -22.6, -22.6) glow_color = Color(0.68, 0.92, 0.18, 1) +detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16) +detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/project.godot b/project.godot index 0dcc344..5c355de 100644 --- a/project.godot +++ b/project.godot @@ -10,7 +10,7 @@ config_version=5 [application] -config/name="Spaceship Test" +config/name="Spaceship" run/main_scene="res://MainMenu.tscn" config/features=PackedStringArray("4.7") config/icon="res://icon.svg" diff --git a/research_lab_surface.gdshader b/research_lab_surface.gdshader new file mode 100644 index 0000000..51d7842 --- /dev/null +++ b/research_lab_surface.gdshader @@ -0,0 +1,117 @@ +shader_type canvas_item; + +// Backdrop for a landed enemy research lab. +// +// The other surfaces are NATURAL: lunar_surface.gdshader erodes craters, +// gas_surface.gdshader bands clouds. A research lab is the one place in the +// game that is BUILT, and it was previously borrowing the crater field with +// a dark red palette - which read as "rocky planet, tinted", not as walking +// onto someone's facility. So this draws a plated floor instead: panel +// seams, service conduits running through them, and lit junctions where two +// conduits cross. +// +// Deliberately loop-free. lunar_surface.gdshader needs a neighbour scan +// because a crater overlaps its cell's borders; nothing here does, so every +// feature is a function of this pixel's own cell and the shader stays a +// handful of hashes per pixel (#68's lesson, applied up front rather than +// after the fact). +// +// The conduits still have to run CONTINUOUSLY across cell borders, which is +// what a neighbour scan would normally buy. The trick is to make a +// horizontal run's existence and height depend only on cell.y, and a +// vertical run's on cell.x: every cell in a row then independently agrees on +// the same answer, so the line joins up without any cell ever looking at +// its neighbours. + +uniform vec2 camera_cell = vec2(0.0, 0.0); +uniform vec2 camera_frac = vec2(0.0, 0.0); +uniform float camera_zoom = 1.0; +uniform vec2 viewport_size = vec2(720.0, 1280.0); +uniform float cell_size = 220.0; +// Shared with the lunar background so the same camera feed drives both, and +// so the field repeats on the same period. See LunarBackground.gd. +uniform float tile_cells = 32.0; +uniform vec4 base_color : source_color = vec4(0.16, 0.12, 0.12, 1.0); +uniform vec4 crater_color : source_color = vec4(0.05, 0.03, 0.03, 1.0); +uniform vec4 highlight_color : source_color = vec4(0.75, 0.20, 0.15, 1.0); + +// Plates per cell, across and down. +const float SUBPANELS = 3.0; +const float SEAM_WIDTH = 0.05; +const float CONDUIT_WIDTH = 0.055; +// How many rows/columns carry a conduit. Low enough that the floor stays +// mostly plating, high enough that a screen usually shows a junction. +const float CONDUIT_CHANCE = 0.38; + +float hash11(float p) { + vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float hash21(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +void fragment() { + vec2 screen_px = UV * viewport_size; + vec2 centered = screen_px - viewport_size * 0.5; + + // Same camera-relative construction as every other background here: an + // absolute world position reaches the tens of thousands, where a mobile + // float has already lost the fractional bits this needs. camera_cell + // arrives pre-wrapped from GDScript's double precision. + vec2 rel = camera_frac + centered * (camera_zoom / cell_size); + vec2 cell_rel = floor(rel); + vec2 local = rel - cell_rel; + vec2 cell = mod(camera_cell + cell_rel, tile_cells); + + // --- plating --------------------------------------------------------- + vec2 sub = local * SUBPANELS; + vec2 sub_cell = floor(sub); + vec2 sub_local = sub - sub_cell; + vec2 edge = min(sub_local, 1.0 - sub_local); + float seam = 1.0 - smoothstep(0.0, SEAM_WIDTH, min(edge.x, edge.y)); + // A little per-plate variation, so a large floor does not read as one + // flat fill: plates are stamped, not poured. + float plate = hash21(cell * SUBPANELS + sub_cell); + + vec3 color = mix(base_color.rgb, crater_color.rgb, plate * 0.40); + color = mix(color, crater_color.rgb, seam * 0.85); + + // --- conduits -------------------------------------------------------- + // Existence and offset keyed to a SINGLE axis of the cell index, which + // is what makes a run continuous across every cell it passes through. + float has_h = step(1.0 - CONDUIT_CHANCE, hash11(cell.y + 0.5)); + float has_v = step(1.0 - CONDUIT_CHANCE, hash11(cell.x + 13.7)); + float run_y = 0.25 + 0.5 * hash11(cell.y + 7.3); + float run_x = 0.25 + 0.5 * hash11(cell.x + 3.1); + + float h_band = has_h * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.y - run_y))); + float v_band = has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.x - run_x))); + + // Charge travelling along the run. Phase is built from cell + local, a + // continuous coordinate, so the pulse crosses cell borders unbroken + // rather than restarting inside every cell. + float pulse_h = 0.55 + 0.45 * sin((cell.x + local.x) * 2.4 - TIME * 1.6); + float pulse_v = 0.55 + 0.45 * sin((cell.y + local.y) * 2.4 - TIME * 1.6); + float conduit = max(h_band * pulse_h, v_band * pulse_v); + // A dark channel under the glow, so a conduit reads as recessed into the + // floor rather than painted on top of it. + color = mix(color, crater_color.rgb, max(h_band, v_band) * 0.55); + color = mix(color, highlight_color.rgb, conduit * 0.75); + + // Junction box wherever a horizontal and a vertical run cross. + float junction = has_h * has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH * 2.4, + length(vec2(local.x - run_x, local.y - run_y)))); + color = mix(color, highlight_color.rgb, junction * 0.9); + + // Same dither as the other backgrounds: the gradients here are shallow + // and dark, which is exactly where 8-bit quantization bands visibly. + float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0; + color += vec3(dither); + + COLOR = vec4(color, 1.0); +} diff --git a/research_lab_surface.gdshader.uid b/research_lab_surface.gdshader.uid new file mode 100644 index 0000000..7690c08 --- /dev/null +++ b/research_lab_surface.gdshader.uid @@ -0,0 +1 @@ +uid://eigpctjmqkr diff --git a/tests/_artillery_telegraph_gas_preview.gd b/tests/_artillery_telegraph_gas_preview.gd index c255b9a..703aaf4 100644 --- a/tests/_artillery_telegraph_gas_preview.gd +++ b/tests/_artillery_telegraph_gas_preview.gd @@ -1,5 +1,9 @@ extends Node2D +# Preview, not a test - an artillery shell in flight with the cross marking +# where it will land. Named "telegraph" from when the tower charged up +# before firing; the warning is now the shell itself plus that cross. + func _ready() -> void: GameState.planet_name = "Violet Tempest" @@ -11,10 +15,15 @@ func _ready() -> void: add_child(surface) var shell: Node = load("res://ArtilleryShell.tscn").instantiate() - shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot shell.aoe_radius = 220.0 surface.add_child(shell) + # Frozen with the shell part-way to its target, which is the moment the + # warning has to work: the shell is visibly in the air and the cross + # marks where it will land. There is no charge-up phase to capture any + # more - the tower fires straight away. shell.launch(Vector2(-600, -600), Vector2.ZERO) - shell.telegraph_left = shell.telegraph_time * 0.45 + shell.shell_pos = Vector2(-260, -260) + shell.position = shell.shell_pos + shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos]) shell.set_process(false) shell.queue_redraw() diff --git a/tests/_artillery_telegraph_rocky_preview.gd b/tests/_artillery_telegraph_rocky_preview.gd index eaa8372..bff4cd0 100644 --- a/tests/_artillery_telegraph_rocky_preview.gd +++ b/tests/_artillery_telegraph_rocky_preview.gd @@ -1,5 +1,9 @@ extends Node2D +# Preview, not a test - an artillery shell in flight with the cross marking +# where it will land. Named "telegraph" from when the tower charged up +# before firing; the warning is now the shell itself plus that cross. + func _ready() -> void: GameState.planet_name = "Kepler's Rest" @@ -11,10 +15,15 @@ func _ready() -> void: add_child(surface) var shell: Node = load("res://ArtilleryShell.tscn").instantiate() - shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot shell.aoe_radius = 220.0 surface.add_child(shell) + # Frozen with the shell part-way to its target, which is the moment the + # warning has to work: the shell is visibly in the air and the cross + # marks where it will land. There is no charge-up phase to capture any + # more - the tower fires straight away. shell.launch(Vector2(-600, -600), Vector2.ZERO) - shell.telegraph_left = shell.telegraph_time * 0.45 + shell.shell_pos = Vector2(-260, -260) + shell.position = shell.shell_pos + shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos]) shell.set_process(false) shell.queue_redraw() diff --git a/tests/_enemy_gallery_preview.gd b/tests/_enemy_gallery_preview.gd new file mode 100644 index 0000000..c6e6a99 --- /dev/null +++ b/tests/_enemy_gallery_preview.gd @@ -0,0 +1,29 @@ +extends Node2D + +# Preview, not a test - every enemy hull side by side, so their silhouettes +# can be compared at a glance. The chaser is included as the reference the +# others are meant to be more distinctive than. +# bash tests/screenshot.sh res://tests/_enemy_gallery_preview.tscn /tmp/enemies.png 15 720x1280 + +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") + + +func _ready() -> void: + var enemy_scene: PackedScene = load("res://Enemy.tscn") + var ids: Array = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"] + var y: float = 200.0 + for id in ids: + var definition: Resource = ENEMY_DEFINITIONS.get_definition(id) + if definition == null: + continue + var enemy: Node2D = enemy_scene.instantiate() + enemy.enemy_id = id + enemy.position = Vector2(230.0, y) + add_child(enemy) + var label := Label.new() + label.text = definition.display_name + label.position = Vector2(330.0, y - 20.0) + label.add_theme_font_size_override("font_size", 30) + label.add_theme_color_override("font_color", definition.glow_color) + add_child(label) + y += 200.0 diff --git a/tests/_enemy_gallery_preview.gd.uid b/tests/_enemy_gallery_preview.gd.uid new file mode 100644 index 0000000..3cd5b3a --- /dev/null +++ b/tests/_enemy_gallery_preview.gd.uid @@ -0,0 +1 @@ +uid://dn5mgaf76i0m3 diff --git a/tests/_enemy_gallery_preview.tscn b/tests/_enemy_gallery_preview.tscn new file mode 100644 index 0000000..362f581 --- /dev/null +++ b/tests/_enemy_gallery_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_enemy_gallery_preview.gd" id="1"] + +[node name="EnemyGalleryPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_research_lab_surface_preview.gd b/tests/_research_lab_surface_preview.gd new file mode 100644 index 0000000..f88d073 --- /dev/null +++ b/tests/_research_lab_surface_preview.gd @@ -0,0 +1,14 @@ +extends Node2D + +# Preview, not a test - the enemy research lab's own backdrop. +# bash tests/screenshot.sh res://tests/_research_lab_surface_preview.tscn /tmp/lab.png 20 720x1280 + + +func _ready() -> void: + GameState.planet_name = "Vault Nine" + GameState.planet_definition_id = "research_lab" + GameState.surviving_ship_count = 3 + GameState.rocks = 128 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) diff --git a/tests/_research_lab_surface_preview.gd.uid b/tests/_research_lab_surface_preview.gd.uid new file mode 100644 index 0000000..a0303df --- /dev/null +++ b/tests/_research_lab_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://b330nrwpm5003 diff --git a/tests/_research_lab_surface_preview.tscn b/tests/_research_lab_surface_preview.tscn new file mode 100644 index 0000000..1679f44 --- /dev/null +++ b/tests/_research_lab_surface_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_research_lab_surface_preview.gd" id="1"] + +[node name="ResearchLabSurfacePreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_web_play_probe.gd b/tests/_web_play_probe.gd new file mode 100644 index 0000000..e384286 --- /dev/null +++ b/tests/_web_play_probe.gd @@ -0,0 +1,29 @@ +extends Node2D + +# Probe, not a test - presses Play on the main menu under the REAL +# gl_compatibility renderer, which is the same path the web export takes +# (WebGL2 == GLES3 compatibility). --headless uses a dummy renderer and so +# cannot surface a failure that only happens once something actually has to +# be rendered or a shader has to compile. +# +# bash tests/screenshot.sh res://tests/_web_play_probe.tscn /tmp/play.png 45 720x1280 + +var menu: Node +var frames: int = 0 +var pressed: bool = false + + +func _ready() -> void: + menu = load("res://MainMenu.tscn").instantiate() + add_child(menu) + + +func _process(_delta: float) -> void: + frames += 1 + if frames == 8 and not pressed: + pressed = true + print("PLAY_PROBE: pressing Play") + menu._on_play_pressed() + if frames == 30: + var scene: Node = get_tree().current_scene + print("PLAY_PROBE: current_scene=%s" % [scene.name if scene != null else ""]) diff --git a/tests/_web_play_probe.gd.uid b/tests/_web_play_probe.gd.uid new file mode 100644 index 0000000..84ce0ce --- /dev/null +++ b/tests/_web_play_probe.gd.uid @@ -0,0 +1 @@ +uid://dtudtqerivj84 diff --git a/tests/_web_play_probe.tscn b/tests/_web_play_probe.tscn new file mode 100644 index 0000000..e63c2a9 --- /dev/null +++ b/tests/_web_play_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_web_play_probe.gd" id="1"] + +[node name="WebPlayProbe" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd index 3fe97c0..8ca93e5 100644 --- a/tests/test_artillery_enemy.gd +++ b/tests/test_artillery_enemy.gd @@ -3,8 +3,9 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_artillery_enemy.gd # Verifies: -# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole -# window before any damage lands +# 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it +# is launched - and the warning is the shell's flight plus the impact +# cross drawn at its target, not a charge-up before anything happens # 2. a ship that leaves the blast radius before impact takes no damage; # one that stays takes the full hit # 3. a shielded ship inside the blast survives, losing exactly one charge @@ -34,6 +35,7 @@ var live_shell: Node var recorded_target: Vector2 = Vector2.ZERO var impact_point: Vector2 = Vector2.ZERO var travel_time: float = 0.0 +var origin: Vector2 = Vector2.ZERO var stage2_wait: float = 0.0 var stage5_wait: float = 0.0 var stage: int = 0 @@ -79,10 +81,9 @@ func _process(delta: float) -> bool: shield_ship = fleet.ships[fleet.ships.size() - 1] shield_ship.position = impact_point + Vector2(-100, 0) - var origin: Vector2 = impact_point + Vector2(400, 400) + origin = impact_point + Vector2(400, 400) travel_time = origin.distance_to(impact_point) / 300.0 shell = load("res://ArtilleryShell.tscn").instantiate() - shell.telegraph_time = 1.0 shell.shell_speed = 300.0 shell.aoe_radius = 220.0 shell.damage = 3 @@ -93,28 +94,40 @@ func _process(delta: float) -> bool: stage = 1 return false 1: - # Mid-telegraph: nobody hurt yet, still visibly telegraphing. - if elapsed < shell.telegraph_time * 0.8: + # The shell must already be travelling - there is no charge-up + # phase to sit in any more - and it must have moved off the + # tower under its own speed. + if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING: + return _fail("the shell is not in the air on the frame after firing") + if elapsed < travel_time * 0.4: return false - if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING: - return _fail("shell left the telegraph phase too early") + if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point): + return _fail("the shell has not closed any distance on its target") + # The warning the player actually gets: a cross on the ground at + # the point of impact. Drawing cannot be asserted headlessly, so + # this checks what it is drawn FROM - the aim point is fixed and + # the marker has a real size. + if not shell.target.is_equal_approx(impact_point): + return _fail("the impact marker would not be drawn at the point the shell was aimed at") + if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0: + return _fail("the impact cross has no size, so nothing marks the landing point") if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \ or not is_instance_valid(shield_ship): - return _fail("damage landed before the telegraph window finished") + return _fail("damage landed before the shell arrived") # Ship B clears the blast radius now, well before impact. ship_leaves.position = impact_point + Vector2(2000, 0) # Captured once, as a plain float: the shell frees itself once - # it resolves, so re-reading shell.telegraph_time from inside - # the wait condition below (evaluated every frame) would touch - # a freed object on any frame after that. - stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5 + # it resolves, so reading off it from inside the wait condition + # below (evaluated every frame) would touch a freed object on + # any frame after that. + stage2_wait = travel_time + 1.5 elapsed = 0.0 stage = 2 return false 2: - # Enough time for the remaining telegraph, travel, and the full - # explosion visual to resolve. + # Enough time for the remaining travel and the full explosion + # visual to resolve. if elapsed < stage2_wait: return false if is_instance_valid(ship_inside): @@ -138,7 +151,6 @@ func _process(delta: float) -> bool: main.add_child(artillery) art_component = artillery.get_component("artillery") art_component.cooldown_left = 0.0 - art_component.telegraph_time = 0.3 art_component.fire_cooldown = 999.0 # only the one shot for this check elapsed = 0.0 stage = 3 @@ -173,7 +185,9 @@ func _process(delta: float) -> bool: # destroy the artillery enemy (and with it art_component) well # before this wait ends, so the condition below must not keep # re-reading a property off a node that might already be freed. - stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5 + # No charge-up to wait out any more - just flight time plus the + # explosion visual. + stage5_wait = (300.0 / 180.0) + 1.5 elapsed = 0.0 stage = 5 return false diff --git a/tests/test_dev_mode.gd b/tests/test_dev_mode.gd index 38b8a2f..63b73c0 100644 --- a/tests/test_dev_mode.gd +++ b/tests/test_dev_mode.gd @@ -131,8 +131,8 @@ func _process(delta: float) -> bool: 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") + if not menu.play_button.visible: + return _fail("gating the development entries also hid Play") # Hiding is presentation; pressing it anyway must still be refused. if not developing: menu._on_dev_mode_pressed() diff --git a/tests/test_dev_mode.gd.uid b/tests/test_dev_mode.gd.uid new file mode 100644 index 0000000..eea061e --- /dev/null +++ b/tests/test_dev_mode.gd.uid @@ -0,0 +1 @@ +uid://xmhkba108cv6 diff --git a/tests/test_distorder_stacking.gd b/tests/test_distorder_stacking.gd new file mode 100644 index 0000000..7d13a09 --- /dev/null +++ b/tests/test_distorder_stacking.gd @@ -0,0 +1,135 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_distorder_stacking.gd +# Each space-time distorder vessel PAST THE FIRST raises the fleet's speed +# ceiling by 1.0, so a second one is worth crewing rather than dead weight. +# +# Covers: +# A. one vessel gives the original ceiling, so nothing changed for a fleet +# that carries a single distorder +# B. each additional vessel adds exactly 1.0 +# C. the fader's full travel reaches the WIDENED ceiling, not the old one +# D. losing a vessel mid-boost pulls the fleet back down to the new +# ceiling rather than leaving it faster than its crew can sustain +# E. full boost still costs the same energy per second however wide the +# ceiling is - more vessels buy more speed, not cheaper speed + +var game_state: Node +var main: Node +var fleet: Node +var fader: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_progression() + game_state.vessel_levels["unlock_distorder"] = 1 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + fader = main.get_node("UI/SpeedFader") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # Room for a fleet of distorders. + game_state.unlocked.append_array(["wing", "squadron", "armada"]) + + # --- A. one vessel is unchanged ------------------------------ + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) + if not is_equal_approx(game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER): + return _fail("one distorder gives a ceiling of %.1f, expected the unchanged %.1f" \ + % [game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER]) + print("1 distorder -> ceiling %.1fx" % game_state.max_fleet_speed_multiplier) + + # --- B. each additional vessel adds 1.0 ---------------------- + for extra in range(1, 4): + fleet.add_ship(GameState.VESSEL_DISTORDER) + var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER + float(extra) + if not is_equal_approx(game_state.max_fleet_speed_multiplier, expected): + return _fail("%d distorders give a ceiling of %.1f, expected %.1f" \ + % [extra + 1, game_state.max_fleet_speed_multiplier, expected]) + print("%d distorders -> ceiling %.1fx" % [extra + 1, game_state.max_fleet_speed_multiplier]) + + # --- C. the fader reaches the widened ceiling ---------------- + # Dragging the track to its top must mean the CURRENT ceiling, or + # the extra vessels would be unreachable through the only control + # that exists for them. + fader._process(0.0) + if not fader.visible: + return _fail("the fader is hidden with four distorder vessels crewed") + var press := InputEventScreenTouch.new() + press.index = 3 + press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0) + press.pressed = true + main._handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = 3 + drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0) + main._handle_drag(drag) + if not is_equal_approx(game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier): + return _fail("dragging the fader to the top reached %.2fx, not the ceiling %.2fx" \ + % [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier]) + print("fader top reaches %.1fx" % game_state.fleet_speed_multiplier) + + # --- E. full boost costs the same per second ----------------- + # The drain is normalized against the current ceiling, so sitting + # at full boost is the same price whatever the ceiling is. + var wide_energy: int = _energy_spent_over(1.0) + + # --- D. losing vessels pulls the boost back down ------------- + var boosted_before: float = game_state.fleet_speed_multiplier + for i in range(fleet.ships.size() - 1, -1, -1): + if is_instance_valid(fleet.ships[i]) and fleet.ships[i].vessel_type == GameState.VESSEL_DISTORDER: + fleet.store_ship(i) + break + if game_state.max_fleet_speed_multiplier >= boosted_before: + return _fail("losing a distorder left the ceiling at %.1f, no lower than the %.1f the fleet was already doing" \ + % [game_state.max_fleet_speed_multiplier, boosted_before]) + if game_state.fleet_speed_multiplier > game_state.max_fleet_speed_multiplier + 0.001: + return _fail("the fleet is still doing %.2fx after its ceiling dropped to %.2fx" \ + % [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier]) + print("losing one pulled %.1fx down to the new %.1fx ceiling" \ + % [boosted_before, game_state.fleet_speed_multiplier]) + + # Same measurement at the narrower ceiling, also at full boost. + game_state.set_fleet_speed_multiplier(game_state.max_fleet_speed_multiplier) + var narrow_energy: int = _energy_spent_over(1.0) + if absi(wide_energy - narrow_energy) > 1: + return _fail("full boost cost %d energy with a wide ceiling and %d with a narrow one - extra vessels changed the PRICE, not just the speed" \ + % [wide_energy, narrow_energy]) + print("full boost costs %d/%d energy per second at either ceiling" % [wide_energy, narrow_energy]) + + print("TEST_DISTORDER_STACKING: PASS") + quit(0) + return true + return false + + +# Energy actually consumed by holding the current multiplier for `seconds`, +# driven directly so the measurement does not depend on frame pacing. +func _energy_spent_over(seconds: float) -> int: + var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + var step: float = 0.05 + var t: float = 0.0 + while t < seconds: + game_state._advance_distorder_drain(step) + t += step + return before - game_state.fleet_resource(GameState.RESOURCE_ENERGY) + + +func _fail(message: String) -> bool: + print("TEST_DISTORDER_STACKING: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_distorder_stacking.gd.uid b/tests/test_distorder_stacking.gd.uid new file mode 100644 index 0000000..80916da --- /dev/null +++ b/tests/test_distorder_stacking.gd.uid @@ -0,0 +1 @@ +uid://cftfhg1hdb5c4 diff --git a/tests/test_fleet_growth.gd.uid b/tests/test_fleet_growth.gd.uid new file mode 100644 index 0000000..6ff75b3 --- /dev/null +++ b/tests/test_fleet_growth.gd.uid @@ -0,0 +1 @@ +uid://hbmlomqcx20q diff --git a/tests/test_lab_research_reward.gd b/tests/test_lab_research_reward.gd new file mode 100644 index 0000000..0d721d4 --- /dev/null +++ b/tests/test_lab_research_reward.gd @@ -0,0 +1,115 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_lab_research_reward.gd +# A harder enemy research lab should be worth more research points, and how +# hard a lab is depends on the layout authored for it - how many capture +# points, how many defenders - which the planet TYPE cannot know. So the +# reward is a per-map value set in the map editor, falling back to the +# planet type's flat rate when a setup does not express an opinion. +# +# Covers: +# A. a setup carries its own reward and it survives a save/load round trip +# B. a map's reward overrides the planet type's flat rate on conquest +# C. a setup that says nothing (0) still gets the type's rate, so every +# map authored before this existed keeps its old value +# D. the editor's control cycles the value and reports it + +var game_state: Node +var editor: 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 field exists and round-trips --------------------- + var setup: Resource = load("res://PlanetSetup.gd").new() + setup.setup_id = "reward_probe" + setup.display_name = "Reward Probe" + setup.surface_profile_id = "rocky" + setup.capture_points = PackedVector2Array([Vector2(10, 10), Vector2(-10, -10)]) + setup.research_point_reward = 5 + var path: String = "user://test_reward_probe.tres" + if ResourceSaver.save(setup, path) != OK: + return _fail("could not save a setup carrying a research reward") + var reloaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + if reloaded == null or int(reloaded.research_point_reward) != 5: + return _fail("the reward did not survive a save/load round trip") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + print("a setup carries its own reward (%d) across a save/load" % reloaded.research_point_reward) + + # --- B. the map's reward beats the planet type's rate -------- + # A research_lab planet's own definition pays a flat 1. + var flat_rate: int = int(load("res://ResearchLabPlanetDefinition.tres").conquest_research_points) + game_state.research_points = 0 + game_state.register_capture_points("hard_lab", 2, "research_lab", 6) + game_state.mark_point_captured("hard_lab", 0) + game_state.mark_point_captured("hard_lab", 1) + if game_state.research_points != 6: + return _fail("a hard lab authored at 6 points awarded %d" % game_state.research_points) + if flat_rate >= 6: + return _fail("the planet type's flat rate (%d) is not below the authored one - this test proves nothing" % flat_rate) + print("a hard lab authored at 6 pays 6, over the planet type's flat %d" % flat_rate) + + # --- C. silence still means the type's rate ------------------ + game_state.research_points = 0 + game_state.register_capture_points("plain_lab", 1, "research_lab", 0) + game_state.mark_point_captured("plain_lab", 0) + if game_state.research_points != flat_rate: + return _fail("a setup with no authored reward paid %d, expected the type's %d" \ + % [game_state.research_points, flat_rate]) + print("a setup with no opinion still pays the planet type's %d" % flat_rate) + + # A harder map must never be worth LESS than an easier one. + if 6 <= flat_rate: + return _fail("authoring a harder lab did not raise its worth") + + editor = load("res://PlanetSetupEditor.tscn").instantiate() + root.add_child(editor) + current_scene = editor + elapsed = 0.0 + stage = 1 + return false + 1: + # --- D. the editor can set it -------------------------------- + if elapsed < 0.2: + return false + editor.new_setup("reward_edit", "Reward Edit", "rocky") + var seen: Array = [] + for i in range(editor.REWARD_OPTIONS.size()): + seen.append(int(editor.current_setup.research_point_reward)) + editor._on_reward_pressed() + # Cycling the whole list must come back round to where it began. + if int(editor.current_setup.research_point_reward) != seen[0]: + return _fail("cycling every reward option did not return to the start") + if seen.size() != editor.REWARD_OPTIONS.size() or seen.max() <= 0: + return _fail("the reward control did not offer a range of values: %s" % [seen]) + editor.current_setup.research_point_reward = 3 + editor._refresh_reward_label() + if not "3" in editor.reward_value_label.text: + return _fail("the editor does not report the chosen reward: '%s'" % editor.reward_value_label.text) + editor.current_setup.research_point_reward = 0 + editor._refresh_reward_label() + if not "DEFAULT" in editor.reward_value_label.text: + return _fail("the editor does not distinguish 'use the planet type's rate': '%s'" \ + % editor.reward_value_label.text) + print("the map editor cycles %s and reports the choice" % [seen]) + + print("TEST_LAB_RESEARCH_REWARD: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_LAB_RESEARCH_REWARD: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_lab_research_reward.gd.uid b/tests/test_lab_research_reward.gd.uid new file mode 100644 index 0000000..9e1d2c9 --- /dev/null +++ b/tests/test_lab_research_reward.gd.uid @@ -0,0 +1 @@ +uid://cka4wugwoaway diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index 938ca89..5a86a7a 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -5,9 +5,8 @@ extends SceneTree # Verifies: # 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and # reaches PlanetSetupEditor.tscn (#48) -# 2. Quit's visibility follows OS.has_feature("web") (this platform is -# not a web export, so this run exercises the "visible" branch; the -# condition itself is what's under test, not the concrete boolean) +# 2. there is no Quit entry in any build - a web build has nothing to quit +# to, and Android's back gesture already does it # 3. Play resets GameState to a fresh game AND reaches Main.tscn var menu: Node @@ -43,8 +42,8 @@ func _process(delta: float) -> bool: 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')") + if menu.has_node("UI/QuitButton") or menu.has_node("UI/QuitLabel"): + return _fail("the menu still carries a Quit entry") _press(menu.editor_button) elapsed = 0.0