diff --git a/.gitignore b/.gitignore index d479896..d651f66 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ android/build/ # Android's "don't index this as media" marker, not project content. .nomedia + +# Per-test verdicts from the last tests/run_tests.sh, kept only so a long run +# whose terminal output was lost can still be read back. +.test-results/ diff --git a/audio/Music.gd b/audio/Music.gd new file mode 100644 index 0000000..43e6ef5 --- /dev/null +++ b/audio/Music.gd @@ -0,0 +1,63 @@ +class_name Music +extends RefCounted + +# Forgejo #148: "the space_4 file in the folder is the main theme of the +# game, make it play all the time." +# +# "All the time" is the whole of the requirement, and it is what decides the +# shape of this. The theme must survive the main menu becoming the game, the +# game becoming a planet surface, and the surface becoming the game again - +# so the player cannot live in any of those scenes, because +# change_scene_to_file() frees the one that is current. +# +# It is parented to the TREE ROOT instead, alongside the GameState autoload, +# which is the same trick for the same reason. Doing it this way rather than +# by adding an autoload keeps project.godot out of it - that file carries +# the developer's own uncommitted window settings, and a change here should +# not drag those into a commit. +# +# ensure() is idempotent by NAME: whatever scene loads next asks for the +# music, and gets the one already playing rather than a second copy of it. + +const THEME_PATH: String = "res://audio/space_4.mp3" +const NODE_NAME: String = "MusicPlayer" +# Under everything else. It is a bed, not an event. +const VOLUME_DB: float = -14.0 + + +# Starts the theme if nothing is playing it yet, and returns the player +# either way. Safe to call from any scene's _ready. +static func ensure(tree: SceneTree) -> AudioStreamPlayer: + if tree == null: + return null + var existing: Node = tree.root.get_node_or_null(NODE_NAME) + if existing != null: + return existing as AudioStreamPlayer + + var stream: AudioStream = load(THEME_PATH) + if stream == null: + return null + # Looping is a property of the STREAM, not of the player, so it is set + # here rather than relied on from the .import file - which Godot + # regenerates, and which would silently take the loop away with it. + if "loop" in stream: + stream.loop = true + + var player := AudioStreamPlayer.new() + player.name = NODE_NAME + player.stream = stream + player.volume_db = VOLUME_DB + player.autoplay = false + # #128 stops the tree whenever a dialog is up. Music is the one thing + # that should not stop with it - a soundtrack cutting out every time + # someone reads a line is worse than no soundtrack. + player.process_mode = Node.PROCESS_MODE_ALWAYS + # A backstop for the loop above: if a stream format ever turns up that + # cannot loop itself, it still comes round again rather than falling + # silent for the rest of the session. + player.finished.connect(player.play) + # Deferred: this is called from a scene's _ready, and the root is busy + # adding that scene's own children at the time. + tree.root.add_child.call_deferred(player) + player.play.call_deferred() + return player diff --git a/audio/Music.gd.uid b/audio/Music.gd.uid new file mode 100644 index 0000000..ff0fdd3 --- /dev/null +++ b/audio/Music.gd.uid @@ -0,0 +1 @@ +uid://bwck7xas3hae3 diff --git a/audio/Sfx.gd b/audio/Sfx.gd new file mode 100644 index 0000000..83b614d --- /dev/null +++ b/audio/Sfx.gd @@ -0,0 +1,96 @@ +class_name Sfx +extends RefCounted + +# Forgejo #146: the three things that happen to something and should be +# heard - a shot leaving a gun, a vessel dying, a structure coming down. +# +# The sounds themselves are synthesised (see audio/make_sfx.py) rather than +# downloaded. The ticket asked for free sources and the good ones are in its +# comments, but a generated sound has no licence to track, no attribution to +# keep, and no binary in the repository whose provenance nobody can check +# later. Swapping in a file from one of those packs is a matter of replacing +# the .wav - nothing here knows or cares which it is. +# +# Parented to the tree root for the same reason Music is: a sound started by +# something that is about to be freed - which is every one of these, a dying +# ship being the obvious case - cannot live on the thing that started it. +# +# A POOL rather than one player, because these overlap constantly: a fleet +# firing is several shots inside one shot's length. The oldest is recycled +# when they are all busy, which is the right thing to lose. + +const NODE_NAME: String = "SfxPlayers" +const VOICES: int = 12 + +const SHOT: String = "shot" +const SHIP_DEATH: String = "ship_death" +const BUILDING_DEATH: String = "building_death" + +const SOUNDS: Dictionary = { + SHOT: "res://audio/sfx_shot.wav", + SHIP_DEATH: "res://audio/sfx_ship_death.wav", + BUILDING_DEATH: "res://audio/sfx_building_death.wav", +} + +# Per-sound level. A shot is fired constantly and a death happens once, so +# they cannot share a volume and stay in proportion. +const LEVELS: Dictionary = { + SHOT: -22.0, + SHIP_DEATH: -6.0, + BUILDING_DEATH: -4.0, +} + +# Shots vary a little in pitch so a stream of them does not turn into one +# flat machine-gun note. The deaths do not: they are events, and a wobbling +# explosion sounds broken rather than varied. +const SHOT_PITCH_SPREAD: float = 0.12 + + +static func _pool(tree: SceneTree) -> Node: + var existing: Node = tree.root.get_node_or_null(NODE_NAME) + if existing != null: + return existing + var pool := Node.new() + pool.name = NODE_NAME + # #128 stops the tree for dialogs. A sound already in flight should + # finish rather than freeze mid-bang. + pool.process_mode = Node.PROCESS_MODE_ALWAYS + tree.root.add_child(pool) + for i in range(VOICES): + var voice := AudioStreamPlayer.new() + voice.process_mode = Node.PROCESS_MODE_ALWAYS + pool.add_child(voice) + return pool + + +# Plays one sound. Safe from a node on its way out of the tree, and safe to +# call far more often than there are voices. +static func play(tree: SceneTree, id: String) -> void: + if tree == null or not SOUNDS.has(id): + return + var stream: AudioStream = load(String(SOUNDS[id])) + if stream == null: + return + var pool: Node = _pool(tree) + var voice: AudioStreamPlayer = null + for child in pool.get_children(): + if not (child as AudioStreamPlayer).playing: + voice = child + break + if voice == null: + # All busy. The oldest is the one furthest through its own sound, so + # it is the one with the least left to lose. + var most: float = -1.0 + for child in pool.get_children(): + var at: float = (child as AudioStreamPlayer).get_playback_position() + if at > most: + most = at + voice = child + if voice == null: + return + voice.stream = stream + voice.volume_db = float(LEVELS.get(id, -10.0)) + voice.pitch_scale = 1.0 + if id == SHOT: + voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD) + voice.play() diff --git a/audio/Sfx.gd.uid b/audio/Sfx.gd.uid new file mode 100644 index 0000000..9bae373 --- /dev/null +++ b/audio/Sfx.gd.uid @@ -0,0 +1 @@ +uid://nhg0c87ie053 diff --git a/audio/make_sfx.py b/audio/make_sfx.py new file mode 100644 index 0000000..4b3934c --- /dev/null +++ b/audio/make_sfx.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Generates the game's sound effects, from scratch. + +Forgejo #146 asked for free SFX sources, and the ones worth using are listed +in that ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound +filtered to CC0. What ships here is synthesised instead, for one reason: a +generated sound has no licence, no attribution to keep track of, and no +binary in the repository whose provenance nobody can check a year from now. + +It also means the sounds are DATA rather than assets. If a shot is too sharp +or an explosion too short, the fix is a number in this file and a re-run, +not a hunt through a sound library. + +#150 and #151 are why the first pass was thrown away. "Shot sounds sound +like a toy" and "death sounds from ship are really cheap", and both were +fair: the shot was a bare descending sine, which is the sound every toy ray +gun has ever made, and the death was one band of filtered noise with a sine +under it - thin, and gone before the eye had finished with the explosion. + +What the rewrite adds, and why each part is there: + + * LAYERS, not a waveform. A real bang is a crack, then a body, then a + tail, and they have different lengths. One envelope over one source + cannot be any of those things. + * A sub. Below about 80Hz is where "big" lives; without it an explosion is + a hiss however loud it is played. + * SATURATION. Soft-clipping folds in harmonics and stops the peak + normalisation below from turning everything into the same quiet mush. + * Detuned partials on the shot, so it is a machine rather than a flute - + the single sine was the whole of the toy problem. + +Run: python3 audio/make_sfx.py +""" +import math +import random +import struct +import wave + +RATE = 44100 + + +def _write(name, samples): + peak = max(1e-9, max(abs(s) for s in samples)) + frames = b"".join(struct.pack(" void: add_to_group("buildings") z_index = -1 @@ -24,6 +36,41 @@ func _ready() -> void: get_node("VisualHost").add_child(visual) +func health_fraction() -> float: + return GameState.building_health_fraction(building_id) if building_id != 0 else 1.0 + + +func is_damaged() -> bool: + return health_fraction() < 0.999 + + +func _process(_delta: float) -> void: + # Redraw only when the number moves. A building that is not being shot at + # costs nothing, which is the same bargain Ship.gd's constant glow makes. + var health: float = health_fraction() + if is_equal_approx(health, _drawn_health): + return + _drawn_health = health + queue_redraw() + + +func _draw() -> void: + if not is_damaged(): + return + # Above the structure rather than across it: the visual scenes all draw + # around their own origin, and a bar laid over one would be read as part + # of the building. + var health: float = health_fraction() + var left: float = -HEALTH_BAR_WIDTH * 0.5 + var top: float = HEALTH_BAR_OFFSET + draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT), + Color(0.02, 0.02, 0.06, 0.85)) + draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH * health, HEALTH_BAR_HEIGHT), + HEALTH_HURT_COLOR.lerp(HEALTH_FULL_COLOR, health)) + draw_rect(Rect2(left, top, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT), + Color(0.55, 0.78, 0.86, 0.55), false, 1.0) + + func available_actions() -> PackedStringArray: if visual == null: return PackedStringArray() diff --git a/buildings/Building.tscn b/buildings/Building.tscn index f6fad65..2190e89 100644 --- a/buildings/Building.tscn +++ b/buildings/Building.tscn @@ -1,8 +1,18 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=4 format=3] [ext_resource type="Script" path="res://buildings/Building.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingHitbox.gd" id="2"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 72.0 [node name="Building" type="Node2D"] script = ExtResource("1") [node name="VisualHost" type="Node2D" parent="."] + +[node name="Hitbox" type="Area2D" parent="."] +script = ExtResource("2") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Hitbox"] +shape = SubResource("1") diff --git a/buildings/BuildingDefinition.gd b/buildings/BuildingDefinition.gd index 7b3f1fd..ca4f598 100644 --- a/buildings/BuildingDefinition.gd +++ b/buildings/BuildingDefinition.gd @@ -4,7 +4,17 @@ extends Resource @export var definition_id: String @export var display_name: String @export_multiline var description: String +# What it costs in ORE, and the whole price when it wants more than ore. +# +# #138: structures all charged rocks and nothing else, whatever they were. +# The dictionary is the same shape VesselDefinition has used since gas +# bombers and laser ships (build_cost / build_resource_costs): when it is +# empty the price is `cost` in ore, and when it is set it IS the price, ore +# included - and its ore entry must agree with `cost`, which the test pins, +# because repair costs are quoted from `cost` alone (a repair drone carries +# ore and nothing else). @export var cost: int +@export var build_resource_costs: Dictionary = {} @export var action_icon: int @export var menu_icon: int @export var visual_scene: PackedScene @@ -19,3 +29,7 @@ extends Resource @export var menu_cost_name: String @export_enum("space", "planet") var placement_mode: String = "space" @export var unique: bool = false +# #99: what a wave has to chew through to level this. Data rather than one +# constant for every structure - an extractor bolted to a rock is not the +# mothership. See GameState.damage_building(). +@export var max_hp: int = 40 diff --git a/buildings/BuildingDefinitions.gd b/buildings/BuildingDefinitions.gd index 972cd6a..4ebdb9a 100644 --- a/buildings/BuildingDefinitions.gd +++ b/buildings/BuildingDefinitions.gd @@ -1,11 +1,14 @@ class_name BuildingDefinitions extends RefCounted -const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab", "gas_burner"] +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "extractor", "transport_hub", "ship_lab", "gas_burner"] const DEFINITIONS: Dictionary = { "mothership": preload("res://buildings/MothershipBuildingDefinition.tres"), "ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"), "command_center": preload("res://buildings/CommandBuildingDefinition.tres"), + "outpost": preload("res://buildings/OutpostBuildingDefinition.tres"), + "planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"), + "spacial_workshop": preload("res://buildings/WorkshopBuildingDefinition.tres"), "extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"), "transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"), "ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"), diff --git a/buildings/BuildingField.gd b/buildings/BuildingField.gd index 79e8dfe..e85844f 100644 --- a/buildings/BuildingField.gd +++ b/buildings/BuildingField.gd @@ -22,9 +22,28 @@ var spawned: Array = [] func _ready() -> void: + # #99: the record is the truth, so the node follows it out. Connected + # here rather than in Building.gd because the field owns `spawned` and + # would otherwise be left holding a freed node. + if not GameState.building_destroyed.is_connected(_on_building_destroyed): + GameState.building_destroyed.connect(_on_building_destroyed) rebuild_all() +func _on_building_destroyed(building_id: int, _type: String) -> void: + var kept: Array = [] + for b in spawned: + if not is_instance_valid(b): + continue + if int(b.building_id) == building_id: + BuildingExplosionVFX.spawn(get_tree().root, b.global_position) + Sfx.play(get_tree(), Sfx.BUILDING_DEATH) + b.queue_free() + continue + kept.append(b) + spawned = kept + + func rebuild_all() -> void: for b in spawned: if is_instance_valid(b): diff --git a/buildings/BuildingHitbox.gd b/buildings/BuildingHitbox.gd new file mode 100644 index 0000000..531a143 --- /dev/null +++ b/buildings/BuildingHitbox.gd @@ -0,0 +1,54 @@ +extends Area2D + +# #101 (#97c): the part of a structure a wave can actually hit. +# +# A Building is a plain Node2D, so nothing could collide with it - enemies +# flew through the mothership without noticing. This gives it a body in the +# same shape the rest of the game already understands: an Area2D in a group, +# exactly like a Ship or an asteroid, so EnemyProjectile needed one extra +# branch rather than a new concept. +# +# Damage is applied on a TICK rather than on area_entered. Entry fires once, +# which is right for a rammer - it dies on the first strike - but would let +# a sniper that drifted inside sit there scratch-free for ever. On a tick, +# an enemy parked on a structure is besieging it, which is what #97 asks +# for. + +const STRIKE_INTERVAL: float = 0.7 + +var _strike_timer: float = 0.0 + + +func _ready() -> void: + add_to_group("player_buildings") + + +func building_id() -> int: + var building: Node = get_parent() + return 0 if building == null else int(building.building_id) + + +# Called by EnemyProjectile, which knows nothing about buildings beyond this +# name - the same call it makes on a Ship. +func take_damage(amount: int) -> void: + GameState.damage_building(building_id(), amount) + + +func _physics_process(delta: float) -> void: + _strike_timer -= delta + if _strike_timer > 0.0: + return + _strike_timer = STRIKE_INTERVAL + var id: int = building_id() + if id == 0: + return + for area in get_overlapping_areas(): + if not is_instance_valid(area) or not area.is_in_group("enemies"): + continue + var damage: int = area.contact_damage() if area.has_method("contact_damage") else 6 + # The structure is told first, for the same reason Ship tells the + # enemy first: resolving the enemy's own death can free it, and a + # rammer must still land its hit on the way out. + GameState.damage_building(id, damage) + if area.has_method("collide_with_player_building"): + area.collide_with_player_building() diff --git a/buildings/BuildingHitbox.gd.uid b/buildings/BuildingHitbox.gd.uid new file mode 100644 index 0000000..e2e375c --- /dev/null +++ b/buildings/BuildingHitbox.gd.uid @@ -0,0 +1 @@ +uid://dggpwqg2fa62x diff --git a/buildings/BuildingVisualBase.gd b/buildings/BuildingVisualBase.gd index 1c0949e..55b4280 100644 --- a/buildings/BuildingVisualBase.gd +++ b/buildings/BuildingVisualBase.gd @@ -26,6 +26,46 @@ const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85) const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) const GAS_BURNER_COLOR: Color = Color(0.95, 0.35, 0.15) const GAS_BURNER_ENERGY_COLOR: Color = Color(0.85, 0.95, 0.15) +# #102. Olive rather than another blue or amber: every existing structure is +# a place you go to DO something, and this is the one you leave people at. +const OUTPOST_COLOR: Color = Color(0.72, 0.86, 0.45) +# #106. A cool grey-blue: this is the one structure that does no work at all, +# it only decides what work the others do. +const PLANNING_COLOR: Color = Color(0.62, 0.74, 0.92) +# #116. Warm steel: the workshop is the only structure whose whole job is +# looking after the others, and it should read as a yard rather than a plant. +const WORKSHOP_COLOR: Color = Color(0.5, 0.95, 0.8) + +# Workshop: an open service gantry with a cradle under it and drone berths +# either side. Deliberately unenclosed - what it holds is other people's +# buildings, arriving and leaving. +const YARD_GANTRY_W: float = 96.0 +const YARD_GANTRY_H: float = 16.0 +const YARD_LEG_DROP: float = 58.0 +const YARD_CRADLE_R: float = 34.0 +const YARD_BERTH_X: float = 78.0 + +# Planning centre: a standing board with rows of tallies on it, flanked by +# two slim pylons. Rectilinear on purpose - every other structure here is +# radial or trussed, and this one is an office. +const PLAN_BOARD_W: float = 86.0 +const PLAN_BOARD_H: float = 62.0 +const PLAN_ROW_COUNT: int = 4 +const PLAN_PYLON_X: float = 104.0 +const PLAN_PYLON_H: float = 54.0 + +# Outpost: a watch mast on a tripod footing, with two open berths beside it +# where a garrison sits. Deliberately the least machinery of any structure +# here - it makes nothing and refines nothing, it just holds a position - +# and deliberately not radial, so it cannot be mistaken for a small hub. +const OUTPOST_MAST_TOP: float = -84.0 +const OUTPOST_MAST_FOOT: float = 46.0 +const OUTPOST_HEAD_RADIUS: float = 20.0 +const OUTPOST_FOOT_SPAN: float = 62.0 +const OUTPOST_FOOT_DROP: float = 26.0 +const OUTPOST_BERTH_X: float = 74.0 +const OUTPOST_BERTH_RADIUS: float = 22.0 +const OUTPOST_VANE_COUNT: int = 3 # Ship lab: a test frame with a vessel held in it and instruments sweeping # over it. Reads as a workshop rather than a factory - the crafter already @@ -112,7 +152,42 @@ var _flash_ring: NeonPiece = null var _wash: NeonPiece = null +# #117: "The ship crafter and the ship lab should only animate when +# something is being crafted in them." +# +# The clock stops rather than the drawing: every moving element is a NeonPiece +# whose position is pushed from this clock, so a frozen clock leaves the +# building standing exactly as it was with the cranes parked. Stopping +# apply_animation() instead would leave them wherever the last frame put +# them, which is not the same thing as idle. +# +# Default true: a mothership and a transport hub turn because that is what +# they are, not because they have an order in hand. +func is_working() -> bool: + return true + + +# The record this visual belongs to. The chain is Building -> VisualHost -> +# this, and the id lives on the Building. +# By tree lookup, not by the bare `GameState` identifier. These visual +# scripts are reached through a BuildingDefinition's visual_scene, which is +# preloaded while the definition catalog is still loading - early enough that +# the autoload is not yet in the global class table, and a bare reference +# fails to COMPILE the whole file. Same hazard GameState notes for WORLD_GEN. +func _state() -> Node: + var tree: SceneTree = get_tree() + return tree.root.get_node_or_null("GameState") if tree != null else null + + +func building_id() -> int: + var host: Node = get_parent() + var building: Node = host.get_parent() if host != null else null + return int(building.building_id) if building != null and "building_id" in building else 0 + + func _process(delta: float) -> void: + if not is_working(): + return anim_time = fmod(anim_time + delta, CYCLE) phase_time += delta apply_animation() @@ -359,6 +434,119 @@ func _draw_command_center() -> void: NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) +func _draw_workshop() -> void: + var c: Color = WORKSHOP_COLOR + var lit: Color = Color(0.86, 1.0, 0.95) + + # The gantry beam, and the legs it stands on. + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(-YARD_GANTRY_W, -YARD_GANTRY_H - 40.0), + Vector2(YARD_GANTRY_W, -YARD_GANTRY_H - 40.0), + Vector2(YARD_GANTRY_W, -40.0), Vector2(-YARD_GANTRY_W, -40.0), + ]), c, 1.0) + for side in [-1.0, 1.0]: + var x: float = YARD_GANTRY_W * side * 0.82 + NeonDraw.draw_glowing_line(self, Vector2(x, -40.0), + Vector2(x, YARD_LEG_DROP), c, 0.8, 0.42) + # Cross-bracing, so it reads as a frame rather than two posts. + NeonDraw.draw_glowing_line(self, Vector2(x, -40.0), + Vector2(x * 0.35, YARD_LEG_DROP), c, 0.45, 0.26) + + # The cradle: an open arc under the beam, with the thing being worked on + # implied by the lit core rather than drawn. + NeonDraw.draw_glowing_path(self, _arc(Vector2(0.0, 6.0), YARD_CRADLE_R, + 0.0, PI, 14), c, 0.85, 0.4) + NeonDraw.draw_glowing_circle(self, Vector2(0.0, 6.0), 11.0, lit, 0.9) + # A hoist line down from the beam to what is in the cradle. + NeonDraw.draw_glowing_line(self, Vector2(0.0, -40.0), Vector2(0.0, -6.0), + c, 0.6, 0.3) + + # Drone berths, one either side, drawn as open brackets. + for side in [-1.0, 1.0]: + var berth: Vector2 = Vector2(YARD_BERTH_X * side, -70.0) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + berth + Vector2(-14.0 * side, -12.0), berth + Vector2(6.0 * side, -12.0), + berth + Vector2(6.0 * side, 12.0), berth + Vector2(-14.0 * side, 12.0), + ]), c, 0.7, 0.32) + NeonDraw.draw_glowing_polygon(self, _diamond(berth, 7.0), lit, 0.85) + + +func _draw_planning_center() -> void: + var c: Color = PLANNING_COLOR + var lit: Color = Color(0.88, 0.94, 1.0) + + # The board. + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(-PLAN_BOARD_W, -PLAN_BOARD_H), Vector2(PLAN_BOARD_W, -PLAN_BOARD_H), + Vector2(PLAN_BOARD_W, PLAN_BOARD_H), Vector2(-PLAN_BOARD_W, PLAN_BOARD_H), + ]), c, 1.0) + + # Rows of tallies at differing lengths - a plan being met unevenly, which + # is what the building is for. + var fractions: Array = [0.86, 0.42, 0.68, 0.24] + for i in range(PLAN_ROW_COUNT): + var y: float = lerpf(-PLAN_BOARD_H + 16.0, PLAN_BOARD_H - 16.0, + float(i) / float(PLAN_ROW_COUNT - 1)) + var left: float = -PLAN_BOARD_W + 14.0 + var span: float = (PLAN_BOARD_W - 14.0) * 2.0 + NeonDraw.draw_glowing_line(self, Vector2(left, y), + Vector2(left + span * float(fractions[i]), y), lit, 0.8, 0.34) + NeonDraw.draw_glowing_line(self, Vector2(left + span * float(fractions[i]), y), + Vector2(left + span, y), c, 0.35, 0.22) + + # Pylons holding it up, and a footing bar under the whole thing. + for side in [-1.0, 1.0]: + var x: float = PLAN_PYLON_X * side + NeonDraw.draw_glowing_line(self, Vector2(x, -PLAN_PYLON_H), + Vector2(x, PLAN_PYLON_H), c, 0.75, 0.4) + NeonDraw.draw_glowing_line(self, Vector2(PLAN_BOARD_W * side, 0.0), + Vector2(x, 0.0), c, 0.55, 0.3) + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2(x, -PLAN_PYLON_H), 9.0), c, 0.9) + NeonDraw.draw_glowing_line(self, Vector2(-PLAN_PYLON_X, PLAN_PYLON_H), + Vector2(PLAN_PYLON_X, PLAN_PYLON_H), c, 0.5, 0.3) + + +func _draw_outpost() -> void: + var c: Color = OUTPOST_COLOR + var lit: Color = Color(0.94, 0.99, 0.82) + + # Tripod footing. + var foot: Vector2 = Vector2(0.0, OUTPOST_MAST_FOOT) + for side in [-1.0, 0.0, 1.0]: + var toe: Vector2 = Vector2(OUTPOST_FOOT_SPAN * side, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP) + NeonDraw.draw_glowing_line(self, foot, toe, c, 0.7, 0.42) + NeonDraw.draw_glowing_line(self, + Vector2(-OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), + Vector2(OUTPOST_FOOT_SPAN, OUTPOST_MAST_FOOT + OUTPOST_FOOT_DROP), c, 0.55, 0.34) + + # The mast, with rungs so it reads as climbable structure rather than a + # drawn line. + var top: Vector2 = Vector2(0.0, OUTPOST_MAST_TOP) + NeonDraw.draw_glowing_line(self, foot, top, c, 0.9, 0.5) + for i in range(4): + var y: float = lerpf(OUTPOST_MAST_FOOT, OUTPOST_MAST_TOP + OUTPOST_HEAD_RADIUS, + float(i + 1) / 5.0) + NeonDraw.draw_glowing_line(self, Vector2(-11.0, y), Vector2(11.0, y), c, 0.45, 0.28) + + # Sensor head: a lit diamond inside a cage, with vanes looking outward. + NeonDraw.draw_glowing_polygon(self, _ring(top, OUTPOST_HEAD_RADIUS, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _diamond(top, OUTPOST_HEAD_RADIUS * 0.5), lit, 0.95) + for i in range(OUTPOST_VANE_COUNT): + var a: float = PI + (float(i) / float(OUTPOST_VANE_COUNT - 1)) * PI + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(self, top + dir * OUTPOST_HEAD_RADIUS, + top + dir * (OUTPOST_HEAD_RADIUS + 16.0), c, 0.7, 0.3) + + # Two open berths. Open, not enclosed: what sits in them belongs to the + # player and flies away, so they are cradles rather than hangars. + for side in [-1.0, 1.0]: + var berth: Vector2 = Vector2(OUTPOST_BERTH_X * side, 4.0) + NeonDraw.draw_glowing_path(self, _arc(berth, OUTPOST_BERTH_RADIUS, + -PI * 0.5, PI * 0.5, 10), c, 0.75, 0.36) + NeonDraw.draw_glowing_line(self, Vector2(12.0 * side, 4.0), + berth - Vector2(OUTPOST_BERTH_RADIUS * side, 0.0), c, 0.6, 0.34) + + # --- mothership: a radial flagship ---------------------------------------- # Not a big fighter. A mothership is the thing everything else orbits, so it # is built radially around a reactor core rather than pointed in a direction: diff --git a/buildings/CommandBuildingDefinition.tres b/buildings/CommandBuildingDefinition.tres index 5d63505..956bca0 100644 --- a/buildings/CommandBuildingDefinition.tres +++ b/buildings/CommandBuildingDefinition.tres @@ -7,6 +7,7 @@ definition_id = "command_center" display_name = "Command Center" description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it." cost = 15 +build_resource_costs = {"ore": 15, "gas": 6} action_icon = 10 menu_icon = 10 visual_scene = ExtResource("2") @@ -18,3 +19,4 @@ map_phase = 0.785398 menu_button_name = "CommandButton" menu_label_name = "CommandLabel" menu_cost_name = "CommandCost" +max_hp = 70 diff --git a/buildings/CrafterBuildingDefinition.tres b/buildings/CrafterBuildingDefinition.tres index ae29ea1..f74a5da 100644 --- a/buildings/CrafterBuildingDefinition.tres +++ b/buildings/CrafterBuildingDefinition.tres @@ -17,3 +17,4 @@ map_radius = 7.0 menu_button_name = "CrafterButton" menu_label_name = "CrafterLabel" menu_cost_name = "CrafterCost" +max_hp = 90 diff --git a/buildings/CrafterBuildingVisual.gd b/buildings/CrafterBuildingVisual.gd index 146824e..b3a01e1 100644 --- a/buildings/CrafterBuildingVisual.gd +++ b/buildings/CrafterBuildingVisual.gd @@ -6,6 +6,16 @@ func _ready() -> void: _build_crafter_pieces() queue_redraw() +# #117: the line runs when there is something on it. An idle crafter with +# its cranes swinging read as a factory working on nothing. +func is_working() -> bool: + var id: int = building_id() + if id == 0: + return false + var state: Node = _state() + return state != null and state.pending_orders(id) > 0 + + func apply_animation() -> void: _animate_crafter() diff --git a/buildings/ExtractorBuildingDefinition.tres b/buildings/ExtractorBuildingDefinition.tres index 32b30c1..6856648 100644 --- a/buildings/ExtractorBuildingDefinition.tres +++ b/buildings/ExtractorBuildingDefinition.tres @@ -14,3 +14,4 @@ menu_button_name = "ExtractorButton" menu_label_name = "ExtractorLabel" menu_cost_name = "ExtractorCost" placement_mode = "planet" +max_hp = 40 diff --git a/buildings/GasBurnerBuildingDefinition.tres b/buildings/GasBurnerBuildingDefinition.tres index 13adca4..59589aa 100644 --- a/buildings/GasBurnerBuildingDefinition.tres +++ b/buildings/GasBurnerBuildingDefinition.tres @@ -1,7 +1,8 @@ -[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3] -[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[gd_resource type="Resource" script_class="BuildingDefinition" format=3 uid="uid://cia6vbju65sbe"] + +[ext_resource type="Script" uid="uid://d10s0frtxiw62" path="res://buildings/BuildingDefinition.gd" id="1"] [ext_resource type="PackedScene" path="res://buildings/GasBurnerBuildingVisual.tscn" id="2"] -[ext_resource type="Script" path="res://buildings/BuildingLogisticsProfile.gd" id="3"] +[ext_resource type="Script" uid="uid://cydp18abylrgd" path="res://buildings/BuildingLogisticsProfile.gd" id="3"] [sub_resource type="Resource" id="Resource_logistics"] script = ExtResource("3") @@ -19,7 +20,11 @@ script = ExtResource("1") definition_id = "gas_burner" display_name = "Gas Burner" description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes." -cost = 30 +cost = 40 +build_resource_costs = { +"gas": 22, +"ore": 40 +} action_icon = 33 menu_icon = 33 visual_scene = ExtResource("2") @@ -27,8 +32,7 @@ capabilities = PackedStringArray("gas_burner") logistics = SubResource("Resource_logistics") map_color = Color(0.95, 0.35, 0.15, 1) map_sides = 5 -map_radius = 7.0 -map_phase = 0.0 menu_button_name = "GasBurnerButton" menu_label_name = "GasBurnerLabel" menu_cost_name = "GasBurnerCost" +max_hp = 60 diff --git a/buildings/MothershipBuildingDefinition.tres b/buildings/MothershipBuildingDefinition.tres index 3a23df3..c998b2f 100644 --- a/buildings/MothershipBuildingDefinition.tres +++ b/buildings/MothershipBuildingDefinition.tres @@ -19,3 +19,4 @@ menu_button_name = "MothershipButton" menu_label_name = "MothershipLabel" menu_cost_name = "MothershipCost" unique = true +max_hp = 240 diff --git a/buildings/OutpostBuildingDefinition.tres b/buildings/OutpostBuildingDefinition.tres new file mode 100644 index 0000000..ccde7a9 --- /dev/null +++ b/buildings/OutpostBuildingDefinition.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://buildings/OutpostBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "outpost" +display_name = "Outpost" +description = "A forward position for the fleet to garrison. Vessels stationed here hold station and fight what comes at them, without you flying back." +cost = 30 +build_resource_costs = {"ore": 30, "gas": 16} +action_icon = 40 +menu_icon = 40 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("garrison") +map_color = Color(0.72, 0.86, 0.45, 1) +map_sides = 3 +map_radius = 7.0 +menu_button_name = "OutpostButton" +menu_label_name = "OutpostLabel" +menu_cost_name = "OutpostCost" +max_hp = 120 diff --git a/buildings/OutpostBuildingVisual.gd b/buildings/OutpostBuildingVisual.gd new file mode 100644 index 0000000..37beb3f --- /dev/null +++ b/buildings/OutpostBuildingVisual.gd @@ -0,0 +1,13 @@ +extends "res://buildings/BuildingVisualBase.gd" + +# Drawn once and never redrawn, like the command centre: nothing about a +# watch mast animates, and per-frame redraws of art that never changes were +# a real framerate cost earlier on. + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_outpost() diff --git a/buildings/OutpostBuildingVisual.gd.uid b/buildings/OutpostBuildingVisual.gd.uid new file mode 100644 index 0000000..afcb945 --- /dev/null +++ b/buildings/OutpostBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://orxnu20av3xv diff --git a/buildings/OutpostBuildingVisual.tscn b/buildings/OutpostBuildingVisual.tscn new file mode 100644 index 0000000..812c7dd --- /dev/null +++ b/buildings/OutpostBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://buildings/OutpostBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("garrison", "transfer") diff --git a/buildings/PlanningBuildingDefinition.tres b/buildings/PlanningBuildingDefinition.tres new file mode 100644 index 0000000..0136217 --- /dev/null +++ b/buildings/PlanningBuildingDefinition.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://buildings/PlanningBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "planning_center" +display_name = "Planning Center" +description = "Holds standing orders for the whole game: say how many of each vessel should always exist, and the ship crafters are kept working until they do." +cost = 50 +build_resource_costs = {"ore": 50, "gas": 30} +action_icon = 41 +menu_icon = 41 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("planning") +map_color = Color(0.62, 0.74, 0.92, 1) +map_sides = 4 +map_radius = 7.5 +map_phase = 0.785398 +menu_button_name = "PlanningButton" +menu_label_name = "PlanningLabel" +menu_cost_name = "PlanningCost" +unique = true +max_hp = 80 diff --git a/buildings/PlanningBuildingVisual.gd b/buildings/PlanningBuildingVisual.gd new file mode 100644 index 0000000..20e11fd --- /dev/null +++ b/buildings/PlanningBuildingVisual.gd @@ -0,0 +1,13 @@ +extends "res://buildings/BuildingVisualBase.gd" + +# Drawn once, like the command centre and the outpost: a board of numbers +# does not animate, and per-frame redraws of art that never changes were a +# real framerate cost earlier on. + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_planning_center() diff --git a/buildings/PlanningBuildingVisual.gd.uid b/buildings/PlanningBuildingVisual.gd.uid new file mode 100644 index 0000000..e77e78e --- /dev/null +++ b/buildings/PlanningBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://utiitnb7womc diff --git a/buildings/PlanningBuildingVisual.tscn b/buildings/PlanningBuildingVisual.tscn new file mode 100644 index 0000000..b8c5ab8 --- /dev/null +++ b/buildings/PlanningBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://buildings/PlanningBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("planning", "transfer") diff --git a/buildings/ShipLabBuildingDefinition.tres b/buildings/ShipLabBuildingDefinition.tres index 4e78e24..5d5e16a 100644 --- a/buildings/ShipLabBuildingDefinition.tres +++ b/buildings/ShipLabBuildingDefinition.tres @@ -6,7 +6,8 @@ script = ExtResource("1") definition_id = "ship_lab" display_name = "Ship Lab" description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs." -cost = 35 +cost = 30 +build_resource_costs = {"ore": 30, "gas": 18} action_icon = 13 menu_icon = 13 visual_scene = ExtResource("2") @@ -18,3 +19,4 @@ map_phase = -1.570796 menu_button_name = "ShipLabButton" menu_label_name = "ShipLabLabel" menu_cost_name = "ShipLabCost" +max_hp = 90 diff --git a/buildings/ShipLabBuildingVisual.gd b/buildings/ShipLabBuildingVisual.gd index b6567c8..3627bef 100644 --- a/buildings/ShipLabBuildingVisual.gd +++ b/buildings/ShipLabBuildingVisual.gd @@ -4,6 +4,16 @@ func _ready() -> void: _build_lab_pieces() queue_redraw() +# #117: the instruments sweep while something is being researched, and are +# parked otherwise. +func is_working() -> bool: + var id: int = building_id() + if id == 0: + return false + var state: Node = _state() + return state != null and not state.lab_research_order(id).is_empty() + + func apply_animation() -> void: _animate_ship_lab() diff --git a/buildings/TransportHubBuildingDefinition.tres b/buildings/TransportHubBuildingDefinition.tres index 864763a..562b569 100644 --- a/buildings/TransportHubBuildingDefinition.tres +++ b/buildings/TransportHubBuildingDefinition.tres @@ -6,7 +6,8 @@ script = ExtResource("1") definition_id = "transport_hub" display_name = "Transport Hub" description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials." -cost = 30 +cost = 28 +build_resource_costs = {"ore": 28, "gas": 20} action_icon = 12 menu_icon = 12 visual_scene = ExtResource("2") @@ -18,3 +19,4 @@ map_phase = 0.392699 menu_button_name = "TransportHubButton" menu_label_name = "TransportHubLabel" menu_cost_name = "TransportHubCost" +max_hp = 80 diff --git a/buildings/WorkshopBuildingDefinition.tres b/buildings/WorkshopBuildingDefinition.tres new file mode 100644 index 0000000..347fc46 --- /dev/null +++ b/buildings/WorkshopBuildingDefinition.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://buildings/WorkshopBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "spacial_workshop" +display_name = "Spacial Workshop" +description = "Sends repair drones out to damaged structures. Repairs cost ore in proportion to the damage, delivered here by hub transporters." +cost = 50 +build_resource_costs = {"ore": 50, "gas": 28, "energy": 18} +action_icon = 42 +menu_icon = 42 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("repair") +map_color = Color(0.5, 0.95, 0.8, 1) +map_sides = 5 +map_radius = 7.0 +menu_button_name = "WorkshopButton" +menu_label_name = "WorkshopLabel" +menu_cost_name = "WorkshopCost" +max_hp = 70 diff --git a/buildings/WorkshopBuildingVisual.gd b/buildings/WorkshopBuildingVisual.gd new file mode 100644 index 0000000..86697ce --- /dev/null +++ b/buildings/WorkshopBuildingVisual.gd @@ -0,0 +1,13 @@ +extends "res://buildings/BuildingVisualBase.gd" + +# Drawn once, like the command centre and the outpost. #117 makes the +# animated buildings stop when idle; this one has nothing that moves, so +# there is nothing to gate. + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_workshop() diff --git a/buildings/WorkshopBuildingVisual.gd.uid b/buildings/WorkshopBuildingVisual.gd.uid new file mode 100644 index 0000000..8ae0692 --- /dev/null +++ b/buildings/WorkshopBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://ctwgqsgr2bsfs diff --git a/buildings/WorkshopBuildingVisual.tscn b/buildings/WorkshopBuildingVisual.tscn new file mode 100644 index 0000000..07b91bb --- /dev/null +++ b/buildings/WorkshopBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://buildings/WorkshopBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("transfer") diff --git a/controllers/AimedFleetAbilityController.gd b/controllers/AimedFleetAbilityController.gd index 191c062..46af21d 100644 --- a/controllers/AimedFleetAbilityController.gd +++ b/controllers/AimedFleetAbilityController.gd @@ -33,7 +33,10 @@ func _ready() -> void: func _process(delta: float) -> void: var had_visuals: bool = aiming or _has_effect_visuals() - button.visible = fleet.has_capability(_component_id()) + # #122: "there's no action button available, only movements". Checked + # here rather than by the director reaching in, because this line runs + # every frame and would put the button straight back. + button.visible = fleet.has_capability(_component_id()) and not GameState.intro_active button.enabled = _next_ready_ship() != null and _can_afford_shot() if not button.visible and aiming: _cancel_aim() diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 829d4d3..c0c3571 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -32,14 +32,14 @@ const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd") get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"), get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"), - get_node("../UI/LabMenu/VesselG"), + get_node("../UI/LabMenu/VesselG"), get_node("../UI/LabMenu/VesselH"), ] @onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") @onready var lab_vessel_labels: Array = [ get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"), get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"), get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"), - get_node("../UI/LabMenu/VesselGLabel"), + get_node("../UI/LabMenu/VesselGLabel"), get_node("../UI/LabMenu/VesselHLabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @@ -109,7 +109,12 @@ var lab_building_id: int = 0 @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel") -@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") +# Typed Control, not PriceTag, and deliberately. PriceTag reads the GameState +# autoload, and an `extends SceneTree` test script that so much as names a +# class like that fails to compile - GameState is not an identifier in that +# context - which then poisons the scene's copy of the same script. The nodes +# are duck-typed everywhere for that reason; see tests/test_menu_price_unification.gd. +@onready var info_cost: Control = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") @onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton") @onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton") @@ -156,10 +161,28 @@ var lab_building_id: int = 0 get_node("../UI/LabMenu/Bar"), ] +# #103: the outpost's own panel. garrison_building_id is remembered for the +# same reason craft_building_id is - _process() clears current_building while +# a menu is open, so by the next frame the panel would not know which outpost +# it belongs to and every button in it would bail out at its `== 0` guard. +@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu") +@onready var garrison_panel: Control = get_node("../UI/GarrisonMenu/Panel") +@onready var garrison_status: Label = get_node("../UI/GarrisonMenu/StatusLabel") +@onready var garrison_close_button: IconButton = get_node("../UI/GarrisonMenu/CloseButton") +var garrison_building_id: int = 0 + +# #106: the standing-order board. No building id is remembered because the +# plan is global - there is one plan for the whole game, and a second +# planning centre is another place to read it rather than a competing one. +@onready var planning_menu: Control = get_node("../UI/PlanningMenu") +@onready var planning_panel: Control = get_node("../UI/PlanningMenu/Panel") +@onready var planning_status: Label = get_node("../UI/PlanningMenu/StatusLabel") +@onready var planning_close_button: IconButton = get_node("../UI/PlanningMenu/CloseButton") + @onready var skill_menu: Control = get_node("../UI/SkillMenu") @onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel") -@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber") +@onready var skill_cost: Control = get_node("../UI/SkillMenu/CostNumber") @onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel") @onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton") @@ -249,8 +272,15 @@ func _seat_hud_columns() -> void: func _ready() -> void: _seat_hud_columns() _layout_hangar() + _build_craft_price_strips() + _build_lab_price_tags() + _layout_craft_menu() build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) + garrison_close_button.pressed.connect(_close_garrison_menu) + garrison_panel.changed.connect(_refresh_garrison) + planning_close_button.pressed.connect(_close_planning_menu) + planning_panel.changed.connect(_refresh_planning) _configure_build_menu_entries() lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_buy_button.pressed.connect(_on_lab_buy) @@ -301,6 +331,13 @@ func _ready() -> void: transfer_button.visible = false hangar_button.visible = false hangar_menu.visible = false + # #128: the prologue's "movement and nothing else" used to be applied by + # _process on the first frame. A dialog now stops the tree, and the + # prologue opens ON one - so that first frame never came and every action + # button was left on screen behind the opening line. Applied here as well, + # where it does not depend on the game running. + if GameState.intro_active: + _hide_all_actions() _layout_context_buttons() @@ -311,12 +348,12 @@ func _configure_build_menu_entries() -> void: continue var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name) var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name) - var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name) + var cost_tag: Control = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name) button.icon = definition.menu_icon button.pressed.connect(_show_building_info.bind(String(definition.definition_id))) label.text = definition.display_name - cost_number.amount = definition.cost - build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) + cost_tag.show_price(GameState.building_costs(String(definition.definition_id))) + build_menu_entries.append([definition.definition_id, button, label, cost_tag, definition]) # True while a modal panel owns the shared context controls. A plain building @@ -343,6 +380,14 @@ func _unhandled_input(event: InputEvent) -> void: skill_tree.handle_touch(event) elif event is InputEventScreenDrag: skill_tree.handle_drag(event) + elif event is InputEventMouseButton: + # #93: a click is not a touch. The project no longer emulates one + # from the other, so without this branch the whole menu is inert + # under a mouse - nothing selects, nothing scrolls, and there is + # no other way to research anything on a desktop. + skill_tree.handle_mouse_button(event) + elif event is InputEventMouseMotion: + skill_tree.handle_mouse_motion(event) # Handled either way. A touch that lands on the panel but misses a # node is still a touch ON THE PANEL - letting it through moved the # fleet and pinch-zoomed the world behind the menu, because @@ -355,6 +400,14 @@ func _unhandled_input(event: InputEvent) -> void: formation_editor.handle_touch(event) elif event is InputEventScreenDrag: formation_editor.handle_drag(event) + elif event is InputEventMouseButton: + # #121: a click is not a touch. Same routing gap as #93's + # research tree - the panel is transparent to GUI input so the + # raw handlers can see the drag, which means nothing turns a + # click into one on the way. + formation_editor.handle_mouse_button(event) + elif event is InputEventMouseMotion: + formation_editor.handle_mouse_motion(event) get_viewport().set_input_as_handled() return if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \ @@ -367,6 +420,19 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: _check_fleet_wipe() + # #122: the prologue leaves the player movement and nothing else. Handled + # at the top, before any of the context detection below, so nothing can + # turn a button back on further down. + if GameState.intro_active: + _hide_all_actions() + return + # Build is permanently under the resource counter (see + # _layout_context_buttons) - it is the one action button nothing else + # ever hides, so the prologue taking it away is the only thing that has + # to give it back. + if not build_button.visible: + build_button.visible = true + if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. @@ -578,6 +644,14 @@ func _on_action_pressed() -> void: elif action == "formation": formation_menu.visible = true formation_editor.queue_redraw() + elif action == "planning": + planning_menu.visible = true + _refresh_planning() + elif action == "garrison": + garrison_building_id = current_building.building_id + garrison_panel.building_id = garrison_building_id + garrison_menu.visible = true + _refresh_garrison() func _on_build_pressed() -> void: @@ -592,24 +666,158 @@ func _planet_under_fleet() -> Node2D: return planet_field.get_planet_fleet_is_on() -# Grid geometry, shared with the layout below. Rows of two, and the menu is -# rebuilt from whatever is currently researched (#21) rather than being a -# fixed arrangement with holes punched in it. -const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0] -const MENU_COL_X: float = 140.0 +# Grid geometry, shared with the layout below. The menu is rebuilt from +# whatever is currently researched (#21) rather than being a fixed +# arrangement with holes punched in it. +# +# Two profiles, because the grid ran out of room. #54 already tightened the +# rows to 223px to fit a fourth, explicitly to avoid a scrollable menu, and a +# ninth structure (#106's planning centre) needs a FIFTH row that will not +# fit at any spacing a button, a label and a cost can share. So past eight +# entries the grid goes to three columns instead, which takes nine into three +# rows - fewer than it uses today, not more. Narrower columns mean narrower +# labels, so those get a taller box to wrap into. +# The build menu's grid. +# +# #136: rows used to be a written-down list of tops, read with +# `tops[mini(row, tops.size() - 1)]`. That clamp silently stacked whatever +# would not fit - the gas burner drawn exactly on top of the transport hub. +# +# #144: and the band the rows live in was written down too, as a pair of +# constants measured against a 1280-tall canvas. The header above it is +# anchored to the TOP of the panel and the grid is anchored to its CENTRE, +# so the gap between them is half the canvas height - which means the +# constants were only ever right at one window shape. At 1080 tall the grid +# started 4px ABOVE the status line and drew straight through it, which is +# what the ticket reported. +# +# So nothing about the geometry is written down against a screen size any +# more. The band is measured from the header that is actually there, the row +# pitch closes up to fit it (never below a row's own height, which is what +# makes overlapping impossible rather than unlikely), and the column count +# rises until the rows fit - a short panel gets a wide grid instead of one +# that runs off the bottom of it. const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_H: float = 100.0 -const MENU_LABEL_HALF: float = 150.0 -const MENU_LABEL_H: float = 52.0 -const MENU_COST_HALF: float = 60.0 -const MENU_COST_H: float = 44.0 -# Gaps from each row's own top to where the label/cost start, sized to the -# shrunk MENU_*_H values above (#54: fitting a 4th row meant tightening -# MENU_ROW_TOPS' spacing to 223px, less than the original 256px a row's -# button+label+cost needed - shrinking those closed the gap without -# resorting to a scrollable menu). +const MENU_COST_HALF: float = 74.0 +# #147: a price is one resource per line, so a row has to leave room for the +# most any structure asks for - three, at the workshop. +const MENU_COST_H: float = 86.0 const MENU_LABEL_OFFSET: float = 106.0 -const MENU_COST_OFFSET: float = 162.0 +const MENU_LABEL_GAP: float = 4.0 + +# The widest a column is ever given, however much room there is: past this +# the entries stop reading as a grid and start reading as a scattering. +const MENU_NATURAL_SPACING: float = 280.0 +# The narrowest a column may be squeezed to - a button plus a little air. +const MENU_MIN_SPACING: float = MENU_BUTTON_HALF * 2.0 + 12.0 +# A caption gets most of its column, not all of it. +const MENU_LABEL_SHARE: float = 0.46 +# Below this a caption is assumed to need two lines. +const MENU_ONE_LINE_HALF: float = 120.0 +const MENU_LABEL_H_ONE: float = 52.0 +const MENU_LABEL_H_TWO: float = 68.0 + +const MENU_SIDE_MARGIN: float = 24.0 +const MENU_HEADER_GAP: float = 16.0 +const MENU_BOTTOM_MARGIN: float = 28.0 +const MENU_MAX_COLUMNS: int = 6 + + +# A centre-anchored grid living under a top-anchored header, which is the +# shape both the build menu and the craft menu have (#144, #147). The gap +# between the two depends on the panel's HEIGHT, so it can never be written +# down as a constant - it has to be measured from the header that is +# actually there. +func grid_band(header_bottom: float, panel_height: float) -> Vector2: + var half: float = panel_height * 0.5 + if half <= 0.0: + half = 640.0 + return Vector2(header_bottom + MENU_HEADER_GAP - half, half - MENU_BOTTOM_MARGIN) + + +# Where one row of such a grid starts. The pitch closes up to fit the band +# and is floored at the row's own height, so rows can tighten but never land +# on each other. +func grid_row_top(row: int, rows: int, row_height: float, band: Vector2, + natural_pitch: float) -> float: + var pitch: float = natural_pitch + if rows > 1: + pitch = minf(pitch, (band.y - band.x - row_height) / float(rows - 1)) + return band.x + maxf(pitch, row_height) * float(row) + + +# The vertical band the grid may occupy, in the menu's own centre-anchored +# space: (top, bottom). +# +# MEASURED, not written down (#144). The header is anchored to the top of the +# panel and the grid to its centre, so the two only agree at one canvas +# height - and the pair of constants this replaces happened to be the ones +# for 1280. Reading the status line's own bottom edge and the panel's own +# height makes the answer right at every window shape, including the one the +# player is actually running. +func menu_band() -> Vector2: + return grid_band(build_status.offset_bottom, build_menu.size.y) + + +# How wide one column is given. Never wider than looks like a grid, never +# narrower than a button, and otherwise whatever the panel has room for. +func menu_column_spacing(columns: int) -> float: + var room: float = build_menu.size.x - MENU_SIDE_MARGIN * 2.0 + if room <= 0.0: + room = 672.0 + return maxf(MENU_MIN_SPACING, minf(MENU_NATURAL_SPACING, room / float(columns))) + + +func menu_label_half(columns: int) -> float: + return menu_column_spacing(columns) * MENU_LABEL_SHARE + + +func menu_label_height(columns: int) -> float: + return MENU_LABEL_H_ONE if menu_label_half(columns) >= MENU_ONE_LINE_HALF \ + else MENU_LABEL_H_TWO + + +func menu_cost_offset(columns: int) -> float: + return MENU_LABEL_OFFSET + menu_label_height(columns) + MENU_LABEL_GAP + + +# How tall one row is, end to end - the cost strip is the last thing in it. +func menu_row_height(columns: int) -> float: + return menu_cost_offset(columns) + MENU_COST_H + + +# The fewest columns whose rows fit the band. Two is the shape the menu is +# meant to have; wider is what a full catalog - or a short window - forces. +func menu_columns(count: int) -> int: + var widest: int = maxi(mini(MENU_MAX_COLUMNS, + int(floor((build_menu.size.x - MENU_SIDE_MARGIN * 2.0) / MENU_MIN_SPACING))), 2) + for columns in range(2, widest + 1): + if _rows_fit(int(ceil(float(count) / float(columns))), columns): + return columns + return widest + + +func menu_rows(count: int) -> int: + var columns: int = menu_columns(count) + return maxi(int(ceil(float(count) / float(columns))), 1) + + +func _rows_fit(rows: int, columns: int) -> bool: + var height: float = menu_row_height(columns) + var band: Vector2 = menu_band() + if rows <= 1: + return band.y - band.x >= height + return (band.y - band.x - height) / float(rows - 1) >= height + + +# Where one row starts. Top of the band, with the pitch closed up when it is +# tight - but never below the row height, so two rows cannot land on each +# other however many structures there are, or however short the window (#136, +# #144). +func menu_row_top(row: int, rows: int, columns: int) -> float: + return grid_row_top(row, rows, menu_row_height(columns), menu_band(), + MENU_NATURAL_SPACING) func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: @@ -634,16 +842,24 @@ func _layout_build_menu() -> void: if visible: shown.append(entry) + var columns: int = menu_columns(shown.size()) + var rows: int = menu_rows(shown.size()) + var spacing: float = menu_column_spacing(columns) + var label_half: float = menu_label_half(columns) + var label_h: float = menu_label_height(columns) + var cost_offset: float = menu_cost_offset(columns) + for i in range(shown.size()): - var row: int = i / 2 - # Centred when it is the only one on its row, rather than hanging in - # the left column looking like something is missing beside it. - var alone: bool = (i == shown.size() - 1) and (i % 2 == 0) - var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X) - var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)] + var row: int = i / columns + # A short last row is CENTRED rather than left-packed, so it does not + # read as a row with something missing beside it. + var in_row: int = mini(shown.size() - row * columns, columns) + var col: int = i % columns + var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * spacing + var top: float = menu_row_top(row, rows, columns) _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) - _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, MENU_LABEL_HALF, MENU_LABEL_H) - _place_entry(shown[i][3], cx, top + MENU_COST_OFFSET, MENU_COST_HALF, MENU_COST_H) + _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h) + _place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H) func _refresh_build_status() -> void: @@ -667,6 +883,11 @@ func _refresh_build_status() -> void: func _refresh_build_option_states() -> void: for entry in build_menu_entries: var definition: Resource = entry[4] + # #138: a price now names several resources, and what the player + # holds of each is on the tag - so it is re-stated here, live, the + # way the craft menu's already was. Re-stating the same price is + # cheap; PriceTag only rebuilds when the price itself changes. + entry[3].show_price(GameState.building_costs(String(definition.definition_id))) var enabled: bool = GameState.is_building_unlocked(definition.definition_id) if definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() @@ -675,6 +896,21 @@ func _refresh_build_option_states() -> void: entry[1].queue_redraw() +# Everything the player can PRESS, as opposed to steer with. Used by the +# prologue (#122), which takes them all away at once. +func _hide_all_actions() -> void: + current_building = null + pending_building = null + current_extractor_index = -1 + pending_extractor_index = -1 + current_landing_planet = null + context_actions_expanded = false + for control in [build_button, action_button, building_action_button, + landing_button, transfer_button, hangar_button]: + if control != null: + control.visible = false + + func _close_build_menu() -> void: # The popup is a child panel of this menu; leaving it "open" while its # parent is hidden is what blocked every button in the game. @@ -683,16 +919,63 @@ func _close_build_menu() -> void: build_menu.visible = false +# --- the outpost's garrison panel (#103) ----------------------------------- + +func _close_garrison_menu() -> void: + garrison_menu.visible = false + garrison_building_id = 0 + + +func _refresh_garrison() -> void: + if garrison_building_id == 0: + return + garrison_panel.building_id = garrison_building_id + garrison_panel.refresh() + var ordered: int = GameState.garrison_desired_total(garrison_building_id) + var crewed: int = GameState.garrison_crew(garrison_building_id).size() + if ordered == 0: + garrison_status.text = "No vessels ordered. Only armed vessels can hold a position." + elif crewed >= ordered: + garrison_status.text = "Garrison at full strength" + else: + garrison_status.text = "%d of %d berths crewed - vessels are sent from the mothership as they become available" \ + % [crewed, ordered] + + +# --- the planning centre's standing orders (#106) --------------------------- + +func _close_planning_menu() -> void: + planning_menu.visible = false + + +func _refresh_planning() -> void: + planning_panel.refresh() + var short: PackedStringArray = PackedStringArray() + for vessel_type in GameState.planned_vessel_types(): + var missing: int = GameState.vessel_shortfall(String(vessel_type)) + if missing > 0: + short.append("%d %s" % [missing, GameState.vessel_name(String(vessel_type))]) + if short.is_empty(): + planning_status.text = "Every standing order is met or already being built" + else: + planning_status.text = "Short: %s" % ", ".join(short) + + +# The ORE half of a price, which is all some callers want (the info popup's +# headline, the tests). costs_of() below is the whole of it. func cost_of(type: String) -> int: - var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) - return int(definition.cost) if definition != null else 0 + return int(costs_of(type).get(GameState.RESOURCE_ORE, 0)) + + +func costs_of(type: String) -> Dictionary: + return GameState.building_costs(type) func _show_building_info(type: String) -> void: var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) if definition == null: return - var cost: int = int(definition.cost) + var costs: Dictionary = GameState.building_costs(type) var lines: Array = [definition.description] var can_build: bool = true @@ -723,13 +1006,15 @@ func _show_building_info(type: String) -> void: # The same pooled test the build itself uses. Asking the bank here while # charging bank-plus-hold below would hide the build button for something # the player can in fact afford. - if can_build and not GameState.can_afford(cost): - lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.available_ore()]) + if can_build and not GameState.can_afford_resources(costs): + # #137: no sentence about the shortfall. The price tag below carries + # what is held over what is wanted, in red - saying it twice, in two + # different shapes, is what the ticket is about. can_build = false info_title.text = definition.display_name info_message.text = "\n\n".join(lines) - info_cost.amount = cost + info_cost.show_price(costs) info_build_button.visible = can_build pending_build_type = type if can_build else "" info_popup.visible = true @@ -771,30 +1056,44 @@ func _confirm_build() -> void: # glyph/HUD rendering the moment it produced anything. build_status.text = "%s has nothing to extract" % planet.planet_name return - if not GameState.spend(definition.cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.available_ore()] + if not GameState.spend_resources(GameState.building_costs(type)): + build_status.text = _cannot_afford(type) return GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id) build_menu.visible = false return if definition.unique and GameState.has_building_of_type(type): - if not GameState.can_afford(definition.cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [ - definition.cost, GameState.available_ore() - ] + if not GameState.can_afford_resources(GameState.building_costs(type)): + build_status.text = _cannot_afford(type) return # Only now is the old one destroyed - never before the new one is # known to be affordable, or the player would be left with none. GameState.remove_buildings_of_type(type) building_field.rebuild_all() - _try_place(type, cost_of(type)) + _try_place(type) -func _try_place(type: String, cost: int) -> void: - if not GameState.spend(cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.available_ore()] +# #138: a structure can want more than rocks, so the refusal has to name +# which of them is short rather than always saying "rocks". Built from the +# price itself, so a cost gaining a resource needs nothing here. +func _cannot_afford(type: String) -> String: + var missing: Array = [] + for resource_type in GameState.building_costs(type): + var id: String = String(resource_type) + var want: int = int(GameState.building_costs(type)[resource_type]) + var have: int = GameState.available_resource(id) + if have < want: + missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have]) + if missing.is_empty(): + return "" + return "Not enough %s" % " and ".join(missing) + + +func _try_place(type: String) -> void: + if not GameState.spend_resources(GameState.building_costs(type)): + build_status.text = _cannot_afford(type) return building_field.place(type, fleet.get_centroid()) build_menu.visible = false @@ -820,7 +1119,7 @@ func _refresh_skill_menu(id: String = "") -> void: # Nothing chosen yet: point the player at the tree rather than # leaving the panel blank, which reads as broken. skill_detail.text = "Select a node to see what it unlocks." - skill_cost.amount = 0 + skill_cost.show_price({}) skill_status.text = "Fleet capacity %d · Research points %d" \ % [GameState.max_fleet_size(), GameState.research_points] return @@ -829,25 +1128,26 @@ func _refresh_skill_menu(id: String = "") -> void: if node.is_empty(): return var points_needed: int = GameState.skill_research_point_cost(id) + # #134: the explanation and the price no longer share a column. The text + # is the left half of the band and the tag is the right half, so a long + # description can grow without ending up on top of the number - which is + # what it used to do, the two being centred over each other. + # + # Both shortfalls it used to spell out - ore and research points - are + # now entries on the tag, held-over-needed, in red when short. One place, + # and it says WHICH one is missing without a sentence per case. skill_detail.text = "%s\n%s" % [node.name, node.desc] - if points_needed > 0: - skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed] - skill_cost.amount = int(node.cost) + skill_cost.show_price(GameState.skill_costs(id), points_needed) if GameState.is_unlocked(id): skill_status.text = "Researched" elif not GameState.prerequisite_met(id): skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name - elif not GameState.can_afford(int(node.cost)): - # available_ore(), not the bank alone: research is paid for the same - # pooled way as everything else, so quoting the bank here would tell - # the player they cannot afford something they can. - skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore() - elif GameState.research_points < points_needed: - # #63: a distinct reason from the ore one above - conflating them - # would leave the player trying to fix the wrong shortage. - skill_status.text = "Not enough research points - you have %d, need %d" \ - % [GameState.research_points, points_needed] + elif not skill_cost.is_affordable(): + # Deliberately blank. The red on the tag is the whole message; the + # line here used to repeat it in words and, being centred, repeat it + # on top of the number as well. + skill_status.text = "" else: # The button that used to appear here was the only thing saying "this # is buyable". With it gone the line has to say so itself, or an @@ -897,6 +1197,114 @@ func _format_missing_costs(costs: Dictionary) -> String: return " · ".join(parts) +# --- the craft menu's own layout (#111) ------------------------------------ +# +# Six vessels were authored as one row of 65px buttons with 105px captions +# overlapping each other, which left "Energy Propulser" clipped and the price +# crushed onto a second line under it. Laid out here instead, in two rows of +# three, so a cell is wide enough for the name to breathe - and the price is +# glyphs rather than words (see PriceTag, which every menu now shares). +# +# #147: "prices are far from the button they are about, and when many +# different resources are needed the costs overlap." Both were true, and +# both were the same cause - a cell laid out with more slack than the row +# pitch had room for. The caption box was 62px for a one-line name, which +# pushed the price 70px clear of the button it belonged to; and the price +# was given 184px of width in a 190px column, so a two-resource price ran +# out of its own box and into its neighbour's. +# +# The caption is one line tall now and the price sits directly under it, and +# PriceTag puts each resource on its own line when they will not fit across +# (which in a column this narrow means whenever there is more than one). +# Rows are placed off the measured band rather than a written-down pair of +# tops, for the same reason the build menu's are - see grid_band(). +const CRAFT_COLUMNS: int = 3 +const CRAFT_ROW_PITCH: float = 260.0 +const CRAFT_COL_X: float = 190.0 +const CRAFT_BUTTON_HALF: float = 56.0 +const CRAFT_BUTTON_H: float = 100.0 +const CRAFT_LABEL_HALF: float = 92.0 +const CRAFT_LABEL_H: float = 44.0 +const CRAFT_LABEL_OFFSET: float = 106.0 +const CRAFT_PRICE_HALF: float = 92.0 +# Room for the tallest price any vessel has, stacked. Read from the tag +# rather than assumed, so a vessel priced in three things still fits. +const CRAFT_PRICE_H: float = 86.0 +const CRAFT_PRICE_OFFSET: float = 154.0 + +var craft_price_strips: Array = [] +# #137: the ship lab's own price tags, built here rather than authored in the +# scene for the same reason the craft menu's are - four identical widgets +# differing only in where they sit. +var lab_price_tags: Array = [] + + +func _build_craft_price_strips() -> void: + var strip_script: Script = load("res://ui/PriceTag.gd") + for i in range(craft_vessel_buttons.size()): + var strip: Control = strip_script.new() + strip.name = "CraftPrice%d" % i + # Centre anchors, matching the buttons and captions authored in the + # scene - _place_entry writes offsets, and an offset means something + # different against a top-left anchor. + strip.anchor_left = 0.5 + strip.anchor_right = 0.5 + strip.anchor_top = 0.5 + strip.anchor_bottom = 0.5 + craft_menu.add_child(strip) + craft_price_strips.append(strip) + + +# One tag per upgrade slot, seated in the gap the caption's second line used +# to occupy. The captions above them shrink to one line to make room, so the +# option block is the same height it was and the row below it does not move. +const LAB_TAG_H: float = 38.0 +const LAB_LABEL_H: float = 40.0 + + +func _build_lab_price_tags() -> void: + var tag_script: Script = load("res://ui/PriceTag.gd") + for i in range(lab_upgrade_labels.size()): + var label: Control = lab_upgrade_labels[i] + var tag: Control = tag_script.new() + tag.name = "LabPrice%d" % i + tag.anchor_left = label.anchor_left + tag.anchor_right = label.anchor_right + tag.anchor_top = label.anchor_top + tag.anchor_bottom = label.anchor_bottom + tag.offset_left = label.offset_left + tag.offset_right = label.offset_right + # Under the caption, which is cut to a single line here so the two + # together occupy what the two-line caption used to. + label.offset_bottom = label.offset_top + LAB_LABEL_H + tag.offset_top = label.offset_bottom + 2.0 + tag.offset_bottom = tag.offset_top + LAB_TAG_H + lab_menu.add_child(tag) + lab_price_tags.append(tag) + + +func craft_row_height() -> float: + return CRAFT_PRICE_OFFSET + CRAFT_PRICE_H + + +func _layout_craft_menu() -> void: + var rows: int = maxi(int(ceil(float(craft_vessel_buttons.size()) + / float(CRAFT_COLUMNS))), 1) + var band: Vector2 = grid_band(craft_status.offset_bottom, craft_menu.size.y) + for i in range(craft_vessel_buttons.size()): + var row: int = i / CRAFT_COLUMNS + var column: int = i % CRAFT_COLUMNS + var cx: float = (float(column) - 1.0) * CRAFT_COL_X + var top: float = grid_row_top(row, rows, craft_row_height(), band, + CRAFT_ROW_PITCH) + _place_entry(craft_vessel_buttons[i], cx, top, CRAFT_BUTTON_HALF, CRAFT_BUTTON_H) + _place_entry(craft_vessel_labels[i], cx, top + CRAFT_LABEL_OFFSET, + CRAFT_LABEL_HALF, CRAFT_LABEL_H) + if i < craft_price_strips.size(): + _place_entry(craft_price_strips[i], cx, top + CRAFT_PRICE_OFFSET, + CRAFT_PRICE_HALF, CRAFT_PRICE_H) + + func _refresh_craft_status() -> void: # Prices live with the buttons they describe. The top of the screen is # intentionally reserved for the shared resource bar and a short action @@ -906,8 +1314,11 @@ func _refresh_craft_status() -> void: for i in range(mini(definitions.size(), craft_vessel_buttons.size())): var definition: Resource = definitions[i] var button: IconButton = craft_vessel_buttons[i] - craft_vessel_labels[i].text = "%s\n%s" % [definition.display_name, - _format_costs(GameState.product_costs(definition.definition_id))] + # #111: the name alone. The price is the strip under it, in glyphs. + craft_vessel_labels[i].text = String(definition.display_name) + if i < craft_price_strips.size(): + craft_price_strips[i].set_costs( + GameState.product_costs(definition.definition_id)) button.icon = definition.icon button.glow_color = definition.glow_color button.enabled = GameState.vessel_is_unlocked(definition.definition_id) @@ -1018,19 +1429,28 @@ func _refresh_lab() -> void: var visible: bool = i < options.size() lab_upgrade_buttons[i].visible = visible lab_upgrade_labels[i].visible = visible + if i < lab_price_tags.size(): + lab_price_tags[i].visible = visible if not visible: continue var option: Dictionary = options[i] - var price_text: String = _format_costs(option.costs if bool(option.unlock) \ - else GameState.research_costs(option.id)) - var point_cost: int = GameState.research_point_cost(String(option.id)) - if point_cost > 0: - price_text += " · %d RP" % point_cost - if not bool(option.unlock) and GameState.upgrade_maxed(option.id): - price_text = "MAX" + # #137: the price is a tag under the option, the way it is under a + # build option and under a vessel in the craft menu. It used to be a + # second line of the caption - "25 ore · 10 gas · 2 RP" - which is + # the same information in a shape no other menu used. + var maxed: bool = not bool(option.unlock) and GameState.upgrade_maxed(option.id) var level_note: String = "" if bool(option.unlock) else " %d/%d" % [ GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)] - lab_upgrade_labels[i].text = "%s%s\n%s" % [option.name, level_note, price_text] + lab_upgrade_labels[i].text = "%s%s" % [option.name, level_note] + if i < lab_price_tags.size(): + var tag: Control = lab_price_tags[i] + tag.visible = not maxed + if maxed: + tag.show_price({}) + else: + tag.show_price(option.costs if bool(option.unlock) \ + else GameState.research_costs(option.id), + GameState.research_point_cost(String(option.id))) lab_upgrade_buttons[i].icon = _upgrade_icon(option) lab_upgrade_buttons[i].glow_color = _vessel_colour(lab_vessel) lab_upgrade_buttons[i].highlighted = i == lab_choice @@ -1262,6 +1682,15 @@ func _hangar_types() -> Array: # each cell room, and the second row costs nothing: the lower half of the # menu was empty. const HANGAR_COLUMNS: int = 3 +# #120: room for a two-line caption under each icon, and air around the rule +# that separates the two halves. +const HANGAR_LABEL_GAP: float = 2.0 +const HANGAR_DIVIDER_GAP: float = 10.0 + +# Where the rule between "available" and "in the fleet" is drawn. Computed by +# the layout and read by the menu's own _draw, so the line cannot drift away +# from the sections it divides. +var hangar_divider_y: float = 0.0 # Arranges both hangar sections as grids sized from the space actually @@ -1286,10 +1715,18 @@ func _layout_hangar() -> void: 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) + # #120: the icon gives up height so the caption under it has somewhere to + # go. Vessel names are two and three words long and were wrapping into a + # box barely tall enough for one line, so the second line was clipped and + # a "Gas Bomber" read as "Gas". + var slot_size: float = minf(cell_height * 0.46, cell_width * 0.40) + # #143: TWO gaps, not one - above the caption and below it. With a single + # gap the box ended exactly HANGAR_LABEL_GAP past the bottom of its own + # cell, which is inside the next row's icon. Two pixels of overhang is + # not much on its own, but it is the difference between "the caption + # cannot reach the row below" and "the caption starts in it". + var label_height: float = maxf( + cell_height - slot_size - HANGAR_LABEL_GAP * 2.0, 1.0) _layout_hangar_section(hangar_available_slots, hangar_available_labels, top, columns, cell_width, cell_height, slot_size, label_height, width) @@ -1297,10 +1734,15 @@ func _layout_hangar() -> void: # 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 + # #120: a rule between the two halves. They are different questions - + # what is parked here, and what is flying - and with only a heading + # between them the grids read as one long list. + hangar_divider_y = available_bottom + HANGAR_DIVIDER_GAP + hangar_fleet_title.position.y = hangar_divider_y + HANGAR_DIVIDER_GAP + var fleet_top: float = hangar_fleet_title.position.y + 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) + hangar_menu.queue_redraw() func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int, @@ -1324,11 +1766,40 @@ func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: in 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.position = Vector2(cell_x, cell_y + slot_size + HANGAR_LABEL_GAP) 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 + # #143: a caption is "Gas Bomber\n2 available" - two lines before + # autowrap, three after it in a narrow column - at a font size fixed + # in the scene. A Label does not clip by default, so the third line + # was drawn straight over the icon of the row below it. + # + # Two things, and both are wanted. The size is fitted to the box the + # layout above worked out, so the text has somewhere to go; and the + # box clips, so however the catalog grows - a longer vessel name, a + # fourth line - nothing can ever reach the next row again. The first + # is what keeps it legible, the second is what keeps it correct. + label.add_theme_font_size_override("font_size", + _hangar_caption_size(label_height)) + label.clip_text = true + + +# The largest caption that still fits HANGAR_CAPTION_LINES lines into the +# height the grid could spare, within what is readable at arm's length. +const HANGAR_CAPTION_LINES: float = 3.0 +const HANGAR_CAPTION_MIN: int = 13 +const HANGAR_CAPTION_MAX: int = 22 +# Godot lays a line out taller than its nominal font size; measured against +# the project font rather than guessed, this is the ratio that keeps three +# lines inside the box. +const HANGAR_LINE_RATIO: float = 1.25 + + +func _hangar_caption_size(label_height: float) -> int: + return clampi(int(label_height / (HANGAR_CAPTION_LINES * HANGAR_LINE_RATIO)), + HANGAR_CAPTION_MIN, HANGAR_CAPTION_MAX) func _refresh_hangar() -> void: diff --git a/controllers/GameController.gd b/controllers/GameController.gd index dfb608a..28999cd 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -102,6 +102,10 @@ var keyboard_steering: bool = false func _ready() -> void: + # #148: the main theme, which outlives this scene - see Music.ensure(). + # This script is the root of BOTH the space scene and the planet + # surface, so one call here covers the whole game. + Music.ensure(get_tree()) if joystick != null: joystick.direction_changed.connect(_on_joystick_direction) @@ -119,6 +123,30 @@ func _held_pivot_direction() -> float: return clampf(dir, -1.0, 1.0) +# #153: "after a dialog popup the godot input system is lost on keypress. If +# I had a key held it keeps moving in the key direction." +# +# It was not lost - it was never delivered. A dialog stops the tree (#128), +# a paused node receives no input, and this controller is paused along with +# everything else. So a key RELEASED while the line was up never reached +# _handle_key, keys_down kept it, and the fleet flew on in that direction +# once the game restarted. +# +# NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which +# makes it the one moment where the question can be asked. The physical +# keyboard is the authority on what is actually held down; keys_down is only +# a record of what we were told, and it is the record that went stale. +func _notification(what: int) -> void: + if what == NOTIFICATION_UNPAUSED: + _forget_released_keys() + + +func _forget_released_keys() -> void: + for key in keys_down.keys(): + if not Input.is_key_pressed(key): + keys_down.erase(key) + + func keyboard_direction() -> Vector2: var dir: Vector2 = Vector2.ZERO for key in KEY_DIRECTIONS: @@ -250,15 +278,13 @@ func _process(delta: float) -> void: # anchor in _process, so this only needs to report the direction. The # keyboard takes precedence, and releasing the keys has to stop the fleet # itself - unlike the stick, there is no centred-again signal to hook. - var keyed: Vector2 = keyboard_direction() - if keyed != Vector2.ZERO: - keyboard_steering = true - fleet.move_in_direction(keyed) - elif keyboard_steering: + # #124: the breach drives. One check here covers the stick, the keyboard + # and the released-again path together - three separate gates would be + # three chances for one of them to keep steering. + if GameState.intro_locks_movement: keyboard_steering = false - fleet.stop() - elif joystick != null and joystick.direction != Vector2.ZERO: - fleet.move_in_direction(joystick.direction) + else: + _apply_steering() var target: Vector2 = fleet.get_centroid() if camera.global_position.distance_to(target) <= camera_snap_distance: @@ -272,6 +298,18 @@ func _process(delta: float) -> void: camera.global_position = camera.global_position.lerp(target, t) +func _apply_steering() -> void: + var keyed: Vector2 = keyboard_direction() + if keyed != Vector2.ZERO: + keyboard_steering = true + fleet.move_in_direction(keyed) + elif keyboard_steering: + keyboard_steering = false + fleet.stop() + elif joystick != null and joystick.direction != Vector2.ZERO: + fleet.move_in_direction(joystick.direction) + + func _unhandled_input(event: InputEvent) -> void: if event is InputEventScreenTouch: _handle_touch(event) @@ -283,8 +321,16 @@ func _unhandled_input(event: InputEvent) -> void: _handle_key(event) elif event is InputEventMouseMotion: _note_keyboard() - elif event is InputEventMouseButton and event.pressed: + if speed_fader != null: + speed_fader.handle_mouse_motion(event) + elif event is InputEventMouseButton: _note_keyboard() + # #118: the fader gets first refusal, so dragging the boost does not + # also pan or zoom the world behind it. + if speed_fader != null and speed_fader.handle_mouse_button(event): + return + if not event.pressed: + return # Wheel zoom, the desktop equivalent of the pinch. if event.button_index == MOUSE_BUTTON_WHEEL_UP: _set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP) diff --git a/controllers/GasBombController.gd b/controllers/GasBombController.gd index f4fc9d3..8642713 100644 --- a/controllers/GasBombController.gd +++ b/controllers/GasBombController.gd @@ -1,16 +1,26 @@ class_name GasBombController extends "res://controllers/AimedFleetAbilityController.gd" -const BOMB_COLOR := Color(0.96, 0.22, 0.72) -const TARGET_COLOR := Color(0.78, 0.32, 1.0) -const MAX_RANGE := 950.0 -const AOE_RADIUS := 180.0 -const DRAG_RADIUS := 260.0 -const PROJECTILE_SPEED := 720.0 -const DAMAGE := 3 -const COOLDOWN := 5.0 -const GAS_PER_SHOT: int = 5 +# The player's end of the gas bomb: the button, the aim, the fleet's +# magazine. The SHOT itself is a GasBombEffect in the world (see that file), +# lifted out of here for the same reason the beam was lifted out of +# LaserController - a garrisoned bomber fires the same bomb from a different +# trigger, and none of this controller is involved in that. +const BOMB_EFFECT = preload("res://vfx/GasBombEffect.gd") +const BOMB_COLOR := BOMB_EFFECT.BOMB_COLOR +const TARGET_COLOR := Color(0.78, 0.32, 1.0) +const MAX_RANGE := VesselGasBomberComponent.MAX_RANGE +const AOE_RADIUS := BOMB_EFFECT.AOE_RADIUS +const DRAG_RADIUS := 260.0 +const DAMAGE := VesselGasBomberComponent.BLAST_DAMAGE +const COOLDOWN := VesselGasBomberComponent.SHOT_COOLDOWN +const GAS_PER_SHOT: int = VesselGasBomberComponent.GAS_PER_SHOT + +# The bombs this controller has thrown that are still alive - in flight or +# still blooming. Split into the two lists the rest of the game already asks +# about rather than one, so "is a bomb still travelling" stays a question +# with a cheap answer. var projectiles: Array = [] var explosions: Array = [] var last_hit_count: int = 0 @@ -22,7 +32,8 @@ var explosion_time: float: get: var longest: float = 0.0 for explosion in explosions: - longest = maxf(longest, float(explosion.time)) + if is_instance_valid(explosion): + longest = maxf(longest, float(explosion.explosion_time)) return longest @@ -48,78 +59,46 @@ func _consume_shot(component: Node) -> bool: func _perform_shot(ship: Node2D, _component: Node) -> void: - projectiles.append({ - "position": ship.global_position, - "target": aim_target, - "trail": PackedVector2Array([ship.global_position]), - }) + # The cooldown was already spent by _consume_shot() above, so this + # launches the effect directly rather than going through fire_ability() + # and spending it twice. + var effect: Node2D = BOMB_EFFECT.launch(get_tree().root, ship.global_position, + aim_target, DAMAGE) + effect.struck.connect(_on_bomb_struck) + projectiles.append(effect) + + +func _on_bomb_struck(hit_count: int) -> void: + last_hit_count = hit_count func _next_ready_bomber() -> Node2D: return _next_ready_ship() -func _advance_effects(delta: float) -> void: - for i in range(projectiles.size() - 1, -1, -1): - var projectile: Dictionary = projectiles[i] - var distance: float = (projectile.position as Vector2).distance_to(projectile.target) - if distance <= PROJECTILE_SPEED * delta: - projectile.position = projectile.target - _explode(projectile.target) - projectiles.remove_at(i) +func _advance_effects(_delta: float) -> void: + # The effects fly, explode and free themselves. This only re-sorts them + # into "still travelling" and "still blooming", and forgets the gone. + var flying: Array = [] + var blooming: Array = [] + for effect in projectiles + explosions: + if not is_instance_valid(effect): + continue + if effect.in_flight(): + flying.append(effect) else: - projectile.position = (projectile.position as Vector2).move_toward( - projectile.target, PROJECTILE_SPEED * delta) - var trail: PackedVector2Array = projectile.trail - trail.append(projectile.position) - while trail.size() > 12: - trail.remove_at(0) - projectile.trail = trail - for i in range(explosions.size() - 1, -1, -1): - var explosion: Dictionary = explosions[i] - explosion.time = maxf(float(explosion.time) - delta, 0.0) - if float(explosion.time) <= 0.0: - explosions.remove_at(i) - - -func _explode(at: Vector2) -> void: - explosions.append({"position": at, "time": 0.8}) - last_hit_count = 0 - # Asteroids too - a blast that clears everything around it but leaves the - # rocks untouched would need explaining, and they take damage the same way. - for enemy in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): - if is_instance_valid(enemy) and enemy is Node2D \ - and enemy.global_position.distance_to(at) <= AOE_RADIUS: - enemy.take_damage(DAMAGE) - last_hit_count += 1 + blooming.append(effect) + projectiles = flying + explosions = blooming func _draw() -> void: - if aiming: - NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8) - _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) - _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) - for projectile in projectiles: - var trail: PackedVector2Array = projectile.trail - if trail.size() >= 2: - for i in range(1, trail.size()): - var alpha: float = float(i) / trail.size() - draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65), - 4.0 + alpha * 4.0, true) - draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35)) - NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0) - for explosion in explosions: - var progress: float = 1.0 - float(explosion.time) / 0.8 - var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) - draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) - _draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress) - for i in range(7): - var angle: float = TAU * float(i) / 7.0 + progress - var cloud_at: Vector2 = explosion.position \ - + Vector2(cos(angle), sin(angle)) * radius * 0.55 - draw_circle(cloud_at, 18.0 + 14.0 * progress, - Color(BOMB_COLOR, (1.0 - progress) * 0.22)) + # Only the aim guide. Each bomb draws itself, in its own node. + if not aiming: + return + NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8) + _draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8) + _draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0) func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void: diff --git a/controllers/LaserController.gd b/controllers/LaserController.gd index 1116d9e..3470864 100644 --- a/controllers/LaserController.gd +++ b/controllers/LaserController.gd @@ -1,18 +1,26 @@ class_name LaserController extends "res://controllers/AimedFleetAbilityController.gd" -const BEAM_COLOR := Color(0.3, 0.9, 1.0) -const TARGET_COLOR := Color(0.6, 0.95, 1.0) -const BEAM_HALF_WIDTH := 38.0 -# The DRAWN beam is scaled from the hit width rather than tuned separately. -# The two were independent numbers and had drifted apart: the beam struck -# anything within 22 units of the line while its outer glow spanned about 11, -# so it killed things it visibly missed. NeonDraw's widest layer is 18px at -# scale 1, so this makes the glow span exactly what the beam hits. -const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0 -const FLASH_DURATION := 0.22 -const ENERGY_PER_SHOT: int = 8 +# The player's end of the laser: the button, the aim, the fleet's magazine. +# The SHOT itself is a LaserBeamEffect in the world (see that file) - it was +# lifted out of here so a garrisoned laser vessel could fire the same beam +# from a different trigger without this controller being involved at all. +# +# What is left here is everything that is genuinely about the player firing: +# where they aimed, what it costs the fleet hold, and the guide line drawn +# while they aim it. +const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd") +const BEAM_COLOR := BEAM_EFFECT.BEAM_COLOR +const TARGET_COLOR := Color(0.6, 0.95, 1.0) +const BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH +const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION +const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT + +# The beams this controller has fired and that are still alive. Kept so the +# fleet's own shots can be reasoned about - "is anything still on screen" for +# the redraw, and the tests' view of what a shot did - without this becoming +# the owner of them again. var beams: Array = [] var last_hit_count: int = 0 @@ -51,51 +59,37 @@ func _consume_shot(component: Node) -> bool: func _perform_shot(ship: Node2D, component: Node) -> void: + # The cooldown was already spent by _consume_shot() above, so this fires + # the effect directly rather than going through fire_ability() and + # spending it twice. var direction: Vector2 = aim_target - _aim_origin() direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP - var beam_to: Vector2 = ship.global_position + direction * component.beam_range - _strike(ship.global_position, beam_to, direction, int(component.damage)) + var effect: Node2D = BEAM_EFFECT.fire(get_tree().root, ship.global_position, + direction, component.beam_range, int(component.damage)) + beams.append(effect) + last_hit_count = int(effect.hit_count) func _next_ready_laser_ship() -> Node2D: return _next_ready_ship() -func _advance_effects(delta: float) -> void: - for i in range(beams.size() - 1, -1, -1): - var beam: Dictionary = beams[i] - beam.time = maxf(float(beam.time) - delta, 0.0) - if float(beam.time) <= 0.0: - beams.remove_at(i) +func _advance_effects(_delta: float) -> void: + # The effects animate and free themselves; this only forgets the ones that + # have gone. + beams = beams.filter(func(beam): return is_instance_valid(beam)) -func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: - beams.append({"from": from, "to": to, "time": FLASH_DURATION}) - last_hit_count = 0 - var range_along: float = from.distance_to(to) - # Asteroids share the enemies' take_damage() contract, so the beam sweeps - # both groups in one pass. Shooting a path clear through a field is a - # perfectly good use of a laser, and the ore it leaves behind is the point. - for enemy in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): - if not is_instance_valid(enemy) or not (enemy is Node2D): - continue - var to_enemy: Vector2 = enemy.global_position - from - var along: float = to_enemy.dot(direction) - if along < 0.0 or along > range_along: - continue - var perpendicular: float = (to_enemy - direction * along).length() - if perpendicular <= BEAM_HALF_WIDTH: - enemy.take_damage(damage) - last_hit_count += 1 +func beam_extension(beam: Node2D) -> float: + return beam.extension() + + +func beam_tip(beam: Node2D) -> Vector2: + return beam.tip() func _draw() -> void: + # Only the aim guide. Each beam draws itself, in its own node. if aiming: NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) - for beam in beams: - var progress: float = 1.0 - float(beam.time) / FLASH_DURATION - var alpha: float = 1.0 - progress - NeonDraw.draw_glowing_line(self, beam.from, beam.to, - BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45)) diff --git a/core/GameState.gd b/core/GameState.gd index 3466e37..7354e73 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -9,6 +9,7 @@ const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd") # global class table is necessarily populated, the same hazard PlanetSetups.gd # works around. const WORLD_GEN = preload("res://world/WorldGen.gd") +const QUESTS = preload("res://core/Quests.gd") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -20,6 +21,28 @@ const WORLD_GEN = preload("res://world/WorldGen.gd") var has_return_position: bool = false var surviving_ship_count: int = STARTING_SHIP_COUNT var surviving_vessel_types: PackedStringArray = PackedStringArray() +# #106: a mirror of what is flying, pushed by Fleet._sync_composition_state() +# whenever the composition changes. GameState cannot see scene nodes, and the +# census the Planning Center works from has to include the fleet. +var fleet_vessel_types: PackedStringArray = PackedStringArray() +# #122: the prologue is running. Set by reset_new_game() so it plays once at +# the start of a NEW game and never on a resumed one, and cleared by the +# director when the prologue hands over. +# +# It is a flag rather than a separate scene because the prologue DIRECTS the +# real game: the real fleet, the real enemies, the real asteroid field. That +# is also what makes "then the game starts" cost nothing - the director stops +# steering and there is nothing left to load. +var intro_active: bool = false +# #124: the breach takes the wheel. Movement input is ignored while this is +# set, so the fleet goes where the script sends it rather than fighting the +# player for the last few seconds of the prologue. +var intro_locks_movement: bool = false +# #123: the prologue's convoy is bigger than any fleet the player can field +# yet - it is not their fleet, it is the one they are about to lose. An +# explicit override rather than a fake doctrine, so nothing about the +# player's own progression has to be bent to fit a cutscene. +var intro_fleet_size: int = 0 var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 @@ -217,6 +240,19 @@ func _inventory_for_record(record: Dictionary) -> RefCounted: if inventory == null: inventory = INVENTORY.new() record.inventory = inventory + # An outpost holds exactly what its garrison can use, and the garrison + # changes. Re-applied on every access rather than when the crew changes, + # because there is no single place a crew change goes through - a vessel + # arriving, one being released and one being shot down are three. + if is_outpost(record): + # Every resource gets a ceiling, not only the ones a magazine wants: + # an outpost is not a depot, and ore parked at one has nothing to do + # there and no transporter coming for it. + var capacities: Dictionary = {} + var magazines: Dictionary = garrison_magazines(int(record.id)) + for resource_type in resource_types(): + capacities[resource_type] = int(magazines.get(resource_type, 0)) + inventory.resource_capacities = capacities # Compatibility migration for state made before #33. Erase each legacy # field once absorbed so it cannot drift out of step with the inventory. if record.has("stock"): @@ -294,9 +330,12 @@ func transfer_gas_to_building(id: int, amount: int) -> int: func transfer_gas_to_fleet(id: int, amount: int) -> int: return transfer_resource_to_fleet(id, RESOURCE_GAS, amount) -const MOTHERSHIP_COST: int = 20 -const SHIP_CRAFTER_COST: int = 12 const SHIP_COST: int = 6 +# #138: the per-building cost constants that used to sit here are gone. A +# structure's price lives in its own BuildingDefinition and is read through +# building_costs(), which is the only thing that knows about the resources +# beyond ore that some of them now want. Two places holding the same number +# is how they end up disagreeing. # --- Buildings ------------------------------------------------------------- @@ -306,6 +345,13 @@ const SHIP_COST: int = 6 const BUILDING_MOTHERSHIP: String = "mothership" const BUILDING_SHIP_CRAFTER: String = "ship_crafter" const BUILDING_COMMAND_CENTER: String = "command_center" +# #102: a forward position the fleet can garrison, researched early because +# it is the answer to waves going after what you have built. +const BUILDING_OUTPOST: String = "outpost" +# #106: where standing orders for the whole fleet are kept. +const BUILDING_PLANNING_CENTER: String = "planning_center" +# #116: repairs what the waves knock down. +const BUILDING_WORKSHOP: String = "spacial_workshop" # Extractors are not placed in space like the others - they are built ON a # planet's surface and tracked separately (see `extractors`). const BUILDING_EXTRACTOR: String = "extractor" @@ -318,11 +364,6 @@ const BUILDING_SHIP_LAB: String = "ship_lab" # inventory exactly like every other building (#54). const BUILDING_GAS_BURNER: String = "gas_burner" -const COMMAND_CENTER_COST: int = 15 -const EXTRACTOR_COST: int = 25 -const TRANSPORT_HUB_COST: int = 30 -const SHIP_LAB_COST: int = 35 -const GAS_BURNER_COST: int = 30 var buildings: Array = [] @@ -362,23 +403,78 @@ const SKILL_TREE: Array = [ "x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1, "desc": "Build command centers, where the fleet formation is arranged.", }, + { + # Early on purpose (#97: "pretty early in the tech tree"). It hangs off + # coordination rather than off a deep branch, so the answer to waves + # attacking your structures is available at about the time the waves + # start being able to hurt them. + "id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination", + "costs": {"ore": 30, "gas": 12}, + "x": 0.66, "unlocks": BUILDING_OUTPOST, "research_points": 1, + "desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.", + }, + { + # The same shape as freight_yards for hubs: a permanent bonus that + # applies to outposts already standing, not only to new ones, so + # researching it never means demolishing and rebuilding. + "id": "garrison_bays", "name": "Garrison Bays", "cost": 110, "requires": "forward_bases", + "costs": {"ore": 110, "gas": 35}, + "x": 0.66, "outpost_bonus": 1, "research_points": 2, + "icon": 40, + "desc": "Permanent: +1 berth at every outpost, including ones already built.", + }, + { + # Deep on purpose, unlike forward_bases: standing orders are a thing + # you want once the fleet is large enough that keeping track of it by + # hand has become work, and it has shipyards behind it because it + # manages what shipyards do. + "id": "logistics_command", "name": "Logistics Command", "cost": 140, + "costs": {"ore": 140, "gas": 40}, + "requires": "vessel_science", "x": 0.94, + "unlocks": BUILDING_PLANNING_CENTER, "research_points": 3, + "desc": "Build a planning center: set how many of each vessel should always exist, and the crafters are kept working until they do.", + }, + { + # Behind power_systems rather than off the fleet branch: a workshop + # is infrastructure that looks after infrastructure, and it should + # arrive after the player has some worth looking after. + "id": "field_engineering", "name": "Field Engineering", "cost": 95, + "costs": {"ore": 95, "gas": 35, "energy": 20}, + "requires": "power_systems", "x": 0.34, + "unlocks": BUILDING_WORKSHOP, "research_points": 2, + "desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.", + }, + { + # Same shape as freight_yards and garrison_bays: a permanent bonus + # that reaches workshops already standing. + "id": "drone_bays", "name": "Drone Bays", "cost": 150, + "costs": {"ore": 150, "gas": 50, "energy": 35}, + "requires": "field_engineering", "x": 0.34, + "workshop_bonus": 1, "research_points": 2, "icon": 43, + "desc": "Permanent: +1 repair drone at every spacial workshop, including ones already built.", + }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", + "costs": {"ore": 40, "gas": 15}, "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, "desc": "Build transport hubs. Each hub includes its own automatic transporters.", }, { "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", + "costs": {"ore": 35, "gas": 10}, "x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1, "desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.", }, { "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", + "costs": {"ore": 80, "gas": 30}, "x": 0.18, "hub_bonus": 1, "research_points": 2, + "icon": 12, "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", }, { "id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight", + "costs": {"ore": 45, "gas": 35}, "x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2, "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", }, @@ -389,6 +485,7 @@ const SKILL_TREE: Array = [ # 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, + "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { @@ -400,12 +497,16 @@ const SKILL_TREE: Array = [ # someone else's lane. # Also cut (70 ore / 2 points), for the same reason as wing. "id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing", + "costs": {"ore": 35, "gas": 12}, "x": 0.82, "fleet_bonus": 1, "research_points": 1, + "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { "id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron", + "costs": {"ore": 90, "gas": 30, "energy": 15}, "x": 0.82, "fleet_bonus": 1, "research_points": 2, + "icon": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { @@ -414,12 +515,16 @@ const SKILL_TREE: Array = [ # 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", + "costs": {"ore": 180, "gas": 60, "energy": 40}, "x": 0.82, "fleet_bonus": 2, "research_points": 4, + "icon": 2, "desc": "Permanent: +2 vessels in the fleet.", }, { "id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup", + "costs": {"ore": 320, "gas": 110, "energy": 80, "dark_matter": 20}, "x": 0.82, "fleet_bonus": 2, "research_points": 6, + "icon": 2, "desc": "Permanent: +2 vessels in the fleet.", }, ] @@ -476,9 +581,26 @@ func prerequisite_met(id: String) -> bool: return node.requires == "" or is_unlocked(node.requires) +# What one doctrine costs. Same rule as buildings and vessels (#138): the +# node's own dictionary when it has one, its ore figure otherwise. +# +# `cost` stays the ore figure either way rather than being folded into the +# dictionary and deleted, so the tree's own display - and everything that +# ever compared two nodes by price - keeps working on a plain number. +func skill_costs(id: String) -> Dictionary: + var node: Dictionary = skill_node(id) + if node.is_empty(): + return {} + var costs: Dictionary = node.get("costs", {}) + if not costs.is_empty(): + return costs.duplicate() + return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {} + + func can_unlock(id: String) -> bool: var node: Dictionary = skill_node(id) - return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \ + return not is_unlocked(id) and prerequisite_met(id) \ + and can_afford_resources(skill_costs(id)) \ and research_points >= skill_research_point_cost(id) @@ -490,7 +612,7 @@ func unlock(id: String) -> bool: if not can_unlock(id): return false var node: Dictionary = skill_node(id) - if not spend(int(node.cost)): + if not spend_resources(skill_costs(id)): return false var points_needed: int = skill_research_point_cost(id) if points_needed > 0: @@ -499,9 +621,27 @@ func unlock(id: String) -> bool: unlocked.append(id) if int(node.get("hub_bonus", 0)) > 0: _sync_hub_transporters() + if int(node.get("workshop_bonus", 0)) > 0: + _sync_workshop_drones() return true +# #112: the mark drawn inside a skill node. A node that unlocks a structure +# borrows that structure's own menu icon, so the tree and the build menu +# cannot disagree about what a shipyard looks like; the rest - doctrines and +# permanent bonuses, which have no building to point at - carry an explicit +# one. -1 means "no mark", and the node is drawn hollow as it always was. +func skill_node_icon(node: Dictionary) -> int: + if node.is_empty(): + return -1 + var unlocks: String = String(node.get("unlocks", "")) + if unlocks != "": + var definition: Resource = building_definition(unlocks) + if definition != null: + return int(definition.menu_icon) + return int(node.get("icon", -1)) + + func is_building_unlocked(type: String) -> bool: for node in SKILL_TREE: if node.get("unlocks", "") == type: @@ -509,14 +649,27 @@ func is_building_unlocked(type: String) -> bool: return true -func research_for_building(type: String) -> String: +# The doctrine that unlocks a structure, BY ID - the handle everything else +# in the tree is keyed on (skill_depth, skill_costs, is_unlocked). +func research_node_for_building(type: String) -> String: for node in SKILL_TREE: if node.get("unlocks", "") == type: - return node.name + return String(node.id) return "" +# The same thing by NAME, which is what a message shows the player. Kept +# apart from the id above because the two are easy to mix up and the mistake +# is silent: skill_depth() of a display name finds no node and answers 0, so +# a deep structure reads as an ungated one. +func research_for_building(type: String) -> String: + var id: String = research_node_for_building(type) + return "" if id == "" else String(skill_node(id).name) + + func max_fleet_size() -> int: + if intro_fleet_size > 0: + return mini(intro_fleet_size, DEFAULT_FORMATION.size()) var n: int = BASE_FLEET_SIZE for id in unlocked: n += int(skill_node(id).get("fleet_bonus", 0)) @@ -533,9 +686,13 @@ func reset_progression() -> void: # process would start from wherever the last one left off instead of a # fresh game. func reset_new_game() -> void: + intro_active = true + intro_locks_movement = false + intro_fleet_size = 0 has_return_position = false surviving_ship_count = STARTING_SHIP_COUNT surviving_vessel_types = PackedStringArray() + fleet_vessel_types = PackedStringArray() return_world_position = Vector2.ZERO return_camera_zoom = 1.0 planet_name = "" @@ -555,7 +712,11 @@ func reset_new_game() -> void: vessel_deliveries.clear() extractors.clear() transporters.clear() + repair_drones.clear() planet_capture.clear() + vessel_targets.clear() + reset_waves() + reset_quests(true) reset_progression() reset_formation() @@ -613,6 +774,9 @@ var vessel_deliveries: Array = [] const VESSEL_DELIVERY_SPEED: float = 180.0 signal order_completed(building_id: int, type: String) +# #99: fired after the record is already gone, so a listener that reacts by +# asking GameState about the world sees the world without it. +signal building_destroyed(building_id: int, type: String) func add_building(type: String, at_position: Vector2) -> int: @@ -621,10 +785,13 @@ func add_building(type: String, at_position: Vector2) -> int: buildings.append({ "id": id, "type": type, "position": at_position, "inventory": INVENTORY.new(), "orders": [], + "hp": building_max_hp(type), }) var building: Dictionary = buildings[-1] if building_has_capability(building, "transport_hub"): _provision_hub_transporters(building) + if building_has_capability(building, "repair"): + _provision_workshop_drones(building) return id @@ -635,6 +802,319 @@ func find_building(id: int) -> Dictionary: return {} +# --- what a structure can take before it comes down (#99) ------------------ +# +# Health lives on the RECORD, not on the Building node. Main.tscn is torn +# down and rebuilt on every trip to a planet surface, so damage kept on a +# node would heal itself the moment the player landed - the same reason the +# buildings themselves live here. + +# What one structure costs, in every resource it wants. +# +# #138: the same shape product_costs() already answers for vessels - the +# dictionary when a definition carries one, the ore figure otherwise - so +# there is one question with one answer whatever is being bought. +func building_costs(type: String) -> Dictionary: + var definition: Resource = building_definition(type) + if definition == null: + return {} + if not definition.build_resource_costs.is_empty(): + return definition.build_resource_costs.duplicate() + return {RESOURCE_ORE: int(definition.cost)} if int(definition.cost) > 0 else {} + + +func building_definition(type: String) -> Resource: + return BUILDING_DEFINITIONS.get_definition(type) + + +func building_max_hp(type: String) -> int: + var definition: Resource = building_definition(type) + return 1 if definition == null else maxi(int(definition.max_hp), 1) + + +func building_hp(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + # Records written before #99 carry no "hp" at all. Reading that as zero + # would present every structure in an existing save as rubble, so a + # missing field means undamaged. + return int(b.get("hp", building_max_hp(String(b.type)))) + + +func building_health_fraction(id: int) -> float: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0.0 + return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0) + + +# The mothership is where the bank, the hangar and research all live, and it +# is where a wiped-out fleet is rebuilt. Losing it would delete the player's +# savings and their stored vessels with no route back, so it bottoms out at +# one hit point instead of dying. Keyed on the capability rather than on the +# type: whatever building holds the bank is the one that cannot be lost. +func building_is_indestructible(building: Dictionary) -> bool: + return building_has_capability(building, "bank") + + +# Returns true when this killed the building. +func damage_building(id: int, amount: int) -> bool: + if amount <= 0: + return false + var b: Dictionary = find_building(id) + if b.is_empty(): + return false + var floor_hp: int = 1 if building_is_indestructible(b) else 0 + b.hp = maxi(building_hp(id) - amount, floor_hp) + if int(b.hp) > 0: + return false + return destroy_building(id) + + +func repair_building(id: int, amount: int) -> int: + if amount <= 0: + return 0 + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var before: int = building_hp(id) + b.hp = mini(before + amount, building_max_hp(String(b.type))) + return int(b.hp) - before + + +func destroy_building(id: int) -> bool: + var b: Dictionary = find_building(id) + if b.is_empty() or building_is_indestructible(b): + return false + var type: String = String(b.type) + var was_hub: bool = building_has_capability(b, "transport_hub") + buildings.erase(b) + if was_hub: + # Its transporters belonged to it and have nowhere to go home to. + var kept: Array = [] + for transporter in transporters: + if int(transporter.get("hub_id", 0)) != id: + kept.append(transporter) + transporters = kept + # Everything else keyed on a building id already copes with the building + # being gone: a transporter mid-delivery puts its cargo back rather than + # destroying it (see _arrive), and find_building simply returns empty. + building_destroyed.emit(id, type) + return true + + +# --- outpost garrisons (#103) ---------------------------------------------- +# +# Two separate things live on an outpost's record, and conflating them was +# the first thing to get wrong here: +# +# garrison what the player ASKED for - {vessel_type: count}. Set in +# the outpost's own menu and otherwise untouched. +# garrison_crew who is actually THERE - a list of vessel types. #104 +# fills it from the mothership; nothing else writes it. +# +# Keeping the order as a wish rather than as an inventory is what lets an +# outpost ask for a vessel nobody has yet and simply wait, instead of the +# request evaporating. + +const GARRISON_BASE_SLOTS: int = 2 +# Which vessels can hold a position. #97: "Only ships that attack can be +# assigned" - so a shield vessel, a hauler or an engine block cannot, however +# much the player might want the shield out there. Written as capabilities +# rather than as a list of types, so a new armed vessel is eligible the day +# it is added and an unarmed one never is by accident. +# +# #105 shipped with "weapon" only, because firing the laser and the gas bomb +# lived in the player's controllers rather than in the vessels' own +# components - a garrisoned laser would have held its position and never +# shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect +# and both components expose the same fire_ability() seam, so a garrison can +# pull either trigger and the list is no longer a apology for a missing +# feature. +const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"] + + +func outpost_slot_capacity() -> int: + var n: int = GARRISON_BASE_SLOTS + for id in unlocked: + n += int(skill_node(id).get("outpost_bonus", 0)) + return n + + +func vessel_can_garrison(vessel_type: String) -> bool: + var definition: Resource = vessel_definition(vessel_type) + if definition == null or not definition.fleet_eligible: + return false + for capability in GARRISON_CAPABILITIES: + if capability in definition.capabilities: + return true + return false + + +func garrison_vessel_types() -> PackedStringArray: + var out: PackedStringArray = PackedStringArray() + for definition in VESSEL_DEFINITIONS.all(): + if vessel_can_garrison(String(definition.definition_id)): + out.append(String(definition.definition_id)) + return out + + +func is_outpost(building: Dictionary) -> bool: + return building_has_capability(building, "garrison") + + +func garrison_desired(id: int) -> Dictionary: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b): + return {} + if not b.has("garrison"): + b.garrison = {} + return b.garrison + + +func garrison_desired_total(id: int) -> int: + var n: int = 0 + for count in garrison_desired(id).values(): + n += int(count) + return n + + +func garrison_desired_of(id: int, vessel_type: String) -> int: + return int(garrison_desired(id).get(vessel_type, 0)) + + +# Returns the count actually set, so the caller can show what happened when +# the request was clamped rather than silently doing something else. +func set_garrison_desired(id: int, vessel_type: String, count: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b) or not vessel_can_garrison(vessel_type): + return 0 + var wanted: Dictionary = garrison_desired(id) + var others: int = garrison_desired_total(id) - int(wanted.get(vessel_type, 0)) + var room: int = maxi(outpost_slot_capacity() - others, 0) + var final_count: int = clampi(count, 0, room) + if final_count <= 0: + wanted.erase(vessel_type) + else: + wanted[vessel_type] = final_count + return final_count + + +func adjust_garrison_desired(id: int, vessel_type: String, delta: int) -> int: + return set_garrison_desired(id, vessel_type, + garrison_desired_of(id, vessel_type) + delta) + + +func garrison_crew(id: int) -> Array: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b): + return [] + if not b.has("garrison_crew"): + b.garrison_crew = [] + return b.garrison_crew + + +# --- what a garrison keeps beside it --------------------------------------- +# +# An outpost stores ammunition for the vessels actually stationed there: 50 +# gas for each gas bomber, 50 energy for each laser. The numbers come off the +# VESSEL definitions, so the outpost never learns which vessel needs what - +# add an armed vessel with a magazine and its outpost storage follows. +# +# This is a CAPACITY, not a stock. Hub transporters fill it the same way they +# fill a gas burner's buffer (see building_logistics_deficit), and the +# garrison AI spends from it. + +func garrison_magazine_capacity(id: int, resource_type: String) -> int: + var total: int = 0 + for vessel_type in garrison_crew(id): + var definition: Resource = vessel_definition(String(vessel_type)) + if definition == null: + continue + if String(definition.garrison_ammo_resource) != resource_type: + continue + total += int(definition.garrison_ammo_capacity) + return total + + +# Every resource this outpost wants held, as {resource_type: capacity}. +func garrison_magazines(id: int) -> Dictionary: + var out: Dictionary = {} + for vessel_type in garrison_crew(id): + var definition: Resource = vessel_definition(String(vessel_type)) + if definition == null: + continue + var resource_type: String = String(definition.garrison_ammo_resource) + if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0: + continue + out[resource_type] = int(out.get(resource_type, 0)) \ + + int(definition.garrison_ammo_capacity) + return out + + +# Spends from the outpost's own store. Returns false when it cannot pay in +# full - a half-priced shot would be worse than no shot. +func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool: + if amount <= 0: + return true + var inventory: RefCounted = building_inventory(id) + if inventory == null or inventory.amount(resource_type) < amount: + return false + inventory.withdraw(resource_type, amount) + return true + + +func garrison_crew_of(id: int, vessel_type: String) -> int: + var n: int = 0 + for crewed in garrison_crew(id): + if String(crewed) == vessel_type: + n += 1 + return n + + +# What this outpost is still short of, as {vessel_type: count}. The one +# question #104 has to answer every tick, so it is asked here rather than +# recomputed by whoever needs it. +func garrison_shortfall(id: int) -> Dictionary: + var out: Dictionary = {} + for vessel_type in garrison_desired(id): + # What is already on its way counts. Without this the dispatcher + # would send the whole hangar to fill one berth, because the crew + # does not grow until the first arrival lands. + var missing: int = int(garrison_desired(id)[vessel_type]) \ + - garrison_crew_of(id, String(vessel_type)) \ + - garrison_inbound_of(id, String(vessel_type)) + if missing > 0: + out[vessel_type] = missing + return out + + +func garrison_inbound_of(id: int, vessel_type: String) -> int: + var n: int = 0 + for delivery in vessel_deliveries: + if int(delivery.get("to_outpost", 0)) == id \ + and String(delivery.type) == vessel_type: + n += 1 + return n + + +func garrison_inbound(id: int) -> int: + var n: int = 0 + for delivery in vessel_deliveries: + if int(delivery.get("to_outpost", 0)) == id: + n += 1 + return n + + +func outpost_ids() -> Array: + var out: Array = [] + for b in buildings: + if is_outpost(b): + out.append(int(b.id)) + return out + + func building_has_capability(building: Dictionary, capability: String) -> bool: if building.is_empty(): return false @@ -1056,6 +1536,454 @@ func _advance_production(delta: float) -> void: order_completed.emit(int(b.id), product) +# --- crewing an outpost from the mothership (#104) -------------------------- +# +# "the game should transfer, whenever available, ships from the mothership to +# the outpost to always fullfill the outpost conditions if possible." +# +# Vessels come from the HANGAR - stored_vessels - and never from the fleet. +# The fleet is what the player is flying; taking a vessel out of it to crew a +# building would be the game undoing the player's own arrangement. "Whenever +# available" therefore means "when one is sitting at the mothership", and an +# outpost asking for a hull nobody has built simply waits. +# +# They travel rather than teleporting, down the same vessel_deliveries path a +# finished crafter order already uses to reach the mothership. + +func _garrison_still_wants(id: int, vessel_type: String) -> bool: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b): + return false + return garrison_crew_of(id, vessel_type) < garrison_desired_of(id, vessel_type) + + +func _take_stored_vessel(vessel_type: String) -> bool: + var index: int = stored_vessels.find(vessel_type) + if index < 0: + return false + stored_vessels.remove_at(index) + return true + + +func _advance_garrison_dispatch() -> void: + var mothership: Variant = get_mothership_position() + if mothership == null: + return + for b in buildings: + if not is_outpost(b): + continue + var id: int = int(b.id) + # Over strength first: an order the player has just lowered should + # release the vessel back to the hangar rather than stranding it. + _return_surplus_garrison(b) + for vessel_type in garrison_shortfall(id): + var needed: int = int(garrison_shortfall(id)[vessel_type]) + for i in range(needed): + if not _take_stored_vessel(String(vessel_type)): + break + vessel_deliveries.append({ + "type": String(vessel_type), "from": mothership, + "to": b.position, "progress": 0.0, "to_outpost": id, + }) + + +func _return_surplus_garrison(b: Dictionary) -> void: + var id: int = int(b.id) + var mothership: Variant = get_mothership_position() + if mothership == null: + return + var crew: Array = garrison_crew(id) + for i in range(crew.size() - 1, -1, -1): + var vessel_type: String = String(crew[i]) + if garrison_crew_of(id, vessel_type) <= garrison_desired_of(id, vessel_type): + continue + crew.remove_at(i) + vessel_deliveries.append({ + "type": vessel_type, "from": b.position, "to": mothership, + "progress": 0.0, "to_outpost": 0, + }) + + +# --- standing orders, and the census behind them (#106) -------------------- +# +# "how many ships of a give type should always be available over the whole +# game (fleet + storage + outposts)". +# +# The targets are GLOBAL, not per building. There is one plan for the whole +# game; a second Planning Center is a second place to read and edit the same +# plan, not a competing one. Storing them per building would mean two +# centres could hold contradictory orders and the crafters would be told to +# satisfy both. +# +# The census is the harder half, and the rule is that every vessel is +# counted exactly once, wherever it happens to be at that instant - flying +# in the fleet, parked in the hangar, stationed at an outpost, or in the air +# between any two of those. Miss the in-flight ones and the planner queues a +# replacement for a vessel that is merely en route. + +var vessel_targets: Dictionary = {} + + +func has_planning_center() -> bool: + for b in buildings: + if building_has_capability(b, "planning"): + return true + return false + + +func vessel_target(vessel_type: String) -> int: + return int(vessel_targets.get(vessel_type, 0)) + + +func set_vessel_target(vessel_type: String, count: int) -> int: + if not vessel_is_fleet_eligible(vessel_type): + return 0 + var final_count: int = maxi(count, 0) + if final_count == 0: + vessel_targets.erase(vessel_type) + else: + vessel_targets[vessel_type] = final_count + return final_count + + +func adjust_vessel_target(vessel_type: String, delta: int) -> int: + return set_vessel_target(vessel_type, vessel_target(vessel_type) + delta) + + +func planned_vessel_types() -> PackedStringArray: + var out: PackedStringArray = PackedStringArray() + for definition in VESSEL_DEFINITIONS.all(): + if bool(definition.fleet_eligible): + out.append(String(definition.definition_id)) + return out + + +# How many of this type exist anywhere. Broken out per place rather than +# summed inline so the census can be read - and tested - one location at a +# time. +func vessel_census(vessel_type: String) -> int: + return vessel_count_in_fleet(vessel_type) \ + + vessel_count_in_hangar(vessel_type) \ + + vessel_count_in_garrisons(vessel_type) \ + + vessel_count_in_transit(vessel_type) + + +func vessel_count_in_fleet(vessel_type: String) -> int: + var n: int = 0 + for flying in fleet_vessel_types: + if String(flying) == vessel_type: + n += 1 + return n + + +func vessel_count_in_hangar(vessel_type: String) -> int: + var n: int = 0 + for stored in stored_vessels: + if String(stored) == vessel_type: + n += 1 + return n + + +func vessel_count_in_garrisons(vessel_type: String) -> int: + var n: int = 0 + for id in outpost_ids(): + n += garrison_crew_of(id, vessel_type) + return n + + +# Both directions of flight: a finished hull on its way to the mothership, +# and a vessel being moved to or from an outpost. All of them are vessels +# that exist and will land somewhere. +func vessel_count_in_transit(vessel_type: String) -> int: + var n: int = 0 + for delivery in vessel_deliveries: + if String(delivery.type) == vessel_type: + n += 1 + return n + + +# Ordered but not yet built. Kept separate from the census on purpose: these +# do not exist yet, so a readout has to be able to say "4 of 6, 2 building" +# rather than claiming six. +func vessel_count_on_order(vessel_type: String) -> int: + var n: int = 0 + for b in buildings: + if not can_produce(String(b.type)): + continue + for order in b.get("orders", []): + if String(order.product) == vessel_type: + n += 1 + return n + + +func vessel_shortfall(vessel_type: String) -> int: + return maxi(vessel_target(vessel_type) + - vessel_census(vessel_type) - vessel_count_on_order(vessel_type), 0) + + +# --- the planner working the crafters (#107) ------------------------------- +# +# "the planning center automatically manage the ship craft building queues to +# ensure that the needed vessels are produced to fullfill the constraints." +# +# Three rules keep this from fighting the player: +# +# It TOPS UP, never rewrites. Orders placed by hand are left exactly where +# they are; the planner only ever appends, and only for a type that is +# genuinely short. Cancelling one of the player's orders to make room for +# one of its own would be the game arguing with them. +# +# Everything counts. A vessel already queued, already flying to the +# mothership, or already stationed at an outpost is a vessel that exists or +# shortly will - see vessel_shortfall(). Without that it re-queues the same +# shortfall every tick and buries the player in hulls. +# +# Nothing happens without a planning centre. Destroy it and the standing +# orders stop being acted on, though they are remembered, so rebuilding +# picks up where it left off rather than starting from a blank plan. + +func _crafter_for_planning() -> Dictionary: + # The shortest queue, so orders spread across the crafters that exist + # rather than piling onto whichever happens to be first in the list. + var best: Dictionary = {} + var best_queue: int = 0 + for b in buildings: + if not can_produce(String(b.type)): + continue + var queued: int = (b.get("orders", []) as Array).size() + if best.is_empty() or queued < best_queue: + best = b + best_queue = queued + return best + + +func _advance_planning() -> void: + if vessel_targets.is_empty() or not has_planning_center(): + return + for vessel_type in vessel_targets: + var type_id: String = String(vessel_type) + if not vessel_is_unlocked(type_id): + continue + var missing: int = vessel_shortfall(type_id) + if missing <= 0: + continue + var crafter: Dictionary = _crafter_for_planning() + if crafter.is_empty(): + return + # ONE per pass, not the whole shortfall. The queue is spread across + # crafters by _crafter_for_planning(), and asking for the whole + # shortfall at once would put it all on the shortest queue before + # that had a chance to change. + queue_order(int(crafter.id), type_id) + + +# --- the spacial workshop and its repair drones (#116) --------------------- +# +# Modelled on the transport hub, deliberately and closely: drones belong to a +# workshop rather than to the player, are provisioned when it is built, are +# never a production order, and can be upgraded at a ship lab like any other +# vessel. Anything that already knows how hub transporters behave knows how +# these do. +# +# What a repair COSTS is the one new idea: a share of the building's own +# price, in proportion to how much of its health is gone. Levelling a crafter +# and rebuilding it costs its full price; patching one up after a wave costs +# a fraction, and the fraction is the damage. + +const BASE_WORKSHOP_DRONES: int = 2 +const REPAIR_DRONE: String = "repair_drone" +# What a drone puts back per trip, before upgrades. +const BASE_REPAIR_AMOUNT: int = 25 +const DRONE_IDLE: String = "idle" +const DRONE_OUTBOUND: String = "outbound" +const DRONE_RETURNING: String = "returning" + +var repair_drones: Array = [] + + +func workshop_drone_capacity() -> int: + var n: int = BASE_WORKSHOP_DRONES + for id in unlocked: + n += int(skill_node(id).get("workshop_bonus", 0)) + return n + + +func has_workshop() -> bool: + for b in buildings: + if building_has_capability(b, "repair"): + return true + return false + + +func workshop_ids() -> Array: + var out: Array = [] + for b in buildings: + if building_has_capability(b, "repair"): + out.append(int(b.id)) + return out + + +# The ore a full repair of this building would take: its own price, scaled by +# the share of its health that is missing. A structure at full health costs +# nothing, one levelled to a sliver costs nearly what it did to build. +func repair_cost(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var definition: Resource = building_definition(String(b.type)) + if definition == null: + return 0 + var missing: float = 1.0 - building_health_fraction(id) + return int(ceil(float(definition.cost) * missing)) + + +func repair_amount() -> int: + return int(vessel_stat(REPAIR_DRONE, "repair_amount")) + + +# What one drone-load of repair costs, for a building that needs one. +func repair_trip_cost(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var definition: Resource = building_definition(String(b.type)) + if definition == null: + return 0 + var per_point: float = float(definition.cost) / float(maxi(building_max_hp(String(b.type)), 1)) + var putting_back: int = mini(repair_amount(), + building_max_hp(String(b.type)) - building_hp(id)) + return maxi(int(ceil(per_point * float(putting_back))), 1) if putting_back > 0 else 0 + + +func _damaged_buildings() -> Array: + var out: Array = [] + for b in buildings: + if building_health_fraction(int(b.id)) < 0.999: + out.append(b) + return out + + +# #116: "Required resources are sent to the spacial workshop". The workshop +# asks hub logistics for the ore its outstanding repairs will need, exactly +# as a gas burner asks for gas - see building_logistics_deficit. +func workshop_ore_demand(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty() or not building_has_capability(b, "repair"): + return 0 + var needed: int = 0 + for damaged in _damaged_buildings(): + needed += repair_cost(int(damaged.id)) + return mini(needed, WORKSHOP_ORE_BUFFER) + + +# A ceiling, so one badly-mauled base does not pull the entire bank into a +# single workshop and starve everything else. +const WORKSHOP_ORE_BUFFER: int = 120 + + +func _provision_workshop_drones(workshop: Dictionary) -> void: + var stationed: int = 0 + for drone in repair_drones: + if int(drone.get("workshop_id", 0)) == int(workshop.id): + stationed += 1 + while stationed < workshop_drone_capacity(): + repair_drones.append({ + "workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0, + "from": workshop.position, "to": workshop.position, "progress": 1.0, + "carrying": 0, + }) + stationed += 1 + + +func _sync_workshop_drones() -> void: + for workshop in buildings: + if building_has_capability(workshop, "repair"): + _provision_workshop_drones(workshop) + + +func repair_drone_position(drone: Dictionary) -> Vector2: + return (drone.from as Vector2).lerp(drone.to as Vector2, + clampf(float(drone.progress), 0.0, 1.0)) + + +func _drone_inbound_to(building_id: int) -> bool: + for drone in repair_drones: + if String(drone.state) == DRONE_OUTBOUND and int(drone.target_id) == building_id: + return true + return false + + +func _advance_repairs(delta: float) -> void: + if repair_drones.is_empty(): + return + var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed") + for drone in repair_drones: + var workshop: Dictionary = find_building(int(drone.workshop_id)) + if workshop.is_empty(): + continue + if String(drone.state) == DRONE_IDLE: + _dispatch_drone(drone, workshop) + continue + var distance: float = (drone.from as Vector2).distance_to(drone.to as Vector2) + drone.progress = float(drone.progress) + delta / maxf(distance / maxf(speed, 1.0), 0.001) + if float(drone.progress) < 1.0: + continue + drone.progress = 1.0 + if String(drone.state) == DRONE_OUTBOUND: + # The ore was taken when it left, so a building demolished in + # transit does not silently consume it - it goes home with the + # load and the deposit below puts it back. + var target: Dictionary = find_building(int(drone.target_id)) + if not target.is_empty(): + repair_building(int(drone.target_id), repair_amount()) + else: + deposit_resource_to_building(int(drone.workshop_id), RESOURCE_ORE, + int(drone.carrying)) + drone.carrying = 0 + drone.target_id = 0 + _send_drone(drone, workshop.position, DRONE_RETURNING) + else: + drone.state = DRONE_IDLE + + +func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void: + var inventory: RefCounted = building_inventory(int(workshop.id)) + if inventory == null: + return + var best: Dictionary = {} + var best_health: float = 1.0 + for damaged in _damaged_buildings(): + var id: int = int(damaged.id) + # One drone per building at a time: two arriving at the same wall + # would spend twice the ore to put back one drone-load of health. + if _drone_inbound_to(id): + continue + var cost: int = repair_trip_cost(id) + if cost <= 0 or inventory.amount(RESOURCE_ORE) < cost: + continue + var health: float = building_health_fraction(id) + if best.is_empty() or health < best_health: + best = damaged + best_health = health + if best.is_empty(): + return + # Worst first: the structure closest to coming down is the one a repair + # is worth most on. + var spend: int = repair_trip_cost(int(best.id)) + inventory.withdraw(RESOURCE_ORE, spend) + drone.carrying = spend + drone.target_id = int(best.id) + drone.from = workshop.position + _send_drone(drone, best.position, DRONE_OUTBOUND) + + +func _send_drone(drone: Dictionary, to: Vector2, state: String) -> void: + drone.from = repair_drone_position(drone) + drone.to = to + drone.progress = 0.0 + drone.state = state + + func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: var lab: Dictionary = find_building(lab_id) if not building_has_capability(lab, "vessel_research") or upgrade_id == "": @@ -1169,6 +2097,24 @@ func has_building_of_type(type: String) -> bool: return false +# Whether a structure of this type exists ANYWHERE, however it is stored. +# +# #145: extractors are not in `buildings`. They are placed onto a planet from +# space and live in `extractors`, with their own position, their own stock +# and their own id - so has_building_of_type("extractor") is false no matter +# how many the player has built. The quest that asks for one (#133) was +# asking a question that could never be answered yes, and no amount of +# building extractors was going to finish it. +# +# Anything asking "does the player have one of these" wants THIS, and +# has_building_of_type() only where the answer really is about the +# `buildings` list itself. +func structure_exists(type: String) -> bool: + if type == BUILDING_EXTRACTOR: + return not extractors.is_empty() + return has_building_of_type(type) + + func remove_buildings_of_type(type: String) -> int: var kept: Array = [] var removed_hub_ids: Array = [] @@ -1330,10 +2276,18 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: func building_logistics_deficit(id: int, resource_type: String) -> int: var building: Dictionary = find_building(id) + var target: int = 0 var profile: Resource = building_logistics_profile(building) - if profile == null: - return 0 - var target: int = int(profile.input_buffers.get(resource_type, 0)) + if profile != null: + target = int(profile.input_buffers.get(resource_type, 0)) + # An outpost has no logistics profile at all - what it wants held depends + # on who is stationed there, which is not something a definition can + # state. Taking the larger of the two keeps the static case untouched. + target = maxi(target, garrison_magazine_capacity(id, resource_type)) + # #116: and a workshop asks for the ore its outstanding repairs will + # take, which likewise depends on the world rather than on a definition. + if resource_type == RESOURCE_ORE: + target = maxi(target, workshop_ore_demand(id)) return maxi(0, target - building_resource(id, resource_type)) @@ -1520,14 +2474,151 @@ func total_capture_points(planet_key: String) -> int: return capture_state(planet_key).get("captured", []).size() +# --- waves (#100) ---------------------------------------------------------- +# +# #97 replaces the roaming spawner - one enemy every few seconds, forever - +# with waves: a long quiet, a warning, then an attack. The CLOCK lives here +# rather than in the space scene for the usual reason, that Main.tscn is +# destroyed on every landing; a wave must not be cancelled, nor restarted +# from zero, by a trip to a planet. +# +# What is deliberately NOT here is the spawning. GameState cannot see the +# world, so EnemySpawner reads the phase and puts the enemies in it. That +# also settles what happens if the wave turns while the player is on a +# surface: the phase goes to ACTIVE with nothing to spawn it, and the wave +# is waiting in space when they come back. +const WAVE_PHASE_PEACE: String = "peace" +const WAVE_PHASE_WARNING: String = "warning" +const WAVE_PHASE_ACTIVE: String = "active" + +const WAVE_PEACE_SECONDS: float = 50.0 +# How long the alert is up before anything arrives. Long enough to fly home +# and take a position, short enough that it is a warning and not a chore. +const WAVE_WARNING_SECONDS: float = 9.0 +# A wave that wandered off and never died must not stall the next one for +# ever. The director normally ends the phase by clearing the wave. +const WAVE_MAX_SECONDS: float = 240.0 + +var wave_number: int = 0 +var wave_phase: String = WAVE_PHASE_PEACE +var wave_timer: float = 0.0 +# Set when a wave turns ACTIVE, cleared by whoever puts it in the world. +# Without it a director arriving mid-wave (the player just came back from a +# planet) could not tell "this wave still owes you enemies" from "this wave +# is already out there and half dead". +var wave_pending: bool = false + + +func reset_waves() -> void: + wave_number = 0 + wave_phase = WAVE_PHASE_PEACE + wave_timer = 0.0 + wave_pending = false + + +func wave_is_incoming() -> bool: + return wave_phase == WAVE_PHASE_WARNING + + +# Deliberately NOT a remaining-seconds accessor. #97: "No timer should be +# visible" - so the one number that could be rendered as a countdown is not +# offered to the UI at all, and the alert cannot drift into being a clock. +func wave_warning_pulse() -> float: + if wave_phase != WAVE_PHASE_WARNING: + return 0.0 + return 0.5 + 0.5 * sin(wave_timer * 7.0) + + +# Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger +# target, so growing it raises the stakes rather than trivialising them. +# The wave number is a slower second term, so a player who never grows still +# meets escalation rather than the same two chasers all game. +func wave_strength(fleet_size: int) -> int: + # The flat term matters: without it the opening wave against a one-vessel + # fleet is a single chaser, which reads as the system not working rather + # than as an easy first wave. + return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2 + + +# Spent most-expensive-first, so a large budget buys heavier enemies rather +# than an ever-larger swarm of chasers. min_strength keeps the openings +# honest: the first waves are chasers whatever the arithmetic says. +const WAVE_ROSTER: Array = [ + {"id": "artillery", "cost": 9, "min_strength": 26}, + {"id": "sniper", "cost": 6, "min_strength": 16}, + {"id": "lancer", "cost": 4, "min_strength": 12}, + {"id": "chaser", "cost": 3, "min_strength": 0}, +] +const WAVE_MAX_ENEMIES: int = 18 + + +func wave_composition(fleet_size: int) -> PackedStringArray: + var budget: int = wave_strength(fleet_size) + var strength: int = budget + var out: PackedStringArray = PackedStringArray() + for entry in WAVE_ROSTER: + if strength < int(entry.min_strength): + continue + var cost: int = int(entry.cost) + # The cheapest tier is last and takes whatever is left, so the budget + # is always spent rather than rounded away. + var share: int = budget if String(entry.id) == "chaser" else budget / 2 + while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES: + out.append(String(entry.id)) + budget -= cost + share -= cost + if out.is_empty(): + out.append("chaser") + return out + + +func advance_waves(delta: float) -> void: + wave_timer += delta + match wave_phase: + WAVE_PHASE_PEACE: + if wave_timer >= WAVE_PEACE_SECONDS: + wave_phase = WAVE_PHASE_WARNING + wave_timer = 0.0 + WAVE_PHASE_WARNING: + if wave_timer >= WAVE_WARNING_SECONDS: + wave_phase = WAVE_PHASE_ACTIVE + wave_timer = 0.0 + wave_number += 1 + wave_pending = true + WAVE_PHASE_ACTIVE: + if wave_timer >= WAVE_MAX_SECONDS: + wave_cleared() + + +# #110: put a wave on the board now, for testing. Skips the peace AND the +# warning: the point of the button is to see the wave, and waiting out a +# nine-second alert every time would make it useless as a tool. +func force_wave_now() -> void: + wave_phase = WAVE_PHASE_ACTIVE + wave_timer = 0.0 + wave_number += 1 + wave_pending = true + + +func wave_cleared() -> void: + wave_phase = WAVE_PHASE_PEACE + wave_timer = 0.0 + wave_pending = false + + func _process(delta: float) -> void: # Runs in the autoload, so mining and transport continue while the player # is on a planet's surface or nowhere near either. + advance_waves(delta) _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) + _advance_garrison_dispatch() + _advance_planning() + _advance_repairs(delta) _advance_resource_converters(delta) _advance_distorder_drain(delta) + _advance_quests() for e in extractors: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE var whole: int = int(floor(float(e.mining_progress))) @@ -1550,9 +2641,19 @@ func _advance_vessel_deliveries(delta: float) -> void: if delivery_speed <= 0.0: delivery_speed = VESSEL_DELIVERY_SPEED delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001) - if float(delivery.progress) >= 1.0: + if float(delivery.progress) < 1.0: + continue + vessel_deliveries.remove_at(i) + # #104: a vessel bound for an outpost joins its crew. If the outpost + # was levelled while it was in the air, or the order was cancelled + # under it, it lands in the hangar instead - the vessel is never + # destroyed by its destination going away, the same rule a + # transporter mid-delivery follows with its cargo. + var outpost_id: int = int(delivery.get("to_outpost", 0)) + if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)): + garrison_crew(outpost_id).append(String(delivery.type)) + else: stored_vessels.append(String(delivery.type)) - vessel_deliveries.remove_at(i) # --- transporters (#19) --------------------------------------------------- @@ -1851,6 +2952,189 @@ func _arrive(t: Dictionary) -> void: t.progress = 1.0 +# --- quests (#129, #131) --------------------------------------------------- + +# The chain lives in Quests.gd; what lives here is how far along it the +# player is, and the answer to "is this one done yet". +# +# Progress LATCHES, and it latches for free: the chain is walked one quest +# at a time, and the moment a goal is met the index moves on and never comes +# back. So collecting five ore and then spending it cannot un-complete the +# quest that asked for five ore - by the time it is spent, nothing is +# asking any more. That is why goals are allowed to be phrased against +# current holdings rather than against a running total nobody would keep in +# step. +# +# It sits in GameState with everything else that has to survive the +# Main.tscn <-> PlanetSurface.tscn swap - a quest completed on a surface +# must still be completed after take-off. + +signal quest_started(id: String) +signal quest_completed(id: String) + +# Whether this playthrough has a quest chain at all. Armed by +# reset_new_game(), which is the only door into a real game (the main menu's +# Play, and start_dev_game on top of it) - exactly the gate intro_active +# already uses. Without it, every scene ever instantiated would open on the +# first quest's briefing, including the ones in the test suite that only +# want a world to look at. +var quests_armed: bool = false +var quest_index: int = 0 +# Whether the current quest has been ANNOUNCED. Kept apart from the index so +# a quest that is already satisfied the moment it starts still gets a frame +# on screen with its own name on it, rather than being skipped silently. +var quest_announced: bool = false + +# Lines the chain wants said, waiting for something that can say them. +# +# Queued here rather than emitted straight at a banner because quests +# advance in this autoload, which runs on the planet surface too - where +# there is no narration banner at all. A line emitted there would simply be +# lost. QuestDirector drains this whenever it is alive, so a quest completed +# underground is read out on the way back up. +var quest_messages: Array = [] + +# Which planets have been LANDED ON, and which kinds of world they were. +# Distinct from explored_chunks, which is only about having flown past. +var visited_planets: Dictionary = {} +var visited_planet_kinds: Dictionary = {} + + +func reset_quests(armed: bool = false) -> void: + quests_armed = armed + quest_index = 0 + quest_announced = false + quest_messages.clear() + visited_planets.clear() + visited_planet_kinds.clear() + + +# Called when the fleet actually sets down on a world. +func note_planet_visited(id: String, definition_id: String) -> void: + if id != "": + visited_planets[id] = true + if definition_id != "": + visited_planet_kinds[definition_id] = true + + +func quest_count() -> int: + return QUESTS.count() + + +func quest_chain_complete() -> bool: + return quest_index >= QUESTS.count() + + +# The quest the player is on, or {} once the chain is done. +func active_quest() -> Dictionary: + return QUESTS.at(quest_index) + + +# One quest by position in the chain, for anything listing what is behind the +# player (#140). Out of range answers {} rather than erroring. +func quest_at(index: int) -> Dictionary: + return QUESTS.at(index) + + +# How far along one quest is, as (have, need). One line per goal kind, and +# every one of them is answered from state this autoload already holds - a +# goal that needed a node to ask would be a goal that stops working on a +# planet surface. +func quest_progress(quest: Dictionary) -> Vector2i: + if quest.is_empty(): + return Vector2i.ZERO + var goal: Dictionary = quest.get("goal", {}) + match String(goal.get("kind", "")): + QUESTS.GOAL_RESOURCE: + var want: int = int(goal.get("amount", 1)) + return Vector2i(mini(available_resource(String(goal.get("resource", + RESOURCE_ORE))), want), want) + QUESTS.GOAL_BUILDING: + return Vector2i(1 if structure_exists(String(goal.get("building", ""))) else 0, 1) + QUESTS.GOAL_FLEET_VESSEL: + var need: int = int(goal.get("count", 1)) + return Vector2i(mini(vessel_count_in_fleet(String(goal.get("vessel", ""))), + need), need) + QUESTS.GOAL_VESSEL_UNLOCKED: + return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1) + QUESTS.GOAL_RESEARCH: + return Vector2i(1 if is_unlocked(String(goal.get("node", ""))) else 0, 1) + QUESTS.GOAL_BANKED: + var banked: int = int(goal.get("amount", 1)) + return Vector2i(mini(banked_resource(String(goal.get("resource", + RESOURCE_ORE))), banked), banked) + QUESTS.GOAL_EXPLORE: + var sectors: int = int(goal.get("sectors", 1)) + return Vector2i(mini(explored_chunks.size(), sectors), sectors) + QUESTS.GOAL_PLANET_VISITS: + var visits: int = int(goal.get("count", 1)) + return Vector2i(mini(visited_planets.size(), visits), visits) + QUESTS.GOAL_PLANET_KINDS: + var kinds: int = int(goal.get("count", 1)) + return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds) + QUESTS.GOAL_CONQUERED: + var wanted: int = int(goal.get("count", 1)) + var held: int = 0 + for key in planet_capture: + if bool(planet_capture[key].get("conquered", false)): + held += 1 + return Vector2i(mini(held, wanted), wanted) + # An unknown goal kind must not be quietly "already done" - that would + # run the whole chain off in one frame. + return Vector2i(0, 1) + + +func quest_goal_met(quest: Dictionary) -> bool: + if quest.is_empty(): + return false + var progress: Vector2i = quest_progress(quest) + return progress.x >= progress.y + + +func say_quest_line(text: String) -> void: + if text.strip_edges() == "": + return + quest_messages.append({"line": text, "speaker": QUESTS.SPEAKER}) + + +# Hands over every line waiting to be said, and empties the queue. Drained +# rather than read so two directors could never read the same line twice. +func take_quest_messages() -> Array: + var taken: Array = quest_messages.duplicate() + quest_messages.clear() + return taken + + +# Ends the current quest whether or not its goal was met. The chain's only +# way forward, used by _advance_quests below and by the tests. +func complete_active_quest() -> bool: + var quest: Dictionary = active_quest() + if quest.is_empty(): + return false + say_quest_line(String(quest.get("done", ""))) + quest_index += 1 + quest_announced = false + quest_completed.emit(String(quest.id)) + return true + + +func _advance_quests() -> void: + # The prologue is already talking, and the fleet in it is not the + # player's. The chain starts when the prologue hands over. + if not quests_armed or intro_active or quest_chain_complete(): + return + var quest: Dictionary = active_quest() + if not quest_announced: + quest_announced = true + say_quest_line(String(quest.get("brief", ""))) + quest_started.emit(String(quest.id)) + # Deliberately no completion check this frame: a quest whose goal + # happens to be met already still gets read out before it closes. + return + if quest_goal_met(quest): + complete_active_quest() + + # --- paying for things ------------------------------------------------------ # # EVERY payment draws on the bank AND on what the ships are carrying. The diff --git a/core/Quests.gd b/core/Quests.gd new file mode 100644 index 0000000..80143af --- /dev/null +++ b/core/Quests.gd @@ -0,0 +1,235 @@ +extends RefCounted + +# Forgejo #129 / #133: the quest chain. +# +# One place, in order, and nothing but data. GameState answers each goal and +# QuestDirector reads each line out; neither of them knows what the quests +# ARE, so adding one here is the whole job of adding one. +# +# Two rules the chain obeys, both learned from the game rather than chosen: +# +# 1. Nothing here may ask for something the player cannot yet reach. The +# sharp edge is RESEARCH POINTS - they come only from conquering a +# planet (GameState.mark_point_captured), so every node with a +# research_points cost sits after `conquest` below. Ore-only nodes - +# Shipyards, Logistics - may come before it. +# 2. A quest names ONE goal, and the brief says what it will take. Asking +# for a gas burner is asking for the Power Systems research too; that +# belongs in the sentence, not in a second entry, or the chain turns +# into a list of errands. +# +# The whole story turns on `mothership`: the fleet builds a transmitter, +# calls the World Ship, and hears nothing - not because the array is weak +# but because the breach put them further out than anyone has been. Every +# quest after it is a search. + +# There is nobody left to talk to, so the quests are the ship talking to +# itself - the same voice the prologue ends on (IntroDirector.SHIP_LOG). +const SPEAKER: String = "KESTREL - LOG" + +# Goal kinds. Each is one line in GameState.quest_progress(). +const GOAL_RESOURCE: String = "resource" +const GOAL_BUILDING: String = "building" +const GOAL_FLEET_VESSEL: String = "fleet_vessel" +const GOAL_VESSEL_UNLOCKED: String = "vessel_unlocked" +const GOAL_EXPLORE: String = "explore" +const GOAL_PLANET_VISITS: String = "planet_visits" +const GOAL_PLANET_KINDS: String = "planet_kinds" +const GOAL_CONQUERED: String = "conquered" +# #141: researching a doctrine, as a step of its own. The chain used to fold +# it into the brief of the thing it unlocks ("spend ore on Shipyards, then +# put a crafter down"), which asks the player to do two unrelated things - +# open a menu they have never opened, and place a structure - off one line. +const GOAL_RESEARCH: String = "research" +# #156: what is in the BANK, which is a different question from what the +# player can lay hands on. GOAL_RESOURCE counts the hold as well, and the +# hold is exactly what this quest is about being too small. +const GOAL_BANKED: String = "banked" + +const CHAIN: Array = [ + { + "id": "first_ore", + "title": "SALVAGE", + "objective": "Break asteroids for 5 ore", + "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 5}, + "brief": "Hull intact. Reactor holding. Everything else we had is on the other side of that hole. There is ore in the rocks out here - break some, and we can start again.", + "done": "Five ore in the hold. It is not much. It is a beginning.", + }, + { + # #142: the gap this fills was a real one. "Collect 5 ore" was + # followed straight by "build the mothership", which costs twenty - + # so the first thing the chain ever asked for was four times what it + # had just taught, with nothing saying so. + "id": "stockpile", + "title": "THE PILE", + "objective": "Break asteroids for 20 ore", + "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20}, + "brief": "Now do that four times over. Nothing permanent gets built out of five ore, and there is nothing else out here to spend it on yet.", + "done": "Twenty in the hold. That is a foundation.", + }, + { + "id": "mothership", + "title": "A PLACE TO STAND", + "objective": "Build the mothership", + "goal": {"kind": GOAL_BUILDING, "building": "mothership"}, + "brief": "A hull is not a base. Build the mothership - it is the only thing in the plans with a transmitter big enough to call the World Ship.", + # The hinge of the whole chain (#129): the answer to "we built the + # transmitter" is that the transmitter was never the problem. + "done": "Mothership online. Calling the World Ship. Calling. Nothing comes back - and the array is not at fault. Wherever the breach put us, it is further out than that signal will ever carry. We find their trail ourselves, or we do not find them.", + }, + { + "id": "chart", + "title": "DEAD RECKONING", + "objective": "Chart 30 sectors", + "goal": {"kind": GOAL_EXPLORE, "sectors": 30}, + "brief": "So we search. Fly, and let the map fill in behind us - whatever is left of their course is somewhere in it.", + "done": "Thirty sectors charted. No sign of the World Ship, but the sky behind us is no longer blank.", + }, + { + # #141: its own step, and the first time the chain sends anyone into + # the research tree at all - so the brief says where the tree IS. + # Every later quest that needs a doctrine only mentions it in + # passing, which is enough once the player has been there once. + "id": "shipyards", + "title": "SHIPYARDS", + "objective": "Research the Shipyards doctrine", + "goal": {"kind": GOAL_RESEARCH, "node": "shipyards"}, + "brief": "One hull will not chart a sky, and we cannot build what we have not remembered how to build. The mothership holds the archive - go aboard it and research Shipyards.", + "done": "Shipyard plans recovered. Everything else worth having is in that archive too.", + }, + { + "id": "crafter", + "title": "A YARD OF OUR OWN", + "objective": "Build a ship crafter", + "goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"}, + "brief": "Now put a ship crafter down. Ore goes in, vessels come out - it is the only way this fleet grows past the one hull that came through the breach.", + "done": "The yard is live. We can build our own now.", + }, + { + "id": "second_hull", + "title": "A SECOND HULL", + "objective": "Fly two vessels at once", + "goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "interceptor", "count": 2}, + "brief": "Order an interceptor at the yard and crew it. Two guns is not a fleet, but it is twice what we flew out of the breach with.", + "done": "Two hulls in formation. It has been a while since this ship flew with company.", + }, + { + "id": "planetfall", + "title": "PLANETFALL", + "objective": "Land on a planet", + "goal": {"kind": GOAL_PLANET_VISITS, "count": 1}, + "brief": "There are worlds out here. Bring the fleet in close to one and take it down - there is more on a surface than there is in all this dark.", + "done": "Down on solid ground. Whatever else this place is, it is somewhere.", + }, + { + "id": "conquest", + "title": "GROUND HELD", + "objective": "Take every capture point on one planet", + "goal": {"kind": GOAL_CONQUERED, "count": 1}, + "brief": "Something got to this surface before us and it is still holding it. Take every point on the planet - what we pull out of their positions is worth more than the ore is.", + # Says out loud where research points come from, because nothing + # else in the game does. + "done": "Planet secured. Their equipment reads out as research data - the first we have had. Every doctrine worth having is going to cost some.", + }, + { + # #156: the extractor is the first thing the chain asks for that + # cannot be paid for out of a single hold. An interceptor carries 20 + # and the fleet is small; the mothership is where anything larger + # than one trip has to accumulate, and nothing had ever said so. + "id": "deposit", + "title": "THE HOLD IS SMALL", + "objective": "Bank 60 ore at the mothership", + "goal": {"kind": GOAL_BANKED, "resource": "ore", "amount": 60}, + "brief": "Every hull out here carries twenty and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it - what is stored there is not carried by anyone.", + "done": "Sixty banked. The hold empties, the vault does not - that is the difference, and everything from here on depends on it.", + }, + { + "id": "extractor", + "title": "STANDING MINE", + "objective": "Build an extractor on a planet", + "goal": {"kind": GOAL_BUILDING, "building": "extractor"}, + "brief": "Research Logistics and put an extractor down on a surface. It keeps digging whether we are standing over it or not.", + "done": "The extractor is cutting. Ore will be there when we come back for it.", + }, + { + "id": "gas", + "title": "THE HEAVY AIR", + "objective": "Bring back 25 gas", + "goal": {"kind": GOAL_RESOURCE, "resource": "gas", "amount": 25}, + "brief": "The banded worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.", + "done": "Twenty-five units of gas aboard. Now we need something that can burn it.", + }, + { + "id": "hub", + "title": "FREIGHT", + "objective": "Build a transport hub", + "goal": {"kind": GOAL_BUILDING, "building": "transport_hub"}, + "brief": "Carrying every load by hand does not scale. Research Freight Handling and build a hub - its transporters run the routes without us.", + "done": "The hub is running its own traffic. That is our hands back.", + }, + { + "id": "burner", + "title": "POWER", + "objective": "Build a gas burner", + "goal": {"kind": GOAL_BUILDING, "building": "gas_burner"}, + "brief": "Research Power Systems and build a gas burner. Gas in, energy out - and energy is what the better weapons run on.", + "done": "The burner is lit. There is power on this fleet for the first time since the breach.", + }, + { + "id": "lab", + "title": "SHIP SCIENCE", + "objective": "Build a ship lab", + "goal": {"kind": GOAL_BUILDING, "building": "ship_lab"}, + "brief": "Research Vessel Science and build a ship lab. Every hull we fly can be made better there, and there are hulls in the archive we have never built.", + "done": "The lab is open.", + }, + { + "id": "shield", + "title": "A NEW HULL", + "objective": "Unlock the shield vessel", + "goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "shield"}, + "brief": "The archive has a shield hull in it. Unlock it at the lab - the fleet has been taking every hit on the paint so far.", + "done": "Shield hull unlocked. There is more than one design in that archive.", + }, + { + "id": "outpost", + "title": "FORWARD BASES", + "objective": "Build an outpost", + "goal": {"kind": GOAL_BUILDING, "building": "outpost"}, + "brief": "We cannot be everywhere. Research Forward Bases and build an outpost - vessels stationed there hold ground we are not standing on.", + "done": "The outpost is crewed. That is one place we no longer have to fly back to.", + }, + { + "id": "worlds", + "title": "THE SURVEY", + "objective": "Land on three different kinds of world", + "goal": {"kind": GOAL_PLANET_KINDS, "count": 3}, + "brief": "Rock, gas, and whatever else is out here. Set down on three different kinds of world - if the World Ship passed this way, it passed something.", + "done": "Three kinds of world surveyed. Every one of them empty of anything that came through with us.", + }, + { + "id": "trail", + "title": "THE TRAIL", + "objective": "Chart 150 sectors", + "goal": {"kind": GOAL_EXPLORE, "sectors": 150}, + "brief": "Then we widen it. A hundred and fifty sectors, and every one of them looked at properly. They are out there.", + "done": "A hundred and fifty sectors charted, and no trail. The search does not stop because the list ran out.", + }, +] + + +static func count() -> int: + return CHAIN.size() + + +static func at(index: int) -> Dictionary: + if index < 0 or index >= CHAIN.size(): + return {} + return CHAIN[index] + + +static func by_id(id: String) -> Dictionary: + for quest in CHAIN: + if String(quest.id) == id: + return quest + return {} diff --git a/core/Quests.gd.uid b/core/Quests.gd.uid new file mode 100644 index 0000000..ac469c3 --- /dev/null +++ b/core/Quests.gd.uid @@ -0,0 +1 @@ +uid://cv5jwd8f74a2f diff --git a/enemies/ArtilleryEnemyDefinition.tres b/enemies/ArtilleryEnemyDefinition.tres index 1e947b1..52973b3 100644 --- a/enemies/ArtilleryEnemyDefinition.tres +++ b/enemies/ArtilleryEnemyDefinition.tres @@ -8,11 +8,12 @@ script = ExtResource("1") enemy_id = "artillery" display_name = "Artillery" max_hp = 8 -speed = 90.0 -acceleration = 180.0 -rotation_speed = 3.0 -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) +speed = 215.0 +acceleration = 390.0 +rotation_speed = 4.2 +hull_points = PackedVector2Array(0, -34, 7, -18, 34, -4, 40, 8, 20, 6, 12, 20, 4, 28, -4, 28, -12, 20, -20, 6, -40, 8, -34, -4, -7, -18) 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) +detail_lines = PackedVector2Array(0, -30, 0, 22, -20, 4, 20, 4, -30, 0, -30, 6, 30, 0, 30, 6, -9, 12, 9, 12) +detail_rings = PackedVector2Array(0, 14, 9, 0, 0, -16, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 6 diff --git a/enemies/ArtilleryShell.gd b/enemies/ArtilleryShell.gd index ce7e46e..e87e77d 100644 --- a/enemies/ArtilleryShell.gd +++ b/enemies/ArtilleryShell.gd @@ -18,7 +18,7 @@ signal finished # 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 shell_speed: float = 460.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 @export var color: Color = Color(1.0, 0.5, 0.2) @@ -30,6 +30,13 @@ const EXPLOSION_DURATION: float = 0.8 # 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 +# #114: the blast throws real debris now. The ring alone read as a decal +# being stamped on the map; a burst gives the moment some weight, and it is +# the same LightBurstVFX every other explosion in the game is built from. +const BLAST_VFX = preload("res://vfx/BombBlastVFX.gd") +# Fins on the falling bomb, so it reads as an object with a direction rather +# than a travelling dot. +const FIN_LENGTH: float = 13.0 var phase: int = Phase.TRAVELING var target: Vector2 = Vector2.ZERO @@ -78,9 +85,16 @@ func _explode() -> void: phase = Phase.EXPLODING explosion_time = EXPLOSION_DURATION position = target + BLAST_VFX.spawn(get_tree().root, target, color) for ship in get_tree().get_nodes_in_group("player_ships"): if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius: ship.take_damage(damage) + # #101: a bomb does not care whether what it landed on flies. The hitbox + # is a child Area2D of the Building, which is a plain Node2D and would + # otherwise be unhittable. + for hitbox in get_tree().get_nodes_in_group("player_buildings"): + if is_instance_valid(hitbox) and hitbox.global_position.distance_to(target) <= aoe_radius: + hitbox.take_damage(damage) func _draw() -> void: @@ -91,14 +105,34 @@ func _draw() -> void: _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) + draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, + Color(color, alpha * 0.6), 5.0, true) + # A hot core down the middle of the smoke, brightest at the + # bomb itself. + draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, + Color(1.0, 0.94, 0.86, alpha * 0.5), 1.6, true) draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35)) + # Fins, laid across the direction of travel. + var heading: Vector2 = (target - shell_pos) + heading = heading.normalized() if heading.length() > 0.001 else Vector2.DOWN + var across: Vector2 = Vector2(-heading.y, heading.x) * FIN_LENGTH * 0.55 + var tail: Vector2 = -heading * FIN_LENGTH + NeonDraw.draw_glowing_line(self, tail - across, tail + across, color, 0.9, 0.4) + NeonDraw.draw_glowing_line(self, tail, heading * 6.0, color, 0.9, 0.45) NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0) Phase.EXPLODING: var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0)) draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18)) _draw_ring(eradius, 1.0 - eprogress) + # A second ring chasing the first outwards, and a white flash + # that is gone almost at once - the two together are what make a + # blast read as a shock rather than a circle growing. + if eprogress < 0.55: + _draw_ring(eradius * 0.62, (0.55 - eprogress) * 1.6) + if eprogress < 0.18: + draw_circle(Vector2.ZERO, eradius * 0.7, + Color(1.0, 0.95, 0.88, (0.18 - eprogress) * 3.0)) # A small cross where the shell will land, plus a dot at the exact point. diff --git a/enemies/ChaserEnemyDefinition.tres b/enemies/ChaserEnemyDefinition.tres index 8d234db..0400774 100644 --- a/enemies/ChaserEnemyDefinition.tres +++ b/enemies/ChaserEnemyDefinition.tres @@ -15,3 +15,4 @@ hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0) glow_color = Color(1, 0.15, 0.2, 1) dies_on_contact = true component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 16 diff --git a/enemies/Enemy.gd b/enemies/Enemy.gd index 9a26869..86cb26a 100644 --- a/enemies/Enemy.gd +++ b/enemies/Enemy.gd @@ -5,6 +5,16 @@ signal destroyed const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd") @export var enemy_id: String = "chaser" +# #113: this one came in with a WAVE, and a wave is an attack on what the +# player has built rather than a hazard sitting in space. It converges on the +# nearest structure from wherever it spawned, ignoring the detection range +# every component otherwise respects. +# +# Per instance rather than per definition: the same chaser is a wave when the +# director launches it and a garrison when a map author places it, and a +# planet garrison that chased the player across the system would be a very +# different thing from what the editor was used to place. +@export var relentless: bool = false var definition: Resource var hp: int @@ -57,10 +67,27 @@ func _physics_process(delta: float) -> void: # explosion, same `destroyed` signal, same bookkeeping for whatever is # listening. func collide_with_player_vessel() -> void: + _resolve_ram() + + +# #101: a structure resolves an impact by the same rule - whether it costs +# the enemy its life is the definition's call, not the target's type. +func collide_with_player_building() -> void: + _resolve_ram() + + +func _resolve_ram() -> void: if definition != null and bool(definition.dies_on_contact): take_damage(hp) +# What one strike against a structure takes off it. Data on the definition, +# for the same reason dies_on_contact is: a lancer arriving at 950px/s is not +# a sniper drifting into a wall. +func contact_damage() -> int: + return 6 if definition == null else int(definition.contact_damage) + + func take_damage(amount: int) -> void: hp -= amount if hp <= 0: diff --git a/enemies/EnemyArtilleryComponent.gd b/enemies/EnemyArtilleryComponent.gd index 9230a2f..0077386 100644 --- a/enemies/EnemyArtilleryComponent.gd +++ b/enemies/EnemyArtilleryComponent.gd @@ -1,20 +1,32 @@ class_name EnemyArtilleryComponent 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, 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. +# The bomber: it moves to where its bombs will land and drops them there. +# +# #114 turned it from a siege piece into an aircraft. It used to creep at +# 20px/s and lob a shell that took five seconds to arrive, which read as a +# thing that was stuck rather than a thing that was dangerous - by the time +# a shell landed the fleet had wandered off, and the tower was still +# approaching. It now flies at its own speed and its bombs travel at 460. +# +# The shell is still a standalone ArtilleryShell added to the root, so an +# already-dropped bomb survives this enemy dying mid-flight, same as +# EnemyProjectile/Projectile already do. var capability_id: String = "artillery" -@export var fire_cooldown: float = 6.0 +@export var fire_cooldown: float = 4.0 @export var fire_range: float = 900.0 -@export var shell_speed: float = 180.0 +# #114: fast enough that the bomb arrives while the target is still roughly +# where it was aimed, and slow enough that the impact cross is a warning +# rather than a formality. +@export var shell_speed: float = 460.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 -@export var drift_speed: float = 20.0 +# It flies now rather than creeping. Kept as its own number rather than the +# definition's `speed` so a run-in can be deliberately slower than a chaser's +# top speed without making the hull slow. +@export var drift_speed: float = 150.0 @export var color: Color = Color(1.0, 0.5, 0.2) var cooldown_left: float = 0.0 @@ -29,10 +41,12 @@ func tick(delta: float) -> void: if cooldown_left > 0.0: return - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null or fleet.ships.is_empty(): + # #101/#113: it bombs what the player has BUILT as readily as what they + # are flying, and asks the shared targeting rather than the fleet alone. + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null: return - var centroid: Vector2 = fleet.get_centroid() + var centroid: Vector2 = target.global_position if enemy.global_position.distance_to(centroid) > fire_range: return @@ -47,18 +61,22 @@ func tick(delta: float) -> void: func _drift(enemy: Node2D, delta: float) -> void: - # Local position throughout, same convention as EnemyPursuitComponent/ - # EnemyStandoffComponent: valid as long as the enemy's container and - # Fleet share a common origin, which every scene in this project does. - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null or fleet.ships.is_empty(): + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null: return - var to_fleet: Vector2 = fleet.get_centroid() - enemy.position - # #69: gated by the same fire_range it already shells within, rather - # than a second tunable that could drift out of sync - outside its own - # firing envelope it has nothing to be drifting toward yet. - if to_fleet.length() > fire_range: + var to_target: Vector2 = target.global_position - enemy.global_position + var distance: float = to_target.length() + if distance < 1.0: return - if to_fleet.length() < 1.0: + # #69: gated by the same fire_range it already bombs within, rather than + # a second tunable that could drift out of sync - outside its own firing + # envelope it has nothing to be flying toward yet. #113: unless it came + # in with a wave, in which case it has somewhere to be. + if distance > fire_range and not EnemyTargets.ignores_range(enemy): return - enemy.position += to_fleet.normalized() * drift_speed * delta + var heading: Vector2 = to_target / distance + enemy.global_position += heading * drift_speed * delta + # #114: it faces where it is going. A bomber that flew sideways was most + # of why the old one read as scenery being nudged along. + enemy.rotation = lerp_angle(enemy.rotation, heading.angle() + PI / 2.0, + enemy.rotation_speed * delta) diff --git a/enemies/EnemyDefinition.gd b/enemies/EnemyDefinition.gd index 4d16006..514c58a 100644 --- a/enemies/EnemyDefinition.gd +++ b/enemies/EnemyDefinition.gd @@ -31,5 +31,9 @@ extends Resource # check: the chaser rams because ramming is its whole behaviour, while a # sniper that happens to drift into a ship has not spent itself doing it. @export var dies_on_contact: bool = false +# #101: what one strike against a player structure takes off it. A rammer +# spends itself on the hit and so hits hardest; a stand-off enemy that drifts +# into a wall barely scratches it, its real threat being its weapon. +@export var contact_damage: int = 6 @export var core_color: Color = Color(0.06, 0.02, 0.03) @export var component_scenes: Array[PackedScene] = [] diff --git a/enemies/EnemyDefinitions.gd b/enemies/EnemyDefinitions.gd index ba34fea..8d2d893 100644 --- a/enemies/EnemyDefinitions.gd +++ b/enemies/EnemyDefinitions.gd @@ -2,9 +2,10 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"] +const ORDER: PackedStringArray = ["chaser", "lancer", "sniper", "artillery", "turret_spawner", "enemy_mothership"] const DEFINITIONS: Dictionary = { "chaser": preload("res://enemies/ChaserEnemyDefinition.tres"), + "lancer": preload("res://enemies/LancerEnemyDefinition.tres"), "sniper": preload("res://enemies/SniperEnemyDefinition.tres"), "artillery": preload("res://enemies/ArtilleryEnemyDefinition.tres"), "turret_spawner": preload("res://enemies/TurretSpawnerEnemyDefinition.tres"), diff --git a/enemies/EnemyLungeComponent.gd b/enemies/EnemyLungeComponent.gd new file mode 100644 index 0000000..762574d --- /dev/null +++ b/enemies/EnemyLungeComponent.gd @@ -0,0 +1,198 @@ +class_name EnemyLungeComponent +extends Node2D + +# Forgejo #96. A ramming enemy that does not simply beeline the way +# EnemyPursuitComponent does: it drifts in from a distance, LOCKS a point, +# rocks back and forth on the spot while it winds up, shows the line it is +# about to fly down, and only then crosses the gap in one fast straight run. +# +# The lock is the whole point. Once WINDUP starts, nothing re-reads the +# fleet: the charge goes to where the target WAS, so the wind-up and the +# trajectory line are a real warning the player can act on, rather than +# decoration on an unavoidable hit. Same bargain as EnemyBigLaserBeam, which +# locks its aim in launch() for exactly this reason. +# +# A Node2D rather than a plain Node because the trajectory line has to be +# drawn somewhere, and top_level keeps that drawing in world space - the +# parent enemy spins to face its target, and a line that spun with it would +# not point where the charge is going. +# +# Never pair with a pursuit or standoff component: all three write the +# enemy's position every tick and would fight over it. + +var capability_id: String = "lunge" + +enum Phase { HUNT, WINDUP, CHARGE, RECOVER } + +# Far enough that it is already on screen, and visibly waiting, before it +# commits - "starts a bit far from the target" is the behaviour, so a lancer +# that locked the instant it could see anything would not read as one. +@export var detection_range: float = 1500.0 +@export var lock_range: float = 820.0 +# The drift in HUNT is deliberately slower than the charge by an order of +# magnitude: the approach is a stalk, the charge is the attack. +@export var approach_speed_factor: float = 0.5 +# Shortened with the cycle count (#115): one back-and-forth in the time two +# and a half used to take would be a slow-motion tell. +@export var windup_time: float = 0.85 +# #96 to the letter: the trajectory line appears 300ms before it leaves. +@export var telegraph_time: float = 0.3 +# The "small back and forth" - it recoils along the charge axis, so the +# motion reads as winding up to spring rather than as drifting. +@export var windup_sway: float = 34.0 +# #115: exactly ONE back and forth, not two and a half. The tell is a recoil +# and a spring; repeating it turned a wind-up into a fidget, and the player +# stopped reading it as "about to happen". +@export var windup_cycles: float = 1.0 +# #115: and it leaves hard. At 950 the charge read as a fast drift rather +# than as the thing the whole wind-up was promising. +@export var charge_speed: float = 1650.0 +# It flies THROUGH the locked point rather than stopping on it. A charge +# that halted exactly where the ship used to be would look like it was +# aiming at empty space on purpose. +@export var charge_overshoot: float = 300.0 +@export var recover_time: float = 1.3 +@export var telegraph_color: Color = Color(1.0, 0.45, 0.15) + +var phase: int = Phase.HUNT +var velocity: Vector2 = Vector2.ZERO +# Where the charge is going, and where it started - both frozen at lock. +var locked_point: Vector2 = Vector2.ZERO +var charge_direction: Vector2 = Vector2.RIGHT +var charge_anchor: Vector2 = Vector2.ZERO +var charge_distance: float = 0.0 +var travelled: float = 0.0 +var windup_left: float = 0.0 +var recover_left: float = 0.0 + + +func setup() -> void: + # World space, not the enemy's: see the class comment. + top_level = true + position = Vector2.ZERO + rotation = 0.0 + + +# True while the trajectory line is up, i.e. the last telegraph_time of the +# wind-up. Public because it is the one piece of state a test can check that +# says "the warning is showing" without reaching into a _draw(). +func is_telegraphing() -> bool: + return phase == Phase.WINDUP and windup_left <= telegraph_time + + +func tick(delta: float) -> void: + var enemy: Node2D = get_parent() + match phase: + Phase.HUNT: + _tick_hunt(enemy, delta) + Phase.WINDUP: + _tick_windup(enemy, delta) + Phase.CHARGE: + _tick_charge(enemy, delta) + Phase.RECOVER: + _tick_recover(enemy, delta) + queue_redraw() + + +func _tick_hunt(enemy: Node2D, delta: float) -> void: + var target: Node2D = _nearest_ship(enemy) + if target == null: + return + var to_target: Vector2 = target.global_position - enemy.global_position + var distance: float = to_target.length() + if distance < 0.001: + return + # #113: see EnemyPursuitComponent. + if not EnemyTargets.ignores_range(enemy) and distance > detection_range: + return + var direction: Vector2 = to_target / distance + if distance <= lock_range: + _lock(enemy, target) + return + var desired: Vector2 = direction * enemy.speed * approach_speed_factor + velocity = velocity.move_toward(desired, enemy.acceleration * delta) + enemy.global_position += velocity * delta + _face(enemy, direction, delta) + + +func _lock(enemy: Node2D, target: Node2D) -> void: + locked_point = target.global_position + charge_anchor = enemy.global_position + var to_target: Vector2 = locked_point - charge_anchor + charge_distance = to_target.length() + charge_direction = to_target.normalized() if charge_distance > 0.001 else Vector2.RIGHT + windup_left = windup_time + travelled = 0.0 + velocity = Vector2.ZERO + phase = Phase.WINDUP + + +func _tick_windup(enemy: Node2D, delta: float) -> void: + windup_left -= delta + # Snap to the locked heading rather than easing into it: the nose is part + # of the warning, and a lancer still turning when the line appears would + # be lying about where it is going. + enemy.rotation = charge_direction.angle() + PI / 2.0 + if windup_left <= 0.0: + phase = Phase.CHARGE + enemy.global_position = charge_anchor + return + # One full back-and-forth per cycle, starting and ending at the anchor, + # leaning BACKWARDS first (-sin) so the recoil comes before the spring. + var progress: float = 1.0 - windup_left / maxf(windup_time, 0.001) + var sway: float = -sin(progress * TAU * windup_cycles) * windup_sway + enemy.global_position = charge_anchor + charge_direction * sway + + +func _tick_charge(enemy: Node2D, delta: float) -> void: + var step: float = charge_speed * delta + travelled += step + enemy.global_position += charge_direction * step + enemy.rotation = charge_direction.angle() + PI / 2.0 + if travelled >= charge_distance + charge_overshoot: + phase = Phase.RECOVER + recover_left = recover_time + velocity = charge_direction * charge_speed + + +func _tick_recover(enemy: Node2D, delta: float) -> void: + # Coasts to a stop instead of stopping dead, then hunts again. Without + # this it would re-lock the frame it finished and the wind-up would stop + # being a rhythm the player can read. + recover_left -= delta + velocity = velocity.move_toward(Vector2.ZERO, enemy.acceleration * delta) + enemy.global_position += velocity * delta + if recover_left <= 0.0: + phase = Phase.HUNT + velocity = Vector2.ZERO + + +func _face(enemy: Node2D, direction: Vector2, delta: float) -> void: + var target_angle: float = direction.angle() + PI / 2.0 + enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta) + + +# #101: a lancer will lock a structure exactly as it locks a vessel - the +# wind-up, the line and the charge are the same, and so is what happens when +# it arrives. +func _nearest_ship(enemy: Node2D) -> Node2D: + return EnemyTargets.nearest_target(self, enemy.global_position) + + +func _draw() -> void: + if not is_telegraphing(): + return + var enemy: Node2D = get_parent() + if enemy == null: + return + # Thin, and brightening as the launch approaches, so it reads as a sight + # line being drawn rather than as a beam already firing. + var urgency: float = clampf(1.0 - windup_left / maxf(telegraph_time, 0.001), 0.0, 1.0) + var from: Vector2 = enemy.global_position + var to: Vector2 = locked_point + charge_direction * charge_overshoot + draw_line(from, to, Color(telegraph_color, 0.18 + 0.30 * urgency), 2.0, true) + draw_line(from, to, Color(1.0, 0.92, 0.82, 0.25 + 0.45 * urgency), 1.0, true) + # A tick across the locked point: the line says WHERE, this says how far. + var across: Vector2 = Vector2(-charge_direction.y, charge_direction.x) * 14.0 + draw_line(locked_point - across, locked_point + across, + Color(telegraph_color, 0.35 + 0.5 * urgency), 2.0, true) diff --git a/enemies/EnemyLungeComponent.gd.uid b/enemies/EnemyLungeComponent.gd.uid new file mode 100644 index 0000000..ebe0b01 --- /dev/null +++ b/enemies/EnemyLungeComponent.gd.uid @@ -0,0 +1 @@ +uid://bfl6w8gq2ht0g diff --git a/enemies/EnemyLungeComponent.tscn b/enemies/EnemyLungeComponent.tscn new file mode 100644 index 0000000..eeddb21 --- /dev/null +++ b/enemies/EnemyLungeComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://enemies/EnemyLungeComponent.gd" id="1"] + +[node name="EnemyLungeComponent" type="Node2D"] +script = ExtResource("1") diff --git a/enemies/EnemyProjectile.gd b/enemies/EnemyProjectile.gd index 88d012d..b9a309b 100644 --- a/enemies/EnemyProjectile.gd +++ b/enemies/EnemyProjectile.gd @@ -37,6 +37,12 @@ func _on_area_entered(area: Area2D) -> void: if area.is_in_group("player_ships"): area.take_damage(damage) queue_free() + return + # #101: structures are targets too. The hitbox is a child Area2D of the + # Building, which is a plain Node2D and could not be hit otherwise. + if area.is_in_group("player_buildings"): + area.take_damage(damage) + queue_free() func _draw() -> void: diff --git a/enemies/EnemyPursuitComponent.gd b/enemies/EnemyPursuitComponent.gd index 6f7ae45..f8f752f 100644 --- a/enemies/EnemyPursuitComponent.gd +++ b/enemies/EnemyPursuitComponent.gd @@ -17,15 +17,23 @@ var velocity: Vector2 = Vector2.ZERO func tick(delta: float) -> void: var enemy: Node2D = get_parent() - var target: Node2D = _nearest_ship(enemy) - if target == null or enemy.position.distance_to(target.position) > detection_range: + # #101: a structure is a target now, not scenery. EnemyTargets keeps a + # ship in the enemy's face ahead of a building further off. + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null: return - var to_target: Vector2 = target.position - enemy.position + # #113: a wave enemy closes on what the player built from wherever it + # arrived. Everything else keeps the radius that stops it reading as + # omniscient (#69). + if not EnemyTargets.ignores_range(enemy) \ + and enemy.global_position.distance_to(target.global_position) > detection_range: + return + var to_target: Vector2 = target.global_position - enemy.global_position if to_target.length() > 1.0: var desired_velocity: Vector2 = to_target.normalized() * enemy.speed velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta) - enemy.position += velocity * delta + enemy.global_position += velocity * delta if velocity.length() > 5.0: var target_angle: float = velocity.angle() + PI / 2.0 @@ -33,15 +41,4 @@ func tick(delta: float) -> void: func _nearest_ship(enemy: Node2D) -> Node2D: - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null or fleet.ships.is_empty(): - return null - - var nearest: Node2D = fleet.ships[0] - var best_dist: float = enemy.position.distance_to(nearest.position) - for ship in fleet.ships: - var d: float = enemy.position.distance_to(ship.position) - if d < best_dist: - best_dist = d - nearest = ship - return nearest + return EnemyTargets.nearest_ship(self, enemy.global_position) diff --git a/enemies/EnemyRangedWeaponComponent.gd b/enemies/EnemyRangedWeaponComponent.gd index 736800b..7820564 100644 --- a/enemies/EnemyRangedWeaponComponent.gd +++ b/enemies/EnemyRangedWeaponComponent.gd @@ -38,17 +38,12 @@ func tick(delta: float) -> void: fire_timer = fire_cooldown +# #101: shoots at whatever EnemyTargets says it is coming for, so a sniper +# shells a depot rather than sitting in range of one doing nothing. func _nearest_ship_in_range(enemy: Node2D) -> Node2D: - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null: + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null: return null - var nearest: Node2D = null - var best_distance: float = fire_range - for ship in fleet.ships: - if not is_instance_valid(ship): - continue - var distance: float = enemy.global_position.distance_to(ship.global_position) - if distance <= best_distance: - best_distance = distance - nearest = ship - return nearest + if enemy.global_position.distance_to(target.global_position) > fire_range: + return null + return target diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index f70ab84..2554887 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -1,42 +1,66 @@ extends Node2D -@export var min_spawn_interval: float = 3.0 -@export var max_spawn_interval: float = 6.0 +# The wave director (#100 / #97). Keeps its old node name on purpose: it is +# still "the thing that puts enemies in the world", and every test that +# wanted a quiet world already says +# `main.get_node("EnemySpawner").set_process(false)`. Renaming it would have +# changed 88 test files to say the same thing differently. +# +# What it no longer does is trickle. It used to spawn one enemy every 3-6 +# seconds for ever, so there was no rhythm to the danger and nothing to +# prepare for. Now the CLOCK lives in GameState (which survives the trip to +# a planet surface) and this node only reacts to it: when a wave turns +# active, it puts the wave in the world; when the wave is dead, it says so +# and the quiet starts again. + @export var spawn_margin: float = 150.0 +# Waves arrive as a ring around the fleet rather than from one bearing: a +# wave that all came from the left would be dodged by flying right. +@export var wave_spread: float = 0.55 @onready var fleet: Node2D = get_node("../Fleet") var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn") -var timer: float = 0.0 -var next_spawn_time: float = 0.0 +# The enemies this director launched, so "the wave is dead" is a question +# about THIS wave and not about every enemy on the map - a planet garrison +# or an enemy mothership's own spawns must not hold the next wave back. +var wave_enemies: Array = [] -func _ready() -> void: - randomize() - _reset_timer() - - -func _process(delta: float) -> void: - timer += delta - if timer >= next_spawn_time: - timer = 0.0 - _reset_timer() - _spawn_enemy() - - -func _reset_timer() -> void: - next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval) - - -func _spawn_enemy() -> void: - if fleet.ships.is_empty(): +func _process(_delta: float) -> void: + wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e)) + if GameState.wave_pending: + _launch_wave() return + if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty(): + GameState.wave_cleared() + +# Public so a test can put a wave in the world without waiting out the peace. +func _launch_wave() -> void: + if fleet == null or fleet.ships.is_empty(): + # Nothing to attack. Leave it pending rather than spending the wave + # on an empty map - the fleet-wipe safety net rebuilds within a frame + # and the wave arrives then. + return + GameState.wave_pending = false + var composition: PackedStringArray = GameState.wave_composition(fleet.ships.size()) var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self) var radius: float = visible_rect.size.length() / 2.0 + spawn_margin - var angle: float = randf() * TAU - var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius - - var enemy: Node2D = enemy_scene.instantiate() - enemy.position = spawn_pos - get_parent().add_child(enemy) + var centre: Vector2 = fleet.get_centroid() + var bearing: float = randf() * TAU + for i in range(composition.size()): + # Spread around one bearing rather than scattered at random: a wave + # should read as an attack arriving, not as the map filling up. + var angle: float = bearing + randf_range(-wave_spread, wave_spread) \ + + TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread + var enemy: Node2D = enemy_scene.instantiate() + # Before add_child: Enemy._ready() resolves enemy_id into a definition. + enemy.enemy_id = composition[i] + # #113: a wave is an attack on what the player has built. It converges + # on the nearest structure from wherever it spawned rather than + # waiting to be flown near. + enemy.relentless = true + enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius + get_parent().add_child(enemy) + wave_enemies.append(enemy) diff --git a/enemies/EnemyStandoffComponent.gd b/enemies/EnemyStandoffComponent.gd index 02e96a6..8225357 100644 --- a/enemies/EnemyStandoffComponent.gd +++ b/enemies/EnemyStandoffComponent.gd @@ -21,12 +21,16 @@ var velocity: Vector2 = Vector2.ZERO func tick(delta: float) -> void: var enemy: Node2D = get_parent() - var target: Node2D = _nearest_ship(enemy) + # #101: holds its preferred range off a building just as off a ship. + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) if target == null: return - var to_target: Vector2 = target.position - enemy.position + var to_target: Vector2 = target.global_position - enemy.global_position var distance: float = to_target.length() - if distance < 1.0 or distance > detection_range: + if distance < 1.0: + return + # #113: see EnemyPursuitComponent - a wave arrives with somewhere to be. + if not EnemyTargets.ignores_range(enemy) and distance > detection_range: return var direction: Vector2 = to_target / distance var retreat_range: float = preferred_range * retreat_range_factor @@ -41,7 +45,7 @@ func tick(delta: float) -> void: desired_velocity = lateral * enemy.speed * 0.5 velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta) - enemy.position += velocity * delta + enemy.global_position += velocity * delta if velocity.length() > 5.0: var target_angle: float = velocity.angle() + PI / 2.0 @@ -49,14 +53,4 @@ func tick(delta: float) -> void: func _nearest_ship(enemy: Node2D) -> Node2D: - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null or fleet.ships.is_empty(): - return null - var nearest: Node2D = fleet.ships[0] - var best_dist: float = enemy.position.distance_to(nearest.position) - for ship in fleet.ships: - var d: float = enemy.position.distance_to(ship.position) - if d < best_dist: - best_dist = d - nearest = ship - return nearest + return EnemyTargets.nearest_ship(self, enemy.global_position) diff --git a/enemies/EnemyTargets.gd b/enemies/EnemyTargets.gd new file mode 100644 index 0000000..60df0dc --- /dev/null +++ b/enemies/EnemyTargets.gd @@ -0,0 +1,73 @@ +class_name EnemyTargets +extends RefCounted + +# #101 (#97c): who a wave enemy is coming for. +# +# Before this, every enemy component asked the same question - "which player +# SHIP is nearest?" - and a structure was scenery they flew straight past. +# #97 makes waves go after what the player has built, so the question +# becomes "which player thing is nearest", asked in one place so pursuit, +# standoff, the lancer's lunge and the ranged weapon cannot drift into +# disagreeing about it. +# +# There is one thumb on the scale. A ship inside SHIP_PRIORITY_RANGE always +# wins, even if a building is nearer: an enemy that ignored the vessels +# shooting it in order to reach a depot two hundred pixels closer would read +# as broken, and would make the fleet unable to defend anything by +# interposing itself. + +const SHIP_PRIORITY_RANGE: float = 520.0 + + +# The GROUP, not Fleet.ships. #105 puts vessels at outposts that are not in +# the fleet at all, and an enemy that ignored the guns actually shooting at +# it - because they were not in the player's formation - would let a +# garrison hold a position without ever being fought back. +static func nearest_ship(node: Node, from: Vector2) -> Node2D: + var nearest: Node2D = null + var best: float = INF + for ship in node.get_tree().get_nodes_in_group("player_ships"): + if not is_instance_valid(ship) or ship.is_queued_for_deletion(): + continue + var d: float = from.distance_to((ship as Node2D).global_position) + if d < best: + best = d + nearest = ship + return nearest + + +static func nearest_building(node: Node, from: Vector2) -> Node2D: + var nearest: Node2D = null + var best: float = INF + for building in node.get_tree().get_nodes_in_group("buildings"): + if not is_instance_valid(building) or building.is_queued_for_deletion(): + continue + var d: float = from.distance_to(building.global_position) + if d < best: + best = d + nearest = building + return nearest + + +static func nearest_target(node: Node, from: Vector2) -> Node2D: + var ship: Node2D = nearest_ship(node, from) + if ship != null and from.distance_to(ship.global_position) <= SHIP_PRIORITY_RANGE: + return ship + var building: Node2D = nearest_building(node, from) + if building == null: + return ship + if ship == null: + return building + return ship if from.distance_to(ship.global_position) \ + <= from.distance_to(building.global_position) else building + + +static func is_building(target: Node) -> bool: + return target != null and target.is_in_group("buildings") + + +# #113: whether this enemy may act on a target however far away it is. +# Asked here rather than read directly, so the three movement components +# cannot drift into disagreeing about what "relentless" means. +static func ignores_range(enemy: Node) -> bool: + return enemy != null and bool(enemy.get("relentless")) diff --git a/enemies/EnemyTargets.gd.uid b/enemies/EnemyTargets.gd.uid new file mode 100644 index 0000000..ba24c32 --- /dev/null +++ b/enemies/EnemyTargets.gd.uid @@ -0,0 +1 @@ +uid://i44mdrpr6fg diff --git a/enemies/LancerEnemyDefinition.tres b/enemies/LancerEnemyDefinition.tres new file mode 100644 index 0000000..5866263 --- /dev/null +++ b/enemies/LancerEnemyDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://enemies/EnemyLungeComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +enemy_id = "lancer" +display_name = "Lancer" +max_hp = 2 +speed = 220.0 +acceleration = 420.0 +rotation_speed = 7.0 +hull_points = PackedVector2Array(0, -32, 6, -8, 11, 14, 4, 10, 0, 20, -4, 10, -11, 14, -6, -8) +glow_color = Color(1, 0.45, 0.15, 1) +detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2) +detail_rings = PackedVector2Array(0, 8, 4, 0) +dies_on_contact = true +component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 22 diff --git a/enemies/MothershipEnemyDefinition.tres b/enemies/MothershipEnemyDefinition.tres index c6bf1d5..c47e042 100644 --- a/enemies/MothershipEnemyDefinition.tres +++ b/enemies/MothershipEnemyDefinition.tres @@ -19,3 +19,4 @@ 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")]) +contact_damage = 34 diff --git a/enemies/SniperEnemyDefinition.tres b/enemies/SniperEnemyDefinition.tres index 7279029..60a3cb9 100644 --- a/enemies/SniperEnemyDefinition.tres +++ b/enemies/SniperEnemyDefinition.tres @@ -17,3 +17,4 @@ 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")]) +contact_damage = 4 diff --git a/enemies/TurretSpawnerEnemyDefinition.tres b/enemies/TurretSpawnerEnemyDefinition.tres index 8f554ce..95a8136 100644 --- a/enemies/TurretSpawnerEnemyDefinition.tres +++ b/enemies/TurretSpawnerEnemyDefinition.tres @@ -16,3 +16,4 @@ 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")]) +contact_damage = 5 diff --git a/logistics/RepairDrone.gd b/logistics/RepairDrone.gd new file mode 100644 index 0000000..81c0e7d --- /dev/null +++ b/logistics/RepairDrone.gd @@ -0,0 +1,40 @@ +extends Node2D + +# #116: purely visual - a repair drone on its way out to a damaged structure +# or back to its workshop. Carries no state of its own; GameState owns the +# simulation and RepairDroneField creates, positions and frees this to match. + +const HULL_COLOR: Color = Color(0.5, 0.95, 0.8) + +var laden: bool = false + + +func _ready() -> void: + add_to_group("repair_drones") + # #91: behind the fleet, like every other piece of ground furniture. + z_index = -1 + queue_redraw() + + +func set_laden(value: bool) -> void: + if laden == value: + return + laden = value + queue_redraw() + + +func _draw() -> void: + # Small and blunt, with a tool arm out front, in the workshop's own + # green-teal: it should read as a tender belonging to a building rather + # than as a fleet vessel or a hub's magenta hauler. + var hull: PackedVector2Array = PackedVector2Array([ + Vector2(0, -11), Vector2(8, -3), Vector2(6, 10), Vector2(-6, 10), Vector2(-8, -3), + ]) + NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0) + NeonDraw.draw_glowing_line(self, Vector2(0, -11), Vector2(0, -18), HULL_COLOR, 0.9, 0.35) + NeonDraw.draw_glowing_line(self, Vector2(-5, -18), Vector2(5, -18), HULL_COLOR, 0.9, 0.35) + if laden: + # The load it is carrying out, in ore's own colour so it reads as + # material rather than as a light on the hull. + NeonDraw.draw_glowing_circle(self, Vector2(0, 3), 5.0, + GameState.resource_color(GameState.RESOURCE_ORE), 1.0) diff --git a/logistics/RepairDrone.gd.uid b/logistics/RepairDrone.gd.uid new file mode 100644 index 0000000..52926ca --- /dev/null +++ b/logistics/RepairDrone.gd.uid @@ -0,0 +1 @@ +uid://bmul8vo7ddm7l diff --git a/logistics/RepairDrone.tscn b/logistics/RepairDrone.tscn new file mode 100644 index 0000000..d40602f --- /dev/null +++ b/logistics/RepairDrone.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://logistics/RepairDrone.gd" id="1"] + +[node name="RepairDrone" type="Node2D"] +script = ExtResource("1") diff --git a/logistics/RepairDroneField.gd b/logistics/RepairDroneField.gd new file mode 100644 index 0000000..1d6b040 --- /dev/null +++ b/logistics/RepairDroneField.gd @@ -0,0 +1,59 @@ +extends Node2D + +# #116: draws the repair drones GameState is simulating, and only while they +# are somewhere the player could see them. +# +# Same arrangement as TransporterField, and for the same reason: the +# simulation is authoritative and the visuals are optional, so a repair run +# happening far away costs nothing but still puts the building back together. +# Position comes from the very same `progress` that decides when the repair +# actually lands. + +@export var visible_radius: float = 2600.0 + +@onready var fleet: Node2D = get_node("../Fleet") + +var drone_scene: PackedScene = preload("res://logistics/RepairDrone.tscn") +# index into GameState.repair_drones -> live node +var active: Dictionary = {} + + +func _process(_delta: float) -> void: + var centre: Vector2 = fleet.get_centroid() + + for i in range(GameState.repair_drones.size()): + var drone: Dictionary = GameState.repair_drones[i] + # An idle drone is parked inside its workshop's own art; drawing it + # there adds a dot sitting on a building rather than a vessel doing + # something. + if String(drone.state) == GameState.DRONE_IDLE: + _despawn(i) + continue + var pos: Vector2 = GameState.repair_drone_position(drone) + if pos.distance_to(centre) > visible_radius: + _despawn(i) + continue + + var node: Node2D = active.get(i) + if node == null or not is_instance_valid(node): + node = drone_scene.instantiate() + get_parent().add_child(node) + active[i] = node + + var previous: Vector2 = node.position + node.position = pos + node.set_laden(int(drone.carrying) > 0) + var travel: Vector2 = pos - previous + if travel.length() > 0.5: + node.rotation = travel.angle() + PI / 2.0 + + for key in active.keys(): + if key >= GameState.repair_drones.size(): + _despawn(key) + + +func _despawn(index: int) -> void: + var node: Node2D = active.get(index) + if node != null and is_instance_valid(node): + node.queue_free() + active.erase(index) diff --git a/logistics/RepairDroneField.gd.uid b/logistics/RepairDroneField.gd.uid new file mode 100644 index 0000000..1d80d65 --- /dev/null +++ b/logistics/RepairDroneField.gd.uid @@ -0,0 +1 @@ +uid://def6v28yy6qeo diff --git a/planets/PlanetAtmosphereController.gd b/planets/PlanetAtmosphereController.gd index 60dc775..75a93fb 100644 --- a/planets/PlanetAtmosphereController.gd +++ b/planets/PlanetAtmosphereController.gd @@ -39,6 +39,12 @@ func _ready() -> void: camera.position = GameState.return_world_position camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) GameState.has_return_position = false + elif GameState.intro_active: + # #108: the prologue opens with a convoy, and crews it itself. This + # node still runs first, so spawning the ordinary opening fleet here + # would be immediately thrown away - and worse, would be the fleet + # for the one frame before the director replaced it. + pass else: # 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 @@ -123,6 +129,11 @@ func _on_enter_pressed() -> void: GameState.planet_name = current_planet.planet_name GameState.planet_id = current_planet.planet_id GameState.planet_definition_id = current_planet.definition_id + # #133: quests ask about worlds LANDED ON, which is a different question + # from the ones flown past that explored_chunks answers. Recorded at the + # moment of committing to the descent - the same moment the three lines + # above are written - so the two can never disagree. + GameState.note_planet_visited(current_planet.planet_id, current_planet.definition_id) var tween: Tween = create_tween() tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) diff --git a/shaders/breach.gdshader b/shaders/breach.gdshader new file mode 100644 index 0000000..792ad09 --- /dev/null +++ b/shaders/breach.gdshader @@ -0,0 +1,72 @@ +shader_type canvas_item; + +// #125: the dimensional breach, as a screen-space lens. +// +// The prologue's warp used to be a handful of lines turning around a point. +// This bends the ACTUAL SCENE around the breach instead - the ships, the +// rocks and the starfield all get dragged into it - which is most of what +// makes a hole in space read as a hole rather than as a decal drawn on top +// of one. +// +// Two effects, both radial and both fading to nothing at the edge of their +// reach, so the shader is invisible outside its own radius and costs a +// clamped texture fetch everywhere else: +// +// swirl angle offset that grows toward the centre, so the scene winds +// around the hole rather than merely sliding toward it +// pull inward displacement, strongest just outside the horizon, which +// is the lensing itself +// +// Inside the horizon nothing is sampled at all: the core is drawn as empty, +// the same decision BlackHoleVisual makes for the black-hole planet, and +// for the same reason - a "black hole" with something visible in the middle +// stops reading as one. + +uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear; + +// Where the breach is, in SCREEN pixels, and how big it has grown. +uniform vec2 breach_center = vec2(360.0, 640.0); +uniform vec2 viewport_size = vec2(720.0, 1280.0); +// The event horizon: everything inside is void. +uniform float horizon_radius = 0.0; +// How far the lens reaches past the horizon. Beyond this the scene is +// untouched. +uniform float lens_radius = 1.0; +// 0 while the breach is opening, 1 at full strength. Scales both effects +// together so one knob fades the whole thing in. +uniform float strength = 0.0; +uniform float swirl_turns = 2.4; +uniform vec4 rim_color : source_color = vec4(0.72, 0.45, 1.0, 1.0); + +void fragment() { + vec2 pixel = UV * viewport_size; + vec2 offset = pixel - breach_center; + float distance_to_centre = length(offset); + + if (distance_to_centre >= lens_radius || strength <= 0.001) { + COLOR = texture(screen_texture, SCREEN_UV); + } else if (distance_to_centre <= horizon_radius) { + // The core. Not black-with-alpha: genuinely nothing, so whatever is + // behind the breach is gone rather than dimmed. + COLOR = vec4(0.0, 0.0, 0.0, 1.0); + } else { + // 1 at the horizon, 0 at the edge of the lens. Squared so the bend + // is concentrated near the hole instead of smeared evenly outward. + float falloff = 1.0 - clamp( + (distance_to_centre - horizon_radius) / max(lens_radius - horizon_radius, 1.0), + 0.0, 1.0); + float bend = falloff * falloff * strength; + + float angle = atan(offset.y, offset.x) + bend * swirl_turns; + float pulled = distance_to_centre - bend * (lens_radius - horizon_radius) * 0.55; + pulled = max(pulled, horizon_radius); + vec2 sampled = breach_center + vec2(cos(angle), sin(angle)) * pulled; + + COLOR = texture(screen_texture, sampled / viewport_size); + + // A hot rim where the lensing is fiercest, which is what gives the + // horizon an edge rather than a fade. + float rim = smoothstep(0.72, 1.0, falloff) * strength; + COLOR.rgb = mix(COLOR.rgb, rim_color.rgb, rim * 0.65); + } +} diff --git a/shaders/breach.gdshader.uid b/shaders/breach.gdshader.uid new file mode 100644 index 0000000..c38904d --- /dev/null +++ b/shaders/breach.gdshader.uid @@ -0,0 +1 @@ +uid://c6eeucdbj3nmi diff --git a/surface/GasVentField.gd b/surface/GasVentField.gd index 29f2f82..e2476ce 100644 --- a/surface/GasVentField.gd +++ b/surface/GasVentField.gd @@ -13,6 +13,14 @@ var vents: Array[Vector2] = [] var progress: float = 0.0 var emission_phase: float = 0.0 func _ready() -> void: + # #91: the vents are ground features and must never cover the fleet. + # SurfaceFeatures is added AFTER Fleet in PlanetSurface.tscn, so at the + # default z_index of 0 the later sibling simply wins and the player flies + # UNDER their own gas source. Same fix, and the same reason, as #50's + # capture rings and the rocks on a rocky surface (Rock.gd) - the + # difference is that this field draws in its own _draw() rather than + # spawning child nodes, so the depth belongs on the field itself. + z_index = -1 var rng := RandomNumberGenerator.new() rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name) for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650))) diff --git a/surface/PlanetSurface.tscn b/surface/PlanetSurface.tscn index a1074be..7e3285a 100644 --- a/surface/PlanetSurface.tscn +++ b/surface/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=16 format=3] +[gd_scene load_steps=19 format=3] [ext_resource type="Script" path="res://controllers/GameController.gd" id="1"] [ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"] @@ -13,6 +13,9 @@ [ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"] [ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"] +[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"] +[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] +[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"] [node name="PlanetSurface" type="Node2D"] script = ExtResource("1") @@ -45,6 +48,9 @@ zoom = Vector2(1, 1) [node name="SurfaceController" type="Node2D" parent="."] script = ExtResource("5") +[node name="QuestDirector" type="Node" parent="."] +script = ExtResource("quest_dir") + [node name="UI" type="CanvasLayer" parent="."] layer = 1 @@ -123,6 +129,37 @@ icon = 4 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +[node name="QuestButton" type="Control" parent="UI"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172.0 +offset_right = -24.0 +offset_top = 160.0 +offset_bottom = 308.0 +script = ExtResource("6") +icon = 44 + +[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 24.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = -24.0 +script = ExtResource("quest_panel") + +[node name="CloseButton" type="Control" parent="UI/QuestPanel"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_right = -24.0 +offset_top = 20.0 +offset_bottom = 136.0 +script = ExtResource("6") +icon = 1 + [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("7") @@ -273,6 +310,16 @@ offset_right = -252 offset_top = -130 offset_bottom = -26 +[node name="NarrationBanner" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 60.0 +offset_right = -60.0 +offset_top = 300.0 +offset_bottom = 428.0 +script = ExtResource("banner") + [node name="FadeOverlay" type="CanvasLayer" parent="."] layer = 10 diff --git a/tests/_run_inside.sh b/tests/_run_inside.sh index a63c666..a38e4a5 100644 --- a/tests/_run_inside.sh +++ b/tests/_run_inside.sh @@ -1,15 +1,19 @@ #!/bin/bash -# Runs INSIDE the proot-distro Ubuntu container - do not call this directly, -# call tests/run_tests.sh from Termux instead. +# The half of the runner that actually runs Godot - do not call this +# directly, call tests/run_tests.sh instead. # -# It exists as its own file so the whole suite runs in ONE `proot-distro -# login`: entering the container costs ~0.5s, which used to be paid 38 times -# over. Everything below is the container-side half of the runner. +# On Termux it runs INSIDE the proot-distro Ubuntu container. It exists as +# its own file so the whole suite runs in ONE `proot-distro login`: entering +# the container costs ~0.5s, which used to be paid 38 times over. On WSL +# there is no container to enter and run_tests.sh calls it directly - the +# scheduling, timeouts and verdicts below are the same either way, which is +# why the Godot binary arrives as an argument rather than being hardcoded. # -# Args: ... +# Args: ... set -uo pipefail +GODOT_BIN="$1"; shift PROJECT_DIR="$1"; shift JOBS="$1"; shift VERBOSE="$1"; shift @@ -18,8 +22,35 @@ VERBOSE="$1"; shift # complete in seconds, even on a busy phone, so make that failure explicit # rather than making the suite look hung. Override for deliberate long runs. TEST_TIMEOUT_SECONDS="${TEST_TIMEOUT_SECONDS:-120}" +# How many times to start a test whose process never got as far as saying +# anything. Under WSL the Godot binary is a Windows executable started across +# the interop boundary, and with six of them going at once that start +# occasionally just fails. It has been seen two ways: exit 127 with a +# completely empty log, and a non-zero exit after the engine banner and not +# one line more. Both moved from test to test between runs and both passed +# alone, so both are properties of the launch rather than of the test. +# +# The line that separates them from a real failure is OUTPUT, not the exit +# code and not the banner: every test says something before it stops - its +# own FAIL verdict, a SCRIPT ERROR, an engine complaint. A log holding +# nothing but the banner is a run that did not happen. This can only ever +# mask a fault that is both silent AND intermittent; a deterministic crash +# still fails all LAUNCH_ATTEMPTS and is reported. +LAUNCH_ATTEMPTS="${LAUNCH_ATTEMPTS:-3}" + + +# Did the process produce any line of its own? The engine banner and blank +# lines are what a bare launch prints by itself and do not count. +# +# Tests the OUTPUT rather than grep's exit status on purpose: `grep -q -v` +# does not agree across greps - ugrep, which is what `grep` resolves to on +# at least one machine this suite runs on, reports success for an empty file +# and failure for a file full of matching lines. A non-empty result is the +# same in every implementation. +_said_anything() { + [[ -n "$(grep -v -e '^[[:space:]]*$' -e '^Godot Engine' "$1")" ]] +} -GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" cd "$PROJECT_DIR" || exit 2 tests=("$@") @@ -53,18 +84,37 @@ run_one() { # 3 parallel runs. Pinned, a test simulates the same 60fps whatever else # the phone is doing, and its result no longer depends on how busy the # machine was. - timeout "$TEST_TIMEOUT_SECONDS" "$GODOT_BIN" --headless --fixed-fps 60 \ - -s "res://tests/$name" >"$log" 2>&1 - local rc=$? + local rc=0 + local attempt=1 + while true; do + timeout "$TEST_TIMEOUT_SECONDS" "$GODOT_BIN" --headless --fixed-fps 60 \ + -s "res://tests/$name" >"$log" 2>&1 + rc=$? + # "Never started" is rc != 0 with a log that says nothing of its own. + # Anything that ran - passed, failed, crashed mid-way - said something. + if (( rc == 0 )) || _said_anything "$log" \ + || (( attempt >= LAUNCH_ATTEMPTS )); then + break + fi + attempt=$(( attempt + 1 )) + sleep 1 + done end=$(date +%s%N) local ms=$(( (end - start) / 1000000 )) + # Retries are reported, never hidden: a test that needed one is telling + # you the machine is struggling, even where it went on to pass. + local retried="" + if (( attempt > 1 )); then + retried=" (started on attempt $attempt)" + fi + # Two independent criteria, because either one alone can lie: a test that # crashes before reaching its verdict exits non-zero without ever printing # FAIL, and a hypothetical `quit(0)` on a failing path would exit clean # while the log says otherwise. if (( rc == 0 )) && grep -q ": PASS" "$log"; then - printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status" + printf 'PASS %6dms %s%s\n' "$ms" "$name" "$retried" > "$outdir/$name.status" elif (( rc == 124 )); then printf 'TIMEOUT %4dms %s (limit %ss)\n' \ "$ms" "$name" "$TEST_TIMEOUT_SECONDS" > "$outdir/$name.status" @@ -75,8 +125,11 @@ run_one() { # indistinguishable from a hang. printf 'KILLED %4dms %s (signal %d - out of memory? try JOBS=1)\n' \ "$ms" "$name" "$(( rc - 128 ))" > "$outdir/$name.status" + elif (( rc != 0 )) && ! _said_anything "$log"; then + printf 'NOSTART %3dms %s (exit %d after %d attempts - the process never came up)\n' \ + "$ms" "$name" "$rc" "$attempt" > "$outdir/$name.status" else - printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status" + printf 'FAIL %6dms %s (exit %d)%s\n' "$ms" "$name" "$rc" "$retried" > "$outdir/$name.status" fi cat "$outdir/$name.status" } diff --git a/tests/run_tests.sh b/tests/run_tests.sh index eea839a..6a25759 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -1,15 +1,27 @@ -#!/data/data/com.termux/files/usr/bin/bash -# Runs the headless GDScript gameplay tests from Termux, via the Godot -# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container. +#!/usr/bin/env bash +# Runs the headless GDScript gameplay tests. # # bash tests/run_tests.sh # everything # bash tests/run_tests.sh formation # only tests matching a substring # JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang # VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too # IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself +# GODOT_BIN=/path/to/godot bash tests/run_tests.sh # pick the binary # -# Three things make this fast, in descending order of how much they matter -# (measured, not guessed - see tests/_profile_startup.gd): +# There are two ways to get a Godot to run them with, picked automatically: +# +# Termux the Godot 4.7 Linux arm64 build installed inside the +# proot-distro Ubuntu container, which is the only way to run +# a desktop Godot on the phone at all. +# WSL the Windows Godot build off /mnt/c. A Linux `godot` on PATH +# is used first if there is one. +# +# The platform decides which Godot runs and where the project is copied to. +# Everything else - the copy itself, the worker count, and +# tests/_run_inside.sh, which does the actual running - is shared. +# +# On Termux, three things make this fast, in descending order of how much +# they matter (measured, not guessed - see tests/_profile_startup.gd): # # 1. It runs from a COPY on Termux's own filesystem, not from the project # directory. The project lives under ~/storage/shared, which is Android's @@ -17,7 +29,15 @@ # alone took 8.5s per test, against ~2s from a native path. Nearly every # test loads Main.tscn, and none of that time is gameplay. The copy is # ~1.4s for the whole suite and its .godot import cache is kept between -# runs. +# runs. WSL copies too, but to a native NTFS path rather than to ext4: +# a Windows Godot reads C: at full speed and would read \\wsl$ slowly, so +# the copy is for ISOLATION rather than for speed there. Six Godot +# processes churning the project directory is six processes writing +# .godot, creating and deleting files under PlanetSetups/, and doing it +# in a working tree that git, an open editor and the player are also +# using. Under that, single tests failed at random - a delete that did +# not take, a scene that would not instantiate - never the same one +# twice. # 2. Tests run JOBS at a time. Each is its own Godot process - they call # quit() and stamp on the GameState autoload, so they cannot share one - # which makes the suite a pile of independent, single-core-bound jobs on @@ -32,6 +52,32 @@ set -uo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLATFORM="termux" +if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then + PLATFORM="wsl" +fi + +# Finding a Godot on WSL. A Linux build on PATH wins - no interop, no drive +# letters - but there usually is not one, and the Windows build the editor +# already runs from is right there on /mnt/c. Two things about picking that +# one up: +# - it must be the *_console.exe. The plain .exe is built as a Windows GUI +# subsystem binary: it detaches from the terminal, so every test would +# produce an empty log and the runner would call all of them failures. +# - sort -V so 4.7.2 beats 4.6.1 when several versions are downloaded. +_find_godot_wsl() { + local found + found="$(command -v godot 2>/dev/null)" + if [[ -n "$found" ]]; then + echo "$found" + return + fi + found="$(find /mnt/c/Users/*/Downloads /mnt/c/Users/*/AppData/Local/Programs \ + "/mnt/c/Program Files" -maxdepth 2 -name 'Godot_v*_console.exe' \ + 2>/dev/null | sort -V | tail -1)" + echo "$found" +} + # Worker count comes from FREE MEMORY, not core count. Each Godot instance is # ~400MB and this phone shares its RAM with everything else running on it. # Getting it wrong is bad in both directions: @@ -64,7 +110,43 @@ _pick_jobs() { JOBS="${JOBS:-$(_pick_jobs)}" VERBOSE="${VERBOSE:-0}" IN_PLACE="${IN_PLACE:-0}" -WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}" + +# Where the copy lives. Termux wants its own native filesystem; WSL wants a +# WINDOWS one, because the thing reading it is a Windows binary and $HOME is +# ext4 behind the \\wsl$ share. %LOCALAPPDATA% is the natural home for it; +# if that cannot be read, fall back to a sibling of the project - still on +# the same native drive, still outside the repo. +_default_work_dir() { + if [[ "$PLATFORM" != "wsl" ]]; then + echo "$HOME/.cache/spaceship-tests" + return + fi + local win_local + win_local="$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r')" + if [[ -n "$win_local" && "$win_local" != *'%'* ]]; then + local unix_local + unix_local="$(wslpath -u "$win_local" 2>/dev/null)" + if [[ -n "$unix_local" ]]; then + echo "$unix_local/spaceship-tests" + return + fi + fi + echo "$(dirname "$PROJECT_DIR")/.spaceship-tests" +} +WORK_DIR="${WORK_DIR:-$(_default_work_dir)}" + +if [[ -z "${GODOT_BIN:-}" ]]; then + if [[ "$PLATFORM" == "wsl" ]]; then + GODOT_BIN="$(_find_godot_wsl)" + if [[ -z "$GODOT_BIN" ]]; then + echo "no Godot found. Install one on PATH, or download a Windows" >&2 + echo "build and point GODOT_BIN at its *_console.exe." >&2 + exit 2 + fi + else + GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" + fi +fi filter="${1:-}" tests=() @@ -91,14 +173,22 @@ if [[ "$IN_PLACE" != "1" ]]; then # keep being run. find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} + # android/ and builds/ are export artefacts no test touches, and the .apk - # is 30MB of nothing useful here. + # is 30MB of nothing useful here. audio/masters/ is the same argument: + # Godot skips it (.gdignore) so no test can reach it, and it is 59MB of + # source WAV that would be copied on every single run. tar -C "$PROJECT_DIR" \ --exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \ + --exclude=./audio/masters \ -cf - . 2>/dev/null | tar -C "$run_dir" -xf - fi -proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \ - "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}" +if [[ "$PLATFORM" == "wsl" ]]; then + bash "$run_dir/tests/_run_inside.sh" \ + "$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}" +else + proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \ + "$GODOT_BIN" "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}" +fi status=$? echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS" diff --git a/tests/test_asteroid_safe_spawn.gd b/tests/test_asteroid_safe_spawn.gd index bf3b5e7..13fd96a 100644 --- a/tests/test_asteroid_safe_spawn.gd +++ b/tests/test_asteroid_safe_spawn.gd @@ -10,16 +10,23 @@ extends SceneTree # test_return_position_matches_planet fail intermittently. # # Verifies: -# 1. _safe_position never returns a point inside the bubble, for candidates -# all around and including the exact centre. This is checked directly -# rather than by looking at where asteroids happened to land: with ~54 -# asteroids spread over 25 chunks, any single run only rarely produces a -# near miss, so an observational check passes just as happily with the -# guard removed and proves nothing. -# 2. candidates already outside the bubble are left exactly where they are, -# so the field is not distorted away from the fleet -# 3. the field still spawns a full complement, and a stationary fleet loses -# no ships +# #126 widens the rule from "not on the fleet" to "not IN VIEW": the old +# bubble was 420px, smaller than the screen, so a rock could and did +# materialise in plain sight several hundred pixels away. +# +# Verifies: +# 1. safe_position never returns a point inside the keep-out area, for +# candidates all around it and including its exact centre. Checked +# directly rather than by looking at where asteroids happened to land: +# with ~54 asteroids over 25 chunks, any single run only rarely +# produces a near miss, so an observational check passes just as +# happily with the guard removed and proves nothing. +# 2. candidates already outside are left exactly where they are, so the +# field is not distorted away from the fleet +# 3. the keep-out area really is the visible rect and not the old bubble +# 4. nothing the field actually placed is on screen +# 5. the field still spawns a full complement, and a stationary fleet +# loses no ships var scene: Node var fleet: Node @@ -53,32 +60,50 @@ func _process(delta: float) -> bool: var centre: Vector2 = fleet.get_centroid() var safe: float = field.safe_spawn_radius + var keep_out: Rect2 = field.keep_out_rect(centre) - # --- the guard itself, deterministically ---------------------- - var worst_inside: float = 1e9 + # --- 3. the keep-out area is the screen, not the old bubble --- + var on_screen: Rect2 = ViewUtils.get_visible_world_rect(field) + if not keep_out.encloses(on_screen): + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area does not cover the visible rect)") + quit(1) + return true + if keep_out.size.x < safe * 2.0 or keep_out.size.y < safe * 2.0: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area shrank below the old bubble)") + quit(1) + return true + print("keep-out %.0fx%.0f covers a %.0fx%.0f screen (old bubble was %.0f across)" % [ + keep_out.size.x, keep_out.size.y, on_screen.size.x, on_screen.size.y, safe * 2.0 + ]) + + # --- 1/2. the guard itself, deterministically ----------------- + var half: Vector2 = keep_out.size * 0.5 + var reach: float = maxf(half.x, half.y) + var let_one_through: bool = false var moved_an_outside_one: bool = false for i in range(72): var a: float = (float(i) / 72.0) * TAU var dir: Vector2 = Vector2(cos(a), sin(a)) # Candidates well inside, just inside, and exactly on centre. - for d in [0.0, 1.0, safe * 0.25, safe * 0.99]: - var got: Vector2 = field._safe_position(centre + dir * d, centre) - worst_inside = minf(worst_inside, got.distance_to(centre)) + for d in [0.0, 1.0, reach * 0.25, reach * 0.7]: + var candidate: Vector2 = keep_out.get_center() + dir * d + if not keep_out.has_point(candidate): + continue + if keep_out.has_point(field.safe_position(candidate, keep_out)): + let_one_through = true # Candidates already outside must be returned untouched. - var outside: Vector2 = centre + dir * (safe * 1.5) - if field._safe_position(outside, centre).distance_to(outside) > 0.001: + var outside: Vector2 = keep_out.get_center() + dir * (reach * 3.0) + if field.safe_position(outside, keep_out).distance_to(outside) > 0.001: moved_an_outside_one = true - print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [ - worst_inside, safe - ]) - if worst_inside < safe - 0.001: - print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)") + if let_one_through: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the guard left a spawn inside the visible area)") quit(1) return true if moved_an_outside_one: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)") quit(1) return true + print("every one of 288 in-view candidates was pushed out; none outside was touched") var asteroids: Array = get_nodes_in_group("asteroids") var closest: float = 1e9 diff --git a/tests/test_asteroid_ship_collision.gd b/tests/test_asteroid_ship_collision.gd index 37a6508..b8c539e 100644 --- a/tests/test_asteroid_ship_collision.gd +++ b/tests/test_asteroid_ship_collision.gd @@ -3,9 +3,15 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_asteroid_ship_collision.gd # Verifies that a ship touching an asteroid destroys BOTH the ship (removed -# from the fleet) and the asteroid itself (mutual destruction), each -# spawning its own coherent explosion VFX: light-burst blue for the ship, -# debris brown for the asteroid. +# from the fleet) and the asteroid itself (mutual destruction), each leaving +# its own coherent VFX behind. +# +# #130 changed what the ship leaves. Its death is a sequence now - the hull +# breaks away from the fleet, drifts clear, and explodes out there - so what +# exists on the frame of the collision is the WRECK, and the explosion comes +# later, somewhere else. That the wreck really does drift and really does go +# up is test_ship_death's job; what matters here is that the collision still +# ends the ship, ends the rock, and leaves each of them something to look at. const CHECK_DELAY: float = 0.05 @@ -55,7 +61,7 @@ func _check_results() -> void: var ship_vfx_ok: bool = false var asteroid_vfx_ok: bool = false for child in root.get_children(): - if child is ShipExplosionVFX: + if child is ShipWreckVFX: ship_vfx_ok = true elif child is AsteroidExplosionVFX: asteroid_vfx_ok = true diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index 28c8d86..43988be 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -20,7 +20,9 @@ var controller: Node var popup: Control var title: Label var message: Label -var cost_number: NeonNumber +# Control, not PriceTag: naming a class whose body reads the GameState +# autoload does not compile inside an `extends SceneTree` script. +var cost_number: Control var build_button: IconButton var stage: int = 0 var elapsed: float = 0.0 @@ -37,6 +39,10 @@ const OPTIONS: Array = [ func _initialize() -> void: game_state = root.get_node("GameState") game_state.rocks = 500 + # #138: some structures want gas as well as rocks, and the popup + # only offers a build button for something the player can pay for. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500) + game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 500) game_state.buildings.clear() game_state.extractors.clear() # Structures are gated behind the mothership's research tree. That gate @@ -89,11 +95,12 @@ func _process(delta: float) -> bool: var informed: bool = popup.visible \ and title.text == option.title \ and message.text.length() > 40 \ - and cost_number.amount == expected_cost \ + and cost_number.priced(GameState.RESOURCE_ORE) == expected_cost \ and build_button.visible var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [ - type, popup.visible, title.text, cost_number.amount, + type, popup.visible, title.text, + cost_number.priced(GameState.RESOURCE_ORE), build_button.visible, untouched ]) if not (informed and untouched): diff --git a/tests/test_build_menu_hides_locked.gd b/tests/test_build_menu_hides_locked.gd index aa60234..ca09841 100644 --- a/tests/test_build_menu_hides_locked.gd +++ b/tests/test_build_menu_hides_locked.gd @@ -17,6 +17,13 @@ extends SceneTree # 4. an entry alone on its row is centred, not hanging in the left column # 5. researched-but-not-usable is still shown greyed (an extractor with no # planet under the fleet is "not now", not "not yet") +# 6. #136 - with every structure researched, no two entries overlap. The +# grid used to be a fixed list of row positions read with a clamp, so +# the first row that would not fit was drawn on top of another one +# 7. #144 - and that holds at any window shape. The header is anchored to +# the top of the panel and the grid to its centre, so the gap between +# them is half the panel height - it was a constant measured at 1280, +# and at 1080 the grid started above the status line and drew through it var game_state: Node var scene: Node @@ -45,6 +52,12 @@ func _shown() -> Array: return out +func _rect(node: Control) -> Rect2: + return Rect2(Vector2(node.offset_left, node.offset_top), + Vector2(node.offset_right - node.offset_left, + node.offset_bottom - node.offset_top)) + + func _slots() -> Array: # (row, x) of every visible button, in menu order. var out: Array = [] @@ -100,21 +113,94 @@ func _process(delta: float) -> bool: # --- 3. consecutive slots, no holes ------------------------- var slots: Array = _slots() - var rows: Array = controller.MENU_ROW_TOPS + var columns: int = controller.menu_columns(slots.size()) + var rows: int = controller.menu_rows(slots.size()) + if columns != 2: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (4 entries laid out in %d columns)" % columns) + quit(1) + return true for i in range(slots.size()): - var want_row: float = rows[i / 2] + var want_row: float = controller.menu_row_top(i / columns, rows, columns) if absf(slots[i][0] - want_row) > 0.01: - print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / 2, want_row]) + print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / columns, want_row]) print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)") quit(1) return true - var want_x: float = -controller.MENU_COL_X if i % 2 == 0 else controller.MENU_COL_X + var spacing: float = controller.menu_column_spacing(columns) + var want_x: float = -spacing * 0.5 if i % 2 == 0 else spacing * 0.5 if absf(slots[i][1] - want_x) > 0.01: print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x]) quit(1) return true print("4 entries packed into consecutive slots, no gaps") + # --- 6. #136: nothing overlaps, with EVERY structure on offer - + # The bug the ticket reported: the row list ran out and the last + # entry was placed on top of one already there. + for node in GameState.SKILL_TREE: + game_state.unlocked.append(String(node.id)) + controller._refresh_build_status() + var all_shown: Array = _shown() + if all_shown.size() < 9: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (only %d structures unlocked - not the crowded case)" % all_shown.size()) + quit(1) + return true + for i in range(all_shown.size()): + for j in range(i + 1, all_shown.size()): + for part in [1, 2, 3]: + var a: Control = all_shown[i][part] + var b: Control = all_shown[j][part] + if _rect(a).intersects(_rect(b)): + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%s and %s overlap at %s / %s)" \ + % [all_shown[i][0], all_shown[j][0], _rect(a), _rect(b)]) + quit(1) + return true + print("%d structures on offer, in %d columns, nothing overlapping" \ + % [all_shown.size(), controller.menu_columns(all_shown.size())]) + + # --- 7. #144: and at any window shape ------------------------ + # The grid is anchored to the panel's CENTRE and the status line + # above it to the panel's TOP, so the gap between them is half + # the panel height. It used to be a written-down constant, right + # only at 1280 - at 1080 the grid started above the status line + # and drew through it, which is what the ticket reported. + var menu: Control = scene.get_node("UI/BuildMenu") + var status: Control = scene.get_node("UI/BuildMenu/StatusLabel") + var restore: float = menu.offset_bottom + for panel_height in [900.0, 1080.0, 1280.0, 1600.0]: + menu.anchor_bottom = 0.0 + menu.offset_top = 0.0 + menu.offset_bottom = panel_height + controller._refresh_build_status() + var half: float = menu.size.y * 0.5 + # The status line's own bottom, in the grid's centre-anchored + # frame - which is the comparison that was never made. + var header_bottom: float = status.offset_bottom - half + var placed: Array = _shown() + for a in range(placed.size()): + for part in [1, 2, 3]: + var box: Rect2 = _rect(placed[a][part]) + if box.position.y < header_bottom: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s is drawn at y=%.0f, above the status line at %.0f)" \ + % [panel_height, placed[a][0], box.position.y, header_bottom]) + quit(1) + return true + for b in range(a + 1, placed.size()): + for part2 in [1, 2, 3]: + if _rect(placed[a][part2]).intersects(_rect(placed[b][part2])): + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s and %s overlap)" \ + % [panel_height, placed[a][0], placed[b][0]]) + quit(1) + return true + print(" %.0fpx tall: %d columns, top row at %.0f, clear of the status line at %.0f" \ + % [panel_height, controller.menu_columns(placed.size()), + _rect(placed[0][1]).position.y, header_bottom]) + menu.anchor_bottom = 1.0 + menu.offset_top = 0.0 + menu.offset_bottom = restore + controller._refresh_build_status() + print("the grid clears the header at every window shape") + # --- 5. researched but not usable is shown, greyed ---------- var extractor_shown: bool = false for e in shown2: diff --git a/tests/test_build_popup_fits_window.gd b/tests/test_build_popup_fits_window.gd new file mode 100644 index 0000000..1aa3d1f --- /dev/null +++ b/tests/test_build_popup_fits_window.gd @@ -0,0 +1,110 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_build_popup_fits_window.gd +# Forgejo #92: the confirm-this-building popup ran off a landscape window. +# +# The card is authored at a fixed 704x1200 around a portrait phone. On a +# window that is wider than it is tall the stretched canvas is ~1080 units +# high, so 1200 units of card hung over both edges - and since the confirm +# and cancel buttons are anchored to the card's own bottom edge, they went +# with it. A player on a desktop could open the popup and then neither buy +# nor dismiss the thing. +# +# Covers: +# A. on every window shape the card lies entirely inside the viewport +# B. so do the two buttons on it, which is the part that made the popup a +# dead end rather than merely an ugly one +# C. it is not blown up on a big screen - the layout inside is tuned at +# the authored size and only ever shrinks +# D. it re-fits when the window changes, rather than only at startup + +# Portrait phone, 16:9 desktop, an ultrawide, and a small laptop. +const SHAPES: Array = [ + Vector2i(720, 1280), Vector2i(1920, 1080), Vector2i(2560, 900), Vector2i(1280, 720), +] + +var game_state: Node +var scene: Node +var controller: Node +var panel: Control +var shapes: Array = [] +var frames: int = 0 +var opened: bool = false + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 500 + game_state.buildings.clear() + game_state.extractors.clear() + shapes = SHAPES.duplicate() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + panel = scene.get_node("UI/BuildMenu/InfoPopup/Panel") + + +func _inside(rect: Rect2, outer: Rect2) -> bool: + # Rect2.encloses() is exclusive at the edges for zero-area cases; this is + # the plain reading - all four sides within, touching allowed. + return rect.position.x >= outer.position.x - 0.5 \ + and rect.position.y >= outer.position.y - 0.5 \ + and rect.end.x <= outer.end.x + 0.5 \ + and rect.end.y <= outer.end.y + 0.5 + + +func _process(_delta: float) -> bool: + frames += 1 + if not opened: + if frames < 12: + return false + controller._on_build_pressed() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) + if not scene.get_node("UI/BuildMenu/InfoPopup").visible: + return _fail("the build popup did not open") + opened = true + frames = 0 + root.size = SHAPES[0] + return false + # Give the resize a few frames to reach the Control layout before reading + # any rect off it. + if frames < 6: + return false + frames = 0 + + var shape: Vector2i = shapes.pop_front() + var screen: Rect2 = Rect2(Vector2.ZERO, panel.get_viewport_rect().size) + var card: Rect2 = Rect2(panel.global_position, panel.size) + print("%s -> canvas %.0fx%.0f, card %.0fx%.0f at %.0f,%.0f" % [ + shape, screen.size.x, screen.size.y, card.size.x, card.size.y, + card.position.x, card.position.y]) + + # A. the card itself + if not _inside(card, screen): + return _fail("at %s the card %s is not inside the screen %s" % [shape, card, screen]) + # B. and what the player has to press on it + for button_name in ["BuildButton", "CloseButton"]: + var button: Control = panel.get_node(button_name) + var button_rect: Rect2 = Rect2(button.global_position, button.size) + if not _inside(button_rect, screen): + return _fail("at %s the %s at %s is off screen" % [shape, button_name, button_rect]) + # C. never bigger than authored + if card.size.x > panel.design_size.x + 0.5 or card.size.y > panel.design_size.y + 0.5: + return _fail("at %s the card grew past its authored %s" % [shape, panel.design_size]) + + if shapes.is_empty(): + # D. the last shape was reached by resizing an already-open popup, so + # every check above already ran against a re-fit rather than against + # a card that happened to be built at the right size. + print("TEST_BUILD_POPUP_FITS_WINDOW: PASS (%d window shapes)" % SHAPES.size()) + quit(0) + return true + root.size = shapes[0] + return false + + +func _fail(message: String) -> bool: + print("TEST_BUILD_POPUP_FITS_WINDOW: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_build_popup_fits_window.gd.uid b/tests/test_build_popup_fits_window.gd.uid new file mode 100644 index 0000000..b6c1be6 --- /dev/null +++ b/tests/test_build_popup_fits_window.gd.uid @@ -0,0 +1 @@ +uid://bt0yr2oys1usk diff --git a/tests/test_building_health.gd b/tests/test_building_health.gd new file mode 100644 index 0000000..5bb4417 --- /dev/null +++ b/tests/test_building_health.gd @@ -0,0 +1,195 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_building_health.gd +# Forgejo #99 (#97a): structures can be hurt, and can come down. +# +# Health lives on the RECORD in GameState rather than on the Building node, +# because Main.tscn is destroyed and rebuilt on every trip to a planet +# surface - damage kept on a node would heal itself the moment the player +# landed. Most of this test therefore talks to GameState directly, and only +# the last case looks at what the scene does with it. +# +# Covers: +# A. every building definition declares a positive max_hp, and a fresh +# record starts at full health +# B. damage subtracts, reports survival, and enough of it destroys the +# record outright +# C. a record written before this existed reads as undamaged, not as +# rubble - an existing save must not open on a field of wrecks +# D. the mothership cannot be destroyed. The bank, the hangar and research +# all live there and a wiped fleet rebuilds there, so losing it would +# be a soft-lock; it bottoms out at 1hp instead +# E. destroying a transport hub takes its transporters with it, and +# nothing else keyed on the id is left pointing at a hole +# F. the Building node follows its record out of existence, and shows +# damage while it is still standing + +var game_state: Node +var scene: Node +var stage: int = 0 +var elapsed: float = 0.0 +var destroyed_events: Array = [] + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.rocks = 5000 + + +func _fail(message: String) -> bool: + print("TEST_BUILDING_HEALTH: FAIL (%s)" % message) + quit(1) + return true + + +func _run_state_cases() -> bool: + var definitions: Script = load("res://buildings/BuildingDefinitions.gd") + + # --- A. every structure declares what it can take ------------------- + for definition in definitions.all(): + if int(definition.max_hp) <= 0: + return _fail("'%s' has max_hp %d - it would be rubble the moment it was placed" \ + % [definition.definition_id, definition.max_hp]) + var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(300.0, 0.0)) + var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER) + if game_state.building_hp(crafter_id) != full: + return _fail("a new crafter opened at %d of %d hp" \ + % [game_state.building_hp(crafter_id), full]) + if not is_equal_approx(game_state.building_health_fraction(crafter_id), 1.0): + return _fail("a new crafter is not at full health") + print("%d definitions all declare max_hp; a new crafter starts at %d" \ + % [definitions.all().size(), full]) + + # --- B. damage, then death ------------------------------------------ + game_state.building_destroyed.connect(func(id: int, type: String): + destroyed_events.append({"id": id, "type": type})) + if game_state.damage_building(crafter_id, full - 1): + return _fail("one hit point short of dead reported the crafter destroyed") + if game_state.building_hp(crafter_id) != 1: + return _fail("hp is %d after taking max_hp - 1" % game_state.building_hp(crafter_id)) + if game_state.building_health_fraction(crafter_id) >= 0.5: + return _fail("a crafter on its last hit point still reads as healthy") + if not game_state.damage_building(crafter_id, 1): + return _fail("the last hit point did not destroy the crafter") + if not game_state.find_building(crafter_id).is_empty(): + return _fail("the destroyed crafter is still in GameState.buildings") + if destroyed_events.size() != 1 or int(destroyed_events[0].id) != crafter_id: + return _fail("building_destroyed did not fire once for the crafter") + # Fired AFTER the record went, so a listener sees the world without it. + print("a crafter takes %d damage and comes down, announcing itself" % full) + + # --- C. a record from before this existed --------------------------- + var legacy_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-300.0, 0.0)) + game_state.find_building(legacy_id).erase("hp") + var lab_full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_LAB) + if game_state.building_hp(legacy_id) != lab_full: + return _fail("a record with no hp field reads as %d, not the %d it should" \ + % [game_state.building_hp(legacy_id), lab_full]) + if game_state.damage_building(legacy_id, 1): + return _fail("one point of damage levelled a building carried over from an old save") + print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full) + + # --- D. the mothership survives anything ---------------------------- + var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + if not game_state.building_is_indestructible(game_state.find_building(mothership_id)): + return _fail("the mothership is not marked indestructible") + if game_state.damage_building(mothership_id, 999999): + return _fail("the mothership was destroyed - the bank, hangar and research went with it") + if game_state.building_hp(mothership_id) != 1: + return _fail("an overwhelmed mothership sits at %d hp, expected to floor at 1" \ + % game_state.building_hp(mothership_id)) + if game_state.destroy_building(mothership_id): + return _fail("destroy_building demolished the mothership outright") + if game_state.repair_building(mothership_id, 999999) <= 0: + return _fail("a battered mothership could not be repaired") + if game_state.building_hp(mothership_id) != game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP): + return _fail("repair did not take the mothership back to full") + print("the mothership floors at 1hp and repairs to full") + + # --- E. a hub takes its transporters with it ------------------------ + var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0)) + var hub_transporters: int = 0 + for transporter in game_state.transporters: + if int(transporter.get("hub_id", 0)) == hub_id: + hub_transporters += 1 + if hub_transporters <= 0: + return _fail("the hub provisioned no transporters, so this case proves nothing") + var others: int = game_state.transporters.size() - hub_transporters + if not game_state.destroy_building(hub_id): + return _fail("the hub could not be destroyed") + for transporter in game_state.transporters: + if int(transporter.get("hub_id", 0)) == hub_id: + return _fail("a transporter is still flying for a hub that no longer exists") + if game_state.transporters.size() != others: + return _fail("destroying the hub took %d unrelated transporters with it" \ + % (others - game_state.transporters.size())) + print("destroying a hub retires its %d transporters and no others" % hub_transporters) + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _run_state_cases(): + return true + # --- F. what the scene does with it ------------------------- + game_state.buildings.clear() + game_state.transporters.clear() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + 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) + var field: Node = scene.get_node("BuildingField") + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + var field: Node = scene.get_node("BuildingField") + var node: Node2D = null + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER: + node = b + if node == null: + return _fail("no crafter node was spawned") + if node.is_damaged(): + return _fail("a freshly placed crafter already shows damage") + game_state.damage_building(node.building_id, game_state.building_max_hp( + GameState.BUILDING_SHIP_CRAFTER) / 2) + node._process(0.0) + if not node.is_damaged(): + return _fail("a crafter at half health does not show it") + print("a half-wrecked crafter reads %.0f%%" % (node.health_fraction() * 100.0)) + game_state.damage_building(node.building_id, 999999) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.2: + return false + var field: Node = scene.get_node("BuildingField") + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER: + return _fail("the crafter node outlived the record it was built from") + print("the node follows its record out of existence") + + print("TEST_BUILDING_HEALTH: PASS") + quit(0) + return true + return false diff --git a/tests/test_building_health.gd.uid b/tests/test_building_health.gd.uid new file mode 100644 index 0000000..abecbcf --- /dev/null +++ b/tests/test_building_health.gd.uid @@ -0,0 +1 @@ +uid://duqp2s4t83jve diff --git a/tests/test_building_redraw_cost.gd b/tests/test_building_redraw_cost.gd index 8e55050..502cc82 100644 --- a/tests/test_building_redraw_cost.gd +++ b/tests/test_building_redraw_cost.gd @@ -30,10 +30,11 @@ var stage: int = 0 var elapsed: float = 0.0 var crane_x_start: float = 0.0 var satellite_rotation_start: float = 0.0 +var game_state: Node func _initialize() -> void: - var game_state: Node = root.get_node("GameState") + game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() scene = load("res://world/Main.tscn").instantiate() @@ -71,6 +72,10 @@ func _process(delta: float) -> bool: quit(1) return true + # #117: an idle crafter holds its clock, so there would be no + # animation to measure the cost of. The point of this test is + # that animating is CHEAP, not that it happens unprompted. + game_state.queue_order(int(_crafter_id(field)), GameState.VESSEL_INTERCEPTOR) crane_x_start = crafter._crane.position.x satellite_rotation_start = mothership._satellites.rotation elapsed = 0.0 @@ -108,3 +113,10 @@ func _process(delta: float) -> bool: quit(0) return true return false + + +func _crafter_id(building_field: Node) -> int: + for b in building_field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_SHIP_CRAFTER: + return int(b.building_id) + return 0 diff --git a/tests/test_crafter_animation.gd b/tests/test_crafter_animation.gd index 2a2eb91..2e371bb 100644 --- a/tests/test_crafter_animation.gd +++ b/tests/test_crafter_animation.gd @@ -12,7 +12,11 @@ extends SceneTree # that never changes were a measurable framerate cost earlier on # 3. the hull assembles progressively over the cycle rather than popping # in whole, and every piece is on before the assembly phase ends -# 4. the cycle loops without anim_time growing without bound +# 4. the cycle loops without anim_time growing without bound, WHILE THERE +# IS SOMETHING ON THE LINE - #117 stops the clock on an idle crafter, +# because a factory whose cranes swing over an empty cradle reads as +# working on nothing +# 5. and an idle crafter really is still var scene: Node var game_state: Node @@ -99,11 +103,35 @@ func _process(delta: float) -> bool: quit(1) return true + # --- #117: idle means idle -------------------------------- + if crafter.is_working(): + print("TEST_CRAFTER_ANIMATION: FAIL (a crafter with an empty queue calls itself working)") + quit(1) + return true time_at_start = crafter.anim_time elapsed = 0.0 stage = 2 return false 2: + if elapsed < 0.4: + return false + if crafter.anim_time != time_at_start: + print("TEST_CRAFTER_ANIMATION: FAIL (an idle crafter kept animating)") + quit(1) + return true + print("idle crafter held its clock at %.3f" % crafter.anim_time) + # Put something on the line, and it should run again. + game_state.queue_order(int(_find(GameState.BUILDING_SHIP_CRAFTER).building_id), + GameState.VESSEL_INTERCEPTOR) + if not crafter.is_working(): + print("TEST_CRAFTER_ANIMATION: FAIL (an order was queued and the crafter is still idle)") + quit(1) + return true + time_at_start = crafter.anim_time + elapsed = 0.0 + stage = 3 + return false + 3: if elapsed < 0.4: return false var advanced: bool = crafter.anim_time != time_at_start diff --git a/tests/test_definitions_intact.gd b/tests/test_definitions_intact.gd new file mode 100644 index 0000000..9adf52f --- /dev/null +++ b/tests/test_definitions_intact.gd @@ -0,0 +1,130 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_definitions_intact.gd +# +# A guard against a failure mode that has now cost this project twice, and +# both times silently. +# +# Opening the project in the Godot editor re-saves .tres files, and its +# writer DROPS properties it considers default-valued. An empty-looking +# PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost +# `capabilities` and `collected_outputs` once (which took the gas burner's +# whole conversion with it), and InterceptorVesselDefinition later lost +# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` - +# which cost the starting vessel its cargo hold, so nothing could be mined. +# +# Neither showed up as an error. The resource still loaded; it was just +# quietly less than it had been, and the failure surfaced somewhere else +# entirely - a full suite reporting ten unrelated tests broken. +# +# So this asserts the SHAPE of the catalog rather than any one field: every +# definition must carry the things that make it what it is, and a capability +# a definition claims must have a component behind it. Anything the editor +# eats fails here first, next to the thing that was eaten. +# +# Covers: +# A. every vessel is whole - name, cost, icon, hull, and stats +# B. a vessel with cargo declares it and has a capacity to go with it +# C. every building is whole, and its menu wiring is complete +# D. a converter building keeps the fields its conversion needs +# E. no definition has lost its component list + +var failures: Array = [] + + +func _fail(message: String) -> String: + failures.append(message) + return message + + +# get() answers null for a property a definition's script never declared, +# and null has no is_empty(). These keep a missing field from looking like +# an empty one - and from taking the rest of the sweep down with it. +func _text(definition: Resource, field: String) -> String: + var value: Variant = definition.get(field) + return "" if value == null else str(value) + + +func _list(definition: Resource, field: String) -> Array: + var value: Variant = definition.get(field) + if value == null: + return [] + return Array(value.keys() if value is Dictionary else value) + + +func _check_vessels() -> void: + for definition in VesselDefinitions.all(): + var id: String = String(definition.definition_id) + # --- A. whole --------------------------------------------------- + if String(definition.display_name).strip_edges() == "": + _fail("vessel %s has no display name" % id) + if definition.base_stats.is_empty(): + _fail("vessel %s has no stats at all" % id) + # Only the hulls that actually join the fleet: a repair drone is a + # logistics unit driven by GameState, with nothing bolted to it. + if definition.fleet_eligible and definition.component_scenes.is_empty(): + _fail("vessel %s flies in the fleet with no components - an empty hull" % id) + if definition.hull_points.is_empty(): + _fail("vessel %s has no hull points - it would draw as the fallback triangle" % id) + # --- B. cargo -------------------------------------------------- + var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0 + var claims: bool = "cargo" in definition.capabilities + if carries != claims: + _fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \ + % [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities]) + if "weapon" in definition.capabilities \ + and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0: + _fail("vessel %s claims a weapon and has no range" % id) + + +func _check_buildings() -> void: + for definition in BuildingDefinitions.all(): + var id: String = String(definition.definition_id) + # --- C. whole -------------------------------------------------- + if String(definition.display_name).strip_edges() == "": + _fail("building %s has no display name" % id) + if String(definition.description).strip_edges() == "": + _fail("building %s has no description - its info popup would be blank" % id) + if int(definition.cost) <= 0: + _fail("building %s is free" % id) + if int(definition.max_hp) <= 0: + _fail("building %s has no hit points" % id) + # Space-placed structures only: an extractor is a marker on a planet + # rather than a thing standing in the world, and has no scene. + if definition.placement_mode == "space" and definition.visual_scene == null: + _fail("building %s stands in space with nothing to draw" % id) + for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]: + if str(definition.get(field)).strip_edges() == "": + _fail("building %s has no %s - the build menu could not place it" % [id, field]) + if definition.capabilities.is_empty(): + _fail("building %s declares no capabilities - it would do nothing at all" % id) + # --- D. converters --------------------------------------------- + # These live on the LOGISTICS profile, a sub-resource of the + # definition, not on the definition itself. Asking the definition + # for them answers null and the whole check quietly never fires - + # which is how the first draft of this test passed while the gas + # burner sat there with its collected_outputs missing. + var logistics: Resource = definition.logistics + if logistics != null and _text(logistics, "conversion_input") != "": + if _text(logistics, "conversion_output") == "": + _fail("building %s converts something into nothing" % id) + if _list(logistics, "collected_outputs").is_empty(): + _fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id) + if _list(logistics, "input_buffers").is_empty(): + _fail("building %s converts with no input buffer" % id) + + +func _initialize() -> void: + _check_vessels() + _check_buildings() + if not failures.is_empty(): + for message in failures: + print(" - %s" % message) + print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size()) + quit(1) + return + print("%d vessels and %d buildings, all intact" \ + % [VesselDefinitions.all().size(), BuildingDefinitions.all().size()]) + print("TEST_DEFINITIONS_INTACT: PASS") + quit(0) diff --git a/tests/test_definitions_intact.gd.uid b/tests/test_definitions_intact.gd.uid new file mode 100644 index 0000000..d7ebbe4 --- /dev/null +++ b/tests/test_definitions_intact.gd.uid @@ -0,0 +1 @@ +uid://d3ury2lcjwer7 diff --git a/tests/test_desktop_controls.gd b/tests/test_desktop_controls.gd new file mode 100644 index 0000000..8467ad0 --- /dev/null +++ b/tests/test_desktop_controls.gd @@ -0,0 +1,184 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_desktop_controls.gd +# Forgejo #118 and #121: two raw-touch controls that a mouse could not +# reach. +# +# Both read InputEventScreenTouch/ScreenDrag, and the project no longer turns +# clicks into them - the same routing gap #93 found in the research tree. +# The boost was unreachable on a desktop and the formation could not be +# rearranged at all. +# +# Driven through the REAL routing in each case (GameController for the +# fader, BuildController for the editor) rather than by calling the controls +# directly, because the routing is what was missing - a test that called +# handle_mouse_button() itself would have passed against both reports. +# +# Covers: +# A. #118 - a click on the fader takes hold of it, dragging moves the +# boost, and releasing lets go +# B. the fader claims the press, so dragging the boost does not also zoom +# the world behind it +# C. #121 - a click grabs a formation slot, moving drags it, and the +# formation really changes +# D. releasing drops the slot, and moving afterwards does not keep dragging +# E. the wheel zooms the formation panel, which is what replaces the pinch + +var game_state: Node +var scene: Node +var controller: Node +var fleet: Node +var fader: Control +var editor: Control +var formation_menu: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.reset_formation() + game_state.intro_active = false + game_state.vessel_levels["unlock_distorder"] = 1 + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + controller = scene.get_node("BuildController") + fader = scene.get_node("UI/SpeedFader") + editor = scene.get_node("UI/FormationMenu/Editor") + formation_menu = scene.get_node("UI/FormationMenu") + + +func _fail(message: String) -> bool: + print("TEST_DESKTOP_CONTROLS: FAIL (%s)" % message) + quit(1) + return true + + +func _click(at: Vector2, pressed: bool, target: Node) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.position = at + target._unhandled_input(event) + + +func _move(to: Vector2, target: Node) -> void: + var event: InputEventMouseMotion = InputEventMouseMotion.new() + event.position = to + target._unhandled_input(event) + + +func _wheel(button: int, at: Vector2) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = button + event.pressed = true + event.position = at + controller._unhandled_input(event) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + # The fader only exists where the fleet can actually boost. + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER])) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + if not fader.visible: + return _fail("the fleet has an Energy Propulser and the fader is still hidden") + if game_state.max_fleet_speed_multiplier <= 1.0: + return _fail("the boost has no range to move through") + + # --- A. click, drag, release ----------------------------- + var top: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5, + fader.track_margin + 2.0) + var bottom: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5, + fader.size.y - fader.track_margin - 2.0) + _click(bottom, true, scene) + if not fader.is_active(): + return _fail("a click on the fader did not take hold of it") + var at_bottom: float = game_state.fleet_speed_multiplier + _move(top, scene) + var at_top: float = game_state.fleet_speed_multiplier + if at_top <= at_bottom: + return _fail("dragging the fader from bottom to top left the boost at %.2f" % at_top) + _click(top, false, scene) + if fader.is_active(): + return _fail("the fader is still held after the button came up") + _move(bottom, scene) + if not is_equal_approx(game_state.fleet_speed_multiplier, at_top): + return _fail("the fader kept following the pointer after release") + print("fader: %.2fx at the bottom, %.2fx at the top, released cleanly" \ + % [at_bottom, at_top]) + + # --- B. it claims the press ------------------------------ + var zoom_before: float = scene.get_node("Camera2D").zoom.x + _click(bottom, true, scene) + _click(bottom, false, scene) + if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before): + return _fail("pressing the fader also moved the camera behind it") + print("the press stops at the fader; the world behind it is untouched") + + # --- C/D/E. the formation editor ------------------------- + formation_menu.visible = true + editor.queue_redraw() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + var slot_before: Vector2 = game_state.formation[0] + var grab: Vector2 = editor.global_position + editor._slot_screen_pos(0) + _click(grab, true, controller) + if editor.dragging_index != 0: + return _fail("a click on the first formation slot grabbed %d" % editor.dragging_index) + _move(grab + Vector2(70.0, 40.0), controller) + if game_state.formation[0] == slot_before: + return _fail("dragging the slot did not move it") + var dragged: Vector2 = game_state.formation[0] + print("slot 0 moved from %s to %s under a mouse" % [str(slot_before), str(dragged)]) + + # --- D. release lets go ---------------------------------- + _click(grab + Vector2(70.0, 40.0), false, controller) + if editor.dragging_index != -1: + return _fail("the slot is still held after the button came up") + _move(grab + Vector2(200.0, 200.0), controller) + if game_state.formation[0] != dragged: + return _fail("the slot kept following the pointer after release") + print("released, and the slot stopped following") + + # --- E. the wheel zooms ---------------------------------- + var scale_before: float = editor.scale_factor + _wheel(MOUSE_BUTTON_WHEEL_UP, editor.global_position + editor.size * 0.5) + if editor.scale_factor <= scale_before: + return _fail("the wheel did not zoom the formation panel in") + _wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5) + _wheel(MOUSE_BUTTON_WHEEL_DOWN, editor.global_position + editor.size * 0.5) + if editor.scale_factor >= scale_before: + return _fail("the wheel did not zoom back out") + print("the wheel zooms the panel both ways (%.2f -> %.2f)" \ + % [scale_before, editor.scale_factor]) + + print("TEST_DESKTOP_CONTROLS: PASS") + quit(0) + return true + return false diff --git a/tests/test_desktop_controls.gd.uid b/tests/test_desktop_controls.gd.uid new file mode 100644 index 0000000..031a129 --- /dev/null +++ b/tests/test_desktop_controls.gd.uid @@ -0,0 +1 @@ +uid://bnvtvdf4lmk2m diff --git a/tests/test_desktop_hides_touch_controls.gd b/tests/test_desktop_hides_touch_controls.gd index 632a24f..2204226 100644 --- a/tests/test_desktop_hides_touch_controls.gd +++ b/tests/test_desktop_hides_touch_controls.gd @@ -12,9 +12,12 @@ extends SceneTree # C. a touch device keeps every one of them, even with a keyboard attached - # a finger is still a way to play it # -# The touchscreen answer is read from DisplayServer, which headless says yes -# to, so B is exercised by driving the handler directly under a stubbed -# answer. That is the whole behaviour: "no touchscreen + keyboard used". +# The touchscreen answer is read from DisplayServer, and headless does not +# answer the same way everywhere: Godot on Android says yes, Godot on Windows +# says no. So B is exercised by driving the handler directly under a stubbed +# answer, and C is skipped - not failed - wherever there is no touchscreen to +# exercise it with. That is the whole behaviour: "no touchscreen + keyboard +# used". const CONTROLS: Array = ["UI/Joystick", "UI/FireStick", "UI/PivotCWButton", "UI/PivotCCWButton"] @@ -60,15 +63,18 @@ func _process(delta: float) -> bool: print("all four touch controls start visible") # --- C. a touchscreen keeps them ---------------------------------- - # This machine reports a touchscreen, so the real code path is the - # touch-device one and can be driven as-is. - if not DisplayServer.is_touchscreen_available(): - return _fail("this environment reports no touchscreen, so case C cannot be exercised here") - controller.keyboard_seen = false - controller._note_keyboard() - if not _all_visible(): - return _fail("a touch device hid its controls when a keyboard was used") - print("touch device keeps all four even after keyboard input") + # Only where the machine actually reports one: there the real code path + # is the touch-device one and can be driven as-is. Where it reports none + # there is nothing to drive, and the static check at the end of B still + # guards the touchscreen condition itself, so skip rather than fail. + if DisplayServer.is_touchscreen_available(): + controller.keyboard_seen = false + controller._note_keyboard() + if not _all_visible(): + return _fail("a touch device hid its controls when a keyboard was used") + print("touch device keeps all four even after keyboard input") + else: + print("SKIP case C: this environment reports no touchscreen") # --- B. no touchscreen: they all go ------------------------------- # The guard is the FIRST thing _note_keyboard checks, so the branch is diff --git a/tests/test_dev_mode.gd b/tests/test_dev_mode.gd index c443c42..57e027d 100644 --- a/tests/test_dev_mode.gd +++ b/tests/test_dev_mode.gd @@ -36,6 +36,9 @@ func _process(delta: float) -> bool: 0: # --- E. a normal game hands over nothing ---------------------- game_state.reset_new_game() + # #108: reset_new_game() also arms the prologue. Nothing below is + # about it, and this test never builds a scene for it to run in. + game_state.intro_active = false 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(): diff --git a/tests/test_dialog_pause.gd b/tests/test_dialog_pause.gd new file mode 100644 index 0000000..17eb57f --- /dev/null +++ b/tests/test_dialog_pause.gd @@ -0,0 +1,217 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_dialog_pause.gd +# Forgejo #128: "When a dialog appears, no command should work and the game +# physics should be paused as long as the message is not acknowledged." +# +# Two claims, and they are checked separately because they can fail +# separately: the world must STOP, and the controls must not ANSWER. Presses +# are pushed through the viewport rather than handed to a handler directly - +# calling a handler by hand would prove nothing about whether the engine +# would have delivered the event at all, which is the whole question here. +# +# What a press does while a dialog is up is ACKNOWLEDGE it (#127) and +# nothing else. So "no command works" is not "the press vanishes": it is +# "the press reached the dialog and reached nothing else", and that is what +# is asserted below. +# +# Covers: +# A. a line goes up and the tree is paused +# B. the world is frozen: the fleet holds still under a movement order +# C. GameState's own clock is frozen too - the wave clock does not run +# D. no command works: a press on a HUD button, delivered the way a finger +# delivers one, acknowledges the dialog and does not fire the button +# E. queued lines hold the pause until the LAST one is acknowledged +# F. after the last one, the world runs and the same press does fire +# G. leaving the tree with a line up does not leave the game stopped with +# nothing left that could start it again + +var game_state: Node +var scene: Node +var fleet: Node2D +var narration: Control +var button: Control +var build_menu: Control +var presses: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var frozen_at: Vector2 = Vector2.ZERO +var frozen_wave_timer: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + # Not the prologue. The prologue has its own dialog coverage + # (test_intro_scene); this is about dialogs in the ordinary game, where + # there are commands to block in the first place. + game_state.intro_active = false + # And no quest chain. Quest briefings are dialogs too (#132), and this + # test is about what a dialog DOES - it needs to be the one deciding + # when one appears. + game_state.reset_quests() + game_state.rocks = 500 + + +func _fail(message: String) -> bool: + print("TEST_DIALOG_PAUSE: FAIL (%s)" % message) + quit(1) + return true + + +func _on_pressed() -> void: + presses += 1 + + +# A press delivered the way the player delivers one: through the viewport, at +# the button's own screen rect, so the engine's routing decides whether it +# arrives rather than the test deciding for it. +func _press_button() -> void: + var at: Vector2 = button.get_global_rect().get_center() + for is_down in [true, false]: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = is_down + event.position = at + event.global_position = at + # in_local_coords, deliberately. The other form runs the position + # through the window's screen transform, and headless has no window - + # the event then lands nowhere and the button looks broken. + root.push_input(event, true) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + narration = scene.get_node("UI/NarrationBanner") + button = scene.get_node("UI/BuildButton") + build_menu = scene.get_node("UI/BuildMenu") + button.pressed.connect(_on_pressed) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + if paused: + return _fail("the game was already stopped before any dialog") + # The button answers when nothing is in the way. Without this + # baseline, D below would prove only that the button was + # unreachable all along. + presses = 0 + _press_button() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + if presses == 0: + return _fail("the build button does not answer a press even with no dialog up") + print("baseline: a press on the build button fires it") + # It opened the build menu, which is modal and would block every + # later press for a reason that has nothing to do with #128. + build_menu.visible = false + + # --- A. a line stops the game ---------------------------- + narration.say("Hold position.", "TEST") + narration.say("And stand by.", "TEST") + if not paused: + return _fail("a dialog went up and the game kept running") + print("dialog up: the tree is paused") + + frozen_at = fleet.get_centroid() + frozen_wave_timer = game_state.wave_timer + # Ordered somewhere it is nowhere near, so even a little drift + # would show up. steer_direction persists, so this order stands + # for the whole frozen second below. + fleet.move_in_direction(Vector2.RIGHT) + presses = 0 + elapsed = 0.0 + stage = 3 + return false + 3: + # --- B and C. a whole second of frozen world ------------- + if elapsed < 1.0: + return false + var moved: float = fleet.get_centroid().distance_to(frozen_at) + if moved > 0.5: + return _fail("the fleet drifted %.1fpx while a dialog was up" % moved) + print("fleet held still for 1s under a movement order (%.2fpx)" % moved) + var ran: float = absf(game_state.wave_timer - frozen_wave_timer) + if ran > 0.001: + return _fail("the wave clock ran on %.2fs while a dialog was up" % ran) + print("the wave clock did not advance") + + # --- D. the press acknowledges, and does nothing else ---- + # #149: a line arrives a character at a time, and a press on one + # still arriving finishes it rather than dismissing it. The + # banner types while the game is stopped - it is the one node + # still running - so a second of frozen world is more than + # enough for this line to have finished on its own. Finished + # explicitly here so the press below is unambiguously the + # acknowledging one; the typing itself is test_dialog_typing's. + narration.finish_typing() + _press_button() + if presses != 0: + return _fail("the build button fired while a dialog was up") + if build_menu.visible: + return _fail("the build menu opened from a press meant for the dialog") + print("a press while the dialog was up did not reach the button") + + # --- E. the queued line keeps the game stopped ----------- + if not narration.is_speaking(): + return _fail("one press acknowledged both queued lines") + if not paused: + return _fail("the game restarted with a line still on screen") + print("a second queued line keeps the game stopped") + + narration.finish_typing() + _press_button() + if presses != 0: + return _fail("the build button fired on the acknowledging press") + if narration.is_speaking(): + return _fail("the banner is still speaking after the last line") + if paused: + return _fail("the last line was acknowledged and the game is still stopped") + print("last line acknowledged: the game is running again") + + frozen_at = fleet.get_centroid() + presses = 0 + elapsed = 0.0 + stage = 4 + return false + 4: + # --- F. and the world answers again ---------------------- + if elapsed < 0.4: + return false + if fleet.get_centroid().distance_to(frozen_at) < 1.0: + return _fail("the fleet is still frozen after the dialog closed") + _press_button() + if presses == 0: + return _fail("the build button stayed dead after the dialog closed") + build_menu.visible = false + print("the world moves again: the fleet flies and the button fires") + + # --- G. torn down mid-line ------------------------------ + narration.say("Interrupted.", "TEST") + if not paused: + return _fail("the dialog did not stop the game the second time") + scene.free() + scene = null + if paused: + return _fail("the scene was torn down mid-dialog and left the game stopped") + print("a scene torn down mid-dialog leaves the game running") + + print("TEST_DIALOG_PAUSE: PASS") + quit(0) + return true + return false diff --git a/tests/test_dialog_pause.gd.uid b/tests/test_dialog_pause.gd.uid new file mode 100644 index 0000000..302c40a --- /dev/null +++ b/tests/test_dialog_pause.gd.uid @@ -0,0 +1 @@ +uid://clgtbnc7pfxbd diff --git a/tests/test_dialog_typing.gd b/tests/test_dialog_typing.gd new file mode 100644 index 0000000..25e9b6c --- /dev/null +++ b/tests/test_dialog_typing.gd @@ -0,0 +1,145 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_dialog_typing.gd +# Forgejo #149: "les dialogues doivent arriver petit a petit, avec un VFX +# associe a des dialogues de science fiction" - dialogs should arrive bit by +# bit, with an effect that reads as science fiction. +# +# The failure worth guarding against is not "it does not type" - it is the +# one that comes with typing: a player clicking at their usual pace skips +# the second line entirely, having never seen a word of it. #127 exists to +# stop exactly that, so a press on a line still arriving has to finish it +# rather than dismiss it. +# +# Covers: +# A. a line arrives a character at a time rather than all at once +# B. the text itself is set whole and REVEALED - growing the string would +# re-wrap the line every frame and make words jump between rows +# C. a press on a line still arriving finishes it and does not dismiss it +# D. a press on a finished line dismisses it, as it always did +# E. it types while the game is stopped, being the one node still running +# F. the sci-fi part: the panel is drawn differently while decoding, and +# settles when the line is complete + +var game_state: Node +var scene: Node +var narration: Control +var stage: int = 0 +var elapsed: float = 0.0 +var first_line: String = "" +var seen: Array = [] + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_DIALOG_TYPING: FAIL (%s)" % message) + quit(1) + return true + + +func _click() -> void: + var press: InputEventMouseButton = InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + narration._unhandled_input(press) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + narration = scene.get_node("UI/NarrationBanner") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + first_line = "Convoy Kestrel, this is a long enough line that decoding it takes a moment." + narration.say(first_line, "TEST") + narration.say("And a second one behind it.", "TEST") + + # --- A and B. it arrives, and the text is whole ---------- + if not narration.is_typing(): + return _fail("the line was complete the instant it was said") + if narration.label.text != first_line: + return _fail("the label holds a partial string - the line will re-wrap as it types") + if narration.label.visible_characters < 0: + return _fail("the whole line is visible already") + seen.append(narration.label.visible_characters) + + # Hand-ticked, so the reveal is measured rather than raced. The + # banner processes while the tree is stopped (#128), which is E. + if not paused: + return _fail("a dialog did not stop the game") + for i in range(12): + narration._process(1.0 / 60.0) + seen.append(narration.label.visible_characters) + var rising: int = 0 + for i in range(1, seen.size()): + if int(seen[i]) > int(seen[i - 1]): + rising += 1 + if rising < 6: + return _fail("the reveal advanced on only %d of %d frames" % [rising, seen.size() - 1]) + if int(seen[-1]) >= first_line.length(): + return _fail("the whole line decoded within 12 frames - that is not arriving") + print("typed while the game was stopped: %d -> %d of %d characters over 12 frames" \ + % [seen[0], seen[-1], first_line.length()]) + + # --- F. and it looks like it is working ------------------ + # is_typing() is the flag _draw() branches on for the sweep, the + # write-head caret and the frame's charge, so its transition IS + # the effect changing. Checked rather than calling _draw() by + # hand, which Godot refuses outside a paint. + if not narration.is_typing(): + return _fail("the test's own ticking finished the line") + narration.queue_redraw() + narration.finish_typing() + if narration.is_typing(): + return _fail("finish_typing() left the line still arriving") + if narration.label.visible_characters != -1: + return _fail("a finished line is still hiding characters") + if narration.SWEEP_SECONDS <= 0.0 or narration.CHARS_PER_SECOND <= 0.0: + return _fail("the decode has no rate to run at") + print("decoding while arriving, settled when it lands (%.0f chars/s, %.1fs sweep)" \ + % [narration.CHARS_PER_SECOND, narration.SWEEP_SECONDS]) + + # --- C. a press finishes rather than dismisses ----------- + narration.say(first_line, "TEST") + # say() queues behind the current line, so start it cleanly. + narration.clear() + narration.say(first_line, "TEST") + narration.say("Second.", "TEST") + if not narration.is_typing(): + return _fail("the fresh line was not arriving") + _click() + if narration.is_typing(): + return _fail("a press did not finish a line that was still arriving") + if narration.current != first_line: + return _fail("a press on an arriving line dismissed it - the next line would be skipped unread") + print("a press on an arriving line finishes it, and does not move on") + + # --- D. and the next press does move on ------------------ + _click() + if narration.current == first_line: + return _fail("a press on a finished line did not dismiss it") + if narration.current != "Second.": + return _fail("dismissing landed on \"%s\"" % narration.current) + print("the press after that moves on to \"%s\"" % narration.current) + + print("TEST_DIALOG_TYPING: PASS") + quit(0) + return true + return false diff --git a/tests/test_dialog_typing.gd.uid b/tests/test_dialog_typing.gd.uid new file mode 100644 index 0000000..f4affc0 --- /dev/null +++ b/tests/test_dialog_typing.gd.uid @@ -0,0 +1 @@ +uid://cwr86i1ak3xdx diff --git a/tests/test_enemy_bomber.gd b/tests/test_enemy_bomber.gd new file mode 100644 index 0000000..69aaa96 --- /dev/null +++ b/tests/test_enemy_bomber.gd @@ -0,0 +1,192 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_bomber.gd +# Forgejo #114: the enemy bomber - the artillery enemy, the one that lobs an +# area shell. +# +# It was a siege piece that crept at 20px/s and threw a shell taking five +# seconds to cross the gap, which read as a thing that was stuck rather than +# a thing that was dangerous: by the time a shell landed the fleet had +# wandered off, and the tower was still approaching. +# +# Covers: +# A. it closes on a target at a speed worth calling flying, and faces +# where it is going +# B. its bombs travel fast enough to arrive while the target is roughly +# where they were aimed +# C. the bomb damages a player vessel it lands on +# D. and a BUILDING it lands on - it used to hit only player_ships, which +# was already out of step with #101 +# E. it bombs a structure with no fleet in sight, through the shared +# targeting rather than asking Fleet directly +# F. its hull is its own, not a copy of another enemy's + +const MIN_FLYING_SPEED: float = 90.0 +const MIN_SHELL_SPEED: float = 350.0 + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var bomber: Node2D +var target_ship: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var start_distance: float = 0.0 +var building_id: int = 0 +var hp_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.intro_active = false + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("BuildingField") + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_BOMBER: FAIL (%s)" % message) + quit(1) + return true + + +func _spawn_bomber(at: Vector2) -> Node2D: + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.enemy_id = "artillery" + enemy.position = at + main.add_child(enemy) + return enemy + + +func _shells() -> Array: + var out: Array = [] + for shell in get_nodes_in_group("artillery_shells"): + if is_instance_valid(shell) and not shell.is_queued_for_deletion(): + out.append(shell) + return out + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + + # --- F. its own silhouette ------------------------------- + var definitions: Script = load("res://enemies/EnemyDefinitions.gd") + var bomber_def: Resource = definitions.get_definition("artillery") + for other in definitions.all(): + if other.enemy_id == "artillery": + continue + if other.hull_points == bomber_def.hull_points: + return _fail("the bomber shares its hull with the %s" % other.enemy_id) + if bomber_def.speed < MIN_FLYING_SPEED: + return _fail("the bomber's top speed is %.0f - that is still creeping" \ + % bomber_def.speed) + print("bomber: %d hull points of its own, %.0fpx/s top speed" \ + % [bomber_def.hull_points.size() / 2, bomber_def.speed]) + + fleet.spawn_ships(1, Vector2.ZERO, + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + target_ship = fleet.ships[0] + target_ship.move_target = target_ship.position + bomber = _spawn_bomber(Vector2(700.0, 0.0)) + start_distance = bomber.global_position.distance_to(target_ship.global_position) + elapsed = 0.0 + stage = 1 + return false + 1: + # --- A. it flies, and faces where it is going ------------ + if elapsed < 1.0: + return false + if not is_instance_valid(bomber): + return _fail("the bomber is gone before it could be measured") + var closed: float = start_distance \ + - bomber.global_position.distance_to(target_ship.global_position) + var speed: float = closed / elapsed + if speed < MIN_FLYING_SPEED: + return _fail("the bomber closed at %.0fpx/s - #114 asks for an aircraft" % speed) + var heading: Vector2 = target_ship.global_position - bomber.global_position + var drift: float = absf(angle_difference(bomber.rotation, heading.angle() + PI / 2.0)) + if drift > PI / 3.0: + return _fail("it flew %.0f degrees away from where it was pointing" \ + % rad_to_deg(drift)) + print("closed %.0fpx in %.1fs (%.0fpx/s), nose within %.0f degrees" \ + % [closed, elapsed, speed, rad_to_deg(drift)]) + + # --- B/C. the bomb arrives, and it hurts ----------------- + var component: Node = bomber.get_component("artillery") + if component.shell_speed < MIN_SHELL_SPEED: + return _fail("its bombs travel at %.0f - too slow to arrive" % component.shell_speed) + component.cooldown_left = 0.0 + elapsed = 0.0 + stage = 2 + return false + 2: + if _shells().is_empty(): + if elapsed > 6.0: + return _fail("the bomber never dropped a bomb") + return false + print("bomb away, travelling at %.0fpx/s" % _shells()[0].shell_speed) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 4.0: + return false + if is_instance_valid(target_ship) and not target_ship.is_queued_for_deletion(): + return _fail("a bomb landed on a stationary vessel and it survived") + print("the vessel it was aimed at is gone") + + # --- D/E. and it bombs what the player has built --------- + for hazard in get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The fleet is parked far away, so nothing here is about ships. + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2.ZERO) + building_id = int(game_state.buildings[-1].id) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.3: + return false + hp_before = game_state.building_hp(building_id) + bomber = _spawn_bomber(Vector2(600.0, 0.0)) + bomber.speed = 0.0 + bomber.get_component("artillery").cooldown_left = 0.0 + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 5.0: + return false + var now: int = game_state.building_hp(building_id) + if now >= hp_before: + return _fail("the bomber shelled a crafter with no fleet in sight and did nothing") + print("with the fleet 9000px away it bombed the crafter: %d -> %d hp" % [hp_before, now]) + + print("TEST_ENEMY_BOMBER: PASS") + quit(0) + return true + return false diff --git a/tests/test_enemy_bomber.gd.uid b/tests/test_enemy_bomber.gd.uid new file mode 100644 index 0000000..424952f --- /dev/null +++ b/tests/test_enemy_bomber.gd.uid @@ -0,0 +1 @@ +uid://dpv7njqvbkrc2 diff --git a/tests/test_enemy_projectile_damage.gd b/tests/test_enemy_projectile_damage.gd index 2492b68..a6ac454 100644 --- a/tests/test_enemy_projectile_damage.gd +++ b/tests/test_enemy_projectile_damage.gd @@ -42,6 +42,18 @@ func _process(delta: float) -> bool: 0: scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) + # set_process(false) only stops NEW asteroids; the ones already + # placed keep drifting under their own _physics_process, and they + # are placed at random. One of them wandering into the shield + # vessel parked out at (2000, 0) spends its single charge, and + # the projectile fired at it a moment later then kills it - which + # presented as "a shielded ship was destroyed by an enemy + # projectile" on roughly one parallel suite run in thirty, and + # never in isolation. Clearing them is what the other combat + # tests already do (see test_sniper_enemy.gd). + for asteroid in get_nodes_in_group("asteroids"): + if is_instance_valid(asteroid): + asteroid.queue_free() fleet.spawn_ships(1, Vector2.ZERO) interceptor = fleet.ships[0] fleet.add_ship(GameState.VESSEL_SHIELD) diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 469aeb2..0cf3223 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -147,7 +147,7 @@ func _process(delta: float) -> bool: controller._confirm_build() var built: bool = game_state.has_extractor_at(planet2.planet_id) - var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_EXTRACTOR)[GameState.RESOURCE_ORE]) print("over planet '%s': enabled=%s built=%s rocks %d->%d charged=%s" % [ planet2.planet_name, button.enabled, built, rocks_before, game_state.rocks, charged ]) diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd index 4c58207..1bc3ab0 100644 --- a/tests/test_fleet_growth.gd +++ b/tests/test_fleet_growth.gd @@ -31,6 +31,13 @@ var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") game_state.reset_new_game() + # #108: reset_new_game() arms the prologue, and the prologue opens with a + # convoy rather than the game's own starting fleet. This test is about + # what the GAME starts from, which is where the prologue hands over - + # see test_intro_scene.gd for the prologue itself. The quest chain goes + # with it: its briefings are dialogs, and a dialog stops the tree (#128). + game_state.intro_active = false + game_state.reset_quests() func _process(delta: float) -> bool: @@ -96,6 +103,11 @@ func _process(delta: float) -> bool: # --- B. what actually flies ---------------------------------- game_state.reset_new_game() + # ... and again here: this is the reset the scene below is built + # from, so the prologue has to be off for THIS one too - and the + # quest chain with it. + game_state.intro_active = false + game_state.reset_quests() scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_garrison_abilities.gd b/tests/test_garrison_abilities.gd new file mode 100644 index 0000000..3d62221 --- /dev/null +++ b/tests/test_garrison_abilities.gd @@ -0,0 +1,271 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_garrison_abilities.gd +# +# Laser vessels and gas bombers can be garrisoned, and fight from an outpost +# using the outpost's own ammunition. +# +# #105 shipped with interceptors only, because firing the laser and the bomb +# lived in the player's controllers rather than in the vessels' components - +# a garrisoned laser would have held its position and never shot. Both +# effects now live in the world (LaserBeamEffect / GasBombEffect) and both +# components expose the same fire_ability() seam, so the garrison AI pulls +# either trigger without knowing which it is. +# +# Covers: +# A. both are garrisonable, and the unarmed vessels still are not +# B. the outpost's magazine is 50 per vessel of the right resource, and it +# is the CREW that decides it - two bombers want 100 gas +# C. it cannot be overfilled, by a transporter or by hand +# D. an outpost short of ammunition asks for it, so hub logistics fill it +# E. a garrisoned laser fires on an enemy, damages it, and pays out of the +# OUTPOST's store rather than the fleet's hold +# F. with an empty store it holds fire - no free shots +# G. a garrisoned gas bomber lands a bomb, and pays in gas +# H. one abstraction, not two: the AI never names an ability + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var outpost_id: int = 0 +var guard: Node2D +var enemy: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var energy_before: int = 0 +var gas_before: int = 0 +var hp_before: int = 0 +var fleet_energy_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append("forward_bases") + game_state.vessel_levels["unlock_laser"] = 1 + game_state.vessel_levels["unlock_gas_bomber"] = 1 + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("GarrisonField") + + +func _fail(message: String) -> bool: + print("TEST_GARRISON_ABILITIES: FAIL (%s)" % message) + quit(1) + return true + + +func _dummy(at: Vector2) -> Node2D: + # Pinned and effectively immortal: the point is whether the garrison + # SHOOTS, and an enemy that died to the first hit would end the + # observation before the amount spent could be read. + var dummy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + dummy.enemy_id = "chaser" + dummy.position = at + main.add_child(dummy) + dummy.speed = 0.0 + dummy.hp = 100000 + return dummy + + +func _station(vessel_type: String) -> void: + game_state.set_garrison_desired(outpost_id, vessel_type, + game_state.garrison_desired_of(outpost_id, vessel_type) + 1) + game_state.garrison_crew(outpost_id).append(vessel_type) + + +func _state_cases() -> bool: + # --- A. who may hold a position ------------------------------------- + var eligible: PackedStringArray = game_state.garrison_vessel_types() + for armed in [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, + GameState.VESSEL_GAS_BOMBER]: + if not (armed in eligible): + return _fail("%s cannot be garrisoned" % armed) + for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER, + GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]: + if game_state.vessel_can_garrison(unarmed): + return _fail("%s can be garrisoned - it does not attack" % unarmed) + print("garrisonable: %s" % str(eligible)) + + # --- B. the magazine follows the crew -------------------------------- + outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(2000.0, 0.0)) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 0: + return _fail("an empty outpost already wants gas") + _station(GameState.VESSEL_GAS_BOMBER) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 50: + return _fail("one bomber asks for %d gas, expected 50" \ + % game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS)) + _station(GameState.VESSEL_GAS_BOMBER) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 100: + return _fail("two bombers ask for %d gas, expected 100" \ + % game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS)) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY) != 0: + return _fail("bombers ask for energy they cannot use") + _station(GameState.VESSEL_LASER) + if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY) != 50: + return _fail("one laser asks for %d energy, expected 50" \ + % game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY)) + print("2 bombers + 1 laser -> 100 gas, 50 energy") + + # --- C. it cannot be overfilled -------------------------------------- + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_GAS, 400) + if game_state.building_resource(outpost_id, GameState.RESOURCE_GAS) != 100: + return _fail("400 gas into a 100-gas outpost left %d" \ + % game_state.building_resource(outpost_id, GameState.RESOURCE_GAS)) + # An interceptor needs no magazine, so it must not create one. + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_ORE, 50) + if game_state.building_resource(outpost_id, GameState.RESOURCE_ORE) != 0: + return _fail("the outpost accepted ore nothing stationed there can use") + print("capped at 100 gas, and refuses what nobody there can use") + + # --- D. and it asks for what it is short of -------------------------- + game_state.building_inventory(outpost_id).set_amount(GameState.RESOURCE_GAS, 20) + var wanted: int = game_state.building_requested_resource(outpost_id, GameState.RESOURCE_GAS) + if wanted != 80: + return _fail("an outpost holding 20 of 100 gas asks for %d" % wanted) + print("holding 20 of 100, it asks hub logistics for the other 80") + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + if _state_cases(): + return true + + # A fresh outpost with a single laser, in the world this time. + game_state.buildings.clear() + main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2.ZERO) + outpost_id = int(game_state.buildings[-1].id) + _station(GameState.VESSEL_LASER) + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_ENERGY, 50) + # The fleet hold is deliberately full of energy: if the garrison + # spent from THERE, this is what would move. + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 100) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + var here: Array = field.ships_at(outpost_id) + if here.size() != 1: + return _fail("the laser outpost put %d vessels in the world" % here.size()) + guard = here[0] + if guard.get_component("laser") == null: + return _fail("the stationed vessel has no laser component") + enemy = _dummy(Vector2(320.0, 0.0)) + hp_before = enemy.hp + energy_before = game_state.building_resource(outpost_id, GameState.RESOURCE_ENERGY) + fleet_energy_before = game_state.fleet_resource(GameState.RESOURCE_ENERGY) + elapsed = 0.0 + stage = 2 + return false + 2: + # --- E. it fires, hits, and pays from the outpost ------------- + if elapsed < 2.0: + return false + if not is_instance_valid(enemy): + return _fail("the target dummy is gone") + if enemy.hp >= hp_before: + return _fail("a garrisoned laser never fired on an enemy 320px away") + var spent: int = energy_before - game_state.building_resource( + outpost_id, GameState.RESOURCE_ENERGY) + if spent <= 0: + return _fail("the laser fired without spending any energy") + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != fleet_energy_before: + return _fail("the garrison spent %d energy out of the FLEET's hold" \ + % (fleet_energy_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY))) + print("garrisoned laser: %d damage dealt, %d energy taken from the outpost, fleet hold untouched" \ + % [hp_before - enemy.hp, spent]) + + # --- F. an empty store holds fire --------------------------- + game_state.building_inventory(outpost_id).set_amount(GameState.RESOURCE_ENERGY, 0) + hp_before = enemy.hp + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 2.0: + return false + if enemy.hp < hp_before: + return _fail("the laser kept firing with an empty magazine") + if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != fleet_energy_before: + return _fail("an empty outpost fell back on the fleet's hold") + print("out of energy: it holds fire rather than taking the fleet's") + + # --- G. the bomber, through the same seam -------------------- + enemy.queue_free() + game_state.buildings.clear() + main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(6000.0, 0.0)) + outpost_id = int(game_state.buildings[-1].id) + _station(GameState.VESSEL_GAS_BOMBER) + game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_GAS, 50) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + var crew: Array = field.ships_at(outpost_id) + if crew.size() != 1: + return _fail("the bomber outpost put %d vessels in the world" % crew.size()) + guard = crew[0] + if guard.get_component("gas_bomber") == null: + return _fail("the stationed vessel has no gas bomber component") + enemy = _dummy(Vector2(6000.0, 320.0)) + hp_before = enemy.hp + gas_before = game_state.building_resource(outpost_id, GameState.RESOURCE_GAS) + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 3.0: + return false + if not is_instance_valid(enemy): + return _fail("the bomber's target is gone") + if enemy.hp >= hp_before: + return _fail("a garrisoned gas bomber never landed a bomb") + var gas_spent: int = gas_before - game_state.building_resource( + outpost_id, GameState.RESOURCE_GAS) + if gas_spent <= 0: + return _fail("the bomb was thrown without spending gas") + print("garrisoned bomber: %d damage dealt, %d gas taken from the outpost" \ + % [hp_before - enemy.hp, gas_spent]) + + # --- H. the AI names no ability ------------------------------ + var source: String = FileAccess.get_file_as_string( + "res://vessels/VesselGarrisonComponent.gd") + for named in ["laser", "gas_bomber", "LaserBeam", "GasBomb"]: + if named in source: + return _fail("the garrison AI mentions \"%s\" - the seam is not doing its job" \ + % named) + print("the garrison AI never names an ability - it asks components what they can do") + + print("TEST_GARRISON_ABILITIES: PASS") + quit(0) + return true + return false diff --git a/tests/test_garrison_abilities.gd.uid b/tests/test_garrison_abilities.gd.uid new file mode 100644 index 0000000..b69c8a1 --- /dev/null +++ b/tests/test_garrison_abilities.gd.uid @@ -0,0 +1 @@ +uid://dswjmvt7wj6ev diff --git a/tests/test_gas_bomber_cooldowns.gd b/tests/test_gas_bomber_cooldowns.gd index c80883e..ff1d616 100644 --- a/tests/test_gas_bomber_cooldowns.gd +++ b/tests/test_gas_bomber_cooldowns.gd @@ -44,8 +44,16 @@ func _process(delta: float) -> bool: skill._process(0.0) if first.is_ready() or not second.is_ready() or not skill.button.enabled: return _fail("the first shot disabled more than its firing bomber") - if not first._filled_hull(fleet.ships[0].definition.hull_points, 0.0).is_empty() \ - or first._filled_hull(fleet.ships[0].definition.hull_points, 0.5).size() < 3: + # _filled_hull returns a LIST of polygons: a notched hull fills as two + # separate puddles until the level rises past the notch, and the gas + # bomber's tail is exactly such a notch. + var hull: PackedVector2Array = fleet.ships[0].definition.hull_points + var empty: Array = first._filled_hull(hull, 0.0) + var half: Array = first._filled_hull(hull, 0.5) + var half_points: int = 0 + for piece in half: + half_points += piece.size() + if not empty.is_empty() or half_points < 3: return _fail("bomber cooldown did not produce an empty-to-filling hull") if skill.projectiles.size() != 1: return _fail("the first bomber did not launch exactly one projectile") diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index d9005de..0f62d24 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -12,6 +12,8 @@ func _initialize() -> void: state.transporters.clear() state.reset_progression() state.rocks = 900 + # #138: doctrines past the first tier want gas as well as rocks. + state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 900) state.research_points = state.skill_research_point_cost("freight_yards") state.unlocked = ["shipyards", "logistics", "freight"] diff --git a/tests/test_input_after_dialog.gd b/tests/test_input_after_dialog.gd new file mode 100644 index 0000000..801c380 --- /dev/null +++ b/tests/test_input_after_dialog.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_input_after_dialog.gd +# Forgejo #153: "after a dialog popup the godot input system is lost on +# keypress. If I had a key held it keeps moving in the key direction." +# +# Nothing was lost. A dialog stops the tree (#128) and a paused node receives +# no input, so a key RELEASED while the line was up never reached the +# controller - keys_down kept it, and the fleet flew on in that direction +# once the game restarted. The keyboard is the authority on what is held; +# keys_down is only a record of what we were told, and it went stale. +# +# Covers: +# A. holding a key steers, as it always did +# B. a release that arrives normally is honoured +# C. the bug: a key held when a dialog opens, released while it is up - +# the release never arrives, and the fleet must still stop +# D. and it is the pause LIFTING that does it, not the dismissal itself, +# so anything else that stops the tree is covered too +# +# Forgejo #154 rides along here, being the same scene: a dialog has to be +# drawn over every panel, and it was drawn under the skill tree. + +const KEY_UP: Key = KEY_W + +var game_state: Node +var scene: Node +var controller: Node +var narration: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_INPUT_AFTER_DIALOG: FAIL (%s)" % message) + quit(1) + return true + + +func _key(keycode: Key, pressed: bool) -> void: + var event: InputEventKey = InputEventKey.new() + event.keycode = keycode + event.pressed = pressed + controller._handle_key(event) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + controller = scene + narration = scene.get_node("UI/NarrationBanner") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + + # --- A. holding a key steers ---------------------------- + _key(KEY_UP, true) + if controller.keyboard_direction() == Vector2.ZERO: + return _fail("holding a key steers nowhere") + print("held key steers %s" % controller.keyboard_direction()) + + # --- B. and a release that arrives is honoured ---------- + _key(KEY_UP, false) + if controller.keyboard_direction() != Vector2.ZERO: + return _fail("a delivered release did not stop the steering") + print("a delivered release stops it") + + # --- C. the bug ------------------------------------------ + # Held, then a dialog opens. The player lets go while reading; + # that release is never delivered, because this node is paused. + _key(KEY_UP, true) + narration.say("Let go while you read this.", "TEST") + if not paused: + return _fail("the dialog did not stop the game, so this proves nothing") + if controller.keyboard_direction() == Vector2.ZERO: + return _fail("the key was already forgotten before the dialog closed") + # ...the release happens here, and reaches nobody. + narration.finish_typing() + if not narration.dismiss(): + return _fail("the dialog would not close") + if paused: + return _fail("closing the dialog left the game stopped") + # NOTIFICATION_UNPAUSED is delivered as part of lifting the + # pause, so by now the stale key must be gone. + if controller.keyboard_direction() != Vector2.ZERO: + return _fail("the fleet is still steering %s after a release that was never delivered" \ + % controller.keyboard_direction()) + if controller.keys_down.has(KEY_UP): + return _fail("the key is still recorded as held") + print("a release swallowed by the pause no longer strands the fleet") + + # --- D. by the pause lifting, not by the dismissal ------- + _key(KEY_UP, true) + paused = true + paused = false + if controller.keyboard_direction() != Vector2.ZERO: + return _fail("stopping and restarting the tree left a stale key held") + print("any pause, not just a dialog, reconciles what is held") + + # --- #154. a dialog draws over every panel --------------- + var ui: Node = scene.get_node("UI") + var banner_at: int = narration.get_index() + var panels: Array = [] + for menu in get_nodes_in_group("modal_menus"): + if ui.is_ancestor_of(menu): + panels.append(menu) + if panels.is_empty(): + return _fail("no modal panels found to be drawn over") + for menu in panels: + if menu.get_index() > banner_at: + return _fail("%s is a later child than the dialog, so it draws on top of it" \ + % menu.name) + if banner_at != ui.get_child_count() - 1: + return _fail("the dialog is child %d of %d - something still draws over it" \ + % [banner_at, ui.get_child_count()]) + print("the dialog is the last child of the UI layer, over all %d panels" % panels.size()) + + print("TEST_INPUT_AFTER_DIALOG: PASS") + quit(0) + return true + return false diff --git a/tests/test_input_after_dialog.gd.uid b/tests/test_input_after_dialog.gd.uid new file mode 100644 index 0000000..29f19f1 --- /dev/null +++ b/tests/test_input_after_dialog.gd.uid @@ -0,0 +1 @@ +uid://ce1elyyw2ds53 diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd new file mode 100644 index 0000000..aae8f1a --- /dev/null +++ b/tests/test_intro_scene.gd @@ -0,0 +1,461 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_intro_scene.gd +# Forgejo #108 (#122, #123, #124): the prologue. +# +# The director drives the REAL scene rather than being a scene of its own, so +# everything below is checked against the real fleet, the real enemies and +# the real asteroid field. +# +# Covers: +# A. it runs on a NEW game and not on a resumed one +# B. "no action button available, only movements" - every pressable +# affordance is gone, and the joystick is not +# C. the convoy: bigger than the player's own ceiling, and mostly holds +# D. the guide speaks, and the banner never shows a line nobody set +# E. the ambush is easy: chasers only, and clearing them advances the beat +# F. the belt is dense, and it is across the lane rather than beside it +# G. the breach takes the wheel: player input stops moving the fleet +# H. the vortex leaves exactly one vessel, and it is the default interceptor +# I. the handover: the flag clears, the buttons come back, the spawner and +# the asteroid field are running again, and the fleet is a new game's +# J. skipping lands in the same state as playing it through +# K. #125 - no beat ends before its own floor, and a beat whose point is +# the guide talking does not end mid-sentence +# L. #125 - the convoy is spread out, with its holds surrounded by escorts +# M. #125 - the breach is a growing black hole with a lens over it, not a +# few lines turning around a point +# N. #127 - a dialog stays up until it is dismissed: no timer closes it, +# a click does, and the click is eaten rather than also steering +# O. #127 - every dialog names its speaker +# P. #128 - the prologue opens STOPPED behind its first line, and +# acknowledging it is what starts the world +# Q. #133 - the handover runs straight into the first quest +# R. #139 - no belt rock is laid down inside the visible screen + +var game_state: Node +var scene: Node +var director: Node +var fleet: Node +var narration: Control +var stage: int = 0 +var elapsed: float = 0.0 +var convoy_size: int = 0 +var skipped_props: Array = [] + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.rocks = 0 + + +func _fail(message: String) -> bool: + print("TEST_INTRO_SCENE: FAIL (%s)" % message) + quit(1) + return true + + +func _load_scene() -> void: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + director = scene.get_node_or_null("IntroDirector") + narration = scene.get_node("UI/NarrationBanner") + + +func _drop_scene() -> void: + scene.queue_free() + scene = null + + +func _hidden(path: String) -> bool: + var node: Control = scene.get_node_or_null(path) + return node == null or not node.visible + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A. a new game gets one ------------------------------ + if not game_state.intro_active: + return _fail("reset_new_game() did not arm the prologue") + _load_scene() + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + if director == null: + return _fail("no IntroDirector in the scene") + if not director.running: + return _fail("the director did not start on a new game") + + # --- N. a dialog waits for the reader -------------------- + var first_line: String = narration.label.text + if first_line.strip_edges() == "": + return _fail("the prologue opened with an empty banner") + # Ten seconds of clock, with nothing pressed. A timer-driven + # banner would have moved on several times over. + for i in range(600): + narration._process(1.0 / 60.0) + if narration.label.text != first_line: + return _fail("the line changed on its own after 10s - #127 asks for a click") + if not narration.visible: + return _fail("the banner closed itself") + # --- P. and the game is STOPPED behind it (#128) --------- + if not paused: + return _fail("the prologue opened on a line with the game still running") + if director.beat_time > 0.01: + return _fail("the director's clock ran on %.2fs behind an unread line" \ + % director.beat_time) + print("the prologue opens stopped: beat clock at %.2fs" % director.beat_time) + + var click: InputEventMouseButton = InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.pressed = true + narration._unhandled_input(click) + if narration.label.text == first_line and narration.is_speaking(): + return _fail("a click did not move the dialog on") + print("line held for 10s untouched, then a click moved it on") + + # --- O. and it says who is talking ----------------------- + if narration.speaker.strip_edges() == "": + return _fail("the dialog names no speaker") + if narration.speaker_label.text != narration.speaker: + return _fail("the speaker label does not show the speaker") + # It sits on the top border, at the left - above the text, and + # left of centre. + if narration.speaker_label.position.y >= narration.label.position.y: + return _fail("the speaker sits below the text, not on the top border") + if narration.speaker_label.position.x > narration.size.x * 0.5: + return _fail("the speaker is not at the left of the border") + print("speaker \"%s\" on the top-left border" % narration.speaker) + + # --- K. nothing ends before its floor -------------------- + # The floors are what #125 bought: every beat had a condition + # that could be satisfied while the guide was still talking. + for b in range(director.Beat.AFTERMATH + 1): + if director._floor_time() <= 0.0 and b != director.Beat.RUN: + return _fail("beat %d has no minimum length" % b) + if director.beat != director.Beat.BRIEF: + return _fail("the opening beat was over before the first line finished") + if not narration.is_speaking(): + return _fail("the opening beat is silent, so waiting for it proves nothing") + # Every beat has room to run past its floor before its deadline, + # so a player who will not finish a beat's task is carried past + # it. Since #128 that deadline is game time and a dialog stops + # the clock, so it is a way past an unfinished BEAT, never past + # an unacknowledged line. + if director._deadline() <= director._floor_time(): + return _fail("the opening beat has no deadline past its floor - a stalled player would be stuck") + print("opening beat still running at %.1fs, guide still talking" % director.beat_time) + + # --- C. the convoy -------------------------------------- + convoy_size = fleet.ships.size() + if convoy_size <= GameState.BASE_FLEET_SIZE: + return _fail("the convoy is %d vessels, no more than a new game's %d slots" \ + % [convoy_size, GameState.BASE_FLEET_SIZE]) + var holds: int = 0 + var escorts: int = 0 + for ship in fleet.ships: + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + escorts += 1 + else: + holds += 1 + if holds <= escorts: + return _fail("the convoy is %d holds to %d escorts - it should be mostly cargo" \ + % [holds, escorts]) + if escorts <= 0: + return _fail("the convoy has no escorts at all") + print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts]) + + # --- L. spread out, and the holds are on the inside ------- + var spread: float = 0.0 + var closest: float = 1.0e9 + for a in fleet.ships: + for b in fleet.ships: + if a == b: + continue + spread = maxf(spread, a.position.distance_to(b.position)) + closest = minf(closest, a.position.distance_to(b.position)) + if spread < 400.0: + return _fail("the whole convoy fits in %.0fpx - that is a wedge, not a convoy" % spread) + if closest < 80.0: + return _fail("two convoy vessels are %.0fpx apart" % closest) + # Every hold must be further in than the furthest-out escort is, + # which is what "surrounded by the default vessels" means. + var escort_reach: float = 0.0 + var hold_reach: float = 0.0 + for ship in fleet.ships: + var from_centre: float = ship.position.length() + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + escort_reach = maxf(escort_reach, from_centre) + else: + hold_reach = maxf(hold_reach, from_centre) + if hold_reach >= escort_reach: + return _fail("a hold sits %.0fpx out, past the escorts at %.0f - they are not screened" \ + % [hold_reach, escort_reach]) + print("convoy %.0fpx across, holds within %.0f and escorts out at %.0f" \ + % [spread, hold_reach, escort_reach]) + + # --- B. movement and nothing else ----------------------- + for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton", + "UI/BuildingActionButton", "UI/EnterAtmosphereButton", + "UI/TransferButton", "UI/HangarButton", "UI/LaserButton", + "UI/GasBombButton"]: + if not _hidden(pressable): + return _fail("%s is offered during the prologue" % pressable) + var joystick: Control = scene.get_node("UI/Joystick") + if not joystick.visible: + return _fail("the joystick is hidden - the player cannot even fly") + print("every action button is away; the joystick is not") + + # --- D. the guide speaks -------------------------------- + if not narration.is_speaking(): + return _fail("the prologue opened without a word from the guide") + if narration.label.text.strip_edges() == "": + return _fail("the banner is up with nothing on it") + print("guide: \"%s\"" % narration.label.text) + + # --- E. the ambush -------------------------------------- + director._begin(director.Beat.AMBUSH) + if director.ambush.is_empty(): + return _fail("the ambush beat spawned nothing") + for enemy in director.ambush: + if String(enemy.enemy_id) != "chaser": + return _fail("the ambush includes a %s - it is meant to be easy" % enemy.enemy_id) + if director._beat_complete(): + return _fail("the ambush counts as cleared while its raiders are alive") + for enemy in director.ambush: + enemy.queue_free() + # #128: the beat announced itself, and an unacknowledged line + # stops the tree - including the director, whose advance is what + # stage 2 is waiting for. Acknowledged here, the way a player + # would, so the clock below is running. + narration.clear() + if paused: + return _fail("clearing the last line left the game stopped") + elapsed = 0.0 + stage = 2 + return false + 2: + # Past the ambush beat's own floor (#125): clearing it is what + # ENDS it, but no beat may end before its floor, so watching for + # the advance means waiting that long. + if elapsed < director._floor_time() + 0.5: + return false + # The director advances itself, so the evidence that clearing the + # ambush ended the beat is that it has MOVED ON - asking + # _beat_complete() again would be asking about a beat it left. + if director.beat <= director.Beat.AMBUSH: + return _fail("killing every raider did not end the ambush beat") + print("%d raiders, all chasers, and clearing them ends the beat" % director.AMBUSH_COUNT) + + # --- F. the belt ---------------------------------------- + director._begin(director.Beat.GAUNTLET) + # Only the rocks still standing: the convoy flies into them, and + # a rock it has already broken is not evidence about the belt. + var standing: Array = [] + for rock in director.rocks: + if is_instance_valid(rock) and not rock.is_queued_for_deletion(): + standing.append(rock) + if standing.size() < 40: + return _fail("the \"pretty dense\" belt is %d rocks" % standing.size()) + var ahead: int = 0 + for rock in standing: + if rock.global_position.y < fleet.get_centroid().y: + ahead += 1 + if ahead < standing.size() / 2: + return _fail("only %d of %d rocks are on the lane ahead" \ + % [ahead, standing.size()]) + print("belt: %d rocks, %d of them across the lane ahead" % [standing.size(), ahead]) + + # --- R. #139: and none of it appears on screen ------------ + # The belt used to be laid out with its own arithmetic, which put + # its near edge within a screen of the fleet - so rocks popped + # into existence in view. It goes through the asteroid field's + # own keep-out now, the same one #126 gave the rest of the game. + var field: Node2D = scene.get_node("AsteroidField") + var keep_out: Rect2 = field.keep_out_rect(fleet.get_centroid()) + var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(field) + if visible_rect.size.x > 1.0 and not keep_out.encloses(visible_rect): + return _fail("the belt's keep-out %s does not cover the screen %s" \ + % [keep_out, visible_rect]) + var nearest: float = 1.0e9 + for rock in standing: + if keep_out.has_point(rock.global_position): + return _fail("a belt rock was laid down at %s, inside the view %s" \ + % [rock.global_position, keep_out]) + nearest = minf(nearest, absf(rock.global_position.y - fleet.get_centroid().y)) + print("no belt rock inside the keep-out %.0fx%.0f; nearest is %.0f ahead" \ + % [keep_out.size.x, keep_out.size.y, nearest]) + + # --- G. the breach takes the wheel ---------------------- + director._begin(director.Beat.BREACH) + if not game_state.intro_locks_movement: + return _fail("the breach beat left the player in control") + # GameController.gd is the script on Main itself, not a child. + var before: Vector2 = fleet.fleet_target + scene._process(0.1) + if fleet.fleet_target != before: + return _fail("player steering still moved the fleet during the breach") + print("the breach has the wheel: steering input moves nothing") + + # --- M. the breach is a black hole ---------------------- + var hole: Node2D = director.breach + if hole == null or not is_instance_valid(hole): + return _fail("the breach beat opened no black hole") + if hole.horizon_radius() > 1.0: + return _fail("the hole was already open the moment the beat began") + director._drive_breach(0.5) + var half_open: float = hole.horizon_radius() + director._drive_breach(1.0) + if hole.horizon_radius() <= half_open: + return _fail("the hole does not grow - %.1f at half, %.1f at full" \ + % [half_open, hole.horizon_radius()]) + if hole.disc_radius() <= hole.horizon_radius(): + return _fail("the accretion disc is inside the horizon") + var lens: ColorRect = scene.get_node("UI/BreachOverlay") + if not lens.visible: + return _fail("the screen-space lens never came up") + if lens.material == null or lens.material.shader == null: + return _fail("the lens has no shader on it") + print("breach: horizon %.0f -> %.0f, disc %.0f, lens up" \ + % [half_open, hole.horizon_radius(), hole.disc_radius()]) + + # --- H. the vortex -------------------------------------- + director._begin(director.Beat.VORTEX) + elapsed = 0.0 + stage = 3 + return false + 3: + # Left to the director's own clock rather than ticked by hand: + # the vortex is a timed animation and driving it twice would not + # be the thing that ships. + if elapsed < 4.2: + return false + var alive: int = 0 + for ship in fleet.ships: + if is_instance_valid(ship) and not ship.is_queued_for_deletion(): + alive += 1 + if alive != 1: + return _fail("the vortex left %d vessels, expected 1" % alive) + if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR: + return _fail("the survivor is a %s, not the default interceptor" \ + % fleet.ships[0].vessel_type) + print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1)) + + # --- I. the handover ------------------------------------ + director.finish() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.3: + return false + if game_state.intro_active or game_state.intro_locks_movement: + return _fail("the prologue finished without clearing its own flags") + if game_state.intro_fleet_size != 0: + return _fail("the convoy's fleet-size override outlived the prologue") + if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE: + return _fail("the fleet still holds %d slots after the prologue" \ + % game_state.max_fleet_size()) + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("the game starts with %d vessels, expected %d" \ + % [fleet.ships.size(), GameState.STARTING_SHIP_COUNT]) + if not scene.get_node("EnemySpawner").is_processing(): + return _fail("waves are still switched off after the prologue") + if not scene.get_node("AsteroidField").is_processing(): + return _fail("the asteroid field is still switched off after the prologue") + scene.get_node("BuildController")._process(0.1) + if _hidden("UI/BuildButton"): + return _fail("the build button never came back") + # #133: the guide has nothing more to say - but the quest chain + # starts exactly here, so what is on the banner now is the first + # objective rather than any leftover of the prologue. + var quest: Dictionary = game_state.active_quest() + if quest.is_empty(): + return _fail("the handover left no first quest") + if narration.is_speaking() and narration.current != String(quest.brief): + return _fail("the banner is still on \"%s\" after the handover" \ + % narration.current) + print("the prologue hands over to the first quest: \"%s\"" % quest.title) + if scene.get_node("UI/BreachOverlay").visible: + return _fail("the breach lens is still over the screen after the handover") + if is_instance_valid(director) and director.breach != null: + return _fail("the black hole outlived the prologue") + print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back") + _drop_scene() + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.3: + return false + # --- A (second half). a resumed game gets no prologue ---- + game_state.intro_active = false + _load_scene() + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.4: + return false + if director != null and is_instance_valid(director) and director.running: + return _fail("the prologue ran again on a game that is past it") + if fleet.ships.size() > GameState.BASE_FLEET_SIZE: + return _fail("a resumed game opened with a convoy") + print("a game already past the prologue does not replay it") + _drop_scene() + elapsed = 0.0 + stage = 7 + return false + 7: + if elapsed < 0.3: + return false + # --- J. skipping lands where playing it through does ----- + game_state.reset_new_game() + _load_scene() + elapsed = 0.0 + stage = 8 + return false + 8: + if elapsed < 0.4: + return false + # Skipped mid-prologue, with props on the board, so the check + # below is about clearing them rather than about an empty stage. + director._begin(director.Beat.AMBUSH) + director._begin(director.Beat.GAUNTLET) + skipped_props = director.ambush + director.rocks + if skipped_props.size() < 40: + return _fail("nothing was on the board to skip past") + director.skip() + elapsed = 0.0 + stage = 9 + return false + 9: + if elapsed < 0.3: + return false + if game_state.intro_active or game_state.intro_locks_movement: + return _fail("skipping left the prologue's flags set") + if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE: + return _fail("skipping left the convoy's fleet size behind") + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("skipping left %d vessels flying" % fleet.ships.size()) + for prop in skipped_props: + if is_instance_valid(prop) and not prop.is_queued_for_deletion(): + return _fail("skipping left the prologue's own props on the board") + print("skipping clears %d props and lands where playing it through does" \ + % skipped_props.size()) + + print("TEST_INTRO_SCENE: PASS") + quit(0) + return true + return false diff --git a/tests/test_intro_scene.gd.uid b/tests/test_intro_scene.gd.uid new file mode 100644 index 0000000..cb06c12 --- /dev/null +++ b/tests/test_intro_scene.gd.uid @@ -0,0 +1 @@ +uid://b5elovr5n525a diff --git a/tests/test_lab_research_reward.gd b/tests/test_lab_research_reward.gd index 14e0154..82948bd 100644 --- a/tests/test_lab_research_reward.gd +++ b/tests/test_lab_research_reward.gd @@ -24,6 +24,11 @@ var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") game_state.reset_new_game() + # #108: reset_new_game() arms the prologue, and the prologue opens with a + # convoy rather than the game's own starting fleet. This test is about + # what the GAME starts from, which is where the prologue hands over - + # see test_intro_scene.gd for the prologue itself. + game_state.intro_active = false func _process(delta: float) -> bool: diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd new file mode 100644 index 0000000..10cfea7 --- /dev/null +++ b/tests/test_lancer_enemy.gd @@ -0,0 +1,288 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_lancer_enemy.gd +# Forgejo #96, the lancer: a ramming enemy that announces itself. +# +# "A new enemy vessel tries to suicide itself like the default vessel. +# Though this new enemy starts a bit far from the target, lock it and +# wait a bit. A small back and forth announce its departure to its +# target, in a straight line, quickly. 300ms before it actually starts +# going, a small line goes from it to its target to announce its +# trajectory." +# +# Covers: +# A. it is in the catalog, composes a lunge component and nothing that +# would fight it over position, and rams like the chaser does +# B. from beyond lock_range it does NOT charge - it closes slowly, so the +# player sees it coming +# C. once locked it holds station for the wind-up and rocks back and forth +# along the charge axis instead of drifting toward the target - ONCE +# (#115), because repeating the tell turned a wind-up into a fidget +# D. the trajectory line comes up during the wind-up and lasts about +# 300ms - not the whole wind-up, and not nothing +# E. the charge is straight, fast, and aimed at where the target WAS: a +# ship that moves after the lock is missed, which is what makes the +# telegraph worth reading +# F. it dies on contact, taking the vessel with it +# G. a wave can actually field one, and still fields chasers - a new +# enemy nobody meets is not a new enemy + +var game_state: Node +var main: Node +var fleet: Node +var lancer: Node2D +var target_ship: Node2D +var victim: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var lunge: Node + +var approach_start: float = 0.0 +var windup_seen: bool = false +var windup_anchor: Vector2 = Vector2.ZERO +var max_sway: float = 0.0 +var max_lateral: float = 0.0 +var sway_crossings: int = 0 +var last_side: int = 0 +var telegraph_time_seen: float = 0.0 +var charge_start: Vector2 = Vector2.ZERO +var dodged_to: Vector2 = Vector2.ZERO +var charge_locked: Vector2 = Vector2.ZERO +var max_off_axis: float = 0.0 +var charge_seen: bool = false +var destroyed_fired: bool = false + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + + +func _spawn_lancer(at: Vector2) -> Node2D: + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.enemy_id = "lancer" + enemy.position = at + main.add_child(enemy) + return enemy + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + + # --- A. catalogued, composed, and a rammer ------------------- + var definitions: Script = load("res://enemies/EnemyDefinitions.gd") + var definition: Resource = definitions.get_definition("lancer") + if definition.enemy_id != "lancer": + return _fail("EnemyDefinitions has no \"lancer\" - get_definition fell back") + if not bool(definition.dies_on_contact): + return _fail("the lancer is not marked dies_on_contact, so it is not a rammer") + + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + target_ship = fleet.ships[0] + # Ship steers toward move_target every frame; pinning it there is + # how a vessel is held still (see test_sniper_enemy.gd). + target_ship.move_target = target_ship.position + + lancer = _spawn_lancer(target_ship.global_position + Vector2(1300.0, 0.0)) + lunge = lancer.get_component("lunge") + if lunge == null: + return _fail("the lancer composed no lunge component") + if lancer.has_capability("pursuit") or lancer.has_capability("standoff"): + return _fail("the lancer also carries a mover that would fight the lunge for position") + if 1300.0 <= lunge.lock_range: + return _fail("the test spawns inside lock_range, so case B proves nothing") + print("lancer: hp %d, lock_range %.0f, windup %.2fs, telegraph %.2fs" % [ + lancer.max_hp, lunge.lock_range, lunge.windup_time, lunge.telegraph_time]) + approach_start = lancer.global_position.distance_to(target_ship.global_position) + elapsed = 0.0 + stage = 1 + return false + 1: + # --- B. it approaches rather than charging from out there ---- + if elapsed < 0.6: + if lunge.phase == lunge.Phase.CHARGE: + return _fail("the lancer charged from beyond its lock_range") + return false + var closed: float = approach_start - lancer.global_position.distance_to( + target_ship.global_position) + if closed <= 1.0: + return _fail("the lancer never closed on the fleet from %.0f away" % approach_start) + # The stalk must be slower than the charge, or the "starts a bit + # far, waits" reading is lost. + var approach_speed: float = closed / elapsed + if approach_speed >= lunge.charge_speed * 0.5: + return _fail("it approached at %.0fpx/s, barely short of its %.0fpx/s charge" % [ + approach_speed, lunge.charge_speed]) + print("closed %.0fpx in %.1fs (%.0fpx/s) without charging" % [ + closed, elapsed, approach_speed]) + elapsed = 0.0 + stage = 2 + return false + 2: + # --- C/D. wind-up: rocks in place, and puts the line up late -- + if lunge.phase == lunge.Phase.WINDUP: + if not windup_seen: + windup_seen = true + windup_anchor = lunge.charge_anchor + charge_locked = lunge.locked_point + var offset: Vector2 = lancer.global_position - windup_anchor + var along: float = offset.dot(lunge.charge_direction) + max_sway = maxf(max_sway, absf(along)) + # One back-and-forth crosses the anchor exactly once: out one + # way, back through it, out the other. Two would cross three + # times. + if absf(along) > 2.0: + var side: int = 1 if along > 0.0 else -1 + if last_side != 0 and side != last_side: + sway_crossings += 1 + last_side = side + var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x) + max_lateral = maxf(max_lateral, absf(offset.dot(across))) + if lunge.is_telegraphing(): + telegraph_time_seen += delta + if dodged_to == Vector2.ZERO: + # What the warning is FOR: the moment the line shows, + # the ship steps off it. The lock is already frozen, + # so this must not drag the charge along with it. + var sidestep: Vector2 = Vector2(-lunge.charge_direction.y, + lunge.charge_direction.x) * 400.0 + target_ship.position += sidestep + target_ship.move_target = target_ship.position + dodged_to = target_ship.position + return false + if not windup_seen: + if elapsed > 6.0: + return _fail("the lancer never entered a wind-up within 6s of locking range") + return false + if lunge.phase != lunge.Phase.CHARGE: + return _fail("the wind-up ended in phase %d rather than a charge" % lunge.phase) + + if max_sway < 1.0: + return _fail("the lancer sat perfectly still through the wind-up - no back and forth") + if max_sway > lunge.windup_sway + 1.0: + return _fail("it moved %.0fpx along the charge axis, more than the %.0fpx sway - that is departing early" % [ + max_sway, lunge.windup_sway]) + if max_lateral > 1.0: + return _fail("it wandered %.0fpx sideways during the wind-up instead of holding station" % max_lateral) + if sway_crossings != 1: + return _fail("the wind-up crossed its anchor %d times - #115 asks for exactly one back and forth" \ + % sway_crossings) + print("wind-up: rocked %.0fpx back and forth once, %.0fpx sideways" % [max_sway, max_lateral]) + + # 300ms of line, give or take the frames either side of the + # threshold. Both bounds matter: a line up for the whole wind-up + # is not a 300ms warning either. + if absf(telegraph_time_seen - lunge.telegraph_time) > 0.1: + return _fail("the trajectory line showed for %.2fs, not the %.2fs it promises" % [ + telegraph_time_seen, lunge.telegraph_time]) + if lunge.telegraph_time >= lunge.windup_time: + return _fail("the line covers the whole wind-up, so it announces nothing") + print("trajectory line up for %.2fs of the %.2fs wind-up" % [ + telegraph_time_seen, lunge.windup_time]) + + charge_start = lancer.global_position + elapsed = 0.0 + stage = 3 + return false + 3: + # --- E. straight, fast, and at the locked point -------------- + if lunge.phase == lunge.Phase.CHARGE: + charge_seen = true + var travel: Vector2 = lancer.global_position - charge_start + var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x) + max_off_axis = maxf(max_off_axis, absf(travel.dot(across))) + return false + if not charge_seen: + return _fail("the charge was over before a single frame of it was observed") + if dodged_to == Vector2.ZERO: + return _fail("the trajectory line never showed, so nothing was dodged") + if not is_instance_valid(target_ship) or target_ship.is_queued_for_deletion(): + return _fail("the ship stepped off the telegraphed line and was hit anyway") + if max_off_axis > 1.0: + return _fail("the charge bent %.0fpx off its locked axis - it homed instead of flying straight" % max_off_axis) + var covered: float = charge_start.distance_to(lancer.global_position) + var speed: float = covered / maxf(elapsed, 0.001) + if speed < lunge.charge_speed * 0.8: + return _fail("the charge averaged %.0fpx/s against a stated %.0fpx/s" % [ + speed, lunge.charge_speed]) + # It aimed at the locked point, not at wherever the ship is now. + var passed_by: float = (charge_locked - target_ship.global_position).length() + if passed_by < 200.0: + return _fail("the charge followed the ship as it dodged - the lock is not frozen") + print("charged %.0fpx at %.0fpx/s, %.0fpx off axis, locked point %.0fpx from the ship" % [ + covered, speed, max_off_axis, passed_by]) + lancer.queue_free() + + # --- F. contact kills both ---------------------------------- + fleet.spawn_ships(1, Vector2(4000.0, 0.0), PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + victim = fleet.ships[0] + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.2: + return false + lancer = _spawn_lancer(victim.global_position) + lancer.destroyed.connect(func(): destroyed_fired = true) + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.5: + return false + if is_instance_valid(lancer) and not lancer.is_queued_for_deletion(): + return _fail("the lancer survived ramming a vessel") + if not destroyed_fired: + return _fail("the lancer vanished without firing `destroyed` - not routed through take_damage") + if is_instance_valid(victim) and not victim.is_queued_for_deletion(): + return _fail("the rammed vessel survived, so the lancer cost nothing to stop") + print("lancer and vessel destroyed each other") + + # --- G. waves can field one --------------------------------- + # This originally checked the roaming spawner's share of lancers. + # #100 removed that spawner entirely: the way a lancer reaches + # the player now is a wave, so that is what has to be able to + # produce one - and it must not have displaced the chaser while + # doing it. + var kinds: Dictionary = {} + var lancer_at: int = 0 + for size in range(1, 12): + for id in game_state.wave_composition(size): + kinds[id] = true + if id == "lancer" and lancer_at == 0: + lancer_at = size + if lancer_at == 0: + return _fail("no wave against a fleet of 1..11 contains a lancer") + if not kinds.has("chaser"): + return _fail("lancers displaced the chaser out of every wave") + print("waves field lancers from a fleet of %d up, alongside %s" \ + % [lancer_at, str(kinds.keys())]) + + print("TEST_LANCER_ENEMY: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_LANCER_ENEMY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_lancer_enemy.gd.uid b/tests/test_lancer_enemy.gd.uid new file mode 100644 index 0000000..2f06979 --- /dev/null +++ b/tests/test_lancer_enemy.gd.uid @@ -0,0 +1 @@ +uid://djjgsxcr37g0j diff --git a/tests/test_laser_animation.gd b/tests/test_laser_animation.gd new file mode 100644 index 0000000..28e763b --- /dev/null +++ b/tests/test_laser_animation.gd @@ -0,0 +1,163 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_laser_animation.gd +# Forgejo #90: the laser shot was one flat line that blinked on at full +# length and faded out. Nothing happened at the ship, nothing moved, and the +# only thing separating a hit from a miss was how long the line lingered. +# +# It now ignites: the blade runs out of the hull over the first slice of the +# flash, holds white-hot, and falls away, with sparks thrown off the hull +# that fired it. +# +# An animation cannot be eyeballed from a headless test, so this asserts the +# things that MAKE it one, and - more importantly - that none of it reached +# the gameplay underneath: +# A. the beam extends: early in the flash it is drawn well short of its +# full length, and by the end of the ignition it has arrived +# B. the extension only ever moves outward, never back down the blade +# C. the flash outlives the frame it was fired on, and clears itself +# D. the DAMAGE is still instant, at full range, on the frame of the shot - +# the picture extends, the weapon does not. A target at the far end +# must be hit before the drawn beam has visibly got there +# E. the hull throws sparks, in the beam's own colour rather than the +# ordinary gun's gold + +var game_state: Node +var main: Node +var fleet: Node +var laser: Node +var far_enemy: Node +var far_health: int = 0 +var extensions: Array = [] +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.stored_vessels.clear() + game_state.reset_progression() + game_state.vessel_levels["unlock_laser"] = 1 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + laser = main.get_node("LaserController") + + +func _sparks() -> Array: + var found: Array = [] + for node in root.get_children(): + if node.get_script() != null \ + and String(node.get_script().resource_path).ends_with("MuzzleSparkVFX.gd"): + found.append(node) + return found + + +func _spawn_enemy(at: Vector2) -> Node: + var enemy: Node = load("res://enemies/Enemy.tscn").instantiate() + enemy.position = at + main.add_child(enemy) + # Still, so where it was hit is where it was put. + enemy.speed = 0.0 + return enemy + + +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) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + for spark in _sparks(): + spark.queue_free() + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_LASER])) + laser._process(0.0) + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + var shooter: Node2D = laser._next_ready_laser_ship() + if shooter == null: + return _fail("no laser ship was ready to fire") + + # D. The target sits at the very end of the beam's reach, in the + # direction it will be fired. If the shot only damaged what the + # drawn blade had swept, this would survive the frame. + var reach: float = shooter.get_component("laser").beam_range + far_enemy = _spawn_enemy(shooter.global_position + Vector2(reach - 40.0, 0.0)) + far_health = far_enemy.hp + laser.fire_at(shooter.global_position + Vector2(reach, 0.0)) + + if laser.beams.is_empty(): + return _fail("firing produced no beam at all") + if laser.last_hit_count < 1: + return _fail("the target at the far end of the beam was not hit on the frame of the shot") + # It may have died outright, which is still a hit; if it lived it + # must have lost hit points. + if is_instance_valid(far_enemy) and far_enemy.hp >= far_health: + return _fail("the far target survived the shot untouched") + print("full-range target hit on the firing frame: %d hp -> %s" % [ + far_health, + str(far_enemy.hp) if is_instance_valid(far_enemy) else "destroyed", + ]) + + # A. and the picture has NOT got there yet on that same frame. + var first: float = laser.beam_extension(laser.beams[0]) + if first >= 0.5: + return _fail("the beam was already %.0f%% extended on the frame it was fired" \ + % [first * 100.0]) + extensions.append(first) + + # E. sparks off the hull, in the beam's colour. + var sparks: Array = _sparks() + if sparks.is_empty(): + return _fail("firing threw no sparks off the hull") + if sparks[0].color != laser.BEAM_COLOR: + return _fail("the muzzle sparks are %s, not the beam's own %s" \ + % [sparks[0].color, laser.BEAM_COLOR]) + print("hull sparks in the beam's colour: %s" % sparks[0].color) + + stage = 2 + elapsed = 0.0 + return false + 2: + # B/C. Sample the extension across the ignition. + if laser.beams.is_empty(): + if extensions.size() < 3: + return _fail("the flash was over after only %d frames - nothing to animate" \ + % extensions.size()) + if extensions[extensions.size() - 1] < 0.999: + return _fail("the beam never reached its full length (stopped at %.2f)" \ + % extensions[extensions.size() - 1]) + print("beam extended over %d frames: %.2f -> %.2f, monotonically" % [ + extensions.size(), extensions[0], extensions[extensions.size() - 1]]) + print("TEST_LASER_ANIMATION: PASS") + quit(0) + return true + var now: float = laser.beam_extension(laser.beams[0]) + if now < extensions[extensions.size() - 1] - 0.001: + return _fail("the beam retracted: %.3f after %.3f" \ + % [now, extensions[extensions.size() - 1]]) + extensions.append(now) + if elapsed > 3.0: + return _fail("the beam flash never cleared") + return false + return false + + +func _fail(message: String) -> bool: + print("TEST_LASER_ANIMATION: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_laser_animation.gd.uid b/tests/test_laser_animation.gd.uid new file mode 100644 index 0000000..39ca389 --- /dev/null +++ b/tests/test_laser_animation.gd.uid @@ -0,0 +1 @@ +uid://6v17x47nyfya diff --git a/tests/test_menu_polish.gd b/tests/test_menu_polish.gd new file mode 100644 index 0000000..be4cc1d --- /dev/null +++ b/tests/test_menu_polish.gd @@ -0,0 +1,264 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_menu_polish.gd +# Forgejo #111, #112, #119 and #120 - four panels that were hard to read, +# each for its own reason. Grouped because they are all "what does this +# panel actually show", and because each alone is a handful of assertions. +# +# Covers: +# A. #111 - the craft menu names no resources: prices are glyphs, and +# every vessel gets a caption that does not overlap its neighbour +# B. #111 - and a price strip really carries the vessel's cost +# D. #143 - a hangar caption fits the box it was given and cannot draw +# outside it, so it never lands on the icon of the row below +# E. #147 - a price with several resources goes one per line and stays +# inside its own box, close to the button it is about +# F. #147 - and no craft entry overlaps another +# C. #120 - the hangar's captions have room for two lines, and there is a +# rule between what is parked and what is flying +# D. #112 - every skill node has a mark, and a node that unlocks a +# building uses that building's own icon +# E. #119 - the command centre draws the vessels actually in the fleet, +# not one placeholder repeated + +var game_state: Node +var scene: Node +var controller: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.reset_formation() + game_state.intro_active = false + game_state.rocks = 5000 + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + controller = scene.get_node("BuildController") + + +func _fail(message: String) -> bool: + print("TEST_MENU_POLISH: FAIL (%s)" % message) + quit(1) + return true + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.4: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- A. the craft menu spells no resource out ------------- + controller.craft_building_id = 1 + controller._refresh_craft_status() + # The caption is the vessel's NAME and nothing else. Checked as an + # exact match rather than by hunting for resource words in it: a + # Gas Bomber is named after a resource and always will be, and + # what #111 removed was the PRICE spelled out underneath. + var fleet_defs: Array = VesselDefinitions.fleet() + for i in range(mini(controller.craft_vessel_labels.size(), fleet_defs.size())): + var caption: String = String(controller.craft_vessel_labels[i].text) + var expected: String = String(fleet_defs[i].display_name) + if caption != expected: + return _fail("a craft caption reads \"%s\", expected just \"%s\"" \ + % [caption, expected]) + # Captions must not sit on top of one another, which is what + # made the old single row unreadable. + for i in range(controller.craft_vessel_labels.size()): + for j in range(i + 1, controller.craft_vessel_labels.size()): + var a: Control = controller.craft_vessel_labels[i] + var b: Control = controller.craft_vessel_labels[j] + if Rect2(a.position, a.size).intersects(Rect2(b.position, b.size)): + return _fail("craft captions %d and %d overlap" % [i, j]) + print("craft captions: %d, one line each, none overlapping" \ + % controller.craft_vessel_labels.size()) + + # --- B. and the price is really there --------------------- + if controller.craft_price_strips.size() != controller.craft_vessel_buttons.size(): + return _fail("%d price strips for %d vessels" \ + % [controller.craft_price_strips.size(), + controller.craft_vessel_buttons.size()]) + var priced: int = 0 + var stacked: int = 0 + for i in range(controller.craft_price_strips.size()): + var definition: Resource = VesselDefinitions.fleet()[i] + var costs: Dictionary = game_state.product_costs(definition.definition_id) + var strip: Control = controller.craft_price_strips[i] + if costs.is_empty(): + continue + if strip.is_free(): + return _fail("%s costs %s and its strip is empty" \ + % [definition.definition_id, str(costs)]) + priced += 1 + # --- E. #147 - a price stays inside its own box ---------- + # A two-resource price used to be laid out across a strip + # narrower than it needed, so it ran off the end and into the + # vessel beside it. + if costs.size() > 1 and not strip.is_stacked(): + return _fail("%s is priced in %d resources on one line - they will not fit %.0fpx" \ + % [definition.definition_id, costs.size(), strip.size.x]) + if costs.size() > 1: + stacked += 1 + if strip.wanted_height() > strip.size.y + 0.5: + return _fail("%s's price needs %.0fpx and its box is %.0f" \ + % [definition.definition_id, strip.wanted_height(), strip.size.y]) + for child in strip.get_children(): + var box: Rect2 = Rect2((child as Control).position, (child as Control).size) + if box.position.x < -0.5 or box.end.x > strip.size.x + 0.5: + return _fail("%s's price runs from %.0f to %.0f in a %.0fpx strip" \ + % [definition.definition_id, box.position.x, box.end.x, strip.size.x]) + # ...and the price sits with the button it is about, not + # adrift below the caption. + var button: Control = controller.craft_vessel_buttons[i] + var gap: float = strip.position.y - (button.position.y + button.size.y) + if gap > 80.0: + return _fail("%s's price sits %.0fpx below its button" \ + % [definition.definition_id, gap]) + if priced == 0: + return _fail("no vessel showed a price at all") + if stacked == 0: + return _fail("no vessel costs more than one resource - E proves nothing") + print("%d vessels show their price as glyphs, %d of them stacked one per line" \ + % [priced, stacked]) + + # --- F. #147 - and the rows do not run into each other ------- + for i in range(controller.craft_vessel_buttons.size()): + for j in range(i + 1, controller.craft_vessel_buttons.size()): + for part in [controller.craft_vessel_buttons, + controller.craft_vessel_labels, controller.craft_price_strips]: + if i >= part.size() or j >= part.size(): + continue + var a: Control = part[i] + var b: Control = part[j] + if Rect2(a.position, a.size).intersects(Rect2(b.position, b.size)): + return _fail("craft entries %d and %d overlap" % [i, j]) + # A price must not reach the button of the row below it. + var mine: Control = controller.craft_price_strips[i] + for k in range(controller.craft_vessel_buttons.size()): + var other: Control = controller.craft_vessel_buttons[k] + if other.position.y > mine.position.y \ + and Rect2(mine.position, mine.size).intersects( + Rect2(other.position, other.size)): + return _fail("the price under vessel %d runs into vessel %d's button" % [i, k]) + print("craft grid: nothing overlapping, prices under their own buttons") + + # --- C. the hangar --------------------------------------- + controller._layout_hangar() + if controller.hangar_divider_y <= 0.0: + return _fail("the hangar laid out with no divider between its halves") + var available_bottom: float = 0.0 + var fleet_top: float = 1.0e9 + for label in controller.hangar_available_labels: + available_bottom = maxf(available_bottom, label.position.y + label.size.y) + for slot in controller.hangar_fleet_slots: + fleet_top = minf(fleet_top, slot.position.y) + if not (controller.hangar_divider_y > available_bottom - 40.0 + and controller.hangar_divider_y < fleet_top): + return _fail("the divider at %.0f is not between the two grids (%.0f / %.0f)" \ + % [controller.hangar_divider_y, available_bottom, fleet_top]) + # Two lines of caption. The font is 24-ish, so anything under + # about 44 cannot show a second line at all. + var caption_height: float = controller.hangar_available_labels[0].size.y + if caption_height < 44.0: + return _fail("hangar captions are %.0fpx tall - a two-word name is clipped" \ + % caption_height) + print("hangar: %.0fpx captions, divider at %.0f between %.0f and %.0f" \ + % [caption_height, controller.hangar_divider_y, available_bottom, fleet_top]) + + # --- D. #143 - and the caption stays in its box -------------- + # The caption is two lines before autowrap and three after it in + # a narrow column, at a size fixed in the scene - so the third + # line was drawn over the icon of the row below. It is fitted to + # the box now, and the box clips, so nothing can reach the next + # row however the vessel names grow. + controller._refresh_hangar() + for pair in [[controller.hangar_available_labels, controller.hangar_available_slots], + [controller.hangar_fleet_labels, controller.hangar_fleet_slots]]: + for i in range(pair[0].size()): + var caption: Label = pair[0][i] + if not caption.clip_text: + return _fail("hangar caption %d can draw outside its box" % i) + var font: Font = caption.get_theme_font("font") + var size_px: int = caption.get_theme_font_size("font_size") + var needed: float = font.get_multiline_string_size(caption.text, + HORIZONTAL_ALIGNMENT_CENTER, caption.size.x, size_px).y + if needed > caption.size.y + 0.5: + return _fail("caption \"%s\" needs %.0fpx at size %d and has %.0f" \ + % [caption.text.replace("\n", " / "), needed, size_px, caption.size.y]) + # And the box itself must not reach the row below it. + for slot in pair[1]: + var below: Rect2 = Rect2(slot.position, slot.size) + if below.position.y > caption.position.y \ + and Rect2(caption.position, caption.size).intersects(below): + return _fail("caption \"%s\" runs into the icon below it" \ + % caption.text.replace("\n", " / ")) + var probe: Label = controller.hangar_available_labels[0] + print("captions fitted to %dpx and clipped; \"%s\" fits its %.0fpx box" \ + % [probe.get_theme_font_size("font_size"), + probe.text.replace("\n", " / "), probe.size.y]) + + # --- D. every skill node has a mark ----------------------- + var tree_view: Control = scene.get_node("UI/SkillMenu/Tree") + var marked: int = 0 + for node in GameState.SKILL_TREE: + var icon: int = game_state.skill_node_icon(node) + if icon < 0: + return _fail("skill node \"%s\" has no icon" % node.id) + marked += 1 + var unlocks: String = String(node.get("unlocks", "")) + if unlocks == "": + continue + var definition: Resource = game_state.building_definition(unlocks) + if definition != null and icon != int(definition.menu_icon): + return _fail("\"%s\" unlocks the %s but draws a different icon" \ + % [node.id, unlocks]) + if tree_view._glyphs.size() != marked: + return _fail("%d nodes have icons but the tree built %d glyphs" \ + % [marked, tree_view._glyphs.size()]) + print("%d skill nodes, every one marked, unlocks matching their building" % marked) + + # --- E. the formation shows the real fleet ---------------- + scene.get_node("Fleet").spawn_ships(2, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD])) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + var editor: Control = scene.get_node("UI/FormationMenu/Editor") + var crewed: PackedStringArray = game_state.fleet_vessel_types + if crewed.size() != 2: + return _fail("the fleet mirror holds %d types, expected 2" % crewed.size()) + var interceptor: Resource = game_state.vessel_definition(GameState.VESSEL_INTERCEPTOR) + var shield: Resource = game_state.vessel_definition(GameState.VESSEL_SHIELD) + if interceptor.hull_points == shield.hull_points: + return _fail("the two vessels share a hull, so this case proves nothing") + if not editor.has_method("_draw_vessel_slot"): + return _fail("the editor has no per-vessel drawing at all") + # The panel draws from the mirror, so the mirror having the right + # types in the right order IS what makes the markers differ. + if String(crewed[0]) != GameState.VESSEL_INTERCEPTOR \ + or String(crewed[1]) != GameState.VESSEL_SHIELD: + return _fail("the fleet mirror is %s, not the order they were crewed in" \ + % str(crewed)) + print("formation draws a %s and a %s, in the slots they fly in" \ + % [interceptor.display_name, shield.display_name]) + + print("TEST_MENU_POLISH: PASS") + quit(0) + return true + return false diff --git a/tests/test_menu_polish.gd.uid b/tests/test_menu_polish.gd.uid new file mode 100644 index 0000000..7d6d751 --- /dev/null +++ b/tests/test_menu_polish.gd.uid @@ -0,0 +1 @@ +uid://ducxwce34qlhp diff --git a/tests/test_menu_price_unification.gd b/tests/test_menu_price_unification.gd new file mode 100644 index 0000000..82e138e --- /dev/null +++ b/tests/test_menu_price_unification.gd @@ -0,0 +1,192 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_menu_price_unification.gd +# Forgejo #137: "the resources at the top are not always shown the same way, +# the price is not always put at the same place... the build menu, the +# research menu, the ship crafter menu would benefit from a more unified +# UX." And #134, which is the same complaint aimed at the skill panel: the +# explanation could land on top of the price, and a shortfall was a sentence +# instead of a number. +# +# The point of the ticket is CONSISTENCY, which is a property of several +# things at once - so this test compares the menus against each other rather +# than against written-down coordinates. A menu that moves is fine; a menu +# that moves away from the others is the bug. +# +# Covers: +# A. every buying menu has a resource bar, in the same place, the same size +# B. every price is the same widget - one class, not three presentations +# C. a price says what you HAVE as well as what it COSTS, with the held +# figure smaller than the price (#134) and red when it is short +# D. #134 - the skill panel's explanation is left of its price, not on top +# of it, and the two do not overlap at any description length +# E. the shortfall is no longer also spelled out in a sentence + +const PRICED_MENUS: Array = ["BuildMenu", "CraftMenu", "LabMenu"] +# Compared by SCRIPT PATH, as a string, and that is not fussiness. +# ResourceBar and PriceTag both read the GameState autoload, and an +# `extends SceneTree` script that so much as pulls one of them in - naming +# the class, or preloading the file - fails to compile, because GameState is +# not an identifier in this context. Worse, GDScript caches that failure, so +# the scene instantiated a few lines below would get a Control with no script +# where its resource bar should be. A string names the file without asking +# for it, and "is this the same script" is the question anyway. +const RESOURCE_BAR_PATH: String = "res://ui/ResourceBar.gd" +const PRICE_TAG_PATH: String = "res://ui/PriceTag.gd" + +var game_state: Node +var scene: Node +var controller: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + game_state.rocks = 40 + + +func _fail(message: String) -> bool: + print("TEST_MENU_PRICE_UNIFICATION: FAIL (%s)" % message) + quit(1) + return true + + +func _script_path(node: Node) -> String: + var script: Script = node.get_script() + return "" if script == null else script.resource_path + + +# The RESOLVED rectangle, not the offsets. Two Controls with different +# anchors have offsets measured from different edges, so comparing those +# compares nothing - and subtracting them produces negative sizes, which +# Rect2.intersects refuses outright. +func _rect(node: Control) -> Rect2: + return node.get_rect() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + controller = scene.get_node("BuildController") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + + # --- A. the same bar, in the same place ------------------ + var bars: Dictionary = {} + for menu in PRICED_MENUS: + var bar: Control = scene.get_node_or_null("UI/%s/Bar" % menu) + if bar == null: + return _fail("%s has no resource bar - the other menus do" % menu) + if _script_path(bar) != RESOURCE_BAR_PATH: + return _fail("%s's bar is not a ResourceBar" % menu) + bars[menu] = bar + var reference: Rect2 = _rect(bars[PRICED_MENUS[0]]) + for menu in PRICED_MENUS: + var here: Rect2 = _rect(bars[menu]) + if here != reference: + return _fail("%s's resource bar sits at %s, the others at %s" \ + % [menu, here, reference]) + if bars[menu].anchor_right != bars[PRICED_MENUS[0]].anchor_right: + return _fail("%s's resource bar is anchored differently" % menu) + print("%d menus, one resource bar geometry: %s" % [PRICED_MENUS.size(), reference]) + + # --- B. one price widget everywhere ---------------------- + var tags: Array = [] + for entry in controller.build_menu_entries: + tags.append(entry[3]) + tags.append_array(controller.craft_price_strips) + tags.append_array(controller.lab_price_tags) + tags.append(controller.info_cost) + tags.append(controller.skill_cost) + if tags.size() < 15: + return _fail("only %d price widgets found - the menus cannot all be covered" % tags.size()) + for tag in tags: + if _script_path(tag) != PRICE_TAG_PATH: + return _fail("a price is drawn by %s, not the shared price tag" \ + % _script_path(tag)) + print("%d prices across the build, craft, lab and skill menus, all one widget" % tags.size()) + + # --- C. have over cost, smaller, red when short ---------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 3) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) + var probe: Control = controller.info_cost + probe.show_price({GameState.RESOURCE_ORE: 25}) + if probe.priced(GameState.RESOURCE_ORE) != 25: + return _fail("the tag does not carry the price it was given") + if probe.held(GameState.RESOURCE_ORE) != 3: + return _fail("the tag reads %d held, the player has 3" \ + % probe.held(GameState.RESOURCE_ORE)) + if not probe.is_short(GameState.RESOURCE_ORE): + return _fail("3 out of 25 is not marked short") + if probe.is_affordable(): + return _fail("a tag the player cannot pay reads as affordable") + # Read off the labels the player actually sees, rather than off + # the constants behind them. + var entry: Dictionary = probe._entry_for(GameState.RESOURCE_ORE) + var have_size: int = entry.have_label.get_theme_font_size("font_size") + var cost_size: int = entry.cost_label.get_theme_font_size("font_size") + if have_size >= cost_size: + return _fail("what you hold (size %d) is not drawn smaller than the price (size %d)" \ + % [have_size, cost_size]) + var short_colour: Color = entry.have_label.get_theme_color("font_color") + if short_colour.r <= short_colour.g or short_colour.r <= short_colour.b: + return _fail("the short figure is drawn in %s, which is not a warning colour" % short_colour) + print("price tag: %d/%d ore, held figure at size %d in %s, price at size %d" \ + % [probe.held(GameState.RESOURCE_ORE), probe.priced(GameState.RESOURCE_ORE), + have_size, short_colour, cost_size]) + # ...and the affordable case is not red, or the colour says nothing. + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 900) + probe.show_price({GameState.RESOURCE_ORE: 25}) + if probe.is_short(GameState.RESOURCE_ORE) or not probe.is_affordable(): + return _fail("900 ore does not cover a price of 25") + if probe._entry_for(GameState.RESOURCE_ORE).have_label \ + .get_theme_color("font_color") == short_colour: + return _fail("an affordable price is still drawn in the shortfall colour") + print("and an affordable price is drawn differently") + + # --- D. #134: the explanation is beside the price -------- + var longest: String = "" + for node in GameState.SKILL_TREE: + if String(node.desc).length() > longest.length(): + longest = String(node.desc) + controller._refresh_skill_menu(String(node.id)) + var detail: Rect2 = _rect(controller.skill_detail) + var price: Rect2 = _rect(controller.skill_cost) + if detail.intersects(price): + return _fail("the explanation (%s) overlaps the price (%s)" % [detail, price]) + if detail.position.x >= price.position.x: + return _fail("the explanation is not left of the price") + print("skill panel: explanation at x=%.0f, price at x=%.0f, no overlap (longest of %d descriptions)" \ + % [detail.position.x, price.position.x, GameState.SKILL_TREE.size()]) + + # --- E. and the shortfall is not also a sentence --------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.unlocked.clear() + controller._refresh_skill_menu("shipyards") + if controller.skill_cost.is_affordable(): + return _fail("a node reads as affordable with no ore at all") + if controller.skill_status.text.to_lower().contains("not enough"): + return _fail("the shortfall is still written out as well: '%s'" \ + % controller.skill_status.text) + print("the shortfall is shown once, on the price, and not repeated in words") + + print("TEST_MENU_PRICE_UNIFICATION: PASS") + quit(0) + return true + return false diff --git a/tests/test_menu_price_unification.gd.uid b/tests/test_menu_price_unification.gd.uid new file mode 100644 index 0000000..a792cc7 --- /dev/null +++ b/tests/test_menu_price_unification.gd.uid @@ -0,0 +1 @@ +uid://py5ckuutwdi6 diff --git a/tests/test_mothership_replacement.gd b/tests/test_mothership_replacement.gd index 2fb9cdd..fd264d7 100644 --- a/tests/test_mothership_replacement.gd +++ b/tests/test_mothership_replacement.gd @@ -111,7 +111,7 @@ func _process(delta: float) -> bool: controller._confirm_build() var only_one: bool = _count_motherships() == 1 var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 - var charged: bool = game_state.rocks == rocks_before - GameState.MOTHERSHIP_COST + var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) print("confirmed: motherships=%d moved=%s rocks %d->%d charged=%s" % [ _count_motherships(), moved, rocks_before, game_state.rocks, charged ]) @@ -121,7 +121,7 @@ func _process(delta: float) -> bool: return true # --- unaffordable replacement must not destroy the old one --- - game_state.rocks = GameState.MOTHERSHIP_COST - 1 + game_state.rocks = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) - 1 _place_fleet(Vector2(2500.0, -800.0)) controller._on_build_pressed() controller._show_building_info(GameState.BUILDING_MOTHERSHIP) diff --git a/tests/test_music_theme.gd b/tests/test_music_theme.gd new file mode 100644 index 0000000..f0cf2f0 --- /dev/null +++ b/tests/test_music_theme.gd @@ -0,0 +1,149 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_music_theme.gd +# Forgejo #148: the main theme plays all the time. +# +# "All the time" is the requirement, so what is checked is CONTINUITY rather +# than "a sound was started once": the same player has to survive the main +# menu becoming the game, the game becoming a planet surface, and the +# surface becoming the game again - and there must never be two of it, +# because a second copy is the theme playing over itself. +# +# Covers: +# A. loading a scene starts the theme +# B. it is parented to the tree root, not to the scene - the thing that +# makes it survive change_scene_to_file at all +# C. the stream loops, so "all the time" outlasts one play-through of it +# D. it keeps playing while a dialog has the game stopped (#128) +# E. swapping scenes neither restarts it nor stacks a second one up + +var game_state: Node +var scene: Node +var player: AudioStreamPlayer +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_MUSIC_THEME: FAIL (%s)" % message) + quit(1) + return true + + +func _players() -> Array: + var found: Array = [] + for node in root.get_children(): + if node is AudioStreamPlayer: + found.append(node) + return found + + +func _load(path: String) -> void: + scene = load(path).instantiate() + root.add_child(scene) + current_scene = scene + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if not _players().is_empty(): + return _fail("something was already playing before any scene loaded") + _load("res://world/Main.tscn") + elapsed = 0.0 + stage = 1 + return false + 1: + # The player is added deferred, so it arrives a frame later. + if elapsed < 0.4: + return false + # --- A and B. it exists, at the root --------------------- + var players: Array = _players() + if players.size() != 1: + return _fail("%d music players at the root, expected 1" % players.size()) + player = players[0] + if player.get_parent() != root: + return _fail("the music player hangs off %s - it would die with the scene" \ + % player.get_parent()) + if not player.playing: + return _fail("the theme is not playing") + if player.stream == null: + return _fail("the music player has no stream on it") + print("theme playing from %s, parented to the tree root" % player.stream.resource_path) + + # --- C. and it comes round again ------------------------- + if not ("loop" in player.stream) or not player.stream.loop: + return _fail("the theme does not loop - it would fall silent after one play") + if player.stream.get_length() < 10.0: + return _fail("the theme is %.1fs long - that is not a main theme" \ + % player.stream.get_length()) + print("%.0fs of theme, looping" % player.stream.get_length()) + + # --- D. and a dialog does not stop it -------------------- + var narration: Control = scene.get_node("UI/NarrationBanner") + narration.say("Stopping everything else.", "TEST") + if not paused: + return _fail("the dialog did not stop the game, so this proves nothing") + if not player.can_process(): + return _fail("the music stops whenever a dialog is up") + print("a dialog stops the game and the music keeps playing") + while narration.dismiss(): + pass + + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + # --- E. through a scene change --------------------------- + game_state.planet_name = "Music Test" + game_state.planet_id = "music:test" + game_state.planet_definition_id = "rocky" + scene.free() + _load("res://surface/PlanetSurface.tscn") + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.4: + return false + var players2: Array = _players() + if players2.size() != 1: + return _fail("%d music players after a scene change - the theme is playing over itself" \ + % players2.size()) + if players2[0] != player: + return _fail("the scene change replaced the music player, restarting the theme") + if not player.playing: + return _fail("the theme stopped when the scene changed") + print("scene changed to a planet surface: same player, still playing") + + scene.free() + _load("res://world/Main.tscn") + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + if _players().size() != 1 or _players()[0] != player: + return _fail("coming back from a surface left %d players" % _players().size()) + if not player.playing: + return _fail("the theme stopped on the way back from a surface") + print("and back again: still one player, still the same one") + + print("TEST_MUSIC_THEME: PASS") + quit(0) + return true + return false diff --git a/tests/test_music_theme.gd.uid b/tests/test_music_theme.gd.uid new file mode 100644 index 0000000..c369daf --- /dev/null +++ b/tests/test_music_theme.gd.uid @@ -0,0 +1 @@ +uid://bafwt54rgcc5l diff --git a/tests/test_outpost_building.gd b/tests/test_outpost_building.gd new file mode 100644 index 0000000..4bcda0b --- /dev/null +++ b/tests/test_outpost_building.gd @@ -0,0 +1,146 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_outpost_building.gd +# Forgejo #102 (#97d): "A new building should be available, pretty early in +# the tech tree, to create outposts." +# +# Covers: +# A. the outpost is in the building catalog, with its own visual, its own +# icon and its own map colour - not a reused one +# B. a skill node unlocks it, and it is EARLY: shallower than the deep +# branches, and reachable for less than the mid-game structures cost +# C. it is hidden from the build menu until that node is researched, and +# appears afterwards, like every other gated structure +# D. it can be placed, it stands up in the world, and it has enough health +# to be worth attacking - it exists to be shot at + +const MAX_EARLY_DEPTH: int = 2 + +var game_state: Node +var scene: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.rocks = 5000 + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_BUILDING: FAIL (%s)" % message) + quit(1) + return true + + +func _catalog_cases() -> bool: + var definitions: Script = load("res://buildings/BuildingDefinitions.gd") + var outpost: Resource = definitions.get_definition(GameState.BUILDING_OUTPOST) + if outpost == null: + return _fail("no \"outpost\" definition in the catalog") + if outpost.visual_scene == null: + return _fail("the outpost has no visual - it would be an invisible structure") + if outpost.menu_button_name == "": + return _fail("the outpost declares no build-menu entry, so it could never be built") + # --- A. its own look ------------------------------------------------- + for other in definitions.all(): + if other.definition_id == GameState.BUILDING_OUTPOST: + continue + if other.menu_icon == outpost.menu_icon: + return _fail("the outpost shares its icon with the %s" % other.definition_id) + if other.visual_scene == outpost.visual_scene: + return _fail("the outpost shares its visual with the %s" % other.definition_id) + if other.map_color.is_equal_approx(outpost.map_color): + return _fail("the outpost shares its map colour with the %s" % other.definition_id) + print("the outpost has its own icon, visual and map colour") + + # --- B. researched, and early ---------------------------------------- + var node_name: String = game_state.research_for_building(GameState.BUILDING_OUTPOST) + if node_name == "": + return _fail("nothing in the skill tree unlocks the outpost") + var node_id: String = "" + for node in GameState.SKILL_TREE: + if node.get("unlocks", "") == GameState.BUILDING_OUTPOST: + node_id = String(node.id) + var depth: int = game_state.skill_depth(node_id) + if depth > MAX_EARLY_DEPTH: + return _fail("\"%s\" sits %d prerequisites deep - that is not early" % [node_name, depth]) + var cost: int = int(game_state.skill_node(node_id).cost) + for node in GameState.SKILL_TREE: + if String(node.get("unlocks", "")) in [GameState.BUILDING_TRANSPORT_HUB, + GameState.BUILDING_SHIP_LAB, GameState.BUILDING_GAS_BURNER]: + if int(node.cost) <= cost: + return _fail("\"%s\" costs %d, no less than the mid-game %s at %d" \ + % [node_name, cost, node.name, node.cost]) + print("\"%s\" is %d deep and costs %d ore - earlier and cheaper than the mid-game structures" \ + % [node_name, depth, cost]) + + # --- C. gated until researched --------------------------------------- + if game_state.is_building_unlocked(GameState.BUILDING_OUTPOST): + return _fail("the outpost is buildable before its node is researched") + game_state.unlocked.append(node_id) + if not game_state.is_building_unlocked(GameState.BUILDING_OUTPOST): + return _fail("researching \"%s\" did not unlock the outpost" % node_name) + print("hidden until \"%s\" is researched, then offered" % node_name) + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _catalog_cases(): + return true + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + 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) + # --- C (continued). the menu shows it ------------------------ + var controller: Node = scene.get_node("BuildController") + var button: Control = scene.get_node("UI/BuildMenu/OutpostButton") + controller._layout_build_menu() + if not button.visible: + return _fail("the outpost is researched but its build-menu button is hidden") + scene.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- D. it stands up, and can be shot at --------------------- + var node: Node2D = null + for b in scene.get_node("BuildingField").spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_OUTPOST: + node = b + if node == null: + return _fail("placing an outpost produced no node") + if node.get_node_or_null("VisualHost").get_child_count() == 0: + return _fail("the outpost node has no visual under it") + if node.get_node_or_null("Hitbox") == null: + return _fail("the outpost has no hitbox - a wave could not touch it") + var hp: int = game_state.building_hp(node.building_id) + var crafter_hp: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER) + if hp < crafter_hp: + return _fail("the outpost has %d hp, less than a crafter's %d - it is meant to take hits" \ + % [hp, crafter_hp]) + print("an outpost stands at %d hp, against a crafter's %d" % [hp, crafter_hp]) + + print("TEST_OUTPOST_BUILDING: PASS") + quit(0) + return true + return false diff --git a/tests/test_outpost_building.gd.uid b/tests/test_outpost_building.gd.uid new file mode 100644 index 0000000..a13dd5e --- /dev/null +++ b/tests/test_outpost_building.gd.uid @@ -0,0 +1 @@ +uid://cphmxtinuv7sl diff --git a/tests/test_outpost_crewing.gd b/tests/test_outpost_crewing.gd new file mode 100644 index 0000000..816d53e --- /dev/null +++ b/tests/test_outpost_crewing.gd @@ -0,0 +1,160 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_outpost_crewing.gd +# Forgejo #104 (#97f): "When an outpost is configured to have a given amount +# of ship, the game should transfer, whenever available, ships from the +# mothership to the outpost to always fullfill the outpost conditions if +# possible." +# +# Vessels come from the HANGAR and never from the fleet: the fleet is what +# the player is flying, and taking a vessel out of it to crew a building +# would be the game undoing the player's own arrangement. +# +# Covers: +# A. an order with nothing in the hangar waits. It does not conjure a +# vessel, and it does not take one out of the fleet +# B. a matching vessel in the hangar is dispatched, travels, and joins the +# crew on arrival +# C. it dispatches exactly the shortfall - what is already in the air +# counts, or the whole hangar leaves to fill one berth +# D. lowering the order releases the surplus back to the hangar +# E. an outpost levelled while a vessel is inbound does not destroy it: +# the vessel lands in the hangar instead + +var game_state: Node +var outpost_id: int = 0 +var second_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var fleet_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append("forward_bases") + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(1200.0, 0.0)) + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_CREWING: FAIL (%s)" % message) + quit(1) + return true + + +# Runs the autoload's own clock without waiting out the real seconds: this +# test is about the dispatcher, not about how long a vessel takes to fly. +func _simulate(seconds: float) -> void: + var step: float = 1.0 / 60.0 + var ticks: int = int(seconds / step) + for i in range(ticks): + game_state._advance_vessel_deliveries(step) + game_state._advance_garrison_dispatch() + + +func _process(_delta: float) -> bool: + # --- A. an order nobody can fill waits ------------------------------ + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 2) + if game_state.garrison_desired_total(outpost_id) != 2: + return _fail("the order did not take") + _simulate(20.0) + if not game_state.garrison_crew(outpost_id).is_empty(): + return _fail("an outpost crewed itself out of an empty hangar") + if not game_state.vessel_deliveries.is_empty(): + return _fail("something was dispatched with nothing in the hangar") + if game_state.garrison_desired_total(outpost_id) != 2: + return _fail("the unfillable order was dropped instead of waiting") + print("an order of 2 with an empty hangar waits, and stays an order") + + # --- B. a hull in the hangar is sent --------------------------------- + game_state.stored_vessels.append(GameState.VESSEL_INTERCEPTOR) + game_state._advance_garrison_dispatch() + if game_state.vessel_deliveries.size() != 1: + return _fail("a vessel in the hangar produced %d deliveries" \ + % game_state.vessel_deliveries.size()) + if not game_state.stored_vessels.is_empty(): + return _fail("the vessel was dispatched and is still in the hangar too") + var delivery: Dictionary = game_state.vessel_deliveries[0] + if int(delivery.get("to_outpost", 0)) != outpost_id: + return _fail("the delivery is not bound for the outpost") + if float(delivery.progress) >= 1.0: + return _fail("the vessel teleported - it should have to fly there") + _simulate(30.0) + if game_state.garrison_crew_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: + return _fail("the vessel never arrived (crew=%s)" \ + % str(game_state.garrison_crew(outpost_id))) + print("one hull in the hangar flies out and joins the crew") + + # --- C. exactly the shortfall --------------------------------------- + for i in range(6): + game_state.stored_vessels.append(GameState.VESSEL_INTERCEPTOR) + game_state._advance_garrison_dispatch() + if game_state.vessel_deliveries.size() != 1: + return _fail("one empty berth dispatched %d vessels" \ + % game_state.vessel_deliveries.size()) + # A second call before the first lands must not send another. + game_state._advance_garrison_dispatch() + if game_state.vessel_deliveries.size() != 1: + return _fail("the dispatcher sent a second vessel for a berth already in the air") + if game_state.stored_vessels.size() != 5: + return _fail("the hangar holds %d, expected 5 left" % game_state.stored_vessels.size()) + _simulate(30.0) + if game_state.garrison_crew(outpost_id).size() != 2: + return _fail("the outpost holds %d of its 2 berths" \ + % game_state.garrison_crew(outpost_id).size()) + game_state._advance_garrison_dispatch() + if not game_state.vessel_deliveries.is_empty(): + return _fail("a full outpost is still being sent vessels") + print("2 berths, 6 spare hulls: it sent exactly 1 and then stopped") + + # --- D. lowering the order releases the surplus ---------------------- + var hangar_before: int = game_state.stored_vessels.size() + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 1) + game_state._advance_garrison_dispatch() + if game_state.garrison_crew(outpost_id).size() != 1: + return _fail("lowering the order to 1 left %d aboard" \ + % game_state.garrison_crew(outpost_id).size()) + _simulate(30.0) + if game_state.stored_vessels.size() != hangar_before + 1: + return _fail("the released vessel did not come home (hangar %d -> %d)" \ + % [hangar_before, game_state.stored_vessels.size()]) + print("lowering the order flies the surplus back to the hangar") + + # --- E. a destination that stops existing --------------------------- + second_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(-1400.0, 0.0)) + game_state.set_garrison_desired(second_id, GameState.VESSEL_INTERCEPTOR, 1) + game_state._advance_garrison_dispatch() + if game_state.garrison_inbound(second_id) != 1: + return _fail("nothing was dispatched to the second outpost") + hangar_before = game_state.stored_vessels.size() + if not game_state.destroy_building(second_id): + return _fail("the second outpost could not be destroyed") + _simulate(30.0) + if game_state.stored_vessels.size() != hangar_before + 1: + return _fail("a vessel inbound to a destroyed outpost was lost") + print("a vessel whose outpost was levelled under it lands back in the hangar") + + # --- A (again). the fleet is never raided --------------------------- + game_state.stored_vessels.clear() + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 3) + fleet_before = game_state.surviving_ship_count + _simulate(20.0) + if game_state.surviving_ship_count != fleet_before: + return _fail("the dispatcher took a vessel out of the fleet") + if game_state.garrison_crew(outpost_id).size() != 1: + return _fail("the outpost gained a vessel from nowhere") + print("with an empty hangar it takes nothing from the fleet") + + print("TEST_OUTPOST_CREWING: PASS") + quit(0) + return true diff --git a/tests/test_outpost_crewing.gd.uid b/tests/test_outpost_crewing.gd.uid new file mode 100644 index 0000000..78b766e --- /dev/null +++ b/tests/test_outpost_crewing.gd.uid @@ -0,0 +1 @@ +uid://b4g5ap4dhwivl diff --git a/tests/test_outpost_garrison_menu.gd b/tests/test_outpost_garrison_menu.gd new file mode 100644 index 0000000..dfc6858 --- /dev/null +++ b/tests/test_outpost_garrison_menu.gd @@ -0,0 +1,206 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_outpost_garrison_menu.gd +# Forgejo #103 (#97e): "Outposts can be occupied by ships. Only ships that +# attack can be assigned. A dedicated menu on each outpost should let you +# compose how you want the outpost to be filled. There's a default of 2 +# vessel per outpost, upgrades can increase this number." +# +# Two things live on an outpost's record and conflating them is the first +# thing to get wrong: the ORDER (what the player asked for) and the CREW +# (who is actually there). This menu edits the order only; #104 fills the +# crew. Keeping the order as a wish is what lets an outpost ask for a hull +# nobody has built yet and simply wait. +# +# Covers: +# A. only armed vessels can be assigned - a shield, a hauler or an engine +# block cannot hold a position however much the player wants it there +# B. two berths by default, and the order cannot exceed them +# C. researching the berth upgrade raises the ceiling at outposts ALREADY +# standing, not only at new ones +# D. the menu opens from the outpost, drives the real order through the +# real buttons, and reads back "here / ordered" rather than one number +# E. an outpost with an order nobody can fill reports it as waiting, and +# the order survives - it is not silently dropped + +var game_state: Node +var scene: Node +var controller: Node +var menu: Control +var panel: Control +var outpost_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.rocks = 5000 + game_state.unlocked.append("forward_bases") + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_GARRISON_MENU: FAIL (%s)" % message) + quit(1) + return true + + +func _state_cases() -> bool: + # --- A. only armed vessels ------------------------------------------ + var eligible: PackedStringArray = game_state.garrison_vessel_types() + if eligible.is_empty(): + return _fail("no vessel at all may be garrisoned") + if not (GameState.VESSEL_INTERCEPTOR in eligible): + return _fail("the interceptor, which carries the fleet's gun, cannot be garrisoned") + for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER, + GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]: + if game_state.vessel_can_garrison(unarmed): + return _fail("%s can be garrisoned - it does not attack" % unarmed) + print("garrisonable: %s" % str(eligible)) + + var id: int = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0)) + outpost_id = id + if not game_state.is_outpost(game_state.find_building(id)): + return _fail("the outpost does not carry the garrison capability") + if game_state.set_garrison_desired(id, GameState.VESSEL_SHIELD, 1) != 0: + return _fail("a shield vessel was accepted into a garrison") + + # --- B. two berths, and the ceiling bites --------------------------- + if GameState.GARRISON_BASE_SLOTS != 2: + return _fail("the default is %d berths, not the 2 the ticket asks for" \ + % GameState.GARRISON_BASE_SLOTS) + if game_state.outpost_slot_capacity() != 2: + return _fail("an unresearched outpost offers %d berths" % game_state.outpost_slot_capacity()) + if game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 5) != 2: + return _fail("an order of 5 was not clamped to the 2 berths available") + if game_state.garrison_desired_total(id) != 2: + return _fail("the outpost holds an order of %d against 2 berths" \ + % game_state.garrison_desired_total(id)) + print("2 berths by default; an order of 5 clamps to 2") + + # --- C. the upgrade reaches existing outposts ----------------------- + var upgrade_id: String = "" + for node in GameState.SKILL_TREE: + if int(node.get("outpost_bonus", 0)) > 0: + upgrade_id = String(node.id) + if upgrade_id == "": + return _fail("no skill node raises the berth count") + game_state.unlocked.append(upgrade_id) + if game_state.outpost_slot_capacity() != 3: + return _fail("researching \"%s\" left the capacity at %d" \ + % [upgrade_id, game_state.outpost_slot_capacity()]) + # The outpost was placed BEFORE the research, which is the case that + # matters: a bonus that only reached new buildings would mean demolishing + # and rebuilding to collect it. + if game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 3) != 3: + return _fail("an outpost built before the upgrade did not gain the berth") + print("\"%s\" raises an already-standing outpost to 3 berths" % upgrade_id) + game_state.set_garrison_desired(id, GameState.VESSEL_INTERCEPTOR, 0) + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _state_cases(): + return true + game_state.buildings.clear() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + menu = scene.get_node("UI/GarrisonMenu") + panel = scene.get_node("UI/GarrisonMenu/Panel") + 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) + scene.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(500.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- D. the real menu, through the real buttons -------------- + var outpost: Node2D = null + for b in scene.get_node("BuildingField").spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_OUTPOST: + outpost = b + if outpost == null: + return _fail("no outpost node was placed") + outpost_id = int(outpost.building_id) + if outpost.primary_action() != "garrison": + return _fail("the outpost's primary action is \"%s\", not \"garrison\"" \ + % outpost.primary_action()) + controller.current_building = outpost + controller._on_action_pressed() + if not menu.visible: + return _fail("the outpost did not open the garrison menu") + if panel.building_id != outpost_id: + return _fail("the panel opened without knowing which outpost it belongs to") + + var row: Dictionary = {} + for candidate in panel._rows: + if String(candidate.type) == GameState.VESSEL_INTERCEPTOR: + row = candidate + if row.is_empty(): + return _fail("the panel has no row for the interceptor") + for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER]: + for candidate in panel._rows: + if String(candidate.type) == unarmed: + return _fail("the panel offers a row for the unarmed %s" % unarmed) + + row.plus.pressed.emit() + row.plus.pressed.emit() + if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 2: + return _fail("two presses of + ordered %d vessels" \ + % game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR)) + if row.count.text != "0/2": + return _fail("the row reads \"%s\" - it should show crew over order" % row.count.text) + row.minus.pressed.emit() + if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: + return _fail("- did not take the order back down") + print("the menu drives the real order: + + - leaves 1, shown as \"%s\"" % row.count.text) + + # The ceiling is enforced through the buttons too, not only in + # GameState - a + that silently did nothing would be worse than a + # disabled one. + row.plus.pressed.emit() + row.plus.pressed.emit() + row.plus.pressed.emit() + var capacity: int = game_state.outpost_slot_capacity() + if game_state.garrison_desired_total(outpost_id) != capacity: + return _fail("pressing + past the ceiling ordered %d against %d berths" \ + % [game_state.garrison_desired_total(outpost_id), capacity]) + if row.plus.enabled: + return _fail("+ is still offered with every berth already spoken for") + print("+ stops at %d berths and greys itself out" % capacity) + + # --- E. an unfillable order waits --------------------------- + if game_state.garrison_crew(outpost_id).size() != 0: + return _fail("the outpost is crewed already, so waiting proves nothing") + controller._refresh_garrison() + var status: String = scene.get_node("UI/GarrisonMenu/StatusLabel").text + if status.strip_edges() == "": + return _fail("an outpost waiting on vessels says nothing at all") + if game_state.garrison_desired_total(outpost_id) != capacity: + return _fail("the order was dropped because it could not be filled") + print("waiting, and saying so: \"%s\"" % status) + + print("TEST_OUTPOST_GARRISON_MENU: PASS") + quit(0) + return true + return false diff --git a/tests/test_outpost_garrison_menu.gd.uid b/tests/test_outpost_garrison_menu.gd.uid new file mode 100644 index 0000000..cf93f3f --- /dev/null +++ b/tests/test_outpost_garrison_menu.gd.uid @@ -0,0 +1 @@ +uid://bbowrt6vthd6h diff --git a/tests/test_outpost_garrison_patrol.gd b/tests/test_outpost_garrison_patrol.gd new file mode 100644 index 0000000..225709d --- /dev/null +++ b/tests/test_outpost_garrison_patrol.gd @@ -0,0 +1,255 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_outpost_garrison_patrol.gd +# Forgejo #105 (#97g): "When a vessel is assigned to an outpost, it +# navigates around the outpost by itself, attacking nearby ennemies if any +# are detected." +# +# Covers: +# A. a crewed outpost puts a real vessel in the world, and that vessel is +# NOT in the fleet - it must not follow the formation, must not eat a +# fleet slot, and must not fly off when the player leaves +# B. left alone it WANDERS its post: it visits genuinely different places, +# it is actually travelling rather than sliding, and it faces where it +# is going (#109 - it used to walk a circle in steps shorter than +# Ship's arrive_radius, so every frame it snapped and zeroed its +# velocity, and Ship only turns while it is moving) +# C. an enemy inside its detection range is engaged and shot at +# D. it will not be walked away: an enemy beyond the leash is ignored, and +# an engagement never drags it further than the leash from its post +# E. it goes back to patrolling once the enemy is gone +# F. losing a garrison vessel frees the berth, so it gets crewed again +# G. enemies fight back - a garrison vessel is a target like any other + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var outpost_id: int = 0 +var guard: Node2D +var component: Node +var enemy: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var orbit_min: float = INF +var orbit_max: float = 0.0 +var visited: Array = [] +var top_speed: float = 0.0 +var moving_frames: int = 0 +var facing_frames: int = 0 +var max_from_post: float = 0.0 +var dummy_hp_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append("forward_bases") + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("GarrisonField") + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_GARRISON_PATROL: FAIL (%s)" % message) + quit(1) + return true + + +# A pinned target that neither shoots back nor dies. The point of these +# stages is where the GARRISON goes and whether it fires, and a sniper that +# killed the guard mid-check, or a chaser that died to it, would end the +# observation before the thing being measured had happened. +func _dummy(at: Vector2) -> Node2D: + var dummy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + dummy.enemy_id = "chaser" + dummy.position = at + main.add_child(dummy) + dummy.speed = 0.0 + dummy.hp = 100000 + return dummy + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The fleet is parked a long way off, so nothing here is the + # fleet's doing. + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + + main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2.ZERO) + outpost_id = int(game_state.buildings[-1].id) + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 1) + game_state.garrison_crew(outpost_id).append(GameState.VESSEL_INTERCEPTOR) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + # --- A. a real vessel, and not a fleet one ------------------- + var here: Array = field.ships_at(outpost_id) + if here.size() != 1: + return _fail("a crewed outpost put %d vessels in the world" % here.size()) + guard = here[0] + component = guard.get_component("garrison") + if component == null: + return _fail("the garrison vessel has no garrison component steering it") + if guard in fleet.ships: + return _fail("the garrison vessel joined the fleet") + if fleet.ships.size() != 1: + return _fail("stationing a vessel changed the fleet to %d ships" % fleet.ships.size()) + print("outpost crewed with 1 vessel, fleet still %d and untouched" % fleet.ships.size()) + elapsed = 0.0 + stage = 2 + return false + 2: + # --- B. it wanders its post --------------------------------- + var from_post: float = guard.global_position.length() + orbit_min = minf(orbit_min, from_post) + orbit_max = maxf(orbit_max, from_post) + visited.append(guard.global_position) + var speed: float = guard.velocity.length() + top_speed = maxf(top_speed, speed) + if speed > 40.0: + # Facing is only meaningful while actually travelling, which + # is the whole of what #109 was about. Measured as a FRACTION + # of the travel rather than as a worst instant: a vessel that + # has just been given a new heading is legitimately still + # swinging onto it at its own rotation_speed, and one frame + # mid-turn says nothing about whether it flies nose-first. + moving_frames += 1 + var drift: float = absf(angle_difference(guard.rotation, + guard.velocity.angle() + PI / 2.0)) + if drift <= PI / 4.0: + facing_frames += 1 + if elapsed < 8.0: + return false + if component.is_engaging(): + return _fail("the garrison thinks it is fighting with no enemy on the map") + if orbit_max > component.leash_range: + return _fail("patrolling took it %.0fpx from its post, past the %.0fpx leash" \ + % [orbit_max, component.leash_range]) + if top_speed < 60.0: + return _fail("the picket never got above %.0fpx/s - it is sliding, not flying" \ + % top_speed) + if moving_frames <= 0: + return _fail("it never travelled at all") + var aligned: float = float(facing_frames) / float(moving_frames) + if aligned < 0.7: + return _fail("it was pointing where it was going for only %.0f%% of its travel" \ + % (aligned * 100.0)) + # Genuinely different places, not one path walked over and over. + var spread: float = 0.0 + for a in visited: + for b in visited: + spread = maxf(spread, a.distance_to(b)) + if spread < 120.0: + return _fail("everywhere it went was within %.0fpx - that is not wandering" % spread) + print("wandered %.0fpx across, %.0f-%.0fpx from its post, up to %.0fpx/s, nose-first %.0f%% of the way" \ + % [spread, orbit_min, orbit_max, top_speed, aligned * 100.0]) + + # --- D (first half). an enemy beyond the leash is ignored ---- + enemy = _dummy(Vector2(component.leash_range + component.engage_distance + 900.0, 0.0)) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 1.0: + return false + if component.is_engaging(): + return _fail("the garrison went after an enemy well beyond its leash") + print("an enemy %.0fpx out is left alone" % enemy.global_position.length()) + enemy.queue_free() + + # --- C. an enemy in reach is engaged and shot at ------------- + enemy = _dummy(Vector2(300.0, 0.0)) + dummy_hp_before = enemy.hp + max_from_post = 0.0 + elapsed = 0.0 + stage = 4 + return false + 4: + if not is_instance_valid(guard): + return _fail("the garrison vessel was destroyed mid-engagement") + max_from_post = maxf(max_from_post, guard.global_position.length()) + if elapsed < 2.5: + return false + if not component.is_engaging(): + return _fail("an enemy 300px from the outpost was not engaged") + # Damage taken, not projectiles in flight: a bolt that has already + # connected has freed itself, so counting live ones at an instant + # reads zero exactly when the gun is working best. + if enemy.hp >= dummy_hp_before: + return _fail("the garrison engaged but the enemy took no damage") + # --- D (second half). the leash holds during a fight --------- + if max_from_post > component.leash_range + 60.0: + return _fail("the fight pulled it %.0fpx from its post, past a %.0fpx leash" \ + % [max_from_post, component.leash_range]) + print("engaged, took %d hp off it, and stayed within %.0fpx of its post" \ + % [dummy_hp_before - enemy.hp, max_from_post]) + + # --- G. and it can be fought back --------------------------- + var targets: Script = load("res://enemies/EnemyTargets.gd") + var picked: Node2D = targets.nearest_ship(main, enemy.global_position) + if picked != guard: + return _fail("an enemy beside the garrison picks %s instead of it - it cannot be fought back" \ + % ("nothing" if picked == null else str(picked.name))) + print("enemies see the garrison as a target too") + + if is_instance_valid(enemy): + enemy.queue_free() + elapsed = 0.0 + stage = 5 + return false + 5: + # --- E. back to the patrol ---------------------------------- + if elapsed < 1.5: + return false + if component.is_engaging(): + return _fail("the garrison is still fighting an enemy that is gone") + print("stood down and went back to its patrol") + + # --- F. a lost vessel frees its berth ------------------------ + if game_state.garrison_crew(outpost_id).size() != 1: + return _fail("the crew is not 1 before the kill") + guard.take_damage(1) + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.3: + return false + if not game_state.garrison_crew(outpost_id).is_empty(): + return _fail("a destroyed garrison vessel still holds its berth") + if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: + return _fail("losing the vessel cancelled the order as well") + if field.ships_at(outpost_id).size() != 0: + return _fail("the dead vessel is still stationed") + print("losing the vessel frees the berth and leaves the order standing") + + print("TEST_OUTPOST_GARRISON_PATROL: PASS") + quit(0) + return true + return false diff --git a/tests/test_outpost_garrison_patrol.gd.uid b/tests/test_outpost_garrison_patrol.gd.uid new file mode 100644 index 0000000..9398f38 --- /dev/null +++ b/tests/test_outpost_garrison_patrol.gd.uid @@ -0,0 +1 @@ +uid://by5nb7yfky01p diff --git a/tests/test_payments_use_fleet_hold.gd b/tests/test_payments_use_fleet_hold.gd index d3165fb..033987d 100644 --- a/tests/test_payments_use_fleet_hold.gd +++ b/tests/test_payments_use_fleet_hold.gd @@ -38,7 +38,7 @@ func _initialize() -> void: game_state.buildings.clear() game_state.extractors.clear() - var cost: int = GameState.MOTHERSHIP_COST + var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) # --- A. the deadlock, exactly as a fresh game presents it --------------- game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) @@ -261,9 +261,9 @@ func _process(delta: float) -> bool: # cost left the pool - and that it left, since the bank was empty # and could not have paid. var total: int = game_state.available_ore() - if total != HOLD_ORE - GameState.MOTHERSHIP_COST: + if total != HOLD_ORE - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]): return _fail("the mothership was placed but %d ore remains, expected %d" \ - % [total, HOLD_ORE - GameState.MOTHERSHIP_COST]) + % [total, HOLD_ORE - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])]) print("built a mothership with an empty bank, paid out of the fleet hold") print("TEST_PAYMENTS_USE_FLEET_HOLD: PASS") diff --git a/tests/test_planet_conquest_gate.gd b/tests/test_planet_conquest_gate.gd index c26a049..a9f864b 100644 --- a/tests/test_planet_conquest_gate.gd +++ b/tests/test_planet_conquest_gate.gd @@ -146,7 +146,7 @@ func _process(delta: float) -> bool: return _fail("conquered planet did not offer a build") controller._confirm_build() var built: bool = game_state.has_extractor_at(planet.planet_id) - var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + var charged: bool = game_state.rocks == rocks_before - int(game_state.building_costs(GameState.BUILDING_EXTRACTOR)[GameState.RESOURCE_ORE]) if not built or not charged: return _fail("conquered planet did not build/charge correctly") diff --git a/tests/test_planning_center.gd b/tests/test_planning_center.gd new file mode 100644 index 0000000..36e81bf --- /dev/null +++ b/tests/test_planning_center.gd @@ -0,0 +1,246 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_planning_center.gd +# Forgejo #106 and #107 (#98a, #98b): the Planning Center. +# +# "let the player tell the system how many ships of a give type should +# always be available over the whole game (fleet + storage + outposts) ... +# the planning center automatically manage the ship craft building queues +# to ensure that the needed vessels are produced to fullfill the +# constraints." +# +# Covers: +# A. the census counts a vessel exactly once wherever it is - flying, +# stored, stationed at an outpost, or in the air between any two +# B. a shortfall is queued at a crafter, and queued vessels count, so it +# does not re-order the same shortfall every tick +# C. it tops up rather than rewriting: an order placed by hand survives, +# and the planner appends beside it +# D. it stops when the target is met, and starts again when one is lost +# E. nothing happens without a planning centre, but the plan is remembered +# across losing and rebuilding one +# F. the panel drives the real targets through the real buttons + +var game_state: Node +var scene: Node +var controller: Node +var menu: Control +var panel: Control +var crafter_id: int = 0 +var outpost_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.vessel_targets.clear() + game_state.fleet_vessel_types = PackedStringArray() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append_array(["shipyards", "coordination", "forward_bases", + "vessel_science", "logistics_command"]) + + +func _fail(message: String) -> bool: + print("TEST_PLANNING_CENTER: FAIL (%s)" % message) + quit(1) + return true + + +func _queued_at_crafter() -> int: + return game_state.pending_orders_of(crafter_id, GameState.VESSEL_INTERCEPTOR) + + +func _state_cases() -> bool: + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0)) + outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(-900.0, 0.0)) + var interceptor: String = GameState.VESSEL_INTERCEPTOR + + # --- A. one vessel, one count, wherever it is ------------------------ + if game_state.vessel_census(interceptor) != 0: + return _fail("an empty game counts %d vessels" % game_state.vessel_census(interceptor)) + game_state.fleet_vessel_types = PackedStringArray([interceptor, interceptor]) + game_state.stored_vessels.append(interceptor) + game_state.set_garrison_desired(outpost_id, interceptor, 1) + game_state.garrison_crew(outpost_id).append(interceptor) + game_state.vessel_deliveries.append({"type": interceptor, "from": Vector2.ZERO, + "to": Vector2(400.0, 0.0), "progress": 0.0, "to_outpost": 0}) + if game_state.vessel_count_in_fleet(interceptor) != 2: + return _fail("the fleet reads %d" % game_state.vessel_count_in_fleet(interceptor)) + if game_state.vessel_count_in_hangar(interceptor) != 1: + return _fail("the hangar reads %d" % game_state.vessel_count_in_hangar(interceptor)) + if game_state.vessel_count_in_garrisons(interceptor) != 1: + return _fail("the garrisons read %d" % game_state.vessel_count_in_garrisons(interceptor)) + if game_state.vessel_count_in_transit(interceptor) != 1: + return _fail("in transit reads %d" % game_state.vessel_count_in_transit(interceptor)) + if game_state.vessel_census(interceptor) != 5: + return _fail("2 flying + 1 stored + 1 stationed + 1 moving counted as %d" \ + % game_state.vessel_census(interceptor)) + print("census: 2 flying, 1 stored, 1 stationed, 1 moving = %d" \ + % game_state.vessel_census(interceptor)) + + # --- E (first half). no centre, no action --------------------------- + game_state.set_vessel_target(interceptor, 8) + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != 0: + return _fail("orders were placed with no planning centre built") + print("with no planning centre, a standing order does nothing") + + game_state.add_building(GameState.BUILDING_PLANNING_CENTER, Vector2(900.0, 0.0)) + if not game_state.has_planning_center(): + return _fail("the planning centre was built but is not recognised") + + # --- B. the shortfall is queued, once ------------------------------ + if game_state.vessel_shortfall(interceptor) != 3: + return _fail("a target of 8 against 5 reads a shortfall of %d" \ + % game_state.vessel_shortfall(interceptor)) + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != 3: + return _fail("20 passes at a shortfall of 3 queued %d orders" % _queued_at_crafter()) + if game_state.vessel_shortfall(interceptor) != 0: + return _fail("the shortfall is still %d with 3 on the slipway" \ + % game_state.vessel_shortfall(interceptor)) + print("a shortfall of 3 queued exactly 3, and 20 more passes added none") + + # --- C. hand-placed orders are left alone --------------------------- + game_state.queue_order(crafter_id, GameState.VESSEL_SHIELD) + var by_hand: int = game_state.pending_orders_of(crafter_id, GameState.VESSEL_SHIELD) + game_state.set_vessel_target(interceptor, 10) + for i in range(20): + game_state._advance_planning() + if game_state.pending_orders_of(crafter_id, GameState.VESSEL_SHIELD) != by_hand: + return _fail("the planner cancelled a shield the player ordered by hand") + if _queued_at_crafter() != 5: + return _fail("raising the target to 10 left %d interceptors queued" % _queued_at_crafter()) + print("a hand-placed shield survives; the planner appended beside it") + + # --- D. it stops, and it reacts to a loss --------------------------- + game_state.set_vessel_target(interceptor, 5) + var queued_before: int = _queued_at_crafter() + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != queued_before: + return _fail("lowering the target changed the queue by %d" \ + % (_queued_at_crafter() - queued_before)) + # Losing one from the fleet is a real shortfall again. + game_state.set_vessel_target(interceptor, 12) + var before_loss: int = _queued_at_crafter() + for i in range(20): + game_state._advance_planning() + var after_top_up: int = _queued_at_crafter() + game_state.fleet_vessel_types = PackedStringArray([interceptor]) + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() <= after_top_up: + return _fail("losing a vessel did not put a replacement on the slipway") + print("target met -> nothing queued; a vessel lost -> %d queued (was %d)" \ + % [_queued_at_crafter() - after_top_up, before_loss]) + + # --- E (second half). the plan outlives the building ---------------- + for b in game_state.buildings: + if game_state.building_has_capability(b, "planning"): + game_state.destroy_building(int(b.id)) + break + if game_state.has_planning_center(): + return _fail("the planning centre survived being destroyed") + if game_state.vessel_target(interceptor) != 12: + return _fail("losing the building erased the plan") + var frozen: int = _queued_at_crafter() + game_state.fleet_vessel_types = PackedStringArray() + for i in range(20): + game_state._advance_planning() + if _queued_at_crafter() != frozen: + return _fail("the planner kept working after its building was destroyed") + print("destroyed: the plan is remembered, and nothing acts on it") + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _state_cases(): + return true + game_state.buildings.clear() + game_state.vessel_targets.clear() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + menu = scene.get_node("UI/PlanningMenu") + panel = scene.get_node("UI/PlanningMenu/Panel") + 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) + scene.get_node("BuildingField").place(GameState.BUILDING_PLANNING_CENTER, + Vector2(900.0, 0.0)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- F. the real panel, through the real buttons ------------- + var node: Node2D = null + for b in scene.get_node("BuildingField").spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_PLANNING_CENTER: + node = b + if node == null: + return _fail("no planning centre node was placed") + if node.primary_action() != "planning": + return _fail("its primary action is \"%s\"" % node.primary_action()) + controller.current_building = node + controller._on_action_pressed() + if not menu.visible: + return _fail("the planning centre did not open its menu") + + var row: Dictionary = {} + for candidate in panel._rows: + if String(candidate.type) == GameState.VESSEL_INTERCEPTOR: + row = candidate + if row.is_empty(): + return _fail("the panel has no row for the interceptor") + row.plus.pressed.emit() + row.plus.pressed.emit() + row.plus.pressed.emit() + if game_state.vessel_target(GameState.VESSEL_INTERCEPTOR) != 3: + return _fail("three presses of + set a target of %d" \ + % game_state.vessel_target(GameState.VESSEL_INTERCEPTOR)) + row.minus.pressed.emit() + if game_state.vessel_target(GameState.VESSEL_INTERCEPTOR) != 2: + return _fail("- did not lower the target") + if not row.count.text.ends_with("/2"): + return _fail("the row reads \"%s\" - it should show have over want" % row.count.text) + # The note is what makes an unmet plan legible: it says where the + # vessels actually are. + if row.note.text.strip_edges() == "" and game_state.vessel_census( + GameState.VESSEL_INTERCEPTOR) > 0: + return _fail("vessels exist but the row says nothing about where they are") + print("the panel drives the real plan: + + + - leaves \"%s\" (%s)" \ + % [row.count.text, row.note.text]) + + var status: String = scene.get_node("UI/PlanningMenu/StatusLabel").text + if status.strip_edges() == "": + return _fail("the planning menu reports nothing at all") + print("status: \"%s\"" % status) + + print("TEST_PLANNING_CENTER: PASS") + quit(0) + return true + return false diff --git a/tests/test_planning_center.gd.uid b/tests/test_planning_center.gd.uid new file mode 100644 index 0000000..3a84239 --- /dev/null +++ b/tests/test_planning_center.gd.uid @@ -0,0 +1 @@ +uid://ct1jflnjjkw5f diff --git a/tests/test_progressive_costs.gd b/tests/test_progressive_costs.gd new file mode 100644 index 0000000..9b19562 --- /dev/null +++ b/tests/test_progressive_costs.gd @@ -0,0 +1,247 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_progressive_costs.gd +# Forgejo #138: "all buildings only cost rocks, you can introduce other +# resources for some buildings, make the cost meaningful depending on how far +# they are in the skill tree. same for skills." +# +# "Meaningful depending on how far they are" is a property of the whole +# table, not of any one entry, so that is what is checked here - and the +# checks are properties rather than transcribed numbers, so rebalancing a +# price does not mean editing this file, while breaking the shape does. +# +# The dangerous half is REACHABILITY. Energy exists only where a gas burner +# has been built, and a gas burner needs Power Systems - so anything at or +# before that depth which asked for energy would be a wall the player has no +# way through. Dark matter is rarer still: black hole worlds. The rule below +# is the one that keeps a price from being a dead end, and it is the reason +# the extractor stays ore-only even though its depth would allow gas. +# +# Covers: +# A. more than one resource is actually in play - the ticket's whole point +# B. no price asks for something unreachable at its own depth +# C. prices grow with depth, for buildings and for doctrines alike +# D. the ore figure and the price dictionary agree, because repairs are +# quoted from the ore figure alone +# E. nothing is free, and nothing charges a resource that does not exist +# F. the quest chain stays payable: no quest asks for a structure whose +# price needs a resource the chain has not reached yet + +# Quests.gd is plain data with no autoload in it, so preloading it here is +# safe where preloading PriceTag or ResourceBar would not be. +const QUESTS = preload("res://core/Quests.gd") + +var game_state: Node + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_PROGRESSIVE_COSTS: FAIL (%s)" % message) + quit(1) + return true + + +# The depth at which each resource first becomes gettable. +# +# ore from the asteroids the game opens on +# gas from a gas giant, which needs no research at all - only flying +# energy from a gas burner, unlocked by Power Systems, so it exists only +# BELOW that node rather than at it +# dark matter from a black hole world: no research either, but the rarest +# thing in the sky, so it is reserved for the deepest tier +func _first_available_depth(resource_type: String) -> int: + match resource_type: + GameState.RESOURCE_ORE, GameState.RESOURCE_GAS: + return 0 + GameState.RESOURCE_ENERGY: + return game_state.skill_depth("power_systems") + 1 + GameState.RESOURCE_DARK_MATTER: + return 4 + return 99 + + +# How deep in the tree a structure sits: the depth of the node that unlocks +# it, or 0 for one that is never gated. +func _building_depth(type: String) -> int: + # The ID, not the display name: skill_depth() of a name silently answers + # 0, which would let an unreachable price through as "ungated". + var gate: String = String(game_state.research_node_for_building(type)) + return 0 if gate == "" else game_state.skill_depth(gate) + + +func _total(costs: Dictionary) -> int: + var sum: int = 0 + for resource_type in costs: + sum += int(costs[resource_type]) + return sum + + +func _check_reachable(what: String, depth: int, costs: Dictionary) -> String: + for resource_type in costs: + var id: String = String(resource_type) + if not (id in game_state.resource_types()): + return "%s is priced in \"%s\", which is not a resource" % [what, id] + var need_depth: int = _first_available_depth(id) + if depth < need_depth: + return "%s sits at depth %d and asks for %s, which nothing produces before depth %d" \ + % [what, depth, game_state.resource_name(id), need_depth] + return "" + + +# Average price per depth, and it must go up. Averaged rather than compared +# entry-by-entry: a tier holds several things of deliberately different +# weight - a doctrine that adds one vessel beside one that unlocks a whole +# branch - and demanding every one of them out-price every shallower one +# would be demanding a flat tree. +func _rising(label: String, totals: Dictionary) -> String: + var depths: Array = totals.keys() + depths.sort() + var previous: float = -1.0 + var trace: PackedStringArray = PackedStringArray() + for depth in depths: + var here: Array = totals[depth] + var mean: float = 0.0 + for value in here: + mean += float(value) + mean /= float(here.size()) + trace.append("d%d=%.0f" % [depth, mean]) + if mean <= previous: + return "%s prices do not rise with depth: %s" % [label, " ".join(trace)] + previous = mean + print(" %s: %s" % [label, " ".join(trace)]) + return "" + + +func _process(_delta: float) -> bool: + # --- buildings ------------------------------------------------------- + var building_totals: Dictionary = {} + var multi_resource: int = 0 + var kinds: Dictionary = {} + for definition in BuildingDefinitions.all(): + var type: String = String(definition.definition_id) + var costs: Dictionary = game_state.building_costs(type) + var depth: int = _building_depth(type) + + # --- E. nothing free ------------------------------------------ + if costs.is_empty() or _total(costs) <= 0: + return _fail("%s costs nothing" % type) + # --- D. the ore figure and the dictionary agree ---------------- + if int(costs.get(GameState.RESOURCE_ORE, 0)) != int(definition.cost): + return _fail("%s is priced at %d ore but its cost field says %d - repairs quote the field" \ + % [type, int(costs.get(GameState.RESOURCE_ORE, 0)), int(definition.cost)]) + # --- B. reachable --------------------------------------------- + var problem: String = _check_reachable(type, depth, costs) + if problem != "": + return _fail(problem) + + if costs.size() > 1: + multi_resource += 1 + for resource_type in costs: + kinds[String(resource_type)] = true + if not building_totals.has(depth): + building_totals[depth] = [] + building_totals[depth].append(_total(costs)) + + # --- A. the ticket's point ------------------------------------------ + if multi_resource < 3: + return _fail("only %d structures cost anything but rocks" % multi_resource) + if kinds.size() < 3: + return _fail("structures between them use only %d kinds of resource" % kinds.size()) + print("%d of %d structures cost more than rocks, across %d resources" \ + % [multi_resource, BuildingDefinitions.all().size(), kinds.size()]) + + # --- C. rising with depth ------------------------------------------- + var rising: String = _rising("structure", building_totals) + if rising != "": + return _fail(rising) + + # --- doctrines ------------------------------------------------------- + var skill_totals: Dictionary = {} + var multi_skills: int = 0 + for node in GameState.SKILL_TREE: + var id: String = String(node.id) + var costs: Dictionary = game_state.skill_costs(id) + var depth: int = game_state.skill_depth(id) + if costs.is_empty() or _total(costs) <= 0: + return _fail("doctrine %s costs nothing" % id) + if int(costs.get(GameState.RESOURCE_ORE, 0)) != int(node.cost): + return _fail("doctrine %s is priced at %d ore but its cost field says %d" \ + % [id, int(costs.get(GameState.RESOURCE_ORE, 0)), int(node.cost)]) + var problem2: String = _check_reachable("doctrine %s" % id, depth, costs) + if problem2 != "": + return _fail(problem2) + if costs.size() > 1: + multi_skills += 1 + if not skill_totals.has(depth): + skill_totals[depth] = [] + skill_totals[depth].append(_total(costs)) + if multi_skills < 5: + return _fail("only %d doctrines cost anything but rocks" % multi_skills) + print("%d of %d doctrines cost more than rocks" % [multi_skills, GameState.SKILL_TREE.size()]) + var rising2: String = _rising("doctrine", skill_totals) + if rising2 != "": + return _fail(rising2) + + # --- the money really changes hands ---------------------------------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500) + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500) + game_state.unlocked = ["shipyards", "logistics", "coordination"] + game_state.research_points = 9 + var gas_before: int = game_state.available_resource(GameState.RESOURCE_GAS) + if not game_state.unlock("freight"): + return _fail("could not research a doctrine with everything in the bank") + var gas_spent: int = gas_before - game_state.available_resource(GameState.RESOURCE_GAS) + if gas_spent != int(game_state.skill_costs("freight")[GameState.RESOURCE_GAS]): + return _fail("researching Freight Handling took %d gas, its price is %d" \ + % [gas_spent, int(game_state.skill_costs("freight")[GameState.RESOURCE_GAS])]) + print("researching Freight Handling really took %d gas as well as its ore" % gas_spent) + + # ...and a doctrine the player cannot pay the gas for is refused, even + # with rocks to spare. This is the case that would have passed silently + # before, when only the ore was ever checked. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 0) + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5000) + if game_state.can_unlock("power_systems"): + return _fail("Power Systems is buyable with no gas at all") + var ore_before: int = game_state.available_resource(GameState.RESOURCE_ORE) + if game_state.unlock("power_systems"): + return _fail("Power Systems was bought with no gas at all") + if game_state.available_resource(GameState.RESOURCE_ORE) != ore_before: + return _fail("a refused purchase still took %d ore" \ + % (ore_before - game_state.available_resource(GameState.RESOURCE_ORE))) + print("a doctrine short of gas is refused, and nothing is taken for it") + + # --- F. the quest chain stays payable -------------------------------- + # Quests.gd walks the player through the game in its own order, which is + # not the tree's. A quest asking for a structure priced in something the + # chain has not sent them for yet is the same dead end as an unreachable + # depth, in a different coordinate system. + var reached: Dictionary = {GameState.RESOURCE_ORE: true} + for quest in QUESTS.CHAIN: + var goal: Dictionary = quest.goal + if String(goal.kind) == QUESTS.GOAL_BUILDING: + for resource_type in game_state.building_costs(String(goal.building)): + if not reached.has(String(resource_type)): + return _fail("quest \"%s\" asks for a %s, priced in %s, before the chain has sent anyone for any" \ + % [quest.id, goal.building, game_state.resource_name(String(resource_type))]) + if String(goal.kind) == QUESTS.GOAL_RESOURCE: + reached[String(goal.resource)] = true + # Building the gas burner is what puts energy in reach. + if String(goal.kind) == QUESTS.GOAL_BUILDING \ + and String(goal.building) == GameState.BUILDING_GAS_BURNER: + reached[GameState.RESOURCE_ENERGY] = true + print("the quest chain never asks for a structure it has not funded") + + print("TEST_PROGRESSIVE_COSTS: PASS") + quit(0) + return true diff --git a/tests/test_progressive_costs.gd.uid b/tests/test_progressive_costs.gd.uid new file mode 100644 index 0000000..666bb14 --- /dev/null +++ b/tests/test_progressive_costs.gd.uid @@ -0,0 +1 @@ +uid://bq6d3l4hxfmkb diff --git a/tests/test_quest_chain.gd b/tests/test_quest_chain.gd new file mode 100644 index 0000000..8cf2aa3 --- /dev/null +++ b/tests/test_quest_chain.gd @@ -0,0 +1,491 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_quest_chain.gd +# Forgejo #129 (#131, #132, #133): the quest chain. +# +# The chain is a campaign, so most of what can go wrong with it is not a +# crash - it is a quest that cannot be finished, one that finishes itself, +# or one that asks for something the player has no way to reach yet. Those +# are what is checked here, against the real catalogue rather than a fixture: +# a chain that passes with test data and strands the player is exactly the +# failure this is for. +# +# Covers: +# A. the model: a fresh game is on the first quest, and every entry in the +# catalogue is well formed and reachable +# B. no quest counts as done before it is done, and every goal kind is +# answerable +# C. progress LATCHES - collecting and then spending does not un-complete +# D. a quest is announced before it can complete, so an already-satisfied +# goal still gets read out +# E. the whole chain can be walked, and the ore quest really is finished by +# collecting ore +# F. #133 - the mothership quest's answer is that the World Ship is out of +# reach, and exploration is what follows +# G. research points only come from conquest, so no quest may ask for a +# doctrine that costs them before the conquest quest +# H. quests do not run during the prologue +# P. #142 - enough rocks are asked for before the first structure is +# Q. #141 - the first structure that needs a doctrine has that doctrine as +# a quest of its own, so research is taught rather than assumed +# R. #145 - every structure quest is actually finishable: building the +# thing it asks for, the way the game builds it, completes it +# I. lines survive having nobody to say them: a quest finished with no +# banner alive is read out by the next one that is +# J. a planet surface carries the chain too - a quest finished down there +# is announced down there, not on the way back up, and the objective is +# reachable from a button there as well +# K. landing is recorded, both the planet and the kind of world it was + +const QUESTS = preload("res://core/Quests.gd") + +var game_state: Node +var stage: int = 0 +var elapsed: float = 0.0 +var spoke: bool = false +var scene: Node +var panel: Control +var quest_button: Control +var narration: Control + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + + +func _fail(message: String) -> bool: + print("TEST_QUEST_CHAIN: FAIL (%s)" % message) + quit(1) + return true + + +# Which skill-tree nodes cost research points, which only conquest supplies. +func _costs_research_points(id: String) -> bool: + return int(game_state.skill_node(id).get("research_points", 0)) > 0 + + +func _catalogue_cases() -> bool: + # --- A. the catalogue is well formed --------------------------------- + if QUESTS.count() < 8: + return _fail("a chain of %d is not a campaign" % QUESTS.count()) + var seen: Dictionary = {} + for i in range(QUESTS.count()): + var quest: Dictionary = QUESTS.at(i) + for field in ["id", "title", "objective", "goal", "brief", "done"]: + if not quest.has(field): + return _fail("quest %d has no %s" % [i, field]) + if field != "goal" and String(quest[field]).strip_edges() == "": + return _fail("quest %s has an empty %s" % [quest.id, field]) + if seen.has(quest.id): + return _fail("two quests share the id %s" % quest.id) + seen[quest.id] = true + # Every goal must be one this build can actually answer. An unknown + # kind reads as never-done and would strand the player there. + var progress: Vector2i = game_state.quest_progress(quest) + if progress.y <= 0: + return _fail("quest %s has a goal of zero, so it is already done" % quest.id) + # --- B. and nothing is complete on a fresh game ------------------ + if game_state.quest_goal_met(quest): + return _fail("quest %s counts as done on a brand new game" % quest.id) + # The things a goal names must exist. + var goal: Dictionary = quest.goal + match String(goal.kind): + QUESTS.GOAL_BUILDING: + if game_state.building_definition(String(goal.building)) == null: + return _fail("quest %s asks for a building that does not exist: %s" \ + % [quest.id, goal.building]) + QUESTS.GOAL_FLEET_VESSEL, QUESTS.GOAL_VESSEL_UNLOCKED: + if game_state.vessel_definition(String(goal.vessel)) == null: + return _fail("quest %s asks for a vessel that does not exist: %s" \ + % [quest.id, goal.vessel]) + QUESTS.GOAL_RESOURCE: + if not (String(goal.resource) in game_state.resource_types()): + return _fail("quest %s asks for a resource that does not exist: %s" \ + % [quest.id, goal.resource]) + QUESTS.GOAL_RESEARCH: + if game_state.skill_node(String(goal.node)).is_empty(): + return _fail("quest %s asks to research a doctrine that does not exist: %s" \ + % [quest.id, goal.node]) + print("catalogue: %d quests, all well formed and none already done" % QUESTS.count()) + + # --- F. the hinge ---------------------------------------------------- + var mothership: Dictionary = QUESTS.by_id("mothership") + if mothership.is_empty(): + return _fail("there is no mothership quest - #129 turns on it") + var answer: String = String(mothership.done).to_lower() + if not ("world ship" in answer): + return _fail("the mothership quest's answer never mentions the World Ship") + if not ("further" in answer or "far" in answer or "carry" in answer): + return _fail("the mothership quest does not say the World Ship is out of reach") + # And what follows it is a search. + var after: Dictionary = QUESTS.at(QUESTS.CHAIN.find(mothership) + 1) + if String(after.goal.kind) != QUESTS.GOAL_EXPLORE: + return _fail("the quest after the mothership is %s, not exploration" % after.id) + print("the mothership answers with distance, and exploration follows it") + + # --- R. #145: every structure quest can actually be finished --------- + # The extractor quest could not be. Extractors are placed onto a planet + # from space and live in their own array, not in `buildings`, so the + # question the goal asked ("is there a building of this type") was one + # that could never be answered yes however many were built. + # + # Checked by BUILDING each one the way the game builds it and watching + # the goal flip, rather than by knowing which array it lands in - the + # knowing is what got this wrong in the first place. + for i in range(QUESTS.count()): + var build_quest: Dictionary = QUESTS.at(i) + if String(build_quest.goal.kind) != QUESTS.GOAL_BUILDING: + continue + var type: String = String(build_quest.goal.building) + game_state.buildings.clear() + game_state.extractors.clear() + if game_state.quest_goal_met(build_quest): + return _fail("quest %s counts as done with nothing built at all" % build_quest.id) + if type == GameState.BUILDING_EXTRACTOR: + game_state.add_extractor("Quest Test", Vector2.ZERO, GameState.RESOURCE_ORE, + "quest:costs") + else: + game_state.add_building(type, Vector2.ZERO) + if not game_state.quest_goal_met(build_quest): + return _fail("building a %s does not finish quest %s - it cannot be completed at all" \ + % [type, build_quest.id]) + game_state.buildings.clear() + game_state.extractors.clear() + print("every structure quest is finished by building the thing it asks for") + + # --- P. #142: enough rocks before anything is asked to be built ------ + # The first structure the chain asks for used to arrive straight after a + # five-ore errand, costing four times that, with nothing in between + # saying so. + var first_build: int = -1 + for i in range(QUESTS.count()): + if String(QUESTS.at(i).goal.kind) == QUESTS.GOAL_BUILDING: + first_build = i + break + if first_build <= 0: + return _fail("the chain opens on a build quest, with nothing gathered first") + var build_type: String = String(QUESTS.at(first_build).goal.building) + var build_price: int = int(game_state.building_costs(build_type).get(GameState.RESOURCE_ORE, 0)) + var gathered: int = 0 + for i in range(first_build): + var goal_before: Dictionary = QUESTS.at(i).goal + if String(goal_before.kind) == QUESTS.GOAL_RESOURCE \ + and String(goal_before.resource) == GameState.RESOURCE_ORE: + gathered = maxi(gathered, int(goal_before.amount)) + if gathered < build_price: + return _fail("the chain asks for a %s costing %d ore, having only ever asked for %d" \ + % [build_type, build_price, gathered]) + # ...and the pile has to FIT. Before the first structure exists there is + # nowhere to bank anything, so the fleet's own hold is the only store + # there is - and a pile larger than it would be a quest that cannot be + # finished at all. It currently fits exactly, with no slack, so this is + # the assertion that says so out loud rather than leaving it to be + # discovered by a player. + var starting: PackedStringArray = PackedStringArray() + for i in range(GameState.STARTING_SHIP_COUNT): + starting.append(GameState.VESSEL_INTERCEPTOR) + var hold: int = game_state.fleet_cargo_capacity(starting) + if gathered > hold: + return _fail("the chain asks for %d ore before anything can bank it, and the starting fleet holds %d" \ + % [gathered, hold]) + print("%d ore is asked for before the %s that costs %d, and the opening hold carries %d" \ + % [gathered, build_type, build_price, hold]) + + # --- Q. #141: research is taught before it is assumed ---------------- + # The first structure in the chain that needs a doctrine must have that + # doctrine as a quest of its own first. Only the FIRST one: once the + # player has been into the tree, a mention in a brief is enough, and + # demanding a research quest per building would double the chain. + var taught: Dictionary = {} + var checked: bool = false + for i in range(QUESTS.count()): + var goal_here: Dictionary = QUESTS.at(i).goal + if String(goal_here.kind) == QUESTS.GOAL_RESEARCH: + taught[String(goal_here.node)] = true + continue + if checked or String(goal_here.kind) != QUESTS.GOAL_BUILDING: + continue + var gate: String = String(game_state.research_node_for_building(String(goal_here.building))) + if gate == "": + continue + checked = true + if not taught.has(gate): + return _fail("the chain asks for a %s, gated behind \"%s\", without ever sending anyone to research it" \ + % [goal_here.building, gate]) + print("the first gated structure (%s) has its doctrine \"%s\" as a quest of its own" \ + % [goal_here.building, gate]) + if not checked: + return _fail("no quest asks for a structure that needs research at all") + + # --- G. nothing asks for points before conquest ---------------------- + var conquest_at: int = -1 + for i in range(QUESTS.count()): + if String(QUESTS.at(i).goal.kind) == QUESTS.GOAL_CONQUERED: + conquest_at = i + break + if conquest_at < 0: + return _fail("no quest ever conquers a planet, so research points are unreachable") + for i in range(conquest_at): + var quest: Dictionary = QUESTS.at(i) + if String(quest.goal.kind) != QUESTS.GOAL_BUILDING: + continue + var research: String = game_state.research_for_building(String(quest.goal.building)) + if research != "" and _costs_research_points(research): + return _fail("quest %s needs %s, which costs research points, before any planet is taken" \ + % [quest.id, research]) + print("no quest needs research points before the conquest quest at %d" % conquest_at) + + # --- H. not during the prologue -------------------------------------- + game_state.reset_new_game() + if not game_state.intro_active: + return _fail("a new game is not in the prologue") + game_state._advance_quests() + if game_state.quest_announced or not game_state.quest_messages.is_empty(): + return _fail("a quest spoke up during the prologue") + game_state.intro_active = false + print("the chain holds its tongue while the prologue runs") + + # --- D. announced before it can complete ----------------------------- + game_state.bank_inventory.deposit(GameState.RESOURCE_ORE, 500) + game_state._advance_quests() + if game_state.quest_index != 0: + return _fail("the first quest completed before it was ever announced") + if game_state.quest_messages.is_empty(): + return _fail("the first quest was not announced") + var brief: String = String(game_state.take_quest_messages()[0].line) + if brief != String(QUESTS.at(0).brief): + return _fail("the first quest announced something other than its own brief") + game_state._advance_quests() + if game_state.quest_index != 1: + return _fail("a satisfied quest did not complete on the frame after it was announced") + print("a quest is read out first, then completed: \"%s\"" % brief.substr(0, 48)) + + # --- C. and it stays complete ---------------------------------------- + game_state.bank_inventory.withdraw(GameState.RESOURCE_ORE, 500) + if game_state.available_resource(GameState.RESOURCE_ORE) != 0: + return _fail("the test failed to spend the ore it just banked") + game_state._advance_quests() + if game_state.quest_index < 1: + return _fail("spending the ore put the chain back on the ore quest") + print("spending the ore did not un-complete the quest that asked for it") + + # --- E. the whole chain walks ---------------------------------------- + var guard: int = 0 + while not game_state.quest_chain_complete() and guard < QUESTS.count() * 4: + guard += 1 + game_state.complete_active_quest() + if not game_state.quest_chain_complete(): + return _fail("the chain would not walk to its end") + if not game_state.active_quest().is_empty(): + return _fail("there is still an active quest after the chain ended") + game_state._advance_quests() + print("the chain walks to the end and stops there") + return false + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _catalogue_cases(): + return true + # --- E (second half). the ore quest, really collected -------- + game_state.reset_new_game() + game_state.intro_active = false + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + panel = scene.get_node("UI/QuestPanel") + quest_button = scene.get_node("UI/QuestButton") + narration = scene.get_node("UI/NarrationBanner") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + # The opening brief is up, which has stopped the game (#128). + if not narration.is_speaking(): + return _fail("the first quest never reached the banner") + if not paused: + return _fail("a quest line did not stop the game") + print("the first quest arrives as a dialog and stops the game") + while narration.dismiss(): + pass + if paused: + return _fail("acknowledging every line left the game stopped") + + # --- the HUD ------------------------------------------------ + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + # #140: the objective lives behind a button next to the map's, + # not on the HUD - it used to sit on top of the wave button. + if panel.visible: + return _fail("the objective panel is open before anyone pressed anything") + if not quest_button.visible: + return _fail("there is no quest button to press") + var map_button: Control = scene.get_node("UI/MapButton") + if quest_button.get_rect().intersects(map_button.get_rect()): + return _fail("the quest button is drawn on top of the map button") + var wave_button: Control = scene.get_node("UI/DevWaveButton") + if quest_button.get_rect().intersects(wave_button.get_rect()): + return _fail("the quest button is drawn on top of the wave button") + if absf(quest_button.get_rect().get_center().y - map_button.get_rect().get_center().y) > 1.0: + return _fail("the quest button does not sit beside the map button") + panel.open() + if not panel.visible: + return _fail("pressing the quest button opened nothing") + var quest: Dictionary = game_state.active_quest() + if panel.quest_label.text != String(quest.title): + return _fail("the panel shows \"%s\", the quest is \"%s\"" \ + % [panel.quest_label.text, quest.title]) + if panel.objective_label.text != String(quest.objective): + return _fail("the panel does not show the objective") + if panel.progress != Vector2i(0, 5): + return _fail("the panel reads %s, expected 0 of 5" % panel.progress) + print("quest button beside the map, clear of the wave button; panel: \"%s\" - %s (%d/%d)" \ + % [panel.quest_label.text, panel.objective_label.text, + panel.progress.x, panel.progress.y]) + panel.close() + + # Collect what the quest asked for, the way the game does. + game_state.collect_cargo(5) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + # #142: five ore finishes the first errand and starts the + # stockpile, rather than jumping straight to a structure costing + # four times what was just asked for. + if String(game_state.active_quest().get("id", "")) != "stockpile": + return _fail("collecting 5 ore did not finish the first ore quest (on %s)" \ + % game_state.active_quest().get("id", "")) + print("5 ore done; the chain asks for the pile before the mothership") + while narration.dismiss(): + pass + game_state.collect_cargo(15) + elapsed = 0.0 + stage = 31 + return false + 31: + # A quest completing puts its line up, and the next quest's brief + # behind it - and each of those stops the tree (#128), which is + # what stops the goal below from ever being checked. Clicked + # through the way a player does, rather than waited out. + if narration.is_speaking(): + # Seen here rather than asserted after the fact: by the time + # the chain has moved on, the lines it said are gone. + spoke = true + while narration.dismiss(): + pass + elapsed = 0.0 + return false + if elapsed < 0.3: + return false + if String(game_state.active_quest().get("id", "")) != "mothership": + return _fail("collecting the full 20 ore did not finish the stockpile (on %s, ore=%d, hold cap=%d)" \ + % [game_state.active_quest().get("id", ""), + game_state.available_resource(GameState.RESOURCE_ORE), + game_state.fleet_inventory.total_capacity]) + if not spoke: + return _fail("finishing a quest said nothing") + print("20 ore collected: the chain moved on to \"%s\"" \ + % game_state.active_quest().title) + + # --- I. a line said with no banner alive -------------------- + scene.free() + scene = null + game_state.complete_active_quest() + if game_state.quest_messages.is_empty(): + return _fail("a quest finished with no banner and dropped its line") + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + narration = scene.get_node("UI/NarrationBanner") + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.3: + return false + if not narration.is_speaking(): + return _fail("the line queued with no banner alive was never said") + print("a quest finished off-scene is read out by the next banner that exists") + while narration.dismiss(): + pass + + # --- J. and the same on a planet surface --------------------- + scene.free() + game_state.planet_name = "Quest Test" + game_state.planet_id = "quest:test" + game_state.planet_definition_id = "rocky" + scene = load("res://surface/PlanetSurface.tscn").instantiate() + root.add_child(scene) + current_scene = scene + narration = scene.get_node_or_null("UI/NarrationBanner") + panel = scene.get_node_or_null("UI/QuestPanel") + quest_button = scene.get_node_or_null("UI/QuestButton") + if narration == null or panel == null or quest_button == null: + return _fail("a planet surface has no banner and no objective panel") + game_state.complete_active_quest() + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.3: + return false + if not narration.is_speaking(): + return _fail("a quest finished on a surface said nothing down there") + print("a quest finished on a surface is announced on the surface") + # The tracker is an ordinary HUD node, so it is frozen along with + # everything else behind a dialog (#128) and comes up as the line + # closes. Acknowledged first, then looked at. + while narration.dismiss(): + pass + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.3: + return false + if not quest_button.visible: + return _fail("the objective button is missing on a planet surface") + panel.open() + if not panel.visible or panel.quest_label.text.strip_edges() == "": + return _fail("the objective panel says nothing on a planet surface") + print("the surface carries the objective too: \"%s\"" % panel.quest_label.text) + panel.close() + + # --- K. landing is recorded ---------------------------------- + game_state.reset_quests() + game_state.note_planet_visited("quest:one", "rocky") + game_state.note_planet_visited("quest:one", "rocky") + game_state.note_planet_visited("quest:two", "gas_giant") + if game_state.visited_planets.size() != 2: + return _fail("landing twice on one planet counted as two worlds") + if game_state.visited_planet_kinds.size() != 2: + return _fail("two kinds of world were not recorded as two kinds") + var kinds: Dictionary = {"kind": QUESTS.GOAL_PLANET_KINDS, "count": 3} + if game_state.quest_goal_met({"goal": kinds}): + return _fail("a three-world survey passed on two worlds") + game_state.note_planet_visited("quest:three", "research_lab") + if not game_state.quest_goal_met({"goal": kinds}): + return _fail("a three-world survey did not pass on three kinds of world") + print("landings recorded: %d planets across %d kinds of world" \ + % [game_state.visited_planets.size(), game_state.visited_planet_kinds.size()]) + + print("TEST_QUEST_CHAIN: PASS") + quit(0) + return true + return false diff --git a/tests/test_quest_chain.gd.uid b/tests/test_quest_chain.gd.uid new file mode 100644 index 0000000..ee20e1a --- /dev/null +++ b/tests/test_quest_chain.gd.uid @@ -0,0 +1 @@ +uid://xvglax7l2t6y diff --git a/tests/test_research_bar_columns.gd b/tests/test_research_bar_columns.gd new file mode 100644 index 0000000..ff65fb1 --- /dev/null +++ b/tests/test_research_bar_columns.gd @@ -0,0 +1,159 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_research_bar_columns.gd +# Forgejo #95, both halves of it. +# +# The research menu's resource table printed BANKED, FLEET and LOCAL, and +# its LOCAL column was always a copy of BANKED. That was not a coincidence: +# the research action lives on the mothership, the mothership also carries +# the "bank" capability, and GameState.building_inventory() redirects any +# bank-capable building to bank_inventory itself. The two columns were the +# same store read twice. +# +# The panel also wore a "Research" heading, which named a screen you can +# only arrive at by pressing the research button, and charged the tree +# canvas 128px for the privilege. +# +# Covers: +# A. the research bar shows BANKED and FLEET and hides LOCAL +# B. the two remaining columns tile the full width - dropping one must not +# leave a third of the table blank +# C. a building that is NOT the bank still gets its LOCAL column, so the +# fix removes a duplicate rather than the feature +# D. no heading above the table, and the tree canvas starts right under it + +const GAP_TOLERANCE: float = 48.0 + +var game_state: Node +var scene: Node +var controller: Node +var skill_menu: Control +var craft_menu: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_formation() + game_state.rocks = 500 + + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + skill_menu = scene.get_node("UI/SkillMenu") + craft_menu = scene.get_node("UI/CraftMenu") + + +func _building(building_type: String) -> Node2D: + for building in scene.get_node("BuildingField").spawned: + if is_instance_valid(building) and building.building_type == building_type: + return building + return null + + +func _shown_headers(bar: Control) -> Array: + var shown: Array = [] + for label in bar._header_labels: + if label.visible and label.text != "": + shown.append(label.text) + return shown + + +func _process(delta: float) -> bool: + elapsed += delta + if stage == 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + var field: Node = scene.get_node("BuildingField") + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(-400.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + if stage != 1 or elapsed < 0.2: + return false + stage = 2 + + var mothership: Node2D = _building(GameState.BUILDING_MOTHERSHIP) + if mothership == null: + return _fail("no mothership was placed") + controller.current_building = mothership + controller._on_action_pressed() + if not skill_menu.visible: + return _fail("the mothership did not open the research menu") + + var bar: Control = scene.get_node("UI/SkillMenu/Bar") + # One frame of _process is what tells the bar its building changed. + bar._process(0.0) + + # --- A. LOCAL is gone from the research table ---------------------- + var headers: Array = _shown_headers(bar) + if headers != ["BANKED", "FLEET"]: + return _fail("the research table shows %s, not just BANKED and FLEET" % str(headers)) + for resource_type in game_state.resource_types(): + var row: Dictionary = bar._rows[resource_type] + if row.local.visible: + return _fail("the LOCAL value for %s is still drawn" % resource_type) + if not row.bank.visible or not row.fleet.visible: + return _fail("dropping LOCAL also hid a column that is not redundant") + print("the research table drops the LOCAL copy of BANKED") + + # --- B. the survivors share the freed width ------------------------- + var first_row: Dictionary = bar._rows[game_state.resource_types()[0]] + var covered: float = (first_row.fleet.position.x + first_row.fleet.size.x) \ + - first_row.bank.position.x + var expected: float = bar.size.x - bar.size.x * 0.25 + if not is_equal_approx(covered, expected): + return _fail("the two columns cover %.0fpx of %.0fpx - the dropped column left a hole" \ + % [covered, expected]) + print("BANKED and FLEET share the whole %.0fpx of value width" % covered) + + # --- C. a non-bank building keeps its LOCAL column ------------------ + skill_menu.visible = false + var crafter: Node2D = _building(GameState.BUILDING_SHIP_CRAFTER) + if crafter == null: + return _fail("no ship crafter was placed") + controller.current_building = crafter + controller._on_action_pressed() + if not craft_menu.visible: + return _fail("the crafter did not open the craft menu") + var craft_bar: Control = scene.get_node("UI/CraftMenu/Bar") + craft_bar._process(0.0) + var craft_headers: Array = _shown_headers(craft_bar) + if craft_headers != ["BANKED", "FLEET", "LOCAL"]: + return _fail("the crafter table shows %s - its LOCAL stock is not the bank" \ + % str(craft_headers)) + print("a building that is not the bank keeps LOCAL") + + # --- D. no heading, and the tree starts under the table ------------- + for child in skill_menu.get_children(): + if child is Label and String(child.text).strip_edges() == "Research": + return _fail("the \"Research\" heading is still in the panel") + var tree_view: Control = scene.get_node("UI/SkillMenu/Tree") + var bar_bottom: float = bar.position.y + bar.size.y + var head_gap: float = tree_view.position.y - bar_bottom + if bar.position.y > GAP_TOLERANCE: + return _fail("%.0fpx of empty panel above the table - the heading's hole" % bar.position.y) + if head_gap < 0.0: + return _fail("the tree canvas overlaps the table above it") + if head_gap > GAP_TOLERANCE: + return _fail("%.0fpx of unused panel between the table and the tree" % head_gap) + print("no heading: the table sits %.0fpx down and the tree is %.0fpx tall" \ + % [bar.position.y, tree_view.size.y]) + + print("TEST_RESEARCH_BAR_COLUMNS: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_RESEARCH_BAR_COLUMNS: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_research_bar_columns.gd.uid b/tests/test_research_bar_columns.gd.uid new file mode 100644 index 0000000..fefb87e --- /dev/null +++ b/tests/test_research_bar_columns.gd.uid @@ -0,0 +1 @@ +uid://cn0b7xfbbo5m8 diff --git a/tests/test_research_double_tap.gd b/tests/test_research_double_tap.gd index 39216a9..3495ec7 100644 --- a/tests/test_research_double_tap.gd +++ b/tests/test_research_double_tap.gd @@ -125,9 +125,15 @@ func _process(delta: float) -> bool: if game_state.is_unlocked(second): return _fail("a node was bought with no ore at all") controller._refresh_skill_menu(second) - if controller.skill_status.text == "": - return _fail("an unaffordable node was refused silently") - print("unaffordable node refused, and says so: '%s'" % controller.skill_status.text) + # #134: the refusal is shown on the price rather than written out in a + # sentence below it - what you hold over what it costs, in red. + if controller.skill_cost.is_affordable(): + return _fail("a node the player cannot pay for reads as affordable") + if not controller.skill_cost.is_short(GameState.RESOURCE_ORE): + return _fail("the ore the player does not have is not marked short") + print("unaffordable node refused, and its price says so: %d/%d ore" \ + % [controller.skill_cost.held(GameState.RESOURCE_ORE), + controller.skill_cost.priced(GameState.RESOURCE_ORE)]) print("TEST_RESEARCH_DOUBLE_TAP: PASS") quit(0) diff --git a/tests/test_research_points.gd b/tests/test_research_points.gd index 32b3b39..0981714 100644 --- a/tests/test_research_points.gd +++ b/tests/test_research_points.gd @@ -65,6 +65,11 @@ func _initialize() -> void: # --- F. a fresh game still reaches a crafter and an extractor ------------- game_state.reset_new_game() + # #108: reset_new_game() arms the prologue, and the prologue opens with a + # convoy rather than the game's own starting fleet. This test is about + # what the GAME starts from, which is where the prologue hands over - + # see test_intro_scene.gd for the prologue itself. + game_state.intro_active = false # 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. diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 81a9c9b..33324b4 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -84,11 +84,11 @@ func _process(delta: float) -> bool: ship.position = mothership_pos fleet.move_to(mothership_pos) field.place(GameState.BUILDING_MOTHERSHIP, mothership_pos) - game_state.spend(GameState.MOTHERSHIP_COST) + game_state.spend(int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])) field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(1400.0, 300.0)) - game_state.spend(GameState.SHIP_CRAFTER_COST) + game_state.spend(int(game_state.building_costs(GameState.BUILDING_SHIP_CRAFTER)[GameState.RESOURCE_ORE])) - var paid: bool = game_state.rocks == before - GameState.MOTHERSHIP_COST - GameState.SHIP_CRAFTER_COST + var paid: bool = game_state.rocks == before - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) - int(game_state.building_costs(GameState.BUILDING_SHIP_CRAFTER)[GameState.RESOURCE_ORE]) var stored: bool = game_state.buildings.size() == 2 print("buildings=%d rocks %d->%d paid=%s" % [ game_state.buildings.size(), before, game_state.rocks, paid diff --git a/tests/test_sfx.gd b/tests/test_sfx.gd new file mode 100644 index 0000000..f80487b --- /dev/null +++ b/tests/test_sfx.gd @@ -0,0 +1,167 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_sfx.gd +# Forgejo #146: a shot, a vessel dying and a structure coming down should be +# heard. +# +# The interesting failures here are not "no sound came out" - they are the +# ones a play-through would not reveal for weeks. A voice pool that runs dry +# under a firing fleet and silently drops every shot after the twelfth. A +# sound started by a node that is being freed on the same frame, which is +# every death in the game. So those are what is checked. +# +# Covers: +# A. every sound the catalog names actually exists and loads +# B. firing plays a shot, and it survives a fleet firing together - no +# voice is lost, none is left stuck +# C. a dying ship is heard, from a node that is freed on the same frame +# D. a destroyed structure is heard +# E. levels are in proportion: a shot fires constantly and a death happens +# once, so a shot must be the quieter of the two +# F. sound keeps working while a dialog has the game stopped (#128) + +var game_state: Node +var scene: Node +var fleet: Node2D +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + game_state.rocks = 500 + + +func _fail(message: String) -> bool: + print("TEST_SFX: FAIL (%s)" % message) + quit(1) + return true + + +func _pool() -> Node: + return root.get_node_or_null(Sfx.NODE_NAME) + + +func _sounding() -> int: + var pool: Node = _pool() + if pool == null: + return 0 + var n: int = 0 + for voice in pool.get_children(): + if (voice as AudioStreamPlayer).playing: + n += 1 + return n + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A. the catalog is real ----------------------------- + for id in Sfx.SOUNDS: + var path: String = String(Sfx.SOUNDS[id]) + var stream: AudioStream = load(path) + if stream == null: + return _fail("the \"%s\" sound (%s) does not load" % [id, path]) + if stream.get_length() <= 0.02: + return _fail("the \"%s\" sound is %.3fs long - that is silence" \ + % [id, stream.get_length()]) + if not Sfx.LEVELS.has(id): + return _fail("the \"%s\" sound has no level set" % id) + print("%d sounds, all loading, all with a level" % Sfx.SOUNDS.size()) + + # --- E. and in proportion ------------------------------- + if float(Sfx.LEVELS[Sfx.SHOT]) >= float(Sfx.LEVELS[Sfx.SHIP_DEATH]): + return _fail("a shot is as loud as a death - one happens constantly, the other once") + print("shot at %.0fdB, ship death at %.0fdB, structure at %.0fdB" \ + % [Sfx.LEVELS[Sfx.SHOT], Sfx.LEVELS[Sfx.SHIP_DEATH], + Sfx.LEVELS[Sfx.BUILDING_DEATH]]) + + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + if _pool() != null and _sounding() > 0: + return _fail("something was already sounding before anything happened") + + # --- B. a shot, and then far more than there are voices -- + Sfx.play(self, Sfx.SHOT) + if _sounding() != 1: + return _fail("firing played %d sounds, expected 1" % _sounding()) + if _pool().get_parent() != root: + return _fail("the sound pool hangs off the scene - a death would take it with it") + for i in range(Sfx.VOICES * 3): + Sfx.play(self, Sfx.SHOT) + var busy: int = _sounding() + if busy != Sfx.VOICES: + return _fail("%d voices sounding after %d shots, the pool holds %d" \ + % [busy, Sfx.VOICES * 3, Sfx.VOICES]) + if _pool().get_child_count() != Sfx.VOICES: + return _fail("the pool grew to %d voices" % _pool().get_child_count()) + print("%d shots into a %d-voice pool: %d sounding, none leaked" \ + % [Sfx.VOICES * 3, Sfx.VOICES, busy]) + + # --- F. and a dialog does not silence it ----------------- + var narration: Control = scene.get_node("UI/NarrationBanner") + narration.say("Everything stops but this.", "TEST") + if not paused: + return _fail("the dialog did not stop the game, so this proves nothing") + for voice in _pool().get_children(): + if not (voice as AudioStreamPlayer).can_process(): + return _fail("sound stops whenever a dialog is up") + print("a dialog stops the game and sound carries on") + while narration.dismiss(): + pass + + for voice in _pool().get_children(): + (voice as AudioStreamPlayer).stop() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + # --- C. a death, from a node freed on the same frame ----- + fleet.spawn_ships(2, Vector2.ZERO, + PackedStringArray([GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_INTERCEPTOR])) + for voice in _pool().get_children(): + (voice as AudioStreamPlayer).stop() + var doomed: Node2D = fleet.ships[0] + doomed.take_damage(1) + if not is_instance_valid(doomed) or not doomed.is_queued_for_deletion(): + return _fail("the ship was not freed, so this is not the case that matters") + if _sounding() == 0: + return _fail("a vessel died in silence") + print("a vessel freed on the frame it dies is still heard") + + # --- D. a structure ------------------------------------ + for voice in _pool().get_children(): + (voice as AudioStreamPlayer).stop() + var field: Node2D = scene.get_node("BuildingField") + var id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, + Vector2(400.0, 0.0)) + field.rebuild_all() + game_state.destroy_building(id) + if _sounding() == 0: + return _fail("a structure came down in silence") + print("a structure coming down is heard") + + print("TEST_SFX: PASS") + quit(0) + return true + return false diff --git a/tests/test_sfx.gd.uid b/tests/test_sfx.gd.uid new file mode 100644 index 0000000..a642d95 --- /dev/null +++ b/tests/test_sfx.gd.uid @@ -0,0 +1 @@ +uid://cru3clxyxahgn diff --git a/tests/test_ship_death.gd b/tests/test_ship_death.gd new file mode 100644 index 0000000..6891abc --- /dev/null +++ b/tests/test_ship_death.gd @@ -0,0 +1,224 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_ship_death.gd +# Forgejo #130: "they would break away from the fleet by a few metres, and +# end up exploding a little further off, with a rather more explosive +# explosion than just a light animation." +# +# Three claims, and the first one is the one that could quietly break +# everything else: the wreck must be SCENERY. A dying ship kept alive to +# animate would still be an Area2D in the player_ships group with live +# weapon components - still shooting, still a target, still colliding - so +# the test checks that the fleet, the group and the node are all rid of it +# on the same frame they always were. +# +# Covers: +# A. the ship itself dies immediately: out of the fleet, out of the group, +# freed - exactly as before +# B. a wreck is left behind, and it travels AWAY from the fleet +# C. it explodes where it ended up, not where it was hit +# D. the explosion is a real one - and no quieter than the one an enemy +# gets, which is the comparison #130 is making +# E. #135 - the prologue hands over an interceptor even when the belt has +# taken every escort + +var game_state: Node +var scene: Node +var fleet: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var wreck: Node2D +var wreck_start: Vector2 = Vector2.ZERO +var fleet_centre: Vector2 = Vector2.ZERO +var last_wreck_at: Vector2 = Vector2.ZERO +var ships_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_SHIP_DEATH: FAIL (%s)" % message) + quit(1) + return true + + +func _wrecks() -> Array: + var out: Array = [] + for node in root.get_children(): + if node is ShipWreckVFX: + out.append(node) + return out + + +func _explosions() -> Array: + var out: Array = [] + for node in root.get_children(): + if node is ShipExplosionVFX: + out.append(node) + return out + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + # A wedge, so there is a formation to break away FROM. + fleet.spawn_ships(3, Vector2.ZERO, + PackedStringArray([GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR])) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if fleet.ships.size() != 3: + return _fail("wanted a fleet of 3 to kill one out of, got %d" % fleet.ships.size()) + fleet_centre = fleet.get_centroid() + var doomed: Node2D = fleet.ships[0] + wreck_start = doomed.position + var before: int = get_nodes_in_group("player_ships").size() + doomed.take_damage(1) + + # --- A. the ship is gone, on this frame ------------------ + if fleet.ships.size() != 2: + return _fail("a killed ship is still in the fleet (%d ships)" % fleet.ships.size()) + if is_instance_valid(doomed) and not doomed.is_queued_for_deletion(): + return _fail("a killed ship was not freed") + ships_before = before + print("the ship leaves the fleet and is freed on the frame it dies") + + # --- B. and leaves a wreck behind ------------------------ + var wrecks: Array = _wrecks() + if wrecks.size() != 1: + return _fail("%d wrecks left behind, expected 1" % wrecks.size()) + wreck = wrecks[0] + if wreck.position.distance_to(wreck_start) > 1.0: + return _fail("the wreck did not start where the ship died") + last_wreck_at = wreck.position + elapsed = 0.0 + stage = 3 + return false + 3: + # Halfway through the drift: still there, and further out. + if elapsed < ShipWreckVFX.DRIFT_SECONDS * 0.5: + return false + if not is_instance_valid(wreck): + return _fail("the wreck was gone before it had finished drifting") + # A frame has passed, so the deletion queue has been flushed: the + # dead ship is out of the group the enemies pick targets from. + if get_nodes_in_group("player_ships").size() != ships_before - 1: + return _fail("a killed ship is still in the player_ships group a frame later") + var travelled: float = wreck.position.distance_to(wreck_start) + if travelled < 40.0: + return _fail("the wreck drifted %.0fpx - it is meant to break away" % travelled) + # Away from the fleet, not through it. + if wreck.position.distance_to(fleet_centre) <= wreck_start.distance_to(fleet_centre): + return _fail("the wreck drifted towards the fleet, not away from it") + if wreck.rotation == 0.0: + return _fail("the wreck is not tumbling") + last_wreck_at = wreck.position + print("wreck drifted %.0fpx clear of the fleet, tumbling" % travelled) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < ShipWreckVFX.DRIFT_SECONDS: + return false + # --- C. it went up where it ended up --------------------- + if is_instance_valid(wreck) and not wreck.is_queued_for_deletion(): + return _fail("the wreck never exploded") + var blasts: Array = _explosions() + if blasts.is_empty(): + return _fail("the wreck vanished without an explosion") + var at: Vector2 = blasts[0].position + if at.distance_to(wreck_start) < 40.0: + return _fail("the explosion happened at the kill (%.0fpx away), not out where the wreck drifted to" \ + % at.distance_to(wreck_start)) + print("it exploded %.0fpx from where the ship was hit" % at.distance_to(wreck_start)) + + # --- D. and it is a real explosion ----------------------- + var ours: ShipExplosionVFX = ShipExplosionVFX.new() + var theirs: EnemyExplosionVFX = EnemyExplosionVFX.new() + if ours.shockwave_radius <= 0.0: + return _fail("a lost vessel still goes up without a shockwave") + if ours.particle_count < theirs.particle_count: + return _fail("losing a vessel (%d particles) is quieter than killing an enemy (%d)" \ + % [ours.particle_count, theirs.particle_count]) + if ours.lifetime < theirs.lifetime: + return _fail("a lost vessel's explosion is shorter than an enemy's") + print("player explosion: %d particles over %.2fs, shockwave %.0f (enemy: %d over %.2fs)" \ + % [ours.particle_count, ours.lifetime, ours.shockwave_radius, + theirs.particle_count, theirs.lifetime]) + ours.free() + theirs.free() + + # --- E. #135: the prologue's survivor -------------------- + scene.free() + game_state.reset_new_game() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.4: + return false + var director: Node2D = scene.get_node("IntroDirector") + var narration: Control = scene.get_node("UI/NarrationBanner") + narration.clear() + # The belt takes every escort on the way through - the corners of + # the convoy are the exposed part of it. This is the case #135 + # reported: what is left is all cargo. + for i in range(fleet.ships.size() - 1, -1, -1): + var ship: Node2D = fleet.ships[i] + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + fleet.ships.remove_at(i) + fleet.formation_offsets.remove_at(i) + ship.free() + if fleet.ships.is_empty(): + return _fail("the test emptied the convoy instead of stripping its escorts") + for ship in fleet.ships: + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + return _fail("an escort survived the test's own belt") + print("convoy stripped to %d holds and no escorts" % fleet.ships.size()) + + director._begin(director.Beat.VORTEX) + if not fleet.has_vessel_type(GameState.VESSEL_INTERCEPTOR): + return _fail("the vortex began with no basic vessel to leave behind") + if String(director._survivor().vessel_type) != GameState.VESSEL_INTERCEPTOR: + return _fail("the survivor is a %s" % director._survivor().vessel_type) + director.finish() + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("the prologue handed over %d vessels" % fleet.ships.size()) + if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR: + return _fail("the prologue handed over a %s, not the basic vessel" \ + % fleet.ships[0].vessel_type) + print("with every escort lost, the prologue still hands over an interceptor") + + print("TEST_SHIP_DEATH: PASS") + quit(0) + return true + return false diff --git a/tests/test_ship_death.gd.uid b/tests/test_ship_death.gd.uid new file mode 100644 index 0000000..7a8c72e --- /dev/null +++ b/tests/test_ship_death.gd.uid @@ -0,0 +1 @@ +uid://b13dmq47yfhjn diff --git a/tests/test_shot_and_vfx_weight.gd b/tests/test_shot_and_vfx_weight.gd new file mode 100644 index 0000000..a599321 --- /dev/null +++ b/tests/test_shot_and_vfx_weight.gd @@ -0,0 +1,153 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_shot_and_vfx_weight.gd +# Forgejo #155 ("default vessel shots should go faster and further") and #152 +# ("add a vfx for dead enemies and for exploded rock and for resource +# collected on fleet"). +# +# The #152 finding is worth recording: all three effects already existed and +# were already wired. What had happened is that #130 gave a lost VESSEL +# thirty particles and a shockwave, which left every other event in the game +# visibly smaller than it - a kill, the thing the player is rewarded for, +# was a twelve-particle puff beside it. So this is about PROPORTION, and +# proportion is what is checked: numbers get rebalanced, an ordering that +# says a rock outweighs a battleship is a bug. +# +# Covers: +# A. the default gun outranges the enemies that stand off from it +# B. a shot crosses that range quickly enough to read as hitting something +# C. a shot's own lifetime is derived from its range, so faster does not +# mean falling short +# D. every one of #152's three events actually produces something +# E. and they are in proportion - a lost vessel is the biggest, a rock is +# not a puff, and nothing is on the base class's defaults + +const ENEMY_STANDOFF = preload("res://enemies/EnemyStandoffComponent.gd") +const EXPLOSION_BASE = preload("res://vfx/ExplosionVFXBase.gd") + +var game_state: Node +var scene: Node +var fleet: Node2D +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_SHOT_AND_VFX_WEIGHT: FAIL (%s)" % message) + quit(1) + return true + + +func _count(of_class) -> int: + var n: int = 0 + for node in root.get_children(): + if is_instance_of(node, of_class): + n += 1 + return n + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A, B, C. the shot ---------------------------------- + var range_units: float = game_state.ship_fire_range(GameState.VESSEL_INTERCEPTOR) + var shot: Node2D = load("res://vessels/Projectile.tscn").instantiate() + var speed: float = shot.speed + shot.set_range(range_units) + var flight: float = shot.lifetime + var standoff: float = ENEMY_STANDOFF.new().preferred_range + + if range_units <= standoff: + return _fail("the default gun reaches %.0f and enemies stand off at %.0f - it is outranged" \ + % [range_units, standoff]) + if flight > 0.9: + return _fail("a shot takes %.2fs to cross its own range - that reads as leaving, not hitting" \ + % flight) + if absf(flight - range_units / speed) > 0.001: + return _fail("lifetime %.3fs does not match range %.0f at speed %.0f - shots would fall short or overshoot" \ + % [flight, range_units, speed]) + print("default gun: %.0f units at %.0f/s, crossing its range in %.2fs (enemies stand off at %.0f)" \ + % [range_units, speed, flight, standoff]) + shot.free() + + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + hazard.free() + + # --- D. an enemy dying is seen -------------------------- + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.enemy_id = "chaser" + enemy.position = Vector2(600.0, 0.0) + scene.add_child(enemy) + var before: int = _count(EnemyExplosionVFX) + enemy.take_damage(999) + if _count(EnemyExplosionVFX) <= before: + return _fail("an enemy died with nothing to show for it") + + # --- ...a rock breaking up ------------------------------ + var rock: Node2D = load("res://world/Asteroid.tscn").instantiate() + rock.position = Vector2(-600.0, 0.0) + scene.add_child(rock) + var rocks_before: int = _count(AsteroidExplosionVFX) + rock.shatter(false) + if _count(AsteroidExplosionVFX) <= rocks_before: + return _fail("a rock broke up with nothing to show for it") + + # --- ...and ore reaching the hold ----------------------- + var pickup_script: GDScript = load("res://vfx/ResourcePickupVFX.gd") + var pickups_before: int = _count(pickup_script) + pickup_script.spawn(root, Vector2.ZERO, GameState.RESOURCE_ORE, 1) + if _count(pickup_script) <= pickups_before: + return _fail("collecting ore showed nothing") + print("all three events produce an effect") + + # --- E. and they are in proportion ---------------------- + var base: Node2D = EXPLOSION_BASE.new() + var ship: Node2D = ShipExplosionVFX.new() + var kill: Node2D = EnemyExplosionVFX.new() + var stone: Node2D = AsteroidExplosionVFX.new() + for pair in [["a lost vessel", ship], ["an enemy kill", kill], ["a broken rock", stone]]: + var vfx: Node2D = pair[1] + if vfx.particle_count <= base.particle_count: + return _fail("%s is still on the base class's %d particles" \ + % [pair[0], base.particle_count]) + if vfx.shockwave_radius <= 0.0: + return _fail("%s has no shockwave behind it" % pair[0]) + if ship.particle_count < kill.particle_count: + return _fail("losing a vessel (%d) is smaller than killing an enemy (%d)" \ + % [ship.particle_count, kill.particle_count]) + if kill.particle_count < stone.particle_count: + return _fail("killing an enemy (%d) is smaller than breaking a rock (%d)" \ + % [kill.particle_count, stone.particle_count]) + print("particles: vessel %d > enemy %d > rock %d > base default %d" \ + % [ship.particle_count, kill.particle_count, stone.particle_count, + base.particle_count]) + for vfx in [base, ship, kill, stone]: + vfx.free() + + print("TEST_SHOT_AND_VFX_WEIGHT: PASS") + quit(0) + return true + return false diff --git a/tests/test_shot_and_vfx_weight.gd.uid b/tests/test_shot_and_vfx_weight.gd.uid new file mode 100644 index 0000000..8704365 --- /dev/null +++ b/tests/test_shot_and_vfx_weight.gd.uid @@ -0,0 +1 @@ +uid://0ny0jxkr87ox diff --git a/tests/test_skill_menu.gd b/tests/test_skill_menu.gd new file mode 100644 index 0000000..bfd714e --- /dev/null +++ b/tests/test_skill_menu.gd @@ -0,0 +1,181 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_skill_menu.gd +# Forgejo #93, both halves of it. +# +# A mouse could not drive the research menu at all. Input reaches the tree +# through BuildController._unhandled_input as raw events (a Control that +# eats them through the GUI system would swallow the drag - see +# SkillTreeView's header), and the tree only ever read touch events. That +# was invisible for as long as the project set emulate_touch_from_mouse, +# which handed it a synthetic touch per click; with that setting gone, the +# menu is inert under a mouse and there is no other way to research +# anything on a desktop. +# +# The panel also still had a hole in it. The buy button below the readout +# went away in #83, and nothing moved into the space, so ~180px of empty +# panel sat under the text while the tree canvas above it stayed cramped. +# +# Covers: +# A. a left click selects a node, through the real routing in +# BuildController rather than by calling the view directly +# B. a second click on the SAME node researches it, which is #83's +# confirm gesture reached with a mouse +# C. holding the left button and moving scrolls the canvas, and moving +# with no button held does NOT - a hovering pointer must not drag the +# tree out from under the reader +# D. the wheel scrolls, in both directions +# E. the readout sits at the bottom of the panel and the tree canvas fills +# everything above it, so neither the dead band nor the cramped tree +# comes back + +const GAP_TOLERANCE: float = 48.0 + +var game_state: Node +var scene: Node +var controller: Node +var skill_menu: Control +var tree_view: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_formation() + game_state.rocks = 500 + + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + skill_menu = scene.get_node("UI/SkillMenu") + tree_view = scene.get_node("UI/SkillMenu/Tree") + + +func _click(at: Vector2, pressed: bool) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.position = at + # Through the controller, not straight into the view: the missing piece + # was the ROUTING, so a test that called tree_view.handle_mouse_button() + # itself would have passed against the reported bug. + controller._unhandled_input(event) + + +func _wheel(button: int) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = button + event.pressed = true + event.position = tree_view.global_position + tree_view.size * 0.5 + controller._unhandled_input(event) + + +func _move(relative: Vector2) -> void: + var event: InputEventMouseMotion = InputEventMouseMotion.new() + event.relative = relative + event.position = tree_view.global_position + tree_view.size * 0.5 + controller._unhandled_input(event) + + +func _node_at(id: String) -> Vector2: + return tree_view.global_position + tree_view.node_screen_pos(game_state.skill_node(id)) + + +func _process(delta: float) -> bool: + elapsed += delta + if stage == 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + scene.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + if stage != 1 or elapsed < 0.2: + return false + stage = 2 + + var mothership: Node2D = null + for building in scene.get_node("BuildingField").spawned: + if is_instance_valid(building) and building.building_type == GameState.BUILDING_MOTHERSHIP: + mothership = building + controller.current_building = mothership + controller._on_action_pressed() + if not skill_menu.visible: + return _fail("the mothership did not open the research menu") + + # --- A. a click selects -------------------------------------------- + var shipyards: Vector2 = _node_at("shipyards") + _click(shipyards, true) + if tree_view.selected_id != "shipyards": + return _fail("a left click on a node selected \"%s\" instead of it" % tree_view.selected_id) + _click(shipyards, false) + print("a left click selects the node under it") + + # --- B. a second click researches ---------------------------------- + if game_state.is_unlocked("shipyards"): + return _fail("shipyards was already researched, so the confirm proves nothing") + _click(shipyards, true) + _click(shipyards, false) + if not game_state.is_unlocked("shipyards"): + return _fail("a second click on the selected node did not research it") + print("a second click on the same node researches it") + + # --- C. held drag scrolls, a bare hover does not -------------------- + var before: float = tree_view.scroll_y + _move(Vector2(0.0, 120.0)) + if not is_equal_approx(tree_view.scroll_y, before): + return _fail("the tree scrolled under a hovering pointer with no button held") + _click(_node_at("shipyards"), true) + _move(Vector2(0.0, 120.0)) + var dragged: float = tree_view.scroll_y + if dragged >= before: + return _fail("holding the button and moving did not scroll the tree (%f -> %f)" \ + % [before, dragged]) + _click(_node_at("shipyards"), false) + _move(Vector2(0.0, 120.0)) + if not is_equal_approx(tree_view.scroll_y, dragged): + return _fail("the tree kept scrolling after the button was released") + print("drag scrolls only while the button is held: %.0f -> %.0f" % [before, dragged]) + + # --- D. the wheel scrolls ------------------------------------------ + _wheel(MOUSE_BUTTON_WHEEL_UP) + var wheeled: float = tree_view.scroll_y + if wheeled >= dragged: + return _fail("the wheel did not scroll the tree upward") + _wheel(MOUSE_BUTTON_WHEEL_DOWN) + if tree_view.scroll_y <= wheeled: + return _fail("the wheel did not scroll back down") + print("the wheel scrolls both ways") + + # --- E. the panel has no dead band at the bottom -------------------- + var detail: Control = scene.get_node("UI/SkillMenu/DetailLabel") + var status: Control = scene.get_node("UI/SkillMenu/StatusLabel") + var tree_bottom: float = tree_view.position.y + tree_view.size.y + var readout_gap: float = detail.position.y - tree_bottom + var floor_gap: float = skill_menu.size.y - (status.position.y + status.size.y) + print("tree %.0fpx tall, %.0fpx to the readout, %.0fpx of panel below it" \ + % [tree_view.size.y, readout_gap, floor_gap]) + if tree_view.size.y <= 0.0: + return _fail("the tree canvas has no height") + if readout_gap < 0.0: + return _fail("the tree canvas overlaps the readout below it") + if readout_gap > GAP_TOLERANCE: + return _fail("%.0fpx of unused panel between the tree and the readout" % readout_gap) + if floor_gap > GAP_TOLERANCE: + return _fail("%.0fpx of dead panel below the readout - the removed buy button's hole" \ + % floor_gap) + + print("TEST_SKILL_MENU: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_SKILL_MENU: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_skill_menu.gd.uid b/tests/test_skill_menu.gd.uid new file mode 100644 index 0000000..d259371 --- /dev/null +++ b/tests/test_skill_menu.gd.uid @@ -0,0 +1 @@ +uid://daagdu7tybuva diff --git a/tests/test_spacial_workshop.gd b/tests/test_spacial_workshop.gd new file mode 100644 index 0000000..3d173b8 --- /dev/null +++ b/tests/test_spacial_workshop.gd @@ -0,0 +1,205 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_spacial_workshop.gd +# Forgejo #116: the Spacial Workshop and its repair drones. +# +# Modelled on the transport hub, so most of what it does is already +# understood: drones belong to a workshop, are provisioned when it is built, +# are never a production order, and are upgraded at a ship lab. What is new +# is what a repair COSTS - a share of the building's own price, in +# proportion to the health it is missing. +# +# Covers: +# A. the building is in the catalog behind its own research, with its own +# look, and it provisions drones the moment it is placed +# B. the drone is a catalogued vessel that CANNOT be crafted, and carries +# its own upgrades +# C. a full repair costs a share of the building's price scaled by the +# damage: undamaged is free, half-wrecked is about half +# D. the workshop asks hub logistics for the ore its repairs will need, +# and stops asking past its buffer +# E. a drone flies out, spends ore, and puts structure back +# F. it will not go with an empty till, and two drones never chase the +# same wall +# G. a building demolished mid-flight gets its ore back rather than +# swallowing it +# H. the berth upgrade reaches workshops already standing + +var game_state: Node +var workshop_id: int = 0 +var crafter_id: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.repair_drones.clear() + game_state.reset_progression() + game_state.reset_waves() + + +func _fail(message: String) -> bool: + print("TEST_SPACIAL_WORKSHOP: FAIL (%s)" % message) + quit(1) + return true + + +func _drones_of(id: int) -> Array: + var out: Array = [] + for drone in game_state.repair_drones: + if int(drone.get("workshop_id", 0)) == id: + out.append(drone) + return out + + +# Runs the autoload's own repair clock without waiting out the real seconds. +func _simulate(seconds: float) -> void: + var step: float = 1.0 / 60.0 + for i in range(int(seconds / step)): + game_state._advance_repairs(step) + + +func _process(_delta: float) -> bool: + var definitions: Script = load("res://buildings/BuildingDefinitions.gd") + + # --- A. catalogued, researched, and it brings its own drones --------- + var workshop_def: Resource = definitions.get_definition(GameState.BUILDING_WORKSHOP) + if workshop_def == null: + return _fail("no \"spacial_workshop\" definition in the catalog") + if workshop_def.visual_scene == null or workshop_def.menu_button_name == "": + return _fail("the workshop has no visual or no build-menu entry") + for other in definitions.all(): + if other.definition_id != GameState.BUILDING_WORKSHOP \ + and other.menu_icon == workshop_def.menu_icon: + return _fail("the workshop shares its icon with the %s" % other.definition_id) + if game_state.is_building_unlocked(GameState.BUILDING_WORKSHOP): + return _fail("the workshop is buildable before its node is researched") + var research: String = "" + for node in GameState.SKILL_TREE: + if node.get("unlocks", "") == GameState.BUILDING_WORKSHOP: + research = String(node.id) + if research == "": + return _fail("nothing in the skill tree unlocks the workshop") + game_state.unlocked.append(research) + + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + workshop_id = game_state.add_building(GameState.BUILDING_WORKSHOP, Vector2(600.0, 0.0)) + if _drones_of(workshop_id).size() != GameState.BASE_WORKSHOP_DRONES: + return _fail("a new workshop stationed %d drones, expected %d" \ + % [_drones_of(workshop_id).size(), GameState.BASE_WORKSHOP_DRONES]) + print("workshop researched at \"%s\", arrives with %d drones" \ + % [research, GameState.BASE_WORKSHOP_DRONES]) + + # --- B. the drone is a vessel, but not a product --------------------- + var drone_def: Resource = game_state.vessel_definition(GameState.REPAIR_DRONE) + if drone_def == null: + return _fail("no repair_drone in the vessel catalog") + if bool(drone_def.fleet_eligible): + return _fail("the repair drone is fleet-eligible - it would be craftable") + if drone_def.upgrades.is_empty(): + return _fail("the repair drone carries no ship-lab upgrades") + if game_state.queue_order(workshop_id, GameState.REPAIR_DRONE): + return _fail("a repair drone was accepted as a production order") + print("repair drone: %d lab upgrades, not craftable" % drone_def.upgrades.size()) + + # --- C. what a repair costs ----------------------------------------- + crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(900.0, 0.0)) + var price: int = int(definitions.get_definition(GameState.BUILDING_SHIP_CRAFTER).cost) + if game_state.repair_cost(crafter_id) != 0: + return _fail("an undamaged crafter costs %d to repair" % game_state.repair_cost(crafter_id)) + var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER) + game_state.damage_building(crafter_id, full / 2) + var half_cost: int = game_state.repair_cost(crafter_id) + if absf(float(half_cost) - float(price) * 0.5) > float(price) * 0.12: + return _fail("a half-wrecked crafter costs %d of its %d price, expected about half" \ + % [half_cost, price]) + print("crafter at %d%% health: %d ore of its %d price" \ + % [int(game_state.building_health_fraction(crafter_id) * 100.0), half_cost, price]) + + # --- D. and it asks for that ore ------------------------------------ + var asked: int = game_state.building_requested_resource(workshop_id, GameState.RESOURCE_ORE) + if asked <= 0: + return _fail("a workshop with a wrecked crafter beside it asks for no ore") + if asked > GameState.WORKSHOP_ORE_BUFFER: + return _fail("it asked for %d ore, past its %d buffer" \ + % [asked, GameState.WORKSHOP_ORE_BUFFER]) + print("workshop asks hub logistics for %d ore" % asked) + + # --- F (first half). an empty till sends nobody ---------------------- + _simulate(4.0) + for drone in _drones_of(workshop_id): + if String(drone.state) != GameState.DRONE_IDLE: + return _fail("a drone left with no ore in the workshop") + if game_state.building_health_fraction(crafter_id) > 0.51: + return _fail("the crafter was repaired out of an empty workshop") + print("empty till: every drone stayed home") + + # --- E. paid for, it flies and mends -------------------------------- + game_state.deposit_resource_to_building(workshop_id, GameState.RESOURCE_ORE, 60) + var ore_before: int = game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) + var hp_before: int = game_state.building_hp(crafter_id) + game_state._advance_repairs(0.016) + var flying: int = 0 + for drone in _drones_of(workshop_id): + if String(drone.state) == GameState.DRONE_OUTBOUND: + flying += 1 + if flying != 1: + return _fail("%d drones set off for one damaged crafter" % flying) + # --- F (second half). the ore leaves with the drone ------------------ + if game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) >= ore_before: + return _fail("a drone left without the ore being taken") + _simulate(30.0) + var hp_after: int = game_state.building_hp(crafter_id) + if hp_after <= hp_before: + return _fail("the drone arrived and the crafter is still at %d hp" % hp_after) + print("drone spent %d ore and put %d structure back" \ + % [ore_before - game_state.building_resource(workshop_id, GameState.RESOURCE_ORE), + hp_after - hp_before]) + + # --- G. a destination that stops existing --------------------------- + game_state.building_inventory(workshop_id).set_amount(GameState.RESOURCE_ORE, 60) + var doomed: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-900.0, 0.0)) + game_state.damage_building(doomed, game_state.building_max_hp(GameState.BUILDING_SHIP_LAB) / 2) + game_state._advance_repairs(0.016) + var carrying: int = 0 + for drone in game_state.repair_drones: + if String(drone.state) == GameState.DRONE_OUTBOUND and int(drone.target_id) == doomed: + carrying = int(drone.carrying) + if carrying <= 0: + return _fail("nothing was dispatched to the damaged lab") + var till_before: int = game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) + game_state.destroy_building(doomed) + _simulate(30.0) + if game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) != till_before + carrying: + return _fail("a drone whose target was levelled under it lost its %d ore" % carrying) + print("target levelled in flight: %d ore came home" % carrying) + + # --- H. the upgrade reaches standing workshops ---------------------- + var bonus_id: String = "" + for node in GameState.SKILL_TREE: + if int(node.get("workshop_bonus", 0)) > 0: + bonus_id = String(node.id) + if bonus_id == "": + return _fail("no skill node raises the drone count") + var before_count: int = _drones_of(workshop_id).size() + game_state.rocks = 5000 + # #138: the deep doctrines want gas and energy too. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 5000) + game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 5000) + game_state.research_points = 20 + if not game_state.unlock(bonus_id): + return _fail("could not research \"%s\"" % bonus_id) + if _drones_of(workshop_id).size() != before_count + 1: + return _fail("a workshop built before the upgrade still has %d drones" \ + % _drones_of(workshop_id).size()) + print("\"%s\" raises an already-standing workshop from %d to %d drones" \ + % [bonus_id, before_count, _drones_of(workshop_id).size()]) + + print("TEST_SPACIAL_WORKSHOP: PASS") + quit(0) + return true diff --git a/tests/test_spacial_workshop.gd.uid b/tests/test_spacial_workshop.gd.uid new file mode 100644 index 0000000..e25a453 --- /dev/null +++ b/tests/test_spacial_workshop.gd.uid @@ -0,0 +1 @@ +uid://bab6h5ejrcxe8 diff --git a/tests/test_surface_feature_depth.gd b/tests/test_surface_feature_depth.gd new file mode 100644 index 0000000..c6f2af3 --- /dev/null +++ b/tests/test_surface_feature_depth.gd @@ -0,0 +1,133 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_surface_feature_depth.gd +# Forgejo #91: on a gas planet the gas source was drawn ON TOP of the player. +# +# SurfaceFeatures is added after Fleet in PlanetSurface.tscn, so anything +# down there that keeps the default z_index of 0 wins the tie against the +# ships purely by being the later sibling. The player then flies under their +# own scenery, which reads as a rendering fault rather than as depth. +# +# Checks the RULE rather than the one node that was wrong: every feature that +# actually draws, on every surface profile that has features, must end up +# behind the fleet. A feature added later is covered without editing this. +# +# Covers: +# A. the gas giant's vent field draws behind the ships (the #91 report) +# B. the same for every other drawing feature on every profile, rocks and +# capture rings included +# C. the fleet's own ships are where the comparison says they are, so a +# future change that pushed the ships back instead cannot make A and B +# pass by moving the goalposts + +const PROFILE_PLANETS: Array = ["gas_giant", "rocky", "research_lab"] + +var game_state: Node +var surface: Node +var planets: Array = [] +var checked: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # The autoload is reached by node, not by the bare `GameState` name: from + # a SceneTree script that name resolves only far enough for constants, + # and an instance member through it is a COMPILE error for the whole + # file. Every other test here does the same. + game_state = root.get_node("GameState") + planets = PROFILE_PLANETS.duplicate() + game_state.reset_progression() + game_state.surviving_ship_count = 3 + _load_next() + + +func _load_next() -> void: + if surface != null and is_instance_valid(surface): + surface.queue_free() + surface = null + if planets.is_empty(): + return + var definition_id: String = String(planets.pop_front()) + game_state.planet_definition_id = definition_id + game_state.planet_name = "Depth Probe" + game_state.planet_id = "depth-probe-%s" % definition_id + surface = load("res://surface/PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + root.add_child(surface) + + +# z_index is RELATIVE to the parent by default, so a node's real depth is the +# running sum up the chain - reading z_index alone would call a +1 child of a +# -1 parent "behind", which it is not. +func _effective_z(node: CanvasItem) -> int: + var total: int = 0 + var current: Node = node + while current is CanvasItem: + total += (current as CanvasItem).z_index + if not (current as CanvasItem).z_as_relative: + break + current = current.get_parent() + return total + + +func _drawing_descendants(node: Node, found: Array) -> Array: + for child in node.get_children(): + # Enemies are spawned under SurfaceFeatures by the planet's setup + # markers, but they are combatants, not scenery: they share the + # fleet's plane on purpose, the same way they do in space. Only the + # ground the fight happens on has to stay behind it. + if child.is_in_group("enemies"): + continue + # has_method("_draw") is true only where a script defines one, which + # is exactly "this node puts pixels on the surface". Plain Node2D + # hosts that only spawn their children draw nothing themselves and + # are free to sit at 0 - the rocky surface's RockField is one. + if child is CanvasItem and child.has_method("_draw"): + found.append(child) + _drawing_descendants(child, found) + return found + + +func _process(delta: float) -> bool: + elapsed += delta + if elapsed < 0.35: + return false + elapsed = 0.0 + + var fleet: Node2D = surface.get_node("Fleet") + if fleet.ships.is_empty(): + return _fail("no ships spawned, so there is nothing to be in front of") + var ship_z: int = _effective_z(fleet.ships[0]) + if ship_z != 0: + # C. The comparison below is "behind the ships", and the ships being + # at 0 is what makes "negative" mean that. + return _fail("ships are at z=%d, not 0 - this test's premise moved" % ship_z) + + var features: Array = _drawing_descendants(surface.get_node("SurfaceFeatures"), []) + if features.is_empty(): + return _fail("%s has no drawing surface features to check" % game_state.planet_definition_id) + for feature in features: + var z: int = _effective_z(feature) + if z >= ship_z: + return _fail("%s on %s draws at z=%d, in front of the fleet at z=%d" % [ + feature.name, game_state.planet_definition_id, z, ship_z + ]) + print("%s: %d drawing features, all behind the fleet" % [ + game_state.planet_definition_id, features.size() + ]) + checked += features.size() + + if planets.is_empty(): + print("TEST_SURFACE_FEATURE_DEPTH: PASS (%d features checked)" % checked) + quit(0) + return true + _load_next() + return false + + +func _fail(message: String) -> bool: + print("TEST_SURFACE_FEATURE_DEPTH: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_surface_feature_depth.gd.uid b/tests/test_surface_feature_depth.gd.uid new file mode 100644 index 0000000..2ad8ca7 --- /dev/null +++ b/tests/test_surface_feature_depth.gd.uid @@ -0,0 +1 @@ +uid://c2jc6arbx322h diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index 503f400..740299d 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -84,16 +84,17 @@ func _process(delta: float) -> bool: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate) quit(1) return true + var hub_cost: int = int(game_state.building_costs(GameState.BUILDING_TRANSPORT_HUB)[GameState.RESOURCE_ORE]) var before: int = game_state.rocks field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB)) - if game_state.rocks != before - GameState.TRANSPORT_HUB_COST: + if game_state.rocks != before - hub_cost: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [ - before - game_state.rocks, GameState.TRANSPORT_HUB_COST + before - game_state.rocks, hub_cost ]) quit(1) return true - print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST) + print("researched and placed for %d ore" % hub_cost) elapsed = 0.0 stage = 1 return false diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd new file mode 100644 index 0000000..ab1e5e8 --- /dev/null +++ b/tests/test_wave_system.gd @@ -0,0 +1,259 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_wave_system.gd +# Forgejo #100 (#97b): waves replace the roaming enemy spawner. +# +# The old EnemySpawner put one enemy on the map every 3-6 seconds for ever. +# There was no rhythm to it and nothing to prepare for. Enemies now arrive +# in waves: a long quiet, a warning, an attack, and quiet again once the +# wave is dead. +# +# The clock lives in GameState because Main.tscn is destroyed on every trip +# to a planet surface - a wave must not be cancelled, nor restarted from +# zero, by a landing. +# +# Covers: +# A. the phase machine runs peace -> warning -> active, and a wave that is +# killed puts it back to peace +# B. no timer is exposed and none is drawn. #97 asks for an alert with no +# visible countdown, so the alert's own text must carry no digits and +# GameState must not offer a remaining-seconds accessor at all +# C. waves get stronger with the FLEET, and with the wave number +# D. the trickle is gone: a live director with no wave running leaves the +# map empty, where the old spawner would have filled it +# E. a launched wave really arrives, around the fleet, and clearing it +# returns the world to peace +# F. the alert is up during the warning and at no other time +# G. #110 - a dev-mode button puts a wave on the board now, skipping both +# the peace and the warning, and it does not exist outside dev mode + +var game_state: Node +var scene: Node +var spawner: Node2D +var fleet: Node2D +var alert: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + + +func _fail(message: String) -> bool: + print("TEST_WAVE_SYSTEM: FAIL (%s)" % message) + quit(1) + return true + + +func _state_cases() -> bool: + # --- A. the phase machine -------------------------------------------- + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("a reset game does not open in peace") + game_state.advance_waves(GameState.WAVE_PEACE_SECONDS - 0.1) + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("the peace ended early") + game_state.advance_waves(0.2) + if not game_state.wave_is_incoming(): + return _fail("the peace ran out without a warning") + if game_state.wave_number != 0: + return _fail("the wave was counted while it was still only a warning") + game_state.advance_waves(GameState.WAVE_WARNING_SECONDS - 0.1) + if not game_state.wave_is_incoming(): + return _fail("the warning ended early") + game_state.advance_waves(0.2) + if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE: + return _fail("the warning did not turn into an attack") + if game_state.wave_number != 1 or not game_state.wave_pending: + return _fail("wave %d turned active without being owed to anyone" % game_state.wave_number) + game_state.wave_cleared() + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE or game_state.wave_pending: + return _fail("clearing the wave did not return the world to peace") + print("peace -> warning -> wave 1 -> peace") + + # --- B. nothing that could be read as a countdown -------------------- + for forbidden in ["wave_seconds_left", "wave_time_left", "wave_countdown", + "seconds_until_wave", "wave_remaining"]: + if game_state.has_method(forbidden): + return _fail("GameState.%s() exists - the alert could become a timer" % forbidden) + game_state.advance_waves(GameState.WAVE_PEACE_SECONDS + 0.1) + if not game_state.wave_is_incoming(): + return _fail("could not get back into a warning for the pulse check") + var pulse: float = game_state.wave_warning_pulse() + if pulse < 0.0 or pulse > 1.0: + return _fail("the warning pulse is %f, outside 0..1" % pulse) + print("no remaining-seconds accessor exists; urgency is a %.2f pulse" % pulse) + + # --- C. strength follows the fleet ----------------------------------- + var small: PackedStringArray = game_state.wave_composition(1) + var large: PackedStringArray = game_state.wave_composition(9) + if small.is_empty(): + return _fail("a wave against a single vessel is empty") + if large.size() <= small.size(): + return _fail("a fleet of 9 draws %d enemies, no more than the %d a single vessel draws" \ + % [large.size(), small.size()]) + if game_state.wave_strength(9) <= game_state.wave_strength(1): + return _fail("wave strength does not rise with fleet size") + var before_number: int = game_state.wave_number + game_state.wave_number = before_number + 20 + var later: PackedStringArray = game_state.wave_composition(1) + game_state.wave_number = before_number + if later.size() <= small.size(): + return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation") + # A big wave must not be the same swarm at greater length. + var heavy_kinds: Dictionary = {} + for id in large: + heavy_kinds[id] = true + if heavy_kinds.size() < 2: + return _fail("a large wave is %d copies of one enemy" % large.size()) + if large.size() > GameState.WAVE_MAX_ENEMIES: + return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES]) + print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \ + % [small.size(), large.size(), heavy_kinds.size()]) + game_state.reset_waves() + return false + + +func _enemy_count() -> int: + var n: int = 0 + for enemy in get_nodes_in_group("enemies"): + if is_instance_valid(enemy) and not enemy.is_queued_for_deletion(): + n += 1 + return n + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _state_cases(): + return true + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + spawner = scene.get_node("EnemySpawner") + fleet = scene.get_node("Fleet") + alert = scene.get_node("UI/WaveAlert") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The director stays ENABLED for this test - it is the thing + # under test. + game_state.reset_waves() + elapsed = 0.0 + stage = 2 + return false + 2: + # --- D. no trickle ------------------------------------------ + # Comfortably longer than the old 3-6s spawn interval, so the + # removed behaviour would have shown up several times over. + if elapsed < 8.0: + if _enemy_count() > 0: + return _fail("an enemy appeared with no wave running - the trickle is still there") + return false + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("the peace should still be running after %.0fs" % elapsed) + print("%.0fs of peace and not one enemy - the old trickle is gone" % elapsed) + + # --- F. the alert is up only during the warning -------------- + if alert.visible: + return _fail("the wave alert is showing during peacetime") + game_state.wave_phase = GameState.WAVE_PHASE_WARNING + game_state.wave_timer = 0.0 + alert._process(0.0) + if not alert.visible: + return _fail("the wave alert did not appear for an incoming wave") + var text: String = alert.label.text + for c in text: + if c >= "0" and c <= "9": + return _fail("the alert reads \"%s\" - that is a timer" % text) + print("the alert reads \"%s\" during the warning, with no digits in it" % text) + elapsed = 0.0 + stage = 3 + return false + 3: + # --- E. the wave really arrives ----------------------------- + game_state.wave_phase = GameState.WAVE_PHASE_ACTIVE + game_state.wave_number += 1 + game_state.wave_pending = true + spawner._process(0.0) + var arrived: int = _enemy_count() + if arrived <= 0: + return _fail("the wave turned active and nothing arrived") + if game_state.wave_pending: + return _fail("the wave was launched but is still owed") + if alert.visible: + alert._process(0.0) + if alert.visible: + return _fail("the alert is still up after the wave arrived") + var centre: Vector2 = fleet.get_centroid() + var nearest: float = INF + for enemy in get_nodes_in_group("enemies"): + if is_instance_valid(enemy): + nearest = minf(nearest, centre.distance_to(enemy.global_position)) + if nearest < 200.0: + return _fail("a wave enemy arrived %.0fpx from the fleet - that is on top of them" % nearest) + print("wave %d arrived: %d enemies, nearest %.0fpx away" \ + % [game_state.wave_number, arrived, nearest]) + + for enemy in get_nodes_in_group("enemies"): + if is_instance_valid(enemy): + enemy.queue_free() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.2: + return false + spawner._process(0.0) + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("killing the whole wave did not bring the quiet back") + print("the wave died and the quiet came back") + + # --- G. the dev button --------------------------------------- + var dev_button: Control = scene.get_node("UI/DevWaveButton") + game_state.dev_mode = false + dev_button._process(0.0) + if dev_button.visible: + return _fail("the dev wave button is offered in a normal game") + game_state.dev_mode = true + dev_button._process(0.0) + if not dev_button.visible: + return _fail("the dev wave button is hidden in dev mode") + var number_before: int = game_state.wave_number + dev_button.button.pressed.emit() + if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE: + return _fail("the button left the wave in \"%s\"" % game_state.wave_phase) + if not game_state.wave_pending: + return _fail("the button turned the wave active without owing it to anyone") + if game_state.wave_number != number_before + 1: + return _fail("the forced wave was not counted") + # The point of the button is to SEE the wave, so it skips the + # nine-second alert as well as the fifty-second peace. + if game_state.wave_is_incoming(): + return _fail("the button stopped at the warning - that is not \"now\"") + spawner._process(0.0) + if _enemy_count() <= 0: + return _fail("the forced wave produced no enemies") + print("dev button: wave %d on the board at once, %d enemies" \ + % [game_state.wave_number, _enemy_count()]) + game_state.dev_mode = false + + print("TEST_WAVE_SYSTEM: PASS") + quit(0) + return true + return false diff --git a/tests/test_wave_system.gd.uid b/tests/test_wave_system.gd.uid new file mode 100644 index 0000000..d6fb756 --- /dev/null +++ b/tests/test_wave_system.gd.uid @@ -0,0 +1 @@ +uid://cpk84uwl8wber diff --git a/tests/test_waves_attack_buildings.gd b/tests/test_waves_attack_buildings.gd new file mode 100644 index 0000000..daee3c0 --- /dev/null +++ b/tests/test_waves_attack_buildings.gd @@ -0,0 +1,240 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_waves_attack_buildings.gd +# Forgejo #101 (#97c): "Waves tries to attack built buildings." +# +# Before this every enemy component asked one question - which player SHIP +# is nearest - so a structure was scenery they flew straight through. A +# Building is also a plain Node2D, so there was nothing to collide with even +# if one had tried. +# +# Covers: +# A. an enemy with no ship anywhere near it goes for a building +# B. a ship in its face still wins: the fleet must be able to defend a +# structure by interposing itself, and an enemy that ignored the +# vessels shooting it to reach a depot slightly closer reads as broken +# C. a chaser that reaches a building damages it, and spends itself doing +# so - the same bargain it makes ramming a vessel +# D. an enemy parked on a structure keeps hurting it, rather than landing +# one hit on entry and then sitting there harmlessly +# E. an enemy projectile damages a structure +# F. enough of it levels the building, and the node goes with the record +# G. #113: a WAVE enemy converges on the nearest structure from any +# distance, where one placed by a map author keeps the detection radius +# that stops it reading as omniscient + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var chaser: Node2D +var sniper: Node2D +var target_building: Node2D +# Held separately: the node is freed when the building comes down, and the +# last stage still has to ask GameState about the record it left behind. +var target_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var hp_watermark: int = 0 +var destroyed_fired: bool = false +var roamer: Node2D +var raider: Node2D +var far_id: int = 0 +var roamer_from: float = 0.0 +var raider_from: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.rocks = 5000 + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("BuildingField") + + +func _fail(message: String) -> bool: + print("TEST_WAVES_ATTACK_BUILDINGS: FAIL (%s)" % message) + quit(1) + return true + + +func _spawn_enemy(enemy_id: String, at: Vector2) -> Node2D: + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.enemy_id = enemy_id + enemy.position = at + main.add_child(enemy) + return enemy + + +func _building_of(type: String) -> Node2D: + for b in field.spawned: + if is_instance_valid(b) and not b.is_queued_for_deletion() and b.building_type == type: + return b + return null + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The fleet is parked far away for A, C and D. It comes back for B. + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(0.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + target_building = _building_of(GameState.BUILDING_SHIP_CRAFTER) + if target_building == null: + return _fail("no crafter was placed") + target_id = int(target_building.building_id) + + # --- A. with no ship near, a building is the target ---------- + var targets: Script = load("res://enemies/EnemyTargets.gd") + var from: Vector2 = Vector2(600.0, 0.0) + var picked: Node2D = targets.nearest_target(main, from) + if picked != target_building: + return _fail("an enemy 600px from a crafter and 9600px from the fleet picked %s" \ + % ("nothing" if picked == null else str(picked.name))) + print("with the fleet 9600px away, the crafter is the target") + + # --- B. a ship in its face still wins ------------------------ + # Deliberately FURTHER from the enemy than the building is: the + # rule is not "nearest thing", it is "the fleet you are fighting". + var guard: Node2D = fleet.ships[0] + guard.position = from + Vector2(0.0, 500.0) + guard.move_target = guard.position + var defended: Node2D = targets.nearest_target(main, from) + if defended != guard: + return _fail("a vessel 500px away lost priority to a building 600px away - the fleet cannot screen anything") + guard.position = Vector2(-9000.0, 0.0) + guard.move_target = guard.position + print("a vessel inside %.0fpx outranks a nearer building" % targets.SHIP_PRIORITY_RANGE) + + # --- C. a chaser spends itself on the structure -------------- + hp_watermark = game_state.building_hp(target_id) + chaser = _spawn_enemy("chaser", target_building.global_position) + chaser.destroyed.connect(func(): destroyed_fired = true) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 1.2: + return false + var now: int = game_state.building_hp(target_id) + if now >= hp_watermark: + return _fail("a chaser sat on the crafter and it took no damage") + if is_instance_valid(chaser) and not chaser.is_queued_for_deletion(): + return _fail("the chaser survived ramming a structure") + if not destroyed_fired: + return _fail("the chaser vanished without firing `destroyed`") + print("a chaser took the crafter from %d to %d hp and died doing it" % [hp_watermark, now]) + + # --- D. a besieger keeps working ---------------------------- + sniper = _spawn_enemy("sniper", target_building.global_position) + sniper.speed = 0.0 + hp_watermark = game_state.building_hp(target_id) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 2.5: + return false + var now: int = game_state.building_hp(target_id) + var strikes: int = hp_watermark - now + if not is_instance_valid(sniper): + return _fail("the sniper died on contact - it is not marked dies_on_contact") + if strikes <= sniper.definition.contact_damage: + return _fail("%.1fs of siege did %d damage - that is one hit on entry, not a siege" \ + % [elapsed, strikes]) + print("%.1fs parked on the crafter cost it %d hp" % [elapsed, strikes]) + sniper.queue_free() + + # --- E. a projectile hits a structure ------------------------ + hp_watermark = game_state.building_hp(target_id) + var projectile: Area2D = load("res://enemies/EnemyProjectile.tscn").instantiate() + projectile.position = target_building.global_position + Vector2(0.0, -140.0) + projectile.damage = 3 + root.add_child(projectile) + projectile.launch(Vector2(0.0, 1.0)) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 1.2: + return false + var now: int = game_state.building_hp(target_id) + if now >= hp_watermark: + return _fail("an enemy bolt passed straight through the crafter") + print("an enemy bolt took %d hp off the crafter" % (hp_watermark - now)) + + # --- F. enough of it levels the place ------------------------ + game_state.damage_building(target_id, + game_state.building_hp(target_id) - 1) + var last: Node2D = _spawn_enemy("chaser", target_building.global_position) + last.speed = 0.0 + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 1.2: + return false + if not game_state.find_building(target_id).is_empty(): + return _fail("the crafter survived on its last hit point") + if _building_of(GameState.BUILDING_SHIP_CRAFTER) != null: + return _fail("the crafter node outlived the record") + print("the crafter came down, and its node went with it") + + # --- G. a wave arrives with somewhere to be ----------------- + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(0.0, 0.0)) + far_id = int(game_state.buildings[-1].id) + # Well past every component's detection_range. + var far_away: Vector2 = Vector2(6000.0, 0.0) + roamer = _spawn_enemy("chaser", far_away) + raider = _spawn_enemy("chaser", far_away + Vector2(0.0, 400.0)) + raider.relentless = true + roamer_from = roamer.global_position.distance_to(Vector2.ZERO) + raider_from = raider.global_position.distance_to(Vector2.ZERO) + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 1.5: + return false + if not is_instance_valid(raider) or not is_instance_valid(roamer): + return _fail("one of the two test chasers is gone") + var raider_now: float = raider.global_position.distance_to(Vector2.ZERO) + var roamer_now: float = roamer.global_position.distance_to(Vector2.ZERO) + if raider_now >= raider_from - 100.0: + return _fail("a wave chaser %.0fpx from a crafter did not close on it" % raider_from) + if roamer_now < roamer_from - 20.0: + return _fail("a chaser nobody sent moved %.0fpx toward a crafter far outside its detection range" \ + % (roamer_from - roamer_now)) + print("wave chaser closed %.0fpx from %.0f away; a placed one held station" \ + % [raider_from - raider_now, raider_from]) + + print("TEST_WAVES_ATTACK_BUILDINGS: PASS") + quit(0) + return true + return false diff --git a/tests/test_waves_attack_buildings.gd.uid b/tests/test_waves_attack_buildings.gd.uid new file mode 100644 index 0000000..cb2e198 --- /dev/null +++ b/tests/test_waves_attack_buildings.gd.uid @@ -0,0 +1 @@ +uid://d21tci2sbahni diff --git a/ui/BreachOverlay.gd b/ui/BreachOverlay.gd new file mode 100644 index 0000000..8f94b30 --- /dev/null +++ b/ui/BreachOverlay.gd @@ -0,0 +1,43 @@ +extends ColorRect + +# #125: drives breach.gdshader. +# +# Lives in the UI layer because it works in SCREEN space - it bends the +# rendered frame, so it has to be the last thing drawn and it has to cover +# the whole viewport. The world-space half of the breach (the horizon, the +# accretion disc, the infalling matter) is DimensionalBreach, a Node2D out +# in the world; this is only the lens over the top of it. +# +# Inert unless something calls set_breach(): the shader is a no-op at zero +# strength, but a full-screen quad that is never needed is still a full +# screen of fragment work, so it hides itself instead. + +const SHADER_PATH: String = "res://shaders/breach.gdshader" + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + if material == null: + var shader_material := ShaderMaterial.new() + shader_material.shader = load(SHADER_PATH) + material = shader_material + + +func clear_breach() -> void: + visible = false + if material != null: + material.set_shader_parameter("strength", 0.0) + + +# `at` is in SCREEN pixels; the director projects the world point through the +# camera before calling, because only it knows where the camera is. +func set_breach(at: Vector2, horizon: float, lens: float, breach_strength: float) -> void: + if material == null: + return + visible = breach_strength > 0.001 + material.set_shader_parameter("breach_center", at) + material.set_shader_parameter("viewport_size", size) + material.set_shader_parameter("horizon_radius", horizon) + material.set_shader_parameter("lens_radius", maxf(lens, horizon + 1.0)) + material.set_shader_parameter("strength", clampf(breach_strength, 0.0, 1.0)) diff --git a/ui/BreachOverlay.gd.uid b/ui/BreachOverlay.gd.uid new file mode 100644 index 0000000..c2aab31 --- /dev/null +++ b/ui/BreachOverlay.gd.uid @@ -0,0 +1 @@ +uid://bs6hvaph1ruxk diff --git a/ui/DevWaveButton.gd b/ui/DevWaveButton.gd new file mode 100644 index 0000000..bcf34cf --- /dev/null +++ b/ui/DevWaveButton.gd @@ -0,0 +1,28 @@ +extends Control + +# #110: a dev-only button that puts a wave on the board now. +# +# Waves are otherwise on a fifty-second clock, which makes anything that +# happens during one - the alert, the pathing, buildings taking damage, an +# outpost garrison actually earning its keep - slow to look at even once. +# +# Dev mode is the same gate the main menu already uses for the map editor: +# these are tools for building the game, and a player who installed a build +# has no use for them. The button does not exist outside it. + +@onready var button: IconButton = get_node("Button") + + +func _ready() -> void: + button.pressed.connect(_on_pressed) + visible = false + + +func _process(_delta: float) -> void: + var show_it: bool = GameState.dev_mode and not GameState.intro_active + if show_it != visible: + visible = show_it + + +func _on_pressed() -> void: + GameState.force_wave_now() diff --git a/ui/DevWaveButton.gd.uid b/ui/DevWaveButton.gd.uid new file mode 100644 index 0000000..1141957 --- /dev/null +++ b/ui/DevWaveButton.gd.uid @@ -0,0 +1 @@ +uid://blhthsbawd7vg diff --git a/ui/FormationEditor.gd b/ui/FormationEditor.gd index 9ecab2a..79a05b8 100644 --- a/ui/FormationEditor.gd +++ b/ui/FormationEditor.gd @@ -47,11 +47,78 @@ func _screen_to_formation(p: Vector2) -> Vector2: # Returns true when the event was consumed. Called from BuildController, # which forwards input while the editor is open. +# #121: the formation could not be rearranged on a desktop at all. Like the +# research tree (#93) and the speed fader (#118), this control reads raw +# touches, and the project no longer turns clicks into them. Wrappers over +# the same press/drag logic, never a second implementation. +# +# The wheel replaces the pinch: a mouse has no second finger, and zoom is +# the other half of what the panel does. +const MOUSE_TOUCH_INDEX: int = -2 +const WHEEL_ZOOM_STEP: float = 1.12 + + +func handle_mouse_button(event: InputEventMouseButton) -> bool: + if not visible: + return false + if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + return _zoom_by(WHEEL_ZOOM_STEP) + if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + return _zoom_by(1.0 / WHEEL_ZOOM_STEP) + if event.button_index != MOUSE_BUTTON_LEFT: + return false + if event.pressed: + return _grab(event.position, MOUSE_TOUCH_INDEX) + if drag_touch_index != MOUSE_TOUCH_INDEX: + return false + dragging_index = -1 + drag_touch_index = -1 + queue_redraw() + return true + + +func handle_mouse_motion(event: InputEventMouseMotion) -> bool: + if drag_touch_index != MOUSE_TOUCH_INDEX or dragging_index == -1: + return false + _move_slot(event.position) + return true + + +func _zoom_by(factor: float) -> bool: + scale_factor = clamp(scale_factor * factor, min_scale, max_scale) + queue_redraw() + return true + + +func _grab(at: Vector2, index: int) -> bool: + if dragging_index != -1: + return false + var local: Vector2 = at - global_position + for i in range(GameState.max_fleet_size()): + if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4: + dragging_index = i + drag_touch_index = index + queue_redraw() + return true + return false + + +func _move_slot(at: Vector2) -> void: + var local: Vector2 = at - global_position + # Clamped so a slot can't be dragged off the panel and become + # unreachable (and so the fleet can't be spread absurdly far apart). + var f: Vector2 = _screen_to_formation(local) + var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor + GameState.formation[dragging_index] = Vector2( + clamp(f.x, -limit, limit), clamp(f.y, -limit, limit) + ) + queue_redraw() + formation_changed.emit() + + func handle_touch(event: InputEventScreenTouch) -> bool: if not visible: return false - var local: Vector2 = event.position - global_position - if event.pressed: touches[event.index] = event.position if touches.size() == 2: @@ -65,15 +132,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool: pinch_active = true queue_redraw() return true - if dragging_index != -1: - return false - for i in range(GameState.max_fleet_size()): - if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4: - dragging_index = i - drag_touch_index = event.index - queue_redraw() - return true - return false + return _grab(event.position, event.index) touches.erase(event.index) if touches.size() < 2: @@ -105,16 +164,7 @@ func handle_drag(event: InputEventScreenDrag) -> bool: if event.index != drag_touch_index or dragging_index == -1: return false - var local: Vector2 = event.position - global_position - # Clamped so a slot can't be dragged off the panel and become - # unreachable (and so the fleet can't be spread absurdly far apart). - var f: Vector2 = _screen_to_formation(local) - var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor - GameState.formation[dragging_index] = Vector2( - clamp(f.x, -limit, limit), clamp(f.y, -limit, limit) - ) - queue_redraw() - formation_changed.emit() + _move_slot(event.position) return true @@ -126,13 +176,46 @@ func _draw() -> void: NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3) NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3) + # #119: each slot is drawn as the vessel actually standing in it, in its + # own colour and its own hull, rather than as an identical placeholder + # dart. Arranging a formation means deciding where the shield sits + # relative to the guns, and that is impossible to see when every marker + # is the same triangle. + var crewed: PackedStringArray = GameState.fleet_vessel_types for i in range(GameState.max_fleet_size()): var p: Vector2 = _slot_screen_pos(i) var held: bool = i == dragging_index - # Ship glyph, so the layout reads the way it will look in flight. - var points: PackedVector2Array = PackedVector2Array([ - p + Vector2(0, -marker_radius), - p + Vector2(-marker_radius * 0.66, marker_radius * 0.72), - p + Vector2(marker_radius * 0.66, marker_radius * 0.72), - ]) - NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0 if held else 0.7) + var vessel_type: String = String(crewed[i]) if i < crewed.size() else "" + var definition: Resource = GameState.vessel_definition(vessel_type) \ + if vessel_type != "" else null + if definition == null: + # An empty slot: the outline of a berth, not a ship. A hollow + # marker is how the panel says "nothing is standing here yet". + _draw_empty_slot(p, held) + continue + _draw_vessel_slot(p, definition, held) + + +# Scaled so the biggest hull in the catalog still fits the marker, and drawn +# from the definition's own points so a vessel reshaped in its .tres is +# reshaped here too. +func _draw_vessel_slot(at: Vector2, definition: Resource, held: bool) -> void: + var points: PackedVector2Array = definition.hull_points + var extent: float = 1.0 + for point in points: + extent = maxf(extent, point.length()) + var factor: float = marker_radius / extent + var shaped: PackedVector2Array = PackedVector2Array() + for point in points: + shaped.append(at + point * factor) + NeonDraw.draw_glowing_polygon(self, shaped, definition.glow_color, + 1.0 if held else 0.75) + + +func _draw_empty_slot(at: Vector2, held: bool) -> void: + var ring: PackedVector2Array = PackedVector2Array() + for i in range(12): + var a: float = (float(i) / 12.0) * TAU + ring.append(at + Vector2(cos(a), sin(a)) * marker_radius * 0.78) + NeonDraw.draw_glowing_polygon(self, ring, Color(0.42, 0.47, 0.58), + 0.8 if held else 0.4) diff --git a/ui/GarrisonPanel.gd b/ui/GarrisonPanel.gd new file mode 100644 index 0000000..b7e12d7 --- /dev/null +++ b/ui/GarrisonPanel.gd @@ -0,0 +1,134 @@ +extends Control + +# #103 (#97e): "A dedicated menu on each outpost should let you compose how +# you want the outpost to be filled." +# +# Builds its own rows from GameState.garrison_vessel_types() rather than +# from nodes placed in Main.tscn. Which vessels can hold a position is +# decided by capability, not by a list of types, so the day an armed vessel +# is added it gets a row here and an unarmed one never does by accident - +# and neither change touches the scene. +# +# The panel edits the ORDER, not the crew. What the player sets here is what +# they want stationed; #104 is what actually gets vessels there. The two are +# shown side by side ("1/2") so an outpost that is waiting on a hull it does +# not have yet reads as waiting rather than as broken. + +# Emitted when the player changes the order, so whatever hosts the panel can +# update its own readout rather than polling. +signal changed + +const ROW_HEIGHT: float = 96.0 +const ROW_GAP: float = 12.0 +const BUTTON_SIZE: float = 76.0 +const NAME_FONT_SIZE: int = 30 +const COUNT_FONT_SIZE: int = 34 +const HEADER_FONT_SIZE: int = 22 + +var building_id: int = 0 + +var _rows: Array = [] +var _capacity_label: Label + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + # _make_label already parents what it builds. + _capacity_label = _make_label("", HEADER_FONT_SIZE, Color(0.62, 0.78, 0.86)) + _capacity_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + for vessel_type in GameState.garrison_vessel_types(): + var row: Dictionary = _make_row(vessel_type) + _rows.append(row) + resized.connect(_layout) + _layout() + + +func _make_label(text_value: String, font_size: int, colour: Color) -> Label: + var label := Label.new() + label.text = text_value + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95)) + label.add_theme_constant_override("outline_size", 2) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + return label + + +func _make_row(vessel_type: String) -> Dictionary: + var definition: Resource = GameState.vessel_definition(vessel_type) + var colour: Color = definition.glow_color if definition != null else Color.WHITE + + var name_label: Label = _make_label(GameState.vessel_name(vessel_type), NAME_FONT_SIZE, colour) + var count_label: Label = _make_label("0", COUNT_FONT_SIZE, Color(0.94, 0.98, 1.0)) + count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + var minus := IconButton.new() + minus.icon = IconButton.Icon.MINUS + minus.glow_color = colour + minus.pressed.connect(_on_adjust.bind(vessel_type, -1)) + add_child(minus) + + var plus := IconButton.new() + plus.icon = IconButton.Icon.PLUS + plus.glow_color = colour + plus.pressed.connect(_on_adjust.bind(vessel_type, 1)) + add_child(plus) + + return {"type": vessel_type, "name": name_label, "count": count_label, + "minus": minus, "plus": plus} + + +func _on_adjust(vessel_type: String, delta: int) -> void: + if building_id == 0: + return + GameState.adjust_garrison_desired(building_id, vessel_type, delta) + refresh() + changed.emit() + + +func _layout() -> void: + if _rows.is_empty(): + return + _capacity_label.position = Vector2.ZERO + _capacity_label.size = Vector2(size.x, 44.0) + var y: float = 52.0 + for row in _rows: + row.name.position = Vector2(0.0, y) + row.name.size = Vector2(size.x * 0.46, ROW_HEIGHT) + var right: float = size.x + row.plus.position = Vector2(right - BUTTON_SIZE, y + (ROW_HEIGHT - BUTTON_SIZE) * 0.5) + row.plus.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + row.count.position = Vector2(right - BUTTON_SIZE * 3.0, y) + row.count.size = Vector2(BUTTON_SIZE * 2.0, ROW_HEIGHT) + row.minus.position = Vector2(right - BUTTON_SIZE * 4.0, + y + (ROW_HEIGHT - BUTTON_SIZE) * 0.5) + row.minus.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + y += ROW_HEIGHT + ROW_GAP + + +func natural_height() -> float: + return 52.0 + float(_rows.size()) * (ROW_HEIGHT + ROW_GAP) + + +func refresh() -> void: + if _rows.is_empty(): + return + var capacity: int = GameState.outpost_slot_capacity() + var ordered: int = GameState.garrison_desired_total(building_id) + _capacity_label.text = "BERTHS %d / %d" % [ordered, capacity] + for row in _rows: + var vessel_type: String = String(row.type) + var want: int = GameState.garrison_desired_of(building_id, vessel_type) + var here: int = GameState.garrison_crew_of(building_id, vessel_type) + # "here / ordered", so an outpost waiting on a hull nobody has built + # yet reads as waiting rather than as having lost one. + row.count.text = "%d/%d" % [here, want] + var unlocked: bool = GameState.vessel_is_unlocked(vessel_type) + row.name.modulate.a = 1.0 if unlocked else 0.45 + row.minus.enabled = unlocked and want > 0 + row.plus.enabled = unlocked and ordered < capacity + row.minus.queue_redraw() + row.plus.queue_redraw() diff --git a/ui/GarrisonPanel.gd.uid b/ui/GarrisonPanel.gd.uid new file mode 100644 index 0000000..a4a21b2 --- /dev/null +++ b/ui/GarrisonPanel.gd.uid @@ -0,0 +1 @@ +uid://djnon3bfsm3c8 diff --git a/ui/HangarMenuPanel.gd b/ui/HangarMenuPanel.gd new file mode 100644 index 0000000..87734fb --- /dev/null +++ b/ui/HangarMenuPanel.gd @@ -0,0 +1,20 @@ +extends Control + +# #120: the rule between the hangar's two halves. +# +# The panel itself draws it rather than a separate node, because the y comes +# from BuildController._layout_hangar() - which is also what places the two +# grids - and a divider that had to be positioned independently would drift +# away from the sections it divides the first time either grid changed size. + +const RULE_COLOR: Color = Color(0.45, 0.9, 0.55) + +@onready var controller: Node = get_node("../../BuildController") + + +func _draw() -> void: + if controller == null or controller.hangar_divider_y <= 0.0: + return + var y: float = controller.hangar_divider_y + NeonDraw.draw_glowing_line(self, Vector2(24.0, y), Vector2(size.x - 24.0, y), + RULE_COLOR, 0.5, 0.4) diff --git a/ui/HangarMenuPanel.gd.uid b/ui/HangarMenuPanel.gd.uid new file mode 100644 index 0000000..bf02bf0 --- /dev/null +++ b/ui/HangarMenuPanel.gd.uid @@ -0,0 +1 @@ +uid://bwqdhbksv0xgf diff --git a/ui/IconButton.gd b/ui/IconButton.gd index 836dd35..aa59414 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -50,6 +50,17 @@ enum Icon { # inserting anywhere earlier renumbers every icon int already stored in a # scene file. PAUSE, RESUME, + # #102: appended, like everything above - inserting anywhere earlier + # renumbers every icon int already stored in every scene. + OUTPOST, + # #106: appended, same rule. + PLANNING_CENTER, + # #116: the workshop and the drone it sends out. Appended, same rule. + WORKSHOP, REPAIR_DRONE, + # #140: the standing objective. Appended, same rule as everything above - + # inserting anywhere earlier renumbers every icon int already stored in + # every scene. + QUEST, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -62,6 +73,11 @@ const REFERENCE_SIZE: float = 56.0 # Selection is an additional frame, never a recolour. It lets menus retain # semantic colours (vessel class, danger, cargo) while still making the # active choice unmistakable. +# #112: the glyph without the button around it. The skill tree draws its own +# hexagon for each node and wants only what is inside one, and a second frame +# nested in that hexagon reads as a button sitting on a node rather than as +# the node's own mark. +@export var show_frame: bool = true @export var highlighted: bool = false: set = set_highlighted # Colour of the bright core stroke. Defaults to near-white, which reads well # on the dark space background; scenes drawing over a bright backdrop (the @@ -201,8 +217,10 @@ func _draw() -> void: var pulse: float = 1.0 if pressed_down else 0.85 var margin: float = 4.0 * s - NeonDraw.draw_glowing_polygon(self, _frame_points(margin), _active_color(), pulse, core_color) - if highlighted: + if show_frame: + NeonDraw.draw_glowing_polygon(self, _frame_points(margin), _active_color(), + pulse, core_color) + if highlighted and show_frame: # The inset frame reads as a selection focus ring. It deliberately uses # the bright core rather than the button's glow colour, so selecting a # blue shield or pink transporter does not change what that colour means. @@ -291,6 +309,38 @@ func _draw() -> void: _draw_laser_shot(center, pulse, s) Icon.DISTORDER_VESSEL: _draw_distorder_vessel(center, pulse, s) + Icon.OUTPOST: + _draw_outpost_icon(center, pulse, s) + Icon.PLANNING_CENTER: + _draw_planning_icon(center, pulse, s) + Icon.WORKSHOP: + _draw_workshop_icon(center, pulse, s) + Icon.REPAIR_DRONE: + _draw_repair_drone_icon(center, pulse, s) + Icon.QUEST: + _draw_quest_icon(center, pulse, s) + + +# #140: a checklist - a page with a tick against the top line. Reads as +# "what I have been asked to do" rather than as a map, a target or a flag, +# each of which this button sits next to. +func _draw_quest_icon(center: Vector2, pulse: float, s: float) -> void: + var half: Vector2 = Vector2(9.0, 12.0) * s + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + center + Vector2(-half.x, -half.y), center + Vector2(half.x, -half.y), + center + Vector2(half.x, half.y), center + Vector2(-half.x, half.y), + center + Vector2(-half.x, -half.y), + ]), _active_color(), pulse, 0.5 * s, core_color) + # The done line, ticked; the two below it still open. + var tick: Vector2 = center + Vector2(-5.0, -5.0) * s + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + tick + Vector2(-1.5, 0.0) * s, tick + Vector2(0.5, 2.0) * s, + tick + Vector2(3.5, -2.5) * s, + ]), _active_color(), pulse, 0.5 * s, core_color) + for row in [1.0, 5.0]: + NeonDraw.draw_glowing_line(self, center + Vector2(-5.5, row) * s, + center + Vector2(5.5, row) * s, _active_color(), pulse * 0.75, + 0.4 * s, core_color) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -659,6 +709,84 @@ func _draw_ship_lab_icon(center: Vector2, pulse: float, s: float) -> void: _active_color(), pulse, 0.35 * s, core_color) +func _draw_workshop_icon(center: Vector2, pulse: float, s: float) -> void: + # A wrench crossed with a girder. Nothing else in the set is a tool, and + # the meaning wanted is "things are mended here" - the crafter's cradle + # already owns "things are made here". + var arm: float = 11.0 * s + NeonDraw.draw_glowing_line(self, center + Vector2(-arm, arm), + center + Vector2(arm * 0.5, -arm * 0.5), _active_color(), pulse, 0.5 * s, core_color) + # The jaws. + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + center + Vector2(arm * 0.2, -arm * 0.95), + center + Vector2(arm * 0.95, -arm * 0.8), + center + Vector2(arm * 0.8, -arm * 0.1), + center + Vector2(arm * 0.15, -arm * 0.3), + ]), _active_color(), pulse, 0.4 * s, core_color) + # The girder it is working on. + NeonDraw.draw_glowing_line(self, center + Vector2(-arm, -arm * 0.35), + center + Vector2(arm * 0.1, arm), _active_color(), pulse * 0.7, 0.35 * s, core_color) + + +func _draw_repair_drone_icon(center: Vector2, pulse: float, s: float) -> void: + # A small hull with a tool arm out in front, so it reads as a tender + # rather than as another fighter. + var r: float = 9.0 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + Vector2(0.0, -r), center + Vector2(r * 0.9, 0.0), + center + Vector2(r * 0.6, r), center + Vector2(-r * 0.6, r), + center + Vector2(-r * 0.9, 0.0), + ]), _active_color(), pulse, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(0.0, -r), + center + Vector2(0.0, -r * 1.9), _active_color(), pulse, 0.35 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(-r * 0.5, -r * 1.9), + center + Vector2(r * 0.5, -r * 1.9), _active_color(), pulse, 0.35 * s, core_color) + + +func _draw_planning_icon(center: Vector2, pulse: float, s: float) -> void: + # A tally board: a frame with three bars of different lengths inside it. + # Nothing else in the set is rectilinear and horizontal, and the meaning + # wanted is "how many of each", not "a place that builds things" - the + # crafter's cradle already owns that. + var w: float = 12.0 * s + var h: float = 11.0 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + Vector2(-w, -h), center + Vector2(w, -h), + center + Vector2(w, h), center + Vector2(-w, h), + ]), _active_color(), pulse, core_color) + var lengths: Array = [0.82, 0.5, 0.66] + for i in range(lengths.size()): + var y: float = center.y + (float(i) - 1.0) * 6.0 * s + var left: float = center.x - w + 3.0 * s + NeonDraw.draw_glowing_line(self, Vector2(left, y), + Vector2(left + (2.0 * w - 6.0 * s) * float(lengths[i]), y), + _active_color(), pulse, 0.4 * s, core_color) + + +func _draw_outpost_icon(center: Vector2, pulse: float, s: float) -> void: + # A watchtower with two vessels holding station off it. The hub's + # octagon already means "things are stationed here and go out from + # here"; this has to mean "things stay here and watch", so it is a + # narrow mast with a sensor head rather than a ring, and the two darts + # beside it are the garrison. + var mast_top: Vector2 = center + Vector2(0.0, -11.0) * s + var mast_foot: Vector2 = center + Vector2(0.0, 9.0) * s + NeonDraw.draw_glowing_line(self, mast_foot, mast_top, _active_color(), pulse, 0.5 * s, core_color) + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + mast_top + Vector2(0.0, -4.0) * s, mast_top + Vector2(4.0, 0.0) * s, + mast_top + Vector2(0.0, 4.0) * s, mast_top + Vector2(-4.0, 0.0) * s, + ]), _active_color(), pulse, core_color) + # Footing, so the mast stands on something rather than floating. + NeonDraw.draw_glowing_line(self, mast_foot + Vector2(-6.0, 0.0) * s, + mast_foot + Vector2(6.0, 0.0) * s, _active_color(), pulse, 0.4 * s, core_color) + for side in [-1.0, 1.0]: + var at: Vector2 = center + Vector2(11.0 * side, -1.0) * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + at + Vector2(0.0, -4.0) * s, at + Vector2(2.6, 3.0) * s, + at + Vector2(-2.6, 3.0) * s, + ]), _active_color(), pulse, core_color) + + func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void: # A ring of docking cradles with a freight box in the middle: reads as # "things are stationed here and go out from here", which is what the hub diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index 129203d..3f6ed71 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -34,6 +34,9 @@ const STATUS_HEIGHT: float = 70.0 func _ready() -> void: + # #148: the theme starts here, at the first screen, and keeps playing + # through every scene after it. + Music.ensure(get_tree()) play_button.pressed.connect(_on_play_pressed) editor_button.pressed.connect(_on_editor_pressed) editor_button.enabled = true diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd new file mode 100644 index 0000000..f9aa22f --- /dev/null +++ b/ui/NarrationBanner.gd @@ -0,0 +1,364 @@ +extends Control + +# #122: what the guide says during the prologue. +# +# A banner rather than a dialogue box: the player is flying while they read +# it, so it sits across the top out of the way of the fleet. +# +# #127 makes it wait for the player. A line stays until it is DISMISSED - +# there is no timer under it - because a prologue whose lines expire on +# their own is a prologue that reads at the game's pace rather than at the +# reader's. Lines queue rather than replacing each other, so a beat that +# fires while the previous line is still up does not silently eat it. +# +# The beats that are about the guide talking hold while this is speaking +# (see IntroDirector._waits_for_narration), so dismissing a line is what +# advances the prologue. +# +# #128 makes that unconditional: while a line is up the WHOLE GAME is +# stopped - get_tree().paused, which freezes the fleet, the enemies, the +# production in GameState, and the director's own beat clock, and which +# stops input reaching any node that is not this one. So there is nothing +# to miss while reading and nothing to fumble a command into, and a beat's +# deadline can no longer run out from under an unacknowledged line: the +# player's click is the only thing that starts the world again. +# +# The pause is owned HERE rather than by each caller, because the thing that +# must not get out of step is "a line is showing" and "the game is stopped", +# and the only code that knows the first is this file. +# +# #149: a line ARRIVES rather than appearing. It is typed out at a readable +# rate, with a scan sweep running down the panel while it does - a +# transmission being decoded rather than a caption being switched on. +# +# A press during the typing FINISHES it instead of dismissing the line. That +# is not a flourish: without it, a player who clicks quickly through two +# lines never sees the second one at all, and the whole point of #127 was +# that lines wait for the reader. + +signal dismissed + +const FADE_IN_SECONDS: float = 0.35 +const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0) +const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95) +const SPEAKER_COLOR: Color = Color(0.62, 0.86, 1.0) +# #125: the panel was a plain rectangle with a line under it. These are the +# pieces that make it read as a transmission window instead - the corners +# are cut and the short edges are brackets rather than sides. All geometry +# rather than art, so it scales with the banner and costs nothing. +const CUT: float = 26.0 +const BRACKET: float = 34.0 +const INSET: float = 7.0 +# #127: the speaker's name sits ON the top edge at the left, in a notch cut +# out of the border, the way a label is riveted to a panel. +const SPEAKER_FONT_SIZE: int = 20 +const SPEAKER_PAD: float = 12.0 +const SPEAKER_INSET: float = 46.0 +const DEFAULT_SPEAKER: String = "CONVOY CONTROL" +# #149: how fast a line decodes. Fast enough not to be a wait, slow enough +# to read as arriving - about three and a half words a second. +const CHARS_PER_SECOND: float = 42.0 +# The scan sweep's travel time, top to bottom of the panel. +const SWEEP_SECONDS: float = 0.9 +const SWEEP_COLOR: Color = Color(0.55, 0.95, 1.0) +# Faint horizontal rules over the whole panel, the way a screen has them. +const SCANLINE_GAP: float = 7.0 + +var label: Label +var speaker_label: Label +var queued: Array = [] +var current: String = "" +var speaker: String = "" +var shown_for: float = 0.0 +# How much of the current line has been decoded, in characters. +var revealed: float = 0.0 +# Whether the pause currently in force is OURS. Checked before lifting it, so +# a pause somebody else put on (a menu, a future one) is never lifted by a +# dialog closing. +var _holds_pause: bool = false + + +func _ready() -> void: + # #128: the one node that keeps running while the game is stopped. Set + # here as well as in _ensure_label() because a banner that pauses the + # tree and then cannot process its own dismissal would wedge the game. + process_mode = Node.PROCESS_MODE_ALWAYS + _ensure_label() + + +# The pause goes on when a line goes up and comes off when the last one is +# gone. Both are idempotent, so it does not matter how many times the state +# is re-derived - only that it is derived from one place (_sync_pause). +func _sync_pause() -> void: + var wanted: bool = current != "" + if wanted == _holds_pause: + return + var tree: SceneTree = get_tree() + if tree == null: + # Not in the tree: there is nothing to pause, and nothing we could + # have paused earlier is still ours to lift. + _holds_pause = false + return + if wanted: + tree.paused = true + _holds_pause = true + return + tree.paused = false + _holds_pause = false + + +# Leaving the tree with a line up - the scene swapping to a planet surface, +# a test dropping the scene - must not leave the game stopped with nothing +# left that could start it again. +func _exit_tree() -> void: + if not _holds_pause: + return + var tree: SceneTree = get_tree() + if tree != null: + tree.paused = false + _holds_pause = false + + +# The director is a world node and this banner lives under the UI layer, +# which readies afterwards - so the first line can arrive before _ready(). +# Building on demand is simpler than making the director wait a frame, and +# it cannot be got wrong twice. +func _ensure_label() -> void: + if label != null: + return + process_mode = Node.PROCESS_MODE_ALWAYS + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + label = Label.new() + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 30) + label.add_theme_color_override("font_color", TEXT_COLOR) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95)) + label.add_theme_constant_override("outline_size", 4) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(label) + + speaker_label = Label.new() + speaker_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + speaker_label.add_theme_font_size_override("font_size", SPEAKER_FONT_SIZE) + speaker_label.add_theme_color_override("font_color", SPEAKER_COLOR) + speaker_label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95)) + speaker_label.add_theme_constant_override("outline_size", 3) + speaker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(speaker_label) + + resized.connect(_layout) + _layout() + + +func _layout() -> void: + if label == null: + return + # The text clears the speaker tab at the top and the brackets at the + # sides, so a long line never runs underneath either. + label.position = Vector2(BRACKET, SPEAKER_FONT_SIZE * 0.9) + label.size = Vector2(maxf(size.x - BRACKET * 2.0, 0.0), + maxf(size.y - SPEAKER_FONT_SIZE * 0.9, 0.0)) + speaker_label.position = Vector2(SPEAKER_INSET + SPEAKER_PAD, + -SPEAKER_FONT_SIZE * 0.5) + speaker_label.size = Vector2(_speaker_width(), SPEAKER_FONT_SIZE) + + +func _speaker_width() -> float: + if speaker_label == null or speaker_label.text == "": + return 0.0 + var font: Font = speaker_label.get_theme_font("font") + if font == null: + return float(speaker_label.text.length()) * SPEAKER_FONT_SIZE * 0.6 + return font.get_string_size(speaker_label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, + SPEAKER_FONT_SIZE).x + + +# Says one line, from someone. It stays up until dismissed. +func say(line: String, who: String = DEFAULT_SPEAKER) -> void: + _ensure_label() + queued.append({"line": line, "speaker": who}) + if current == "": + _advance() + + +func clear() -> void: + queued.clear() + current = "" + speaker = "" + shown_for = 0.0 + visible = false + _sync_pause() + + +func is_speaking() -> bool: + return current != "" or not queued.is_empty() + + +# Closes the current line and shows whatever is behind it. Public so a test +# - or any other way of dismissing a dialog that gets added later - has the +# same door the click uses. +func dismiss() -> bool: + if current == "": + return false + # #149: still arriving, so this press is "show me the rest" rather than + # "I have read it". It still counts as handled - the press must not fall + # through and steer the fleet either way. + if is_typing(): + finish_typing() + return true + dismissed.emit() + _advance() + return true + + +func _advance() -> void: + shown_for = 0.0 + revealed = 0.0 + if queued.is_empty(): + current = "" + speaker = "" + visible = false + _sync_pause() + return + var next: Dictionary = queued.pop_front() + current = String(next.line) + speaker = String(next.speaker) + revealed = 0.0 + label.text = current + # The full text is set and then hidden a character at a time, rather than + # the text growing. Growing it would re-wrap the line on nearly every + # frame, so words would jump between rows as they were typed. + label.visible_characters = 0 + speaker_label.text = speaker + visible = true + _layout() + queue_redraw() + _sync_pause() + + +func is_typing() -> bool: + return current != "" and revealed < float(current.length()) + + +# Shows the whole line at once. What a press does while it is still arriving. +func finish_typing() -> void: + revealed = float(current.length()) + if label != null: + label.visible_characters = -1 + queue_redraw() + + +func _process(delta: float) -> void: + if current == "": + return + # The fade-in, the prompt's blink, and the decode. Nothing here closes + # the line - that is the player's job (#127). + shown_for += delta + if is_typing(): + revealed = minf(revealed + delta * CHARS_PER_SECOND, float(current.length())) + label.visible_characters = -1 if not is_typing() else int(revealed) + queue_redraw() + + +func _unhandled_input(event: InputEvent) -> void: + if current == "": + return + var pressed: bool = (event is InputEventScreenTouch and event.pressed) \ + or (event is InputEventMouseButton and event.pressed \ + and event.button_index == MOUSE_BUTTON_LEFT) + if not pressed: + return + # A dismissed line eats the press: it must not also be steering the + # fleet or firing something on the way through. + if dismiss(): + get_viewport().set_input_as_handled() + + +# The panel's outline: a rectangle with its corners cut off, and a notch in +# the top edge where the speaker's name sits. Returned rather than drawn so +# the fill and the stroke are guaranteed to be the same shape. +func _frame() -> PackedVector2Array: + var w: float = size.x + var h: float = size.y + var tab_start: float = SPEAKER_INSET + var tab_end: float = SPEAKER_INSET + _speaker_width() + SPEAKER_PAD * 2.0 + var lift: float = SPEAKER_FONT_SIZE * 0.9 + return PackedVector2Array([ + Vector2(CUT, 0.0), + # The notch: up over the speaker's name and back down. + Vector2(tab_start, 0.0), Vector2(tab_start + 6.0, -lift), + Vector2(tab_end - 6.0, -lift), Vector2(tab_end, 0.0), + Vector2(w - CUT, 0.0), Vector2(w, CUT), + Vector2(w, h - CUT), Vector2(w - CUT, h), Vector2(CUT, h), + Vector2(0.0, h - CUT), Vector2(0.0, CUT), + ]) + + +func _draw() -> void: + if current == "": + return + var alpha: float = clampf(shown_for / FADE_IN_SECONDS, 0.0, 1.0) + label.modulate.a = alpha + speaker_label.modulate.a = alpha + var frame: PackedVector2Array = _frame() + + draw_colored_polygon(frame, Color(0.02, 0.05, 0.12, 0.78 * alpha)) + NeonDraw.draw_glowing_polygon(self, frame, Color(FRAME_COLOR, alpha), 0.8) + + # Brackets at the short edges: a doubled inner line that stops well short + # of the corners, so the panel reads as held open rather than closed. + for side in [0.0, 1.0]: + var x: float = lerpf(INSET, size.x - INSET, side) + NeonDraw.draw_glowing_line(self, Vector2(x, CUT + INSET), + Vector2(x, size.y - CUT - INSET), Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3) + var arm: float = BRACKET * (1.0 if side < 0.5 else -1.0) + for y in [CUT + INSET, size.y - CUT - INSET]: + NeonDraw.draw_glowing_line(self, Vector2(x, y), Vector2(x + arm, y), + Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3) + + # #127: nothing under the text. The tick strip that used to run along the + # bottom edge read as an underline attached to the last line rather than + # as part of the frame. + + # #149: the panel reads as a screen decoding something. + # + # Faint rules across the whole of it, always - a surface with a raster + # rather than a sheet of glass - and a bright sweep running down it while + # the line is still arriving, which stops when the decode does. The + # sweep is the thing that says "this is happening now"; without it a + # half-typed line just looks like a short line. + var inner: Rect2 = Rect2(Vector2(INSET, CUT * 0.5), + Vector2(maxf(size.x - INSET * 2.0, 0.0), maxf(size.y - CUT, 0.0))) + var rule: float = inner.position.y + while rule < inner.end.y: + draw_line(Vector2(inner.position.x, rule), Vector2(inner.end.x, rule), + Color(SWEEP_COLOR, 0.05 * alpha), 1.0) + rule += SCANLINE_GAP + + if is_typing(): + var sweep: float = fposmod(shown_for, SWEEP_SECONDS) / SWEEP_SECONDS + var y: float = lerpf(inner.position.y, inner.end.y, sweep) + # Brightest at the head with a short trail above it, so it reads as + # travelling rather than as a line that moved. + for step in range(6): + var trail: float = y - float(step) * 3.0 + if trail < inner.position.y: + break + draw_line(Vector2(inner.position.x, trail), Vector2(inner.end.x, trail), + Color(SWEEP_COLOR, alpha * 0.30 * (1.0 - float(step) / 6.0)), 1.0) + # And the frame carries the same charge while it is working. + NeonDraw.draw_glowing_line(self, Vector2(INSET, y), Vector2(size.x - INSET, y), + Color(SWEEP_COLOR, alpha * 0.5), 0.7, 0.25) + + # A blinking caret at the bottom right - the one thing that says a click + # is what moves this on. + # Solid while decoding - there is nothing to prompt for yet - and + # blinking once the line is complete and waiting on the reader. + var blink: float = 1.0 if is_typing() else 0.45 + 0.55 * absf(sin(shown_for * 3.0)) + var tip: Vector2 = Vector2(size.x - CUT - 6.0, size.y - INSET - 12.0) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + tip + Vector2(-11.0, -7.0), tip, tip + Vector2(-11.0, 7.0), + ]), Color(0.78, 0.94, 1.0, alpha * blink), 0.9, 0.4) diff --git a/ui/NarrationBanner.gd.uid b/ui/NarrationBanner.gd.uid new file mode 100644 index 0000000..4c9f106 --- /dev/null +++ b/ui/NarrationBanner.gd.uid @@ -0,0 +1 @@ +uid://cwj4638bvrmc8 diff --git a/ui/NeonPanel.gd b/ui/NeonPanel.gd index 5e212bf..55803ab 100644 --- a/ui/NeonPanel.gd +++ b/ui/NeonPanel.gd @@ -7,6 +7,17 @@ extends Control # hard-edged neon of the buttons and HUD. # # Draws itself from its own rect, so it can be sized freely in the scene. +# +# #92: it also SHRINKS itself to whatever window it is in. The card is +# authored at a fixed size around a portrait phone, and on a landscape +# window - where the canvas is far wider than it is tall - a card taller +# than the screen simply ran off the top and the bottom, taking the confirm +# and cancel buttons with it. The buttons are anchored to the panel's own +# bottom edge, so bringing the edge back on screen brings them back too. +# +# Only ever shrinks. Growing the card on a big screen would blow the +# authored proportions up with it, and the layout inside is tuned, not +# fluid. @export var glow_color: Color = Color(0.25, 0.85, 1.0) @export var core_color: Color = Color(1.0, 1.0, 1.0) @@ -15,6 +26,47 @@ extends Control @export var fill_color: Color = Color(0.02, 0.03, 0.07, 1.0) @export var corner_cut: float = 26.0 @export var bracket_length: float = 46.0 +# Breathing room kept between the card and the edge of the screen, so a +# fitted panel reads as a card on a backdrop rather than as a full-bleed +# page that happens to have a border. +@export var screen_margin: float = 24.0 + +# The size the card was authored at, and the point it is centred on. Read +# once, before any fitting has happened - re-reading them later would +# measure a previous fit and let the card ratchet smaller on every resize. +var design_size: Vector2 = Vector2.ZERO +var design_centre: Vector2 = Vector2.ZERO + + +func _ready() -> void: + # Fitting adjusts the four offsets around a fixed centre, which is only + # meaningful for a card pinned to a point - both anchor pairs collapsed, + # as a centred dialog's are. A panel stretched between different anchors + # already follows its parent and is left alone. + if not (is_equal_approx(anchor_left, anchor_right) \ + and is_equal_approx(anchor_top, anchor_bottom)): + return + design_size = Vector2(offset_right - offset_left, offset_bottom - offset_top) + design_centre = Vector2(offset_left + offset_right, offset_top + offset_bottom) * 0.5 + get_viewport().size_changed.connect(_fit_to_screen) + _fit_to_screen() + + +func _fit_to_screen() -> void: + if design_size == Vector2.ZERO: + return + # The viewport rect, not the window: with stretch/mode "canvas_items" the + # UI is laid out in stretched canvas units, and those are the units these + # offsets are in. + var available: Vector2 = get_viewport_rect().size - Vector2(screen_margin, screen_margin) * 2.0 + var fitted: Vector2 = Vector2( + minf(design_size.x, maxf(available.x, 0.0)), + minf(design_size.y, maxf(available.y, 0.0))) + offset_left = design_centre.x - fitted.x * 0.5 + offset_right = design_centre.x + fitted.x * 0.5 + offset_top = design_centre.y - fitted.y * 0.5 + offset_bottom = design_centre.y + fitted.y * 0.5 + queue_redraw() func _draw() -> void: diff --git a/ui/PlanningPanel.gd b/ui/PlanningPanel.gd new file mode 100644 index 0000000..935367b --- /dev/null +++ b/ui/PlanningPanel.gd @@ -0,0 +1,150 @@ +extends Control + +# #106 (#98a): where the player says how many of each vessel should always +# exist. +# +# Like GarrisonPanel it builds its own rows from the catalog, so a new +# fleet-eligible vessel gets a row the day it is added without touching the +# scene. Unlike GarrisonPanel the targets are GLOBAL - there is one plan for +# the whole game, and a second planning centre is another place to read the +# same plan rather than a competing one. +# +# Each row reads "have / want" with what is being built alongside it, so a +# plan that is merely waiting on a production queue is distinguishable from +# one nothing is being done about. That distinction is the whole readout: +# without it a player looking at "2 / 5" cannot tell whether the system is +# working. + +signal changed + +const ROW_HEIGHT: float = 84.0 +const ROW_GAP: float = 8.0 +const BUTTON_SIZE: float = 68.0 +const NAME_FONT_SIZE: int = 28 +const COUNT_FONT_SIZE: int = 30 +const NOTE_FONT_SIZE: int = 20 +const HEADER_FONT_SIZE: int = 22 + +var _rows: Array = [] +var _header: Label + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + _header = _make_label("", HEADER_FONT_SIZE, Color(0.62, 0.78, 0.86)) + _header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + for vessel_type in GameState.planned_vessel_types(): + _rows.append(_make_row(String(vessel_type))) + resized.connect(_layout) + _layout() + + +func _make_label(text_value: String, font_size: int, colour: Color) -> Label: + var label := Label.new() + label.text = text_value + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95)) + label.add_theme_constant_override("outline_size", 2) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + return label + + +func _make_row(vessel_type: String) -> Dictionary: + var definition: Resource = GameState.vessel_definition(vessel_type) + var colour: Color = definition.glow_color if definition != null else Color.WHITE + + var name_label: Label = _make_label(GameState.vessel_name(vessel_type), + NAME_FONT_SIZE, colour) + var note_label: Label = _make_label("", NOTE_FONT_SIZE, Color(0.62, 0.78, 0.86)) + var count_label: Label = _make_label("0/0", COUNT_FONT_SIZE, Color(0.94, 0.98, 1.0)) + count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + var minus := IconButton.new() + minus.icon = IconButton.Icon.MINUS + minus.glow_color = colour + minus.pressed.connect(_on_adjust.bind(vessel_type, -1)) + add_child(minus) + + var plus := IconButton.new() + plus.icon = IconButton.Icon.PLUS + plus.glow_color = colour + plus.pressed.connect(_on_adjust.bind(vessel_type, 1)) + add_child(plus) + + return {"type": vessel_type, "name": name_label, "note": note_label, + "count": count_label, "minus": minus, "plus": plus} + + +func _on_adjust(vessel_type: String, delta: int) -> void: + GameState.adjust_vessel_target(vessel_type, delta) + refresh() + changed.emit() + + +func _layout() -> void: + if _rows.is_empty(): + return + _header.position = Vector2.ZERO + _header.size = Vector2(size.x, 40.0) + var y: float = 46.0 + for row in _rows: + row.name.position = Vector2(0.0, y) + row.name.size = Vector2(size.x * 0.44, ROW_HEIGHT * 0.58) + row.note.position = Vector2(0.0, y + ROW_HEIGHT * 0.54) + row.note.size = Vector2(size.x * 0.52, ROW_HEIGHT * 0.42) + var right: float = size.x + row.plus.position = Vector2(right - BUTTON_SIZE, y + (ROW_HEIGHT - BUTTON_SIZE) * 0.5) + row.plus.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + row.count.position = Vector2(right - BUTTON_SIZE * 3.2, y) + row.count.size = Vector2(BUTTON_SIZE * 2.2, ROW_HEIGHT) + row.minus.position = Vector2(right - BUTTON_SIZE * 4.2, + y + (ROW_HEIGHT - BUTTON_SIZE) * 0.5) + row.minus.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + y += ROW_HEIGHT + ROW_GAP + + +func natural_height() -> float: + return 46.0 + float(_rows.size()) * (ROW_HEIGHT + ROW_GAP) + + +func refresh() -> void: + if _rows.is_empty(): + return + var planned: int = 0 + for vessel_type in GameState.vessel_targets: + planned += int(GameState.vessel_targets[vessel_type]) + _header.text = "STANDING ORDERS %d VESSELS" % planned + for row in _rows: + var vessel_type: String = String(row.type) + var want: int = GameState.vessel_target(vessel_type) + var have: int = GameState.vessel_census(vessel_type) + var building: int = GameState.vessel_count_on_order(vessel_type) + row.count.text = "%d/%d" % [have, want] + # Where they are, not merely how many: a plan can look unmet while + # every vessel it asked for is sitting at an outpost. + var parts: PackedStringArray = PackedStringArray() + var flying: int = GameState.vessel_count_in_fleet(vessel_type) + var stored: int = GameState.vessel_count_in_hangar(vessel_type) + var stationed: int = GameState.vessel_count_in_garrisons(vessel_type) + var moving: int = GameState.vessel_count_in_transit(vessel_type) + if flying > 0: + parts.append("%d flying" % flying) + if stored > 0: + parts.append("%d stored" % stored) + if stationed > 0: + parts.append("%d stationed" % stationed) + if moving > 0: + parts.append("%d moving" % moving) + if building > 0: + parts.append("%d building" % building) + row.note.text = ", ".join(parts) + var unlocked: bool = GameState.vessel_is_unlocked(vessel_type) + row.name.modulate.a = 1.0 if unlocked else 0.45 + row.minus.enabled = unlocked and want > 0 + row.plus.enabled = unlocked + row.minus.queue_redraw() + row.plus.queue_redraw() diff --git a/ui/PlanningPanel.gd.uid b/ui/PlanningPanel.gd.uid new file mode 100644 index 0000000..08bb8fd --- /dev/null +++ b/ui/PlanningPanel.gd.uid @@ -0,0 +1 @@ +uid://b1q61txbsw5o3 diff --git a/ui/PriceTag.gd b/ui/PriceTag.gd new file mode 100644 index 0000000..ce14a1d --- /dev/null +++ b/ui/PriceTag.gd @@ -0,0 +1,283 @@ +class_name PriceTag +extends Control + +# Forgejo #137 (and #134): one way of saying what something costs. +# +# The game had three. The build menu drew a NeonNumber under each option; the +# craft menu drew glyph-and-number strips (#111); the ship lab spelled prices +# out inside the option's caption - "25 ore · 10 gas · 2 RP" - and the skill +# tree put the number in the middle of the panel with a sentence underneath +# saying whether you could afford it. Four menus, four answers to the same +# question, and a player learning one of them learned nothing about the next. +# +# This is that answer, once: for each thing a purchase costs, its glyph, then +# what you HAVE over what it COSTS. +# +# [ore] 12/25 [gas] 40/10 +# +# #134 asks for the two numbers to be told apart by more than position, so +# the amount you have is drawn smaller than the price - a price is a fact +# about the thing, what you hold is a fact about you - and it turns red when +# it is not enough. That replaces the "Not enough rocks - you have 12" line +# the skill panel used to carry: the shortfall is now shown where the price +# is rather than described in a sentence somewhere else on the panel. +# +# Research points ride along as an entry with a written mark rather than a +# glyph, because they are not a resource - they have no inventory, no colour +# in the catalog, and nothing carries them. + +enum Align { LEFT, CENTER, RIGHT } + +const GLYPH_SIZE: float = 26.0 +const GAP: float = 5.0 +const PAIR_GAP: float = 18.0 +# Between stacked lines (#147). +const STACK_GAP: float = 4.0 +const COST_FONT_SIZE: int = 24 +# Deliberately smaller than the price. See the note above. +const HAVE_FONT_SIZE: int = 17 +const SHORT_COLOR: Color = Color(1.0, 0.36, 0.32) +const HAVE_COLOR: Color = Color(0.60, 0.73, 0.82) +const POINT_COLOR: Color = Color(0.72, 0.62, 1.0) +const POINT_MARK: String = "RP" + +@export var alignment: int = Align.CENTER +# #147: "each resource type should go on a new line". A multi-resource price +# used to be laid out across one row whatever the tag's width, which under a +# craft button meant running off the end of it and into the vessel beside. +# +# So more than one resource means one per line, everywhere - not only where +# it would otherwise overflow. Both because that is what the ticket asks +# for, and because the overflow case was MARGINAL rather than absent: two +# resources came to 174px in a 184px box, and the box only stopped being +# wide enough once the player was holding three figures rather than one. +# A layout that is correct until you get rich is not correct. +var _stacked: bool = false +# Off for a price with nothing to compare against - a shop with no wallet in +# the same breath. Every menu in the game wants it on; it exists so a caller +# that only has a number can still use this widget rather than inventing a +# fifth way to draw one. +@export var show_available: bool = true + +var _entries: Array = [] +# The price currently on show. Kept so a caller that re-states the same price +# every frame - the craft menu does, and since #138 the build menu does too - +# refreshes the HELD figures instead of tearing down and rebuilding a dozen +# Labels sixty times a second. +var _shown_costs: Dictionary = {} +var _shown_points: int = 0 +var _built: bool = false + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + resized.connect(_layout) + + +# costs: {resource_type: amount}. points: research points, 0 for none. +func show_price(costs: Dictionary, points: int = 0) -> void: + if _built and points == _shown_points and costs == _shown_costs: + # Same price, possibly a different wallet. + _refresh_held() + return + _shown_costs = costs.duplicate() + _shown_points = points + _built = true + _clear() + for resource_type in GameState.resource_types(): + var amount: int = int(costs.get(resource_type, 0)) + if amount <= 0: + continue + _add_entry(String(resource_type), "", amount, + GameState.available_resource(String(resource_type)), + GameState.resource_color(resource_type)) + if points > 0: + _add_entry("", POINT_MARK, points, GameState.research_points, POINT_COLOR) + _layout() + + +# The old name, kept because the craft menu speaks in costs alone and reads +# better for it. +func set_costs(costs: Dictionary) -> void: + show_price(costs) + + +# What the player holds, re-read. The price does not move; the wallet does. +func _refresh_held() -> void: + var relayout: bool = false + for entry in _entries: + var have: int = GameState.research_points if String(entry.resource) == "" \ + else GameState.available_resource(String(entry.resource)) + if have == int(entry.have): + continue + entry.have = have + entry.short = have < int(entry.cost) + if entry.have_label == null: + continue + entry.have_label.text = "%d/" % have + entry.have_label.add_theme_color_override("font_color", + SHORT_COLOR if entry.short else HAVE_COLOR) + var width: float = _text_width(entry.have_label, HAVE_FONT_SIZE) + if not is_equal_approx(width, float(entry.have_width)): + entry.have_width = width + relayout = true + if relayout: + _layout() + + +func is_free() -> bool: + return _entries.is_empty() + + +# Whether the price is currently one resource per line. For anything that +# needs to leave room for it - and for the test. +func is_stacked() -> bool: + return _stacked + + +# How tall this price wants to be, laid out as it will actually be laid out. +# Callers size the box from this rather than guessing a number of lines. +func wanted_height() -> float: + if _entries.size() <= 1: + return GLYPH_SIZE + return GLYPH_SIZE * float(_entries.size()) + STACK_GAP * float(_entries.size() - 1) + + +func _row_width() -> float: + var total: float = 0.0 + for entry in _entries: + total += _entry_width(entry) + PAIR_GAP + return maxf(total - PAIR_GAP, 0.0) + + +# Whether anything on this tag is out of reach. The one question every caller +# used to answer for itself, in its own words. +func is_affordable() -> bool: + for entry in _entries: + if int(entry.have) < int(entry.cost): + return false + return true + + +# What this tag says about one resource. Accessors rather than a public +# dictionary so a caller - or a test - reads the same numbers the player +# does, off the thing that drew them. +func priced(resource_type: String) -> int: + return int(_entry_for(resource_type).get("cost", 0)) + + +func held(resource_type: String) -> int: + return int(_entry_for(resource_type).get("have", 0)) + + +func is_short(resource_type: String) -> bool: + return bool(_entry_for(resource_type).get("short", false)) + + +func _entry_for(resource_type: String) -> Dictionary: + for entry in _entries: + if String(entry.resource) == resource_type: + return entry + return {} + + +func _clear() -> void: + for entry in _entries: + for node in [entry.mark, entry.have_label, entry.cost_label]: + if node != null: + node.queue_free() + _entries.clear() + + +func _add_entry(resource_type: String, mark_text: String, cost: int, have: int, + colour: Color) -> void: + var mark: Control + if resource_type != "": + var glyph := ResourceGlyph.new() + glyph.resource_type = resource_type + glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE + mark = glyph + else: + mark = _make_label(mark_text, HAVE_FONT_SIZE, colour) + add_child(mark) + + var short: bool = have < cost + var have_label: Label = null + if show_available: + have_label = _make_label("%d/" % have, HAVE_FONT_SIZE, + SHORT_COLOR if short else HAVE_COLOR) + add_child(have_label) + var cost_label: Label = _make_label(str(cost), COST_FONT_SIZE, colour) + add_child(cost_label) + + _entries.append({ + "resource": resource_type, + "mark": mark, "have_label": have_label, "cost_label": cost_label, + "cost": cost, "have": have, "short": short, + "have_width": 0.0 if have_label == null else _text_width(have_label, HAVE_FONT_SIZE), + "cost_width": _text_width(cost_label, COST_FONT_SIZE), + }) + + +func _make_label(text: String, font_size: int, colour: Color) -> Label: + var label := Label.new() + label.text = text + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95)) + label.add_theme_constant_override("outline_size", 2) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + return label + + +func _text_width(label: Label, font_size: int) -> float: + var font: Font = label.get_theme_font("font") + if font == null: + return float(label.text.length()) * float(font_size) * 0.6 + return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x + + +func _entry_width(entry: Dictionary) -> float: + return GLYPH_SIZE + GAP + float(entry.have_width) + float(entry.cost_width) + + +func _start_x(width: float) -> float: + match alignment: + Align.CENTER: + return (size.x - width) * 0.5 + Align.RIGHT: + return size.x - width + return 0.0 + + +func _place(entry: Dictionary, x: float, top: float, height: float) -> float: + entry.mark.position = Vector2(x, top + (height - GLYPH_SIZE) * 0.5) + entry.mark.size = Vector2(GLYPH_SIZE, GLYPH_SIZE) + x += GLYPH_SIZE + GAP + if entry.have_label != null: + entry.have_label.position = Vector2(x, top) + entry.have_label.size = Vector2(float(entry.have_width), height) + x += float(entry.have_width) + entry.cost_label.position = Vector2(x, top) + entry.cost_label.size = Vector2(float(entry.cost_width), height) + return x + float(entry.cost_width) + + +func _layout() -> void: + var total: float = _row_width() + _stacked = _entries.size() > 1 + + if not _stacked: + var x: float = _start_x(total) + for entry in _entries: + x = _place(entry, x, 0.0, size.y) + PAIR_GAP + return + + # One resource per line, the block centred in the height it was given so + # a tag with room to spare does not sit against its own top edge. + var block: float = wanted_height() + var y: float = maxf((size.y - block) * 0.5, 0.0) + for entry in _entries: + _place(entry, _start_x(_entry_width(entry)), y, GLYPH_SIZE) + y += GLYPH_SIZE + STACK_GAP diff --git a/ui/PriceTag.gd.uid b/ui/PriceTag.gd.uid new file mode 100644 index 0000000..6dae455 --- /dev/null +++ b/ui/PriceTag.gd.uid @@ -0,0 +1 @@ +uid://4qp8g8ogbigj diff --git a/ui/QuestPanel.gd b/ui/QuestPanel.gd new file mode 100644 index 0000000..e152675 --- /dev/null +++ b/ui/QuestPanel.gd @@ -0,0 +1,194 @@ +extends Control + +# Forgejo #140: "quests overlap the new wave button. create a quest button +# next to the map one." +# +# The standing tracker #132 put on the HUD sat in the right-hand column, on +# top of the wave button - and there is no free strip of that screen to move +# it to, because everything down that edge is already something. So the +# objective stops living on the HUD and lives behind a button, like the map: +# a mark next to the map's own, and a panel when you press it. +# +# Owned the way WorldMap owns its open button rather than wired through +# BuildController: the button does one thing and it is this panel's. +# +# What it shows is the objective and the road behind it - what is done, and +# what is being asked for now. NOT what comes next: the chain is meant to +# open the game up a step at a time (#133), and a list of everything still +# to come is that discovery given away on the first press. + +const GLOW: Color = Color(0.42, 0.86, 0.95) +const TEXT: Color = Color(0.82, 0.94, 1.0) +const DONE: Color = Color(0.55, 1.0, 0.68) +const MUTED: Color = Color(0.52, 0.66, 0.76) +const PAD: float = 34.0 +const TITLE_SIZE: int = 40 +const HEADING_SIZE: int = 22 +const QUEST_SIZE: int = 34 +const OBJECTIVE_SIZE: int = 26 +const DONE_SIZE: int = 22 +const BAR_HEIGHT: float = 14.0 +const ROW_HEIGHT: float = 34.0 + +@onready var open_button: IconButton = get_node("../QuestButton") +@onready var close_button: IconButton = get_node("CloseButton") + +var title_label: Label +var heading_label: Label +var quest_label: Label +var objective_label: Label +var count_label: Label +var done_heading: Label +var done_labels: Array[Label] = [] +var progress: Vector2i = Vector2i.ZERO +var bar_rect: Rect2 = Rect2() + + +func _ready() -> void: + visible = false + # Transparent to GUI input, like every other panel here - see + # IconButton._blocked_by_modal for why the buttons check instead. + mouse_filter = Control.MOUSE_FILTER_IGNORE + title_label = _make_label(TITLE_SIZE, TEXT) + heading_label = _make_label(HEADING_SIZE, GLOW) + quest_label = _make_label(QUEST_SIZE, GLOW) + objective_label = _make_label(OBJECTIVE_SIZE, TEXT) + objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + count_label = _make_label(HEADING_SIZE, TEXT) + count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + done_heading = _make_label(HEADING_SIZE, GLOW) + if open_button != null: + open_button.pressed.connect(open) + if close_button != null: + close_button.pressed.connect(close) + resized.connect(_layout) + if open_button != null and GameState.intro_active: + # #128: a dialog stops the tree and the prologue opens on one, so the + # first _process frame never comes. Applied here too. + open_button.visible = false + _layout() + + +func _make_label(font_size: int, colour: Color) -> Label: + var label := Label.new() + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.03, 0.07, 0.95)) + label.add_theme_constant_override("outline_size", 3) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + return label + + +func open() -> void: + _refresh() + visible = true + + +func close() -> void: + visible = false + + +func _layout() -> void: + if title_label == null: + return + var inner: float = maxf(size.x - PAD * 2.0, 0.0) + var y: float = PAD + title_label.position = Vector2(PAD, y) + title_label.size = Vector2(inner, float(TITLE_SIZE) * 1.4) + y += float(TITLE_SIZE) * 2.2 + heading_label.position = Vector2(PAD, y) + heading_label.size = Vector2(inner, float(HEADING_SIZE) * 1.4) + y += float(HEADING_SIZE) * 1.8 + quest_label.position = Vector2(PAD, y) + quest_label.size = Vector2(inner, float(QUEST_SIZE) * 1.3) + count_label.position = quest_label.position + count_label.size = quest_label.size + y += float(QUEST_SIZE) * 1.5 + objective_label.position = Vector2(PAD, y) + objective_label.size = Vector2(inner, float(OBJECTIVE_SIZE) * 2.6) + y += float(OBJECTIVE_SIZE) * 2.9 + bar_rect = Rect2(Vector2(PAD, y), Vector2(inner, BAR_HEIGHT)) + y += BAR_HEIGHT * 3.5 + done_heading.position = Vector2(PAD, y) + done_heading.size = Vector2(inner, float(HEADING_SIZE) * 1.4) + y += float(HEADING_SIZE) * 1.9 + for label in done_labels: + label.position = Vector2(PAD + 12.0, y) + label.size = Vector2(inner - 12.0, ROW_HEIGHT) + y += ROW_HEIGHT + queue_redraw() + + +func _refresh() -> void: + var quest: Dictionary = GameState.active_quest() + title_label.text = "Objectives" + if quest.is_empty(): + heading_label.text = "NOTHING OUTSTANDING" + quest_label.text = "The list has run out." + objective_label.text = "The search does not stop because there is nothing left to tick off." + count_label.text = "" + progress = Vector2i(1, 1) + else: + heading_label.text = "CURRENT OBJECTIVE %d OF %d" \ + % [GameState.quest_index + 1, GameState.quest_count()] + quest_label.text = String(quest.title) + objective_label.text = String(quest.objective) + progress = GameState.quest_progress(quest) + # A one-of-one goal is a yes/no, and "0 / 1" reads as a broken + # counter rather than as a task not started. + count_label.text = "%d / %d" % [progress.x, progress.y] if progress.y > 1 else "" + + # The road behind. Deliberately no list of what is still to come. + var completed: int = GameState.quest_index + done_heading.text = "DONE (%d)" % completed + while done_labels.size() < completed: + var label: Label = _make_label(DONE_SIZE, DONE) + done_labels.append(label) + for i in range(done_labels.size()): + var shown: bool = i < completed + done_labels[i].visible = shown + if shown: + done_labels[i].text = " ✓ %s" % String(GameState.quest_at(i).title) + _layout() + + +func _process(_delta: float) -> void: + # #122: the prologue leaves the player movement and nothing else, and + # this is a button like any other. Owned here rather than by + # BuildController, the same way the map owns its own. + if open_button != null: + var offered: bool = not GameState.intro_active + if open_button.visible != offered: + open_button.visible = offered + if not offered and visible: + close() + if visible: + # Live, so ore arriving while the panel is open works the bar up + # instead of looking stuck. + _refresh() + + +func _draw() -> void: + if not visible: + return + draw_rect(Rect2(Vector2.ZERO, size), Color(0.01, 0.04, 0.08, 0.93)) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + Vector2(0.0, 0.0), Vector2(size.x, 0.0), Vector2(size.x, size.y), + Vector2(0.0, size.y), Vector2(0.0, 0.0), + ]), Color(GLOW, 0.8), 0.8, 0.8) + # A rule under the title, so the header reads as a header. + var rule_y: float = PAD + float(TITLE_SIZE) * 1.6 + NeonDraw.draw_glowing_line(self, Vector2(PAD, rule_y), Vector2(size.x - PAD, rule_y), + Color(GLOW, 0.5), 0.6, 0.4) + + if bar_rect.size.x <= 0.0: + return + draw_rect(bar_rect, Color(GLOW, 0.16)) + var filled: float = 0.0 if progress.y <= 0 \ + else clampf(float(progress.x) / float(progress.y), 0.0, 1.0) + if filled <= 0.0: + return + draw_rect(Rect2(bar_rect.position, Vector2(bar_rect.size.x * filled, bar_rect.size.y)), + Color(DONE if filled >= 1.0 else GLOW, 0.85)) diff --git a/ui/QuestPanel.gd.uid b/ui/QuestPanel.gd.uid new file mode 100644 index 0000000..607aab4 --- /dev/null +++ b/ui/QuestPanel.gd.uid @@ -0,0 +1 @@ +uid://buu5pg1783c6k diff --git a/ui/ResourceBar.gd b/ui/ResourceBar.gd index f47f1e1..019e57b 100644 --- a/ui/ResourceBar.gd +++ b/ui/ResourceBar.gd @@ -25,6 +25,11 @@ const LOCAL_COLOR: Color = Color(0.45, 0.9, 0.55) var _header_labels: Array[Label] = [] var _rows: Dictionary = {} +# The value columns, in the order they are drawn. LOCAL is dropped from this +# list where it would only repeat another column - see _local_is_redundant(). +const VALUE_KEYS: Array = ["bank", "fleet", "local"] +var _local_shown: bool = true + func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -74,15 +79,45 @@ func _make_value_label(colour: Color) -> Label: return label +# Whether a LOCAL column would say anything a reader could act on. Two ways +# it would not: +# +# #95: at a building that IS the bank - the mothership carries both the "bank" +# and the "research" capability, so the research menu is always opened from one +# - building_inventory() redirects to bank_inventory itself, and LOCAL is not +# merely equal to BANKED but the very same store read twice. Printing the +# number again teaches nothing and costs a third of the table. +# +# #137: and at no building at all, which is how the build menu opens it - it +# is reached from the fleet's own button rather than from a structure. There +# is no "here" to report on, and a column of em-dashes is worth less than the +# width it takes from the two columns that do say something. +func _local_is_redundant() -> bool: + if building_id == 0: + return true + return GameState.building_inventory(building_id) == GameState.bank_inventory + + +func _visible_value_keys() -> Array: + return VALUE_KEYS if _local_shown else VALUE_KEYS.slice(0, 2) + + func _layout() -> void: if _header_labels.is_empty(): return + var keys: Array = _visible_value_keys() var resource_width: float = size.x * 0.25 - var value_width: float = (size.x - resource_width) / 3.0 - var columns: Array = [0.0, resource_width, resource_width + value_width, - resource_width + value_width * 2.0] - var widths: Array = [resource_width, value_width, value_width, value_width] + var value_width: float = (size.x - resource_width) / float(keys.size()) + var columns: Array = [0.0] + var widths: Array = [resource_width] + for i in range(keys.size()): + columns.append(resource_width + value_width * float(i)) + widths.append(value_width) for i in range(_header_labels.size()): + var shown: bool = i < columns.size() + _header_labels[i].visible = shown + if not shown: + continue _header_labels[i].position = Vector2(columns[i], 0.0) _header_labels[i].size = Vector2(widths[i], HEADER_HEIGHT) @@ -100,8 +135,11 @@ func _layout() -> void: row.glyph.size = Vector2(content_height, content_height) row.name.position = Vector2(content_height + 10.0, y) row.name.size = Vector2(resource_width - content_height - 10.0, row_height) - for i in range(3): - var label: Label = row[["bank", "fleet", "local"][i]] + for i in range(VALUE_KEYS.size()): + var label: Label = row[VALUE_KEYS[i]] + label.visible = i < keys.size() + if not label.visible: + continue label.position = Vector2(columns[i + 1], y) label.size = Vector2(widths[i + 1], row_height) row_index += 1 @@ -109,6 +147,12 @@ func _layout() -> void: func _process(_delta: float) -> void: + # building_id is assigned by whichever panel opens, so the column count can + # change between frames; re-lay out only when it actually flips. + var local_shown: bool = not _local_is_redundant() + if local_shown != _local_shown: + _local_shown = local_shown + _layout() for resource_type in GameState.resource_types(): var row: Dictionary = _rows.get(resource_type, {}) if row.is_empty(): diff --git a/ui/SkillTreeView.gd b/ui/SkillTreeView.gd index b1992d6..49951c6 100644 --- a/ui/SkillTreeView.gd +++ b/ui/SkillTreeView.gd @@ -13,6 +13,14 @@ extends Control # before the menu can act on them, which is the bug that made the formation # editor's drag silently do nothing. Everything in this menu must stay # mouse_filter = IGNORE. +# +# #93: a mouse is a first-class way to drive this menu, not something +# emulation turns into touches. The project used to set +# emulate_touch_from_mouse, which made every click arrive here as an +# InputEventScreenTouch for free; with that off, a menu that only reads +# touch events is simply dead under a mouse - which is what the report was. +# So the press and the pan are the behaviour, and touch and mouse are two +# ways in to them. signal node_picked(id: String) @@ -29,6 +37,9 @@ const LOCKED_PULSE: float = 0.5 @export var node_radius: float = 46.0 @export var row_spacing: float = 250.0 +# One wheel notch: about a third of the default row spacing, so a notch is a +# clear step without skipping a whole depth of the tree. +const WHEEL_STEP: float = 90.0 # #83: a second tap on the node already selected buys it. The separate buy # button is gone - it sat at the bottom of the panel, a long way from the node @@ -58,8 +69,48 @@ func _max_scroll() -> float: return maxf(0.0, _content_height() - size.y) +# #112: one glyph per node, as a child IconButton with its frame turned off. +# Drawn as nodes rather than into _draw() because the glyphs already exist +# there and are non-trivial - reimplementing a dozen of them here would be +# two places to change every time one is redrawn. They are inert: +# MOUSE_FILTER_IGNORE means the tree's own raw-input handling still sees +# every press, exactly as it did when there was nothing on top of the nodes. +var _glyphs: Dictionary = {} + + +func _build_glyphs() -> void: + for node in GameState.SKILL_TREE: + var icon: int = GameState.skill_node_icon(node) + if icon < 0: + continue + var glyph := IconButton.new() + glyph.icon = icon + glyph.show_frame = false + glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(glyph) + _glyphs[String(node.id)] = glyph + + +func _layout_glyphs() -> void: + var span: float = node_radius * 1.5 + for node in GameState.SKILL_TREE: + var glyph: Control = _glyphs.get(String(node.id)) + if glyph == null: + continue + var centre: Vector2 = node_screen_pos(node) + glyph.position = centre - Vector2(span, span) * 0.5 + glyph.size = Vector2(span, span) + # Off the top or bottom of the panel entirely: hidden rather than + # clipped, so a glyph never bleeds past the canvas the tree scrolls + # inside. + glyph.visible = centre.y > -span and centre.y < size.y + span + glyph.glow_color = _state_color(node) + glyph.queue_redraw() + + func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE + _build_glyphs() call_deferred("reset_scroll") @@ -93,13 +144,43 @@ func _state_color(node: Dictionary) -> Color: # Returns true when the press landed on a node, so the caller knows the # touch was consumed. func handle_touch(event: InputEventScreenTouch) -> bool: + return handle_press(event.pressed, event.position) + + +func handle_drag(event: InputEventScreenDrag) -> bool: + return handle_pan(event.relative) + + +# A left click is the same gesture as a tap. Other buttons are left alone so +# they stay available for whatever else wants them. +func handle_mouse_button(event: InputEventMouseButton) -> bool: + if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + return handle_pan(Vector2(0.0, WHEEL_STEP)) + if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + return handle_pan(Vector2(0.0, -WHEEL_STEP)) + if event.button_index != MOUSE_BUTTON_LEFT: + return false + return handle_press(event.pressed, event.position) + + +# Only while the button is held. A touch drag cannot arrive without a finger +# on the glass, but the mouse moves across the menu the whole time it is +# open, and panning the tree under a hovering pointer would make the menu +# impossible to read. +func handle_mouse_motion(event: InputEventMouseMotion) -> bool: + if not _dragging: + return false + return handle_pan(event.relative) + + +func handle_press(pressed: bool, at: Vector2) -> bool: if not visible: return false - if not event.pressed: + if not pressed: _dragging = false return true _dragging = true - var local: Vector2 = event.position - global_position + var local: Vector2 = at - global_position for node in GameState.SKILL_TREE: if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25: var confirming: bool = selected_id == node.id @@ -112,17 +193,18 @@ func handle_touch(event: InputEventScreenTouch) -> bool: return false -func handle_drag(event: InputEventScreenDrag) -> bool: +func handle_pan(relative: Vector2) -> bool: if not visible: return false # Dragging a finger upward reveals higher research; dragging downward # returns to the root. Clamp hard so the canvas never leaves an empty gap. - scroll_y = clampf(scroll_y - event.relative.y, 0.0, _max_scroll()) + scroll_y = clampf(scroll_y - relative.y, 0.0, _max_scroll()) queue_redraw() return true func _draw() -> void: + _layout_glyphs() # Links first, so nodes sit on top of them. for node in GameState.SKILL_TREE: if node.requires == "": diff --git a/ui/SpeedFader.gd b/ui/SpeedFader.gd index 69d9a0f..bebda79 100644 --- a/ui/SpeedFader.gd +++ b/ui/SpeedFader.gd @@ -77,17 +77,45 @@ func _blocked_by_modal() -> bool: return false +# #118: the same fader, driven by a mouse. +# +# The project used to set emulate_touch_from_mouse, which handed every raw +# touch handler a synthetic touch per click; with that gone this control was +# inert on a desktop and there was no other way to reach the boost. Written +# as thin wrappers over the touch path rather than as a second +# implementation, so the two cannot drift - the same mistake #93 fixed in +# the research tree. +const MOUSE_TOUCH_INDEX: int = -2 + + +func handle_mouse_button(event: InputEventMouseButton) -> bool: + if event.button_index != MOUSE_BUTTON_LEFT: + return false + return _press(event.pressed, event.position, MOUSE_TOUCH_INDEX) + + +func handle_mouse_motion(event: InputEventMouseMotion) -> bool: + if active_touch_index != MOUSE_TOUCH_INDEX: + return false + _update_from_local(event.position - global_position) + return true + + func handle_touch(event: InputEventScreenTouch) -> bool: - var local: Vector2 = event.position - global_position - if event.pressed: + return _press(event.pressed, event.position, event.index) + + +func _press(pressed: bool, at: Vector2, index: int) -> bool: + var local: Vector2 = at - global_position + if pressed: if not visible or active_touch_index != -1 or _blocked_by_modal(): return false if not Rect2(Vector2.ZERO, size).has_point(local): return false - active_touch_index = event.index + active_touch_index = index _update_from_local(local) return true - if event.index != active_touch_index: + if index != active_touch_index: return false active_touch_index = -1 queue_redraw() diff --git a/ui/WaveAlert.gd b/ui/WaveAlert.gd new file mode 100644 index 0000000..69b779c --- /dev/null +++ b/ui/WaveAlert.gd @@ -0,0 +1,68 @@ +extends Control + +# #100: "an small alert on top right appear when a new wave is coming. No +# timer should be visible." +# +# The second half of that is a constraint on this file, not just a note. +# GameState deliberately offers no remaining-seconds accessor - only +# wave_warning_pulse(), which is a 0..1 throb - so there is no number here +# that could be rendered as a countdown even by accident. The urgency is +# carried by the pulse rate, which says "soon" without saying "in 4". + +const GLOW: Color = Color(1.0, 0.45, 0.2) +const TEXT: String = "WAVE INCOMING" + +var label: Label + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + label = Label.new() + label.text = TEXT + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 22) + label.add_theme_color_override("font_color", GLOW) + label.add_theme_color_override("font_outline_color", Color(0.05, 0.01, 0.0, 0.95)) + label.add_theme_constant_override("outline_size", 3) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + resized.connect(_layout) + _layout() + + +func _layout() -> void: + if label == null: + return + # Room on the left for the chevron _draw() puts there. + label.position = Vector2(size.y, 0.0) + label.size = Vector2(maxf(size.x - size.y, 0.0), size.y) + + +func _process(_delta: float) -> void: + var incoming: bool = GameState.wave_is_incoming() + if incoming != visible: + visible = incoming + if incoming: + queue_redraw() + + +func _draw() -> void: + if not visible: + return + var pulse: float = GameState.wave_warning_pulse() + var alpha: float = 0.35 + 0.65 * pulse + draw_rect(Rect2(Vector2.ZERO, size), Color(0.16, 0.04, 0.01, 0.62)) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + Vector2(0.0, 0.0), Vector2(size.x, 0.0), Vector2(size.x, size.y), + Vector2(0.0, size.y), Vector2(0.0, 0.0), + ]), Color(GLOW, alpha), 0.9, 0.7) + # A warning triangle, not a clock face. + var centre: Vector2 = Vector2(size.y * 0.5, size.y * 0.5) + var radius: float = size.y * 0.28 + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + centre + Vector2(0.0, -radius), + centre + Vector2(radius * 0.92, radius * 0.72), + centre + Vector2(-radius * 0.92, radius * 0.72), + ]), Color(GLOW, alpha), 1.0) diff --git a/ui/WaveAlert.gd.uid b/ui/WaveAlert.gd.uid new file mode 100644 index 0000000..04b2319 --- /dev/null +++ b/ui/WaveAlert.gd.uid @@ -0,0 +1 @@ +uid://bvd55xvfgelhs diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index 0ba61f4..dd45d00 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -96,6 +96,13 @@ func vessel_types() -> PackedStringArray: func _sync_composition_state() -> void: + # #106: the Planning Center counts vessels across the whole game, and + # the fleet is the one place it cannot see - the ships are scene nodes + # and GameState outlives the scene. Pushed here rather than polled, + # alongside the cargo capacity and distorder count that already are, + # and for the same reason: composition changes are authoritative, and + # this is the one function every one of them goes through. + GameState.fleet_vessel_types = vessel_types() GameState.sync_fleet_cargo_capacity(vessel_types()) # Composition changes are authoritative; scene teardown is not. Keeping # this out of SpeedFader prevents an outgoing scene's empty fleet from diff --git a/vessels/GarrisonField.gd b/vessels/GarrisonField.gd new file mode 100644 index 0000000..a29e530 --- /dev/null +++ b/vessels/GarrisonField.gd @@ -0,0 +1,96 @@ +extends Node2D + +# #105 (#97g): keeps the vessels stationed at outposts in step with what +# GameState says is stationed there. +# +# Garrison vessels are NOT in Fleet.ships. They are spawned here, under a +# node of their own, so the formation, the fleet's speed fader and +# max_fleet_size() all continue to be about the fleet - a garrison that +# counted against the fleet's slots would mean stationing a vessel cost the +# player a place in their own wedge, which is the opposite of what an outpost +# is for. +# +# The record is the truth, as everywhere else: this node is rebuilt from +# GameState.garrison_crew() when the space scene loads, so a garrison +# survives a trip to a planet surface. + +var ship_scene: PackedScene = preload("res://vessels/Ship.tscn") +const GARRISON_COMPONENT = preload("res://vessels/VesselGarrisonComponent.gd") + +# {outpost_id: [ship, ...]} +var stationed: Dictionary = {} + + +func _ready() -> void: + add_to_group("garrison_field") + sync() + + +func _process(_delta: float) -> void: + sync() + + +func ships_at(outpost_id: int) -> Array: + var out: Array = [] + for ship in stationed.get(outpost_id, []): + if is_instance_valid(ship) and not ship.is_queued_for_deletion(): + out.append(ship) + return out + + +func all_ships() -> Array: + var out: Array = [] + for outpost_id in stationed: + out.append_array(ships_at(outpost_id)) + return out + + +func sync() -> void: + # Retire whole outposts that no longer exist, then reconcile the rest. + for outpost_id in stationed.keys(): + if GameState.find_building(outpost_id).is_empty(): + for ship in ships_at(outpost_id): + ship.queue_free() + stationed.erase(outpost_id) + + for outpost_id in GameState.outpost_ids(): + var record: Dictionary = GameState.find_building(outpost_id) + var crew: Array = GameState.garrison_crew(outpost_id) + var here: Array = ships_at(outpost_id) + stationed[outpost_id] = here + if here.size() == crew.size(): + continue + if here.size() > crew.size(): + # The record shrank under us - a vessel was released or lost + # while this node was not looking. + for i in range(crew.size(), here.size()): + here[i].queue_free() + stationed[outpost_id] = here.slice(0, crew.size()) + continue + for i in range(here.size(), crew.size()): + here.append(_spawn(outpost_id, String(crew[i]), record.position, i)) + + +func _spawn(outpost_id: int, vessel_type: String, at: Vector2, index: int) -> Node2D: + var ship: Area2D = ship_scene.instantiate() + # Before add_child, so Ship._ready() builds the right vessel - the same + # order Fleet.add_ship() relies on. + ship.vessel_type = vessel_type + var spread: float = TAU * float(index) / 4.0 + ship.position = at + Vector2(cos(spread), sin(spread)) * 170.0 + add_child(ship) + var component: Node = GARRISON_COMPONENT.new() + component.outpost_id = outpost_id + component.station = at + ship.add_child(component) + component.setup() + # A garrison vessel that dies frees its berth, so #104 sends another. + ship.destroyed.connect(_on_garrison_lost.bind(outpost_id, vessel_type)) + return ship + + +func _on_garrison_lost(outpost_id: int, vessel_type: String) -> void: + var crew: Array = GameState.garrison_crew(outpost_id) + var index: int = crew.find(vessel_type) + if index >= 0: + crew.remove_at(index) diff --git a/vessels/GarrisonField.gd.uid b/vessels/GarrisonField.gd.uid new file mode 100644 index 0000000..056124c --- /dev/null +++ b/vessels/GarrisonField.gd.uid @@ -0,0 +1 @@ +uid://twdm4fm0wovs diff --git a/vessels/GasBomberVesselDefinition.tres b/vessels/GasBomberVesselDefinition.tres index 7ec8383..a43f850 100644 --- a/vessels/GasBomberVesselDefinition.tres +++ b/vessels/GasBomberVesselDefinition.tres @@ -25,3 +25,5 @@ unlock_cost = 0 unlock_resource_costs = {"gas": 30} research_point_cost = 1 unlock_icon = 31 +garrison_ammo_resource = "gas" +garrison_ammo_capacity = 50 diff --git a/vessels/InterceptorVesselDefinition.tres b/vessels/InterceptorVesselDefinition.tres index b99b0cb..c3e8c05 100644 --- a/vessels/InterceptorVesselDefinition.tres +++ b/vessels/InterceptorVesselDefinition.tres @@ -17,5 +17,5 @@ glow_color = Color(0.25, 0.85, 1, 1) fleet_eligible = true capabilities = PackedStringArray("weapon", "cargo") component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")]) -base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 950.0, "max_speed": 690.0, "acceleration": 2025.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 1200.0, "max_speed": 690.0, "acceleration": 2025.0, "delivery_speed": 180.0, "cargo_capacity": 20.0} upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")]) diff --git a/vessels/LaserVesselDefinition.tres b/vessels/LaserVesselDefinition.tres index db53c39..76a840d 100644 --- a/vessels/LaserVesselDefinition.tres +++ b/vessels/LaserVesselDefinition.tres @@ -28,3 +28,5 @@ unlock_cost = 0 unlock_resource_costs = {"energy": 40} research_point_cost = 1 unlock_icon = -1 +garrison_ammo_resource = "energy" +garrison_ammo_capacity = 50 diff --git a/vessels/Projectile.gd b/vessels/Projectile.gd index aa966a9..26281ae 100644 --- a/vessels/Projectile.gd +++ b/vessels/Projectile.gd @@ -2,7 +2,11 @@ extends Area2D # #86: faster. The bolt used to crawl far enough behind a moving target that # leading a shot was guesswork rather than skill. -@export var speed: float = 850.0 +# #155: was 850, which took 1.1s to cross the gun's own range - long enough +# that a shot read as leaving the ship rather than as hitting something. The +# range below is derived from this (see set_range), so raising it makes shots +# faster without making them fall short. +@export var speed: float = 1400.0 # How long a bolt lives if nothing sets a range. VesselWeaponComponent always # does - see set_range() - so this only covers a projectile fired by something # that has no range of its own. diff --git a/vessels/RepairDroneVesselDefinition.tres b/vessels/RepairDroneVesselDefinition.tres new file mode 100644 index 0000000..30b0212 --- /dev/null +++ b/vessels/RepairDroneVesselDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=4 format=3] +[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"] +[ext_resource type="Resource" path="res://vessels/RepairYieldUpgrade.tres" id="2"] +[ext_resource type="Resource" path="res://vessels/RepairSpeedUpgrade.tres" id="3"] +[resource] +script = ExtResource("1") +definition_id = "repair_drone" +display_name = "Repair Drone" +description = "Workshop-owned tender. Flies out to damaged structures and puts them back together, spending ore delivered to its workshop. Cannot be crafted - a workshop brings its own." +build_cost = 0 +build_time = 0.0 +icon = 43 +glow_color = Color(0.5, 0.95, 0.8, 1) +hull_points = PackedVector2Array(0, -14, 9, -4, 7, 10, -7, 10, -9, -4) +base_stats = {"max_speed": 620.0, "acceleration": 1800.0, "transport_speed": 190.0, "repair_amount": 25.0, "delivery_speed": 180.0} +upgrades = Array[Resource]([ExtResource("2"), ExtResource("3")]) diff --git a/vessels/RepairSpeedUpgrade.tres b/vessels/RepairSpeedUpgrade.tres new file mode 100644 index 0000000..ab28a76 --- /dev/null +++ b/vessels/RepairSpeedUpgrade.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "rep_speed" +display_name = "Yard Thrusters" +description = "+80 speed on repair runs." +base_cost = 35 +max_level = 3 +research_point_cost = 1 +icon = 22 +additive_modifiers = {"transport_speed": 80.0} diff --git a/vessels/RepairYieldUpgrade.tres b/vessels/RepairYieldUpgrade.tres new file mode 100644 index 0000000..05bea8b --- /dev/null +++ b/vessels/RepairYieldUpgrade.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "rep_yield" +display_name = "Heavy Plating" +description = "+12 structure put back on every repair run." +base_cost = 40 +max_level = 3 +research_point_cost = 1 +icon = 17 +additive_modifiers = {"repair_amount": 12.0} diff --git a/vessels/Ship.gd b/vessels/Ship.gd index 6a15ac8..b2fb789 100644 --- a/vessels/Ship.gd +++ b/vessels/Ship.gd @@ -170,12 +170,35 @@ func take_damage(_amount: int) -> bool: for child in get_children(): if child.has_method("absorb_hit") and child.absorb_hit(): return true - ShipExplosionVFX.spawn(get_tree().root, position) + # #130: the hull comes off the formation and explodes further out, rather + # than going up on the spot. The wreck is its own node - this one is + # still freed on this frame, so nothing about who is in the fleet, who + # can be shot, or who is a target changes. + ShipWreckVFX.spawn(get_tree().root, self, _breakaway_direction()) + # #146: the bang goes with the wreck being thrown clear, not with the + # explosion at the end of its drift - the hit is what the player needs + # to hear, and hearing it late reads as a different event. + Sfx.play(get_tree(), Sfx.SHIP_DEATH) destroyed.emit() queue_free() return false +# Which way is "out of the fleet". Away from the formation's own centre, so a +# wreck leaves the wedge instead of tumbling back through it; a ship flying +# alone - or one not under a Fleet at all - simply carries on the way it was +# already pointed. +func _breakaway_direction() -> Vector2: + var parent: Node = get_parent() + if parent != null and parent.has_method("get_centroid"): + var out: Vector2 = position - (parent.get_centroid() as Vector2) + if out.length() > 1.0: + return out.normalized() + if velocity.length() > 1.0: + return velocity.normalized() + return Vector2.UP.rotated(rotation) + + func _on_area_entered(area: Area2D) -> void: if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): return diff --git a/vessels/VesselCooldownFillComponent.gd b/vessels/VesselCooldownFillComponent.gd index 8c009c1..c72294b 100644 --- a/vessels/VesselCooldownFillComponent.gd +++ b/vessels/VesselCooldownFillComponent.gd @@ -7,6 +7,14 @@ extends Node2D var cooldown_duration: float = 1.0 var cooldown_left: float = 0.0 +# Two vertices this close together are the same vertex as far as the fill is +# concerned - a hull is tens of units across, so this is well below anything +# that could be a real edge. +const WELD_DISTANCE: float = 0.01 +# Below this the fill is a sliver nobody can see, and drawing it is all cost +# and no picture. +const MIN_FILL_AREA: float = 0.5 + func setup() -> void: show_behind_parent = true @@ -33,34 +41,109 @@ func tick(delta: float) -> void: queue_redraw() +# --- the ability seam ------------------------------------------------------ +# +# Everything a caller needs to fire this vessel's ability WITHOUT knowing +# which ability it is. Both the player's controllers and the outpost garrison +# AI go through these, so a beam and a bomb are the same shape of thing to +# whoever pulls the trigger, and adding a third ability means implementing +# these rather than editing the AI. +# +# `ability_ammo_*` describe the magazine, not where it comes from: the fleet +# spends from its hold, a garrison from its outpost's store, and neither +# concern belongs down here. + +func has_ability() -> bool: + return ability_range() > 0.0 + + +func ability_range() -> float: return 0.0 +func ability_ammo_resource() -> String: return "" +func ability_ammo_cost() -> int: return 0 +func ability_cooldown() -> float: return cooldown_duration + + +# Spends the cooldown and puts the effect in the world. Returns false when the +# ability was not ready - ammunition is the CALLER's business, because only +# the caller knows which store it is spending from. +func fire_ability(_world: Node, _origin: Vector2, _at: Vector2) -> bool: + return false + + func cooldown_progress() -> float: return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001) -func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array: +# The part of the hull that is "filled up" at this progress, as a list of +# polygons - plural, because a hull with a notch in it (the gas bomber's +# tail, the distorder's waist) genuinely fills as two separate puddles +# before the level rises past the notch. +# +# Geometry2D does the clipping. This used to walk the hull itself and cut +# each edge against the fill line, which is Sutherland-Hodgman, and +# Sutherland-Hodgman is only sound for a CONVEX subject: on a notched hull +# it stitches the pieces back together with a zero-width bridge, and a +# polygon with a zero-width bridge is not one Godot's triangulator will +# accept. It said so, once per frame per cooling vessel, for as long as any +# cooldown ran. +func _filled_hull(points: PackedVector2Array, progress: float) -> Array: if points.size() < 3 or progress <= 0.001: - return PackedVector2Array() + return [] if progress >= 0.999: - return points.duplicate() + return [points.duplicate()] var top: float = INF var bottom: float = -INF + var left: float = INF + var right: float = -INF for point in points: top = minf(top, point.y) bottom = maxf(bottom, point.y) + left = minf(left, point.x) + right = maxf(right, point.x) var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0)) - var clipped := PackedVector2Array() - var previous: Vector2 = points[-1] - var previous_inside: bool = previous.y >= fill_y - for current in points: - var current_inside: bool = current.y >= fill_y - if current_inside != previous_inside: - var amount: float = (fill_y - previous.y) / (current.y - previous.y) - clipped.append(previous.lerp(current, amount)) - if current_inside: - clipped.append(current) - previous = current - previous_inside = current_inside - return clipped + # Deliberately oversized sideways and downwards, so the box's own edges + # never coincide with the hull's and the only cut is the horizontal one. + var box := PackedVector2Array([ + Vector2(left - 8.0, fill_y), Vector2(right + 8.0, fill_y), + Vector2(right + 8.0, bottom + 8.0), Vector2(left - 8.0, bottom + 8.0), + ]) + var pieces: Array = [] + for piece in Geometry2D.intersect_polygons(points, box): + var drawable: PackedVector2Array = _drawable(piece) + if not drawable.is_empty(): + pieces.append(drawable) + return pieces + + +# Last guard before the renderer. The clipper is sound where the old walk +# was not, but it can still hand back a vertex repeated at a corner, or a +# sliver of a puddle at the very start of a fill - neither is worth drawing +# and both are the shapes triangulation objects to. +func _drawable(points: PackedVector2Array) -> PackedVector2Array: + var welded := PackedVector2Array() + for point in points: + if welded.is_empty() \ + or welded[welded.size() - 1].distance_to(point) > WELD_DISTANCE: + welded.append(point) + # The polygon is implicitly closed, so the last point meeting the first is + # the same duplication seen from the other end. + while welded.size() >= 2 \ + and welded[0].distance_to(welded[welded.size() - 1]) <= WELD_DISTANCE: + welded.remove_at(welded.size() - 1) + if welded.size() < 3 or _area(welded) < MIN_FILL_AREA: + return PackedVector2Array() + return welded + + +# Shoelace, unsigned: the clip preserves the hull's winding, but which way a +# hull winds is the definition's business and not worth depending on here. +func _area(points: PackedVector2Array) -> float: + var total: float = 0.0 + var previous: Vector2 = points[points.size() - 1] + for point in points: + total += previous.x * point.y - point.x * previous.y + previous = point + return absf(total) * 0.5 func _draw() -> void: @@ -75,9 +158,8 @@ func _draw() -> void: ship.core_color, 0.75) return draw_colored_polygon(hull, Color(_cooling_color(), 0.58)) - var filled: PackedVector2Array = _filled_hull(hull, cooldown_progress()) - if filled.size() >= 3: - draw_colored_polygon(filled, Color(ready_color, 0.68)) + for piece in _filled_hull(hull, cooldown_progress()): + draw_colored_polygon(piece, Color(ready_color, 0.68)) func _ready_color() -> Color: return Color.WHITE diff --git a/vessels/VesselDefinition.gd b/vessels/VesselDefinition.gd index 3abb992..ba2a988 100644 --- a/vessels/VesselDefinition.gd +++ b/vessels/VesselDefinition.gd @@ -24,6 +24,13 @@ extends Resource Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16), ]) @export var fleet_eligible: bool = false +# What one of these keeps beside it when stationed at an outpost, and which +# resource that is. Data here rather than a table in the outpost, so a new +# armed vessel brings its own magazine with it and the outpost never has to +# learn about it. 0 means it needs no ammunition to fight - the interceptor's +# gun is the case that does. +@export var garrison_ammo_resource: String = "" +@export var garrison_ammo_capacity: int = 0 @export var capabilities: PackedStringArray = [] @export var component_scenes: Array[PackedScene] = [] @export var base_stats: Dictionary = {} diff --git a/vessels/VesselDefinitions.gd b/vessels/VesselDefinitions.gd index dbc00a8..58370ed 100644 --- a/vessels/VesselDefinitions.gd +++ b/vessels/VesselDefinitions.gd @@ -1,7 +1,7 @@ class_name VesselDefinitions extends RefCounted -const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser", "distorder"] +const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser", "distorder", "repair_drone"] const DEFINITIONS: Dictionary = { "interceptor": preload("res://vessels/InterceptorVesselDefinition.tres"), "shield": preload("res://vessels/ShieldVesselDefinition.tres"), @@ -10,6 +10,7 @@ const DEFINITIONS: Dictionary = { "carrier": preload("res://vessels/CarrierVesselDefinition.tres"), "laser": preload("res://vessels/LaserVesselDefinition.tres"), "distorder": preload("res://vessels/DistorderVesselDefinition.tres"), + "repair_drone": preload("res://vessels/RepairDroneVesselDefinition.tres"), } static func get_definition(definition_id: String) -> Resource: diff --git a/vessels/VesselGarrisonComponent.gd b/vessels/VesselGarrisonComponent.gd new file mode 100644 index 0000000..c447b9f --- /dev/null +++ b/vessels/VesselGarrisonComponent.gd @@ -0,0 +1,195 @@ +class_name VesselGarrisonComponent +extends Node + +# #105 (#97g): "When a vessel is assigned to an outpost, it navigates around +# the outpost by itself, attacking nearby ennemies if any are detected." +# +# Attached to a Ship at spawn by GarrisonField rather than declared on the +# vessel definition: garrisoning is a ROLE a hull is put into, not a property +# of the hull. The same interceptor is a fleet vessel one minute and a +# garrison the next, and nothing about it changes but who is steering. +# +# It steers by writing move_target and lets Ship's own steering do the +# flying, so a garrison vessel accelerates, banks and brakes exactly like one +# in the formation. It is not in Fleet.ships, so it does not follow the +# formation, does not count against max_fleet_size(), and does not move when +# the player flies away - which is the whole point of stationing it. + +var capability_id: String = "garrison" + +# Which outpost this belongs to, and where that outpost is. The position is +# copied rather than looked up every tick: the record is the truth, and if +# the outpost is levelled the vessel should hold the ground it was left on +# rather than snap to the origin. +var outpost_id: int = 0 +var station: Vector2 = Vector2.ZERO + +# #109: it used to walk a circle, one small step per frame. That looked +# wrong for two reasons and the second was the real one. A ring is not what +# a picket does - but worse, each step was shorter than Ship's arrive_radius, +# so every frame the vessel SNAPPED onto its target and set velocity to zero, +# and Ship only turns to face a heading while it is actually moving. The +# result slid around the outpost pointing whichever way it had spawned. +# +# So: real destinations, far enough apart to be flown to. The vessel picks a +# point somewhere in its patrol area, goes there, pauses, and picks another. +@export var wander_radius: float = 340.0 +# A new leg is never shorter than this, or the snap-and-stop problem comes +# straight back. +@export var wander_min_leg: float = 150.0 +@export var wander_arrive: float = 60.0 +@export var wander_pause: float = 0.8 +# A leg that somehow cannot be reached must not strand the picket. +@export var wander_leg_timeout: float = 12.0 +@export var detection_range: float = 950.0 +# How far from its post it will go. Without a leash a garrison would be +# drawn off by anything that flew past, which is exactly what stationing it +# was meant to stop - and the outpost would be left open behind it. +@export var leash_range: float = 620.0 +# Held off the target rather than ramming it: these are gunships, and a +# vessel that closed to zero would trade itself for a chaser every time. +@export var engage_distance: float = 300.0 + +var _engaging: bool = false +var _wander_to: Vector2 = Vector2.ZERO +var _leg_time: float = 0.0 +var _pause_left: float = 0.0 + + +func setup() -> void: + _pick_leg() + + +func is_engaging() -> bool: + return _engaging + + +func tick(delta: float) -> void: + var ship: Node2D = get_parent() + var target: Node2D = _nearest_enemy(ship) + _engaging = target != null + if target == null: + _patrol(ship, delta) + _hold_fire(ship) + return + _engage(ship, target, delta) + + +func _patrol(ship: Node2D, delta: float) -> void: + if _pause_left > 0.0: + # Sitting still between legs, which is most of what makes it read as + # a picket holding a position rather than a thing on a rail. + _pause_left -= delta + return + _leg_time += delta + if ship.global_position.distance_to(_wander_to) <= wander_arrive \ + or _leg_time >= wander_leg_timeout: + _pause_left = randf_range(wander_pause * 0.5, wander_pause * 1.5) + _pick_leg() + return + ship.move_target = _wander_to + + +func _pick_leg() -> void: + var ship: Node2D = get_parent() + var from: Vector2 = ship.global_position if ship != null else station + # Rejection-sampled rather than solved: a handful of tries always finds a + # point that is both inside the patrol area and far enough to be worth + # flying to, and the alternative is arithmetic nobody will read. + for attempt in range(8): + var angle: float = randf() * TAU + var radius: float = sqrt(randf()) * wander_radius + var candidate: Vector2 = station + Vector2(cos(angle), sin(angle)) * radius + if from.distance_to(candidate) >= wander_min_leg: + _wander_to = candidate + _leg_time = 0.0 + return + # Nothing far enough came up - head straight out to the edge instead. + var away: Vector2 = (from - station) + away = away.normalized() if away.length() > 1.0 else Vector2.RIGHT + _wander_to = station - away * wander_radius + _leg_time = 0.0 + + +func patrol_destination() -> Vector2: + return _wander_to + + +func _engage(ship: Node2D, target: Node2D, delta: float) -> void: + # Keep station off the target, but never further from the post than the + # leash allows - the approach is clamped to a circle around the outpost + # rather than to a straight line, so it still manoeuvres while pinned. + var to_target: Vector2 = target.global_position - ship.global_position + var distance: float = to_target.length() + var wanted: Vector2 = target.global_position + if distance > 0.001: + wanted = target.global_position - to_target.normalized() * engage_distance + var from_station: Vector2 = wanted - station + if from_station.length() > leash_range: + wanted = station + from_station.normalized() * leash_range + ship.move_target = wanted + # Breaking off starts a fresh leg from wherever the fight ended, rather + # than flying back to a waypoint chosen before it began. + _wander_to = wanted + _leg_time = 0.0 + _pause_left = 0.0 + + _shoot(ship, target, distance, to_target) + + +# Two kinds of armament, one rule: point the gun, or fire the ability. Which +# one this vessel has is a question about its components, not about its type, +# so a third armed vessel needs nothing here. +func _shoot(ship: Node2D, target: Node2D, distance: float, to_target: Vector2) -> void: + var weapon: Node = _component(ship, "weapon") + if weapon != null: + # The gun is a held trigger: it is aimed every tick and stops the + # moment the direction is cleared. + weapon.set_fire_direction(to_target if distance <= weapon.fire_range else Vector2.ZERO) + for component in ship.get_children(): + if not component.has_method("fire_ability") or not component.has_ability(): + continue + if not component.is_ready() or distance > component.ability_range(): + continue + # The magazine is the OUTPOST's, not the fleet's hold. A garrison + # spends what was flown out to it, which is the whole point of the + # outpost holding a store at all - and if the store is empty the + # vessel holds its fire rather than firing free shots. + var resource_type: String = component.ability_ammo_resource() + var cost: int = component.ability_ammo_cost() + if not GameState.spend_garrison_ammo(outpost_id, resource_type, cost): + continue + if not component.fire_ability(get_tree().root, ship.global_position, + target.global_position): + # It refused after being paid - put the ammunition back rather + # than burning it on a shot that never happened. + GameState.building_inventory(outpost_id).deposit(resource_type, cost) + + +func _component(ship: Node2D, capability: String) -> Node: + return ship.get_component(capability) if ship.has_method("get_component") else null + + +func _hold_fire(ship: Node2D) -> void: + var weapon: Node = _component(ship, "weapon") + if weapon != null: + weapon.set_fire_direction(Vector2.ZERO) + + +func _nearest_enemy(ship: Node2D) -> Node2D: + var nearest: Node2D = null + var best: float = detection_range + for enemy in get_tree().get_nodes_in_group("enemies"): + if not is_instance_valid(enemy) or enemy.is_queued_for_deletion(): + continue + var at: Vector2 = (enemy as Node2D).global_position + # Judged from the POST, not from the vessel: otherwise a garrison + # that had drifted out to its leash would see further still, and + # could be walked away from its outpost one enemy at a time. + if station.distance_to(at) > leash_range + engage_distance: + continue + var d: float = ship.global_position.distance_to(at) + if d < best: + best = d + nearest = enemy + return nearest diff --git a/vessels/VesselGarrisonComponent.gd.uid b/vessels/VesselGarrisonComponent.gd.uid new file mode 100644 index 0000000..1b51da5 --- /dev/null +++ b/vessels/VesselGarrisonComponent.gd.uid @@ -0,0 +1 @@ +uid://c3xwl4xetfdyn diff --git a/vessels/VesselGasBomberComponent.gd b/vessels/VesselGasBomberComponent.gd index b46bc33..d9a0ede 100644 --- a/vessels/VesselGasBomberComponent.gd +++ b/vessels/VesselGasBomberComponent.gd @@ -11,6 +11,28 @@ func _init() -> void: cooldown_duration = 5.0 +const BOMB_EFFECT = preload("res://vfx/GasBombEffect.gd") +const GAS_PER_SHOT: int = 5 +const MAX_RANGE: float = 950.0 +const BLAST_DAMAGE: int = 3 +const SHOT_COOLDOWN: float = 5.0 + + +func ability_range() -> float: return MAX_RANGE +func ability_ammo_resource() -> String: return GameState.RESOURCE_GAS +func ability_ammo_cost() -> int: return GAS_PER_SHOT +func ability_cooldown() -> float: return SHOT_COOLDOWN + + +func fire_ability(world: Node, origin: Vector2, at: Vector2) -> bool: + if not consume_shot(SHOT_COOLDOWN): + return false + # Unlike the beam, the bomb goes to the POINT it was aimed at, and it has + # to travel there - which is what makes it dodgeable. + BOMB_EFFECT.launch(world, origin, at, BLAST_DAMAGE) + return true + + func consume_shot(duration: float) -> bool: return _begin_cooldown(duration) diff --git a/vessels/VesselLaserComponent.gd b/vessels/VesselLaserComponent.gd index 278c32a..aa03d12 100644 --- a/vessels/VesselLaserComponent.gd +++ b/vessels/VesselLaserComponent.gd @@ -22,6 +22,29 @@ func apply_stats() -> void: queue_redraw() +# #90/#103 follow-up: the beam is a LaserBeamEffect in the world, not +# something the fleet's controller draws. See the class comment there. +const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd") +# What one shot costs, and how much a garrisoned laser keeps beside it. Data +# on the component rather than in the controller: a garrison spends the same +# ammunition through a different trigger. +const ENERGY_PER_SHOT: int = 8 + + +func ability_range() -> float: return beam_range +func ability_ammo_resource() -> String: return GameState.RESOURCE_ENERGY +func ability_ammo_cost() -> int: return ENERGY_PER_SHOT + + +func fire_ability(world: Node, origin: Vector2, at: Vector2) -> bool: + if not consume_shot(): + return false + var direction: Vector2 = at - origin + # A beam always reaches full range; the aim chooses its direction only. + BEAM_EFFECT.fire(world, origin, direction, beam_range, damage) + return true + + func consume_shot() -> bool: return _begin_cooldown(cooldown_duration) diff --git a/vessels/VesselWeaponComponent.gd b/vessels/VesselWeaponComponent.gd index f52d064..2e2c89b 100644 --- a/vessels/VesselWeaponComponent.gd +++ b/vessels/VesselWeaponComponent.gd @@ -109,4 +109,7 @@ func tick(delta: float) -> void: # #87: sparks off the hull that fired, so a shot is visibly attached to # the vessel it came from. MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading) + # #146: and heard. Beside the spark rather than inside it, because the + # spark is about this hull and the sound is about the shot. + Sfx.play(ship.get_tree(), Sfx.SHOT) fire_timer = fire_cooldown diff --git a/vfx/AsteroidExplosionVFX.gd b/vfx/AsteroidExplosionVFX.gd index 9830458..a377667 100644 --- a/vfx/AsteroidExplosionVFX.gd +++ b/vfx/AsteroidExplosionVFX.gd @@ -7,6 +7,20 @@ extends ExplosionVFXBase const COLOR: Color = Color(0.65, 0.5, 0.35) +func _init() -> void: + # #152: this took the base class's defaults - 12 chunks over half a + # second, no shockwave - which is why breaking a rock, the thing the + # player does more than anything else in the game, barely registered. + # Matter rather than light, so it throws more and slower than an energy + # burst does, and its shockwave is dust rather than a flash. + particle_count = 24 + lifetime = 0.7 + min_speed = 70.0 + max_speed = 330.0 + shockwave_radius = 72.0 + shockwave_color = Color(0.72, 0.6, 0.44) + + static func spawn(parent: Node, world_position: Vector2) -> void: var vfx: AsteroidExplosionVFX = AsteroidExplosionVFX.new() vfx.position = world_position @@ -14,7 +28,7 @@ static func spawn(parent: Node, world_position: Vector2) -> void: func _random_particle_size() -> float: - return randf_range(3.0, 7.0) + return randf_range(4.0, 11.0) # Debris tumbles independently of its flight path, unlike the light-burst diff --git a/vfx/BombBlastVFX.gd b/vfx/BombBlastVFX.gd new file mode 100644 index 0000000..845a832 --- /dev/null +++ b/vfx/BombBlastVFX.gd @@ -0,0 +1,26 @@ +class_name BombBlastVFX +extends LightBurstVFX + + +func _init() -> void: + # #114: the bomber's blast. Between a hull going up and a structure + # coming down - it is the biggest thing an enemy can do to the player + # without destroying anything outright, and it should land like it. + color = Color(1.0, 0.5, 0.2) + particle_count = 26 + lifetime = 0.8 + min_speed = 140.0 + max_speed = 520.0 + shockwave_radius = 150.0 + shockwave_color = Color(1.0, 0.78, 0.4) + + +# Takes the colour from the shell that threw it, so a bomber recoloured in +# its definition does not explode in the default orange. +static func spawn(parent: Node, world_position: Vector2, + blast_color: Color = Color(1.0, 0.5, 0.2)) -> void: + var vfx: BombBlastVFX = BombBlastVFX.new() + vfx.position = world_position + vfx.color = blast_color + vfx.shockwave_color = blast_color.lerp(Color(1.0, 0.95, 0.8), 0.45) + parent.add_child(vfx) diff --git a/vfx/BombBlastVFX.gd.uid b/vfx/BombBlastVFX.gd.uid new file mode 100644 index 0000000..0bc2d50 --- /dev/null +++ b/vfx/BombBlastVFX.gd.uid @@ -0,0 +1 @@ +uid://db55ne20sg6ak diff --git a/vfx/BuildingExplosionVFX.gd b/vfx/BuildingExplosionVFX.gd new file mode 100644 index 0000000..c594bd0 --- /dev/null +++ b/vfx/BuildingExplosionVFX.gd @@ -0,0 +1,22 @@ +class_name BuildingExplosionVFX +extends LightBurstVFX + + +func _init() -> void: + # #99: bigger and slower than a hull going up. A structure coming down is + # the largest thing the player can lose without losing the run, and it + # has to be legible from across the map rather than mistaken for another + # kill in the fight that caused it. + color = Color(1.0, 0.6, 0.2) + particle_count = 34 + lifetime = 1.05 + min_speed = 90.0 + max_speed = 520.0 + shockwave_radius = 190.0 + shockwave_color = Color(1.0, 0.78, 0.35) + + +static func spawn(parent: Node, world_position: Vector2) -> void: + var vfx: BuildingExplosionVFX = BuildingExplosionVFX.new() + vfx.position = world_position + parent.add_child(vfx) diff --git a/vfx/BuildingExplosionVFX.gd.uid b/vfx/BuildingExplosionVFX.gd.uid new file mode 100644 index 0000000..13275e2 --- /dev/null +++ b/vfx/BuildingExplosionVFX.gd.uid @@ -0,0 +1 @@ +uid://diurn5n848453 diff --git a/vfx/DimensionalBreach.gd b/vfx/DimensionalBreach.gd new file mode 100644 index 0000000..922c29e --- /dev/null +++ b/vfx/DimensionalBreach.gd @@ -0,0 +1,119 @@ +class_name DimensionalBreach +extends Node2D + +# #125: the hole itself, in world space. +# +# The prologue's warp was a few lines turning around a point. This is built +# on the same idiom as BlackHoleVisual - an empty core with an accretion +# disc of counter-rotating ARC SEGMENTS around it, hotter and thicker toward +# the centre - because that is the part of the black-hole planet that reads +# well, and a breach and a black hole should look like the same kind of +# thing. +# +# What it adds over that planet is growth and infall. A planet's disc is +# steady; this one opens from nothing, drags streamers in from well outside +# its own disc, and keeps accelerating, so the picture says "this is +# happening now" rather than "this has always been here". +# +# grow() drives everything from one 0..1 number, so the director never has +# to know about rings or streamers - it says how far along the breach is and +# this decides what that looks like. + +const CORE_RADIUS: float = 54.0 +const RING_COUNT: int = 6 +const RING_STEP: float = 19.0 +const DISC_COLOR: Color = Color(0.55, 0.15, 0.85) +const DISC_HOT_COLOR: Color = Color(0.92, 0.62, 1.0) +const RIM_COLOR: Color = Color(0.98, 0.88, 1.0) +# Streamers of matter falling in from outside the disc. They are what makes +# the hole look like it is PULLING rather than merely spinning. +const STREAMER_COUNT: int = 14 +const STREAMER_REACH: float = 5.2 +const STREAMER_STEPS: int = 14 + +# 0 = closed, 1 = fully open. Everything below is scaled from it. +var progress: float = 0.0 +var disc_phase: float = 0.0 + + +func grow(value: float) -> void: + progress = clampf(value, 0.0, 1.0) + queue_redraw() + + +func horizon_radius() -> float: + return CORE_RADIUS * progress + + +func disc_radius() -> float: + return (CORE_RADIUS + float(RING_COUNT - 1) * RING_STEP) * progress + + +func _process(delta: float) -> void: + # The spin accelerates as it opens - a hole that turned at a constant + # rate while growing read as a picture being scaled up. + disc_phase += delta * (0.6 + progress * 2.4) + queue_redraw() + + +func _draw() -> void: + if progress <= 0.001: + return + var horizon: float = horizon_radius() + + # The core is drawn as genuinely empty - an opaque disc of nothing, not a + # dark tint. Same decision as BlackHoleVisual, and for the same reason: a + # black hole with something visible in the middle stops reading as one. + draw_circle(Vector2.ZERO, horizon, Color(0.0, 0.0, 0.0, 1.0)) + + _draw_streamers(horizon) + + for i in range(RING_COUNT): + var radius: float = (CORE_RADIUS + float(i) * RING_STEP) * progress + # 1.0 at the innermost ring, fading outward - drives colour, weight + # and spin, exactly as the planet's disc does. + var t: float = 1.0 - float(i) / float(maxi(RING_COUNT - 1, 1)) + var ring_color: Color = DISC_COLOR.lerp(DISC_HOT_COLOR, t * 0.7) + # Alternating direction per ring reads as turbulence rather than as + # one disc rotating rigidly. + var spin: float = disc_phase * (0.3 + t * 0.9) * (1.0 if i % 2 == 0 else -1.0) + for arc_i in range(2): + var start: float = spin + float(arc_i) * PI + 0.15 + var end: float = start + PI - 0.3 + var points := PackedVector2Array() + for j in range(25): + var angle: float = lerpf(start, end, float(j) / 24.0) + points.append(Vector2(cos(angle), sin(angle)) * radius) + NeonDraw.draw_glowing_path(self, points, ring_color, + (0.5 + t * 0.5) * progress, 0.45 + t * 0.4) + + # The rim: a bright, thin circle right on the horizon. It is what gives + # the void an edge instead of a fade, and it is drawn last so nothing + # else washes over it. + if horizon > 2.0: + draw_arc(Vector2.ZERO, horizon, 0.0, TAU, 48, + Color(RIM_COLOR, 0.85 * progress), 3.0, true) + draw_arc(Vector2.ZERO, horizon * 1.06, 0.0, TAU, 48, + Color(DISC_HOT_COLOR, 0.4 * progress), 7.0, true) + + +func _draw_streamers(horizon: float) -> void: + # Each streamer is a spiral falling from outside the disc down onto the + # horizon. They wind tighter as the breach opens, so the pull visibly + # strengthens rather than the picture merely getting bigger. + var outer: float = disc_radius() * STREAMER_REACH + if outer <= horizon + 1.0: + return + for i in range(STREAMER_COUNT): + var lead: float = disc_phase * 1.6 + TAU * float(i) / float(STREAMER_COUNT) + var points := PackedVector2Array() + for j in range(STREAMER_STEPS + 1): + var t: float = float(j) / float(STREAMER_STEPS) + # Cubic, so a streamer spends most of its length far out and + # whips through the last stretch - which is what infall does. + var radius: float = lerpf(outer, horizon, t * t * t) + var angle: float = lead + t * TAU * (0.55 + progress * 0.85) + points.append(Vector2(cos(angle), sin(angle)) * radius) + var fade: float = (0.16 + 0.5 * progress) * (0.5 + 0.5 * sin(lead * 3.0)) + NeonDraw.draw_glowing_path(self, points, + DISC_COLOR.lerp(DISC_HOT_COLOR, 0.35), maxf(fade, 0.08), 0.3) diff --git a/vfx/DimensionalBreach.gd.uid b/vfx/DimensionalBreach.gd.uid new file mode 100644 index 0000000..634fdaf --- /dev/null +++ b/vfx/DimensionalBreach.gd.uid @@ -0,0 +1 @@ +uid://c4pp0012dcwf diff --git a/vfx/DimensionalBreach.tscn b/vfx/DimensionalBreach.tscn new file mode 100644 index 0000000..3cbe4d6 --- /dev/null +++ b/vfx/DimensionalBreach.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://vfx/DimensionalBreach.gd" id="1"] + +[node name="DimensionalBreach" type="Node2D"] +script = ExtResource("1") diff --git a/vfx/EnemyExplosionVFX.gd b/vfx/EnemyExplosionVFX.gd index 1588a58..a0ca24d 100644 --- a/vfx/EnemyExplosionVFX.gd +++ b/vfx/EnemyExplosionVFX.gd @@ -7,13 +7,17 @@ func _init() -> void: # as a small red puff that was easy to miss in a fight - the moment the # player is being rewarded for should be the loudest thing on screen for # an instant. + # #152: and again. #130 gave a lost VESSEL 30 particles and a 120-unit + # shockwave, which left a kill - the thing the player is being rewarded + # for - visibly the smaller event of the two. Back in proportion: still + # below a vessel's own death, but no longer a puff beside it. color = Color(1.0, 0.25, 0.18) - particle_count = 22 - lifetime = 0.62 - min_speed = 120.0 - max_speed = 460.0 - shockwave_radius = 86.0 - shockwave_color = Color(1.0, 0.55, 0.3) + particle_count = 32 + lifetime = 0.75 + min_speed = 170.0 + max_speed = 620.0 + shockwave_radius = 130.0 + shockwave_color = Color(1.0, 0.6, 0.32) static func spawn(parent: Node, world_position: Vector2) -> void: diff --git a/vfx/GasBombEffect.gd b/vfx/GasBombEffect.gd new file mode 100644 index 0000000..a2758f2 --- /dev/null +++ b/vfx/GasBombEffect.gd @@ -0,0 +1,107 @@ +class_name GasBombEffect +extends Node2D + +# The gas bomb itself: the flight, the blast and the picture of both, with +# nothing in it about who threw it. +# +# Lifted out of GasBombController for the same reason LaserBeamEffect was +# lifted out of LaserController - the shot was something only the FLEET could +# do, and a garrisoned bomber needs the same shot from a different trigger +# and a different magazine. Added to the tree root, self-animating, +# self-freeing, and it outlives the vessel that threw it. + +signal struck(hit_count: int) + +const BOMB_COLOR := Color(0.96, 0.22, 0.72) +const AOE_RADIUS := 180.0 +const PROJECTILE_SPEED := 720.0 +const EXPLOSION_DURATION := 0.8 + +var target: Vector2 = Vector2.ZERO +var damage: int = 3 +var trail: PackedVector2Array = PackedVector2Array() +var exploded: bool = false +var explosion_time: float = 0.0 +var hit_count: int = 0 + + +static func launch(world: Node, origin: Vector2, at: Vector2, + blast_damage: int = 3) -> GasBombEffect: + var effect := GasBombEffect.new() + effect.position = origin + effect.target = at + effect.damage = blast_damage + effect.trail = PackedVector2Array([origin]) + world.add_child(effect) + return effect + + +func in_flight() -> bool: + return not exploded + + +func _process(delta: float) -> void: + if not exploded: + _advance_flight(delta) + queue_redraw() + return + explosion_time = maxf(explosion_time - delta, 0.0) + queue_redraw() + if explosion_time <= 0.0: + queue_free() + + +func _advance_flight(delta: float) -> void: + var distance: float = position.distance_to(target) + if distance <= PROJECTILE_SPEED * delta: + position = target + _explode() + return + position = position.move_toward(target, PROJECTILE_SPEED * delta) + trail.append(position) + while trail.size() > 12: + trail.remove_at(0) + + +func _explode() -> void: + exploded = true + explosion_time = EXPLOSION_DURATION + hit_count = 0 + # Asteroids too - a blast that clears everything around it but leaves the + # rocks untouched would need explaining, and they take damage the same way. + for enemy in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids"): + if is_instance_valid(enemy) and enemy is Node2D \ + and enemy.global_position.distance_to(target) <= AOE_RADIUS: + enemy.take_damage(damage) + hit_count += 1 + struck.emit(hit_count) + + +func _draw() -> void: + # Drawn in this node's own space while it flies (it moves), and around the + # blast point once it lands (it does not). + if not exploded: + if trail.size() >= 2: + for i in range(1, trail.size()): + var alpha: float = float(i) / trail.size() + draw_line(trail[i - 1] - position, trail[i] - position, + Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true) + draw_circle(Vector2.ZERO, 12.0, Color(BOMB_COLOR, 0.35)) + NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 7.0, BOMB_COLOR, 1.0) + return + var progress: float = 1.0 - explosion_time / EXPLOSION_DURATION + var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0)) + draw_circle(Vector2.ZERO, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16)) + draw_ring(Vector2.ZERO, radius, BOMB_COLOR, 1.0 - progress) + for i in range(7): + var angle: float = TAU * float(i) / 7.0 + progress + var cloud_at: Vector2 = Vector2(cos(angle), sin(angle)) * radius * 0.55 + draw_circle(cloud_at, 18.0 + 14.0 * progress, + Color(BOMB_COLOR, (1.0 - progress) * 0.22)) + + +func draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void: + draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) + draw_arc(center, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) + draw_arc(center, radius, 0.0, TAU, 64, Color(1.0, 0.86, 0.96, 0.9 * alpha), 2.0, true) diff --git a/vfx/GasBombEffect.gd.uid b/vfx/GasBombEffect.gd.uid new file mode 100644 index 0000000..af3f344 --- /dev/null +++ b/vfx/GasBombEffect.gd.uid @@ -0,0 +1 @@ +uid://8erny7m1ghuj diff --git a/vfx/LaserBeamEffect.gd b/vfx/LaserBeamEffect.gd new file mode 100644 index 0000000..d530230 --- /dev/null +++ b/vfx/LaserBeamEffect.gd @@ -0,0 +1,139 @@ +class_name LaserBeamEffect +extends Node2D + +# The laser shot itself: the strike and the picture of it, with nothing in it +# about who fired or why. +# +# This used to live inside LaserController, which meant the beam was a thing +# only the FLEET could do - the player's laser button, the fleet's hold, the +# fleet's aim. Garrisoned vessels (#103 follow-up) need the same shot with a +# different trigger and a different magazine, so the effect moved out here. +# Same shape as EnemyBigLaserBeam and ArtilleryShell on the enemy side: added +# to the tree root, self-animating, self-freeing, and it outlives the vessel +# that fired it. +# +# The damage below is dealt at the INSTANT of the shot, in full, along the +# whole beam. The extension in _draw is the picture of that, not a travelling +# projectile - a beam that had to arrive would be a change to how the weapon +# plays. + +signal struck(hit_count: int) + +const BEAM_COLOR := Color(0.3, 0.9, 1.0) +const BEAM_HALF_WIDTH := 38.0 +# The DRAWN beam is scaled from the hit width rather than tuned separately. +# The two were independent numbers and had drifted apart: the beam struck +# anything within 22 units of the line while its outer glow spanned about 11, +# so it killed things it visibly missed. NeonDraw's widest layer is 18px at +# scale 1, so this makes the glow span exactly what the beam hits. +const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0 +# #90: the beam was one flat line that blinked on at full length and faded. +# It now IGNITES - it runs out of the ship over the first slice of its life, +# holds white-hot, and falls away. +const FLASH_DURATION := 0.34 +const IGNITE_FRACTION := 0.3 +# The white-hot rod down the middle of the coloured glow. Drawn INSIDE the +# beam's own width, never wider: the drawn beam is deliberately the same span +# as the hit test, and a halo painted past that would put the "it visibly +# missed" complaint back the other way round. +const CORE_SCALE := 0.42 +const CORE_COLOR := Color(0.85, 0.99, 1.0) +const TIP_RADIUS := BEAM_HALF_WIDTH * 0.55 +const MUZZLE_RADIUS := BEAM_HALF_WIDTH * 0.8 +# By path, not by class name - see the note at the top of MuzzleSparkVFX. +const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") + +var from: Vector2 = Vector2.ZERO +var to: Vector2 = Vector2.ZERO +var time: float = FLASH_DURATION +# Per beam, so two lasers firing together do not flicker in lockstep. +var phase: float = 0.0 +var hit_count: int = 0 + + +static func fire(world: Node, origin: Vector2, direction: Vector2, + beam_range: float, damage: int) -> LaserBeamEffect: + var effect := LaserBeamEffect.new() + var heading: Vector2 = direction.normalized() if direction.length() > 0.001 else Vector2.UP + effect.from = origin + effect.to = origin + heading * beam_range + effect.phase = randf() * TAU + world.add_child(effect) + effect._strike(heading, damage) + # #90: something happens at the hull, not just out in space. The ordinary + # gun already does this (#87); the laser fired in complete silence at its + # own end, which is most of why a shot read as a line appearing rather + # than as a vessel firing. + MUZZLE_SPARK.spawn(world, origin, heading, BEAM_COLOR) + return effect + + +func _strike(direction: Vector2, damage: int) -> void: + hit_count = 0 + var range_along: float = from.distance_to(to) + # Asteroids share the enemies' take_damage() contract, so the beam sweeps + # both groups in one pass. Shooting a path clear through a field is a + # perfectly good use of a laser, and the ore it leaves behind is the point. + for enemy in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids"): + if not is_instance_valid(enemy) or not (enemy is Node2D): + continue + var to_enemy: Vector2 = enemy.global_position - from + var along: float = to_enemy.dot(direction) + if along < 0.0 or along > range_along: + continue + var perpendicular: float = (to_enemy - direction * along).length() + if perpendicular <= BEAM_HALF_WIDTH: + enemy.take_damage(damage) + hit_count += 1 + struck.emit(hit_count) + queue_redraw() + + +func _process(delta: float) -> void: + time = maxf(time - delta, 0.0) + queue_redraw() + if time <= 0.0: + queue_free() + + +# How far along its length the beam is currently drawn. Cubic ease-out, so it +# leaves the ship fast and settles into full reach rather than crawling. +# Public because it is the whole of #90's "it comes out of the ship". +func extension() -> float: + var progress: float = 1.0 - time / FLASH_DURATION + var out: float = clampf(progress / IGNITE_FRACTION, 0.0, 1.0) + return 1.0 - pow(1.0 - out, 3.0) + + +func tip() -> Vector2: + return from.lerp(to, extension()) + + +func _draw() -> void: + var progress: float = 1.0 - time / FLASH_DURATION + # Full brightness while it extends, then away. Squared so it holds its + # light and drops at the end, instead of dimming from the first frame the + # way a plain linear fade did. + var decay: float = clampf((progress - IGNITE_FRACTION) / (1.0 - IGNITE_FRACTION), 0.0, 1.0) + var alpha: float = 1.0 - decay * decay + # A live blade hums. Small enough to be texture rather than a blink. + var flicker: float = 1.0 + 0.09 * sin(time * 70.0 + phase) + var at: Vector2 = tip() + + NeonDraw.draw_glowing_line(self, from, at, BEAM_COLOR, alpha, + BEAM_WIDTH_SCALE * flicker * (1.0 - decay * 0.45)) + # The white-hot rod, over the same span at a fraction of the width. This + # is where the extra light comes from: brighter through the middle, not + # broader across. + NeonDraw.draw_glowing_line(self, from, at, CORE_COLOR, alpha, + BEAM_WIDTH_SCALE * CORE_SCALE * flicker) + # The leading cap, brightest while the blade is still running out. + var cap: float = TIP_RADIUS * (0.7 + 0.3 * (1.0 - decay)) * flicker + NeonDraw.draw_glowing_circle(self, at, cap, CORE_COLOR, alpha) + # And the flare left at the emitter, which fades faster than the beam so + # the hull does not keep glowing after the shot is over. + var muzzle: float = MUZZLE_RADIUS * (1.0 - progress) + if muzzle > 1.0: + NeonDraw.draw_glowing_circle(self, from, muzzle, CORE_COLOR, + alpha * (1.0 - progress)) diff --git a/vfx/LaserBeamEffect.gd.uid b/vfx/LaserBeamEffect.gd.uid new file mode 100644 index 0000000..fe58699 --- /dev/null +++ b/vfx/LaserBeamEffect.gd.uid @@ -0,0 +1 @@ +uid://qi5bj1nj717c diff --git a/vfx/MuzzleSparkVFX.gd b/vfx/MuzzleSparkVFX.gd index a51a0ce..6a0b3b7 100644 --- a/vfx/MuzzleSparkVFX.gd +++ b/vfx/MuzzleSparkVFX.gd @@ -23,15 +23,21 @@ const LENGTH: float = 9.0 const COLOR: Color = Color(1.0, 0.88, 0.42) var age: float = 0.0 +var color: Color = COLOR var sparks: Array = [] -static func spawn(parent: Node, world_position: Vector2, direction: Vector2) -> void: +# The colour is optional and defaults to the gun's own: #90 wanted the laser +# to do something at the hull too, and a cyan weapon throwing gold sparks +# would read as a different weapon firing from the same ship. +static func spawn(parent: Node, world_position: Vector2, direction: Vector2, + spark_color: Color = COLOR) -> void: if direction.length() < 0.001: return var vfx: Node2D = (load(SELF_PATH) as GDScript).new() vfx.position = world_position vfx.rotation = direction.angle() + vfx.color = spark_color parent.add_child(vfx) @@ -63,5 +69,5 @@ func _draw() -> void: var heading: Vector2 = Vector2.RIGHT.rotated(float(spark.angle)) var start: Vector2 = heading * float(spark.speed) * age var end: Vector2 = start + heading * float(spark.length) - NeonDraw.draw_glowing_line(self, start, end, COLOR, fade, 0.35, - COLOR.darkened(0.7)) + NeonDraw.draw_glowing_line(self, start, end, color, fade, 0.35, + color.darkened(0.7)) diff --git a/vfx/ShipExplosionVFX.gd b/vfx/ShipExplosionVFX.gd index 789f956..df701c7 100644 --- a/vfx/ShipExplosionVFX.gd +++ b/vfx/ShipExplosionVFX.gd @@ -3,7 +3,24 @@ extends LightBurstVFX func _init() -> void: + # #130: "a rather more explosive explosion than just a light animation". + # This used to take the base class's defaults - 12 particles, half a + # second, no shockwave - which made losing one of your own vessels a + # quieter event on screen than killing an enemy, whose explosion #87 + # had already been told to be the loudest thing in the frame. Now it is + # the louder of the two, which is the right way round: the enemy's is a + # reward, this one is a loss. + # + # #152 lifted the enemy's and the asteroid's to match; this goes up with + # them, because the ORDER is the point rather than the numbers. Losing a + # vessel must stay the bigger event than killing something. color = Color(0.25, 0.85, 1.0) + particle_count = 38 + lifetime = 0.95 + min_speed = 190.0 + max_speed = 640.0 + shockwave_radius = 150.0 + shockwave_color = Color(0.6, 0.95, 1.0) static func spawn(parent: Node, world_position: Vector2) -> void: diff --git a/vfx/ShipWreckVFX.gd b/vfx/ShipWreckVFX.gd new file mode 100644 index 0000000..e827a7c --- /dev/null +++ b/vfx/ShipWreckVFX.gd @@ -0,0 +1,113 @@ +class_name ShipWreckVFX +extends Node2D + +# Forgejo #130: a vessel's death, as a short sequence rather than a moment. +# +# A ship used to vanish on the frame it was hit and leave a twelve-particle +# puff behind - lighter than the one an ENEMY gets (see EnemyExplosionVFX, +# which #87 made deliberately loud). Losing a hull is the more expensive +# event of the two and it read as the cheaper one. +# +# So the hull comes off the formation and keeps going: it breaks away from +# the fleet, tumbling and venting, for the length of DRIFT_SECONDS, and only +# then goes up - out where it has some room, rather than inside the wedge it +# was flying in. +# +# It is a separate node rather than the Ship staying alive a little longer, +# and that is the important part. A dying Ship still in the tree is still an +# Area2D in the player_ships group with live weapon components: it would keep +# shooting, keep being shot at, keep counting as a target, and keep colliding +# on its way out. Everything about the ship's own lifecycle - the `destroyed` +# signal, leaving Fleet.ships, freeing the node - happens exactly when it did +# before. What is left behind is scenery. + +const DRIFT_SECONDS: float = 0.62 +# How hard it is pushed out of the formation. Enough to clear the wedge in +# the time above, not so much that it reads as being thrown. +const BREAKAWAY_SPEED: float = 300.0 +const DRAG: float = 0.72 +const SPIN_MIN: float = 2.4 +const SPIN_MAX: float = 7.0 +const VENT_COUNT: int = 4 +const VENT_SPEED: float = 90.0 +# The hull darkens as it goes, so the drift reads as dying rather than as +# flying away. +const FADE_TO: float = 0.35 + +var hull: PackedVector2Array = PackedVector2Array() +var glow_color: Color = Color(0.25, 0.85, 1.0) +var core_color: Color = Color.WHITE +var velocity: Vector2 = Vector2.ZERO +var spin: float = 0.0 +var age: float = 0.0 +var vents: Array = [] +var _blown: bool = false + + +# `away` is the direction out of the formation, so the wreck leaves the fleet +# rather than drifting through the middle of it. +static func spawn(parent: Node, ship: Node2D, away: Vector2) -> ShipWreckVFX: + var wreck := ShipWreckVFX.new() + wreck.position = ship.position + wreck.rotation = ship.rotation + wreck.glow_color = ship.glow_color + wreck.core_color = ship.core_color + if ship.definition != null: + wreck.hull = ship.definition.hull_points + var drift: Vector2 = away if away.length() > 0.01 else Vector2.UP.rotated(ship.rotation) + # Its own momentum plus the push: a ship hit at speed carries on the way + # it was already going. + wreck.velocity = ship.velocity + drift.normalized() * BREAKAWAY_SPEED + wreck.spin = randf_range(SPIN_MIN, SPIN_MAX) * (1.0 if randf() < 0.5 else -1.0) + parent.add_child(wreck) + return wreck + + +func _ready() -> void: + if hull.is_empty(): + hull = PackedVector2Array([Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16)]) + for i in range(VENT_COUNT): + var angle: float = TAU * float(i) / float(VENT_COUNT) + randf() * 0.6 + vents.append({ + "pos": Vector2.ZERO, + "vel": Vector2(cos(angle), sin(angle)) * randf_range(VENT_SPEED * 0.4, VENT_SPEED), + "len": randf_range(9.0, 18.0), + }) + + +func _process(delta: float) -> void: + age += delta + if age >= DRIFT_SECONDS: + _blow_up() + return + position += velocity * delta + velocity = velocity.lerp(Vector2.ZERO, clampf(DRAG * delta, 0.0, 1.0)) + rotation += spin * delta + for vent in vents: + vent.pos += vent.vel * delta + vent.vel *= 0.94 + queue_redraw() + + +func _blow_up() -> void: + if _blown: + return + _blown = true + # Where it ended up, not where it was hit - the point of the drift. + if is_inside_tree(): + ShipExplosionVFX.spawn(get_tree().root, global_position) + queue_free() + + +func _draw() -> void: + var t: float = clampf(age / DRIFT_SECONDS, 0.0, 1.0) + var fade: float = lerpf(1.0, FADE_TO, t) + NeonDraw.draw_glowing_polygon(self, hull, Color(glow_color, fade), 1.0, + Color(core_color, fade)) + # What is coming out of it. Drawn in the wreck's own local space, so the + # vents tumble with the hull the way a breach in it would. + var vent_colour: Color = Color(1.0, 0.72, 0.35, (1.0 - t) * 0.9) + for vent in vents: + var dir: Vector2 = Vector2(vent.vel).normalized() + NeonDraw.draw_glowing_line(self, vent.pos, vent.pos + dir * float(vent.len), + vent_colour, 0.8, 0.4) diff --git a/vfx/ShipWreckVFX.gd.uid b/vfx/ShipWreckVFX.gd.uid new file mode 100644 index 0000000..1f69cec --- /dev/null +++ b/vfx/ShipWreckVFX.gd.uid @@ -0,0 +1 @@ +uid://c30rnnoa6fkcd diff --git a/world/AsteroidField.gd b/world/AsteroidField.gd index e7a938a..6db56b7 100644 --- a/world/AsteroidField.gd +++ b/world/AsteroidField.gd @@ -7,10 +7,17 @@ extends Node2D @export var check_interval: float = 1.0 @export var spawn_radius_chunks: int = 2 -# No asteroid may be created within this distance of the fleet. At cruise -# speed this is well over a second of warning, so anything that reaches the -# fleet was visible on its way in. +# #126: no asteroid may be created anywhere the player can SEE. It used to be +# a fixed bubble around the fleet, which was smaller than the screen - so a +# rock could and did materialise in plain view, several hundred pixels from +# the fleet but well inside the window. The real rule is the visible rect, +# and this radius is now only the floor under it: what to use when the +# viewport cannot be measured (no camera yet on the very first frame of a +# scene, or a headless run with no window at all). @export var safe_spawn_radius: float = 420.0 +# Pushed this far past the edge of the visible rect, so a rock placed just +# outside does not drift into view on the same frame it was created. +@export var offscreen_margin: float = 140.0 @export var despawn_radius_chunks: int = 4 @export var asteroids_per_chunk: int = 6 @export var min_speed: float = 12.0 @@ -67,31 +74,65 @@ func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int: return max(abs(a.x - b.x), abs(a.y - b.y)) -func _safe_position(candidate: Vector2, centre: Vector2) -> Vector2: - # Nothing may materialise on top of the fleet. Chunks are populated around - # wherever the fleet happens to be - including the chunk it is standing in - # - so without this an asteroid could appear directly on a ship and - # destroy it with no chance to react. That is worst on the frame a scene - # loads: arriving back from a planet could cost a ship instantly. - if candidate.distance_to(centre) >= safe_spawn_radius: +# The area a new asteroid must stay out of: whatever is on screen, or the +# fallback bubble around the fleet when there is no screen to measure. +# +# Public since #139: the prologue lays its own debris belt (IntroDirector) +# with its own arithmetic and had the same bug this fixed - rocks appearing +# inside the view. One rule, in one place, borrowed rather than restated. +# +# Chunks are populated around wherever the fleet happens to be, including the +# chunk it is standing in, so without this a rock could appear directly on a +# ship - worst on the frame a scene loads, where arriving back from a planet +# could cost a vessel instantly. #126 widens that from "not on the fleet" to +# "not in view", which is the same rule the player actually notices. +func keep_out_rect(centre: Vector2) -> Rect2: + var fallback := Rect2(centre - Vector2(safe_spawn_radius, safe_spawn_radius), + Vector2(safe_spawn_radius, safe_spawn_radius) * 2.0) + if get_viewport() == null: + return fallback + var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self) + if visible_rect.size.x < 1.0 or visible_rect.size.y < 1.0: + return fallback + visible_rect = visible_rect.grow(offscreen_margin) + # Never smaller than the old bubble: on a heavily zoomed-in view the + # screen can be narrower than the distance a rock needs to be safe. + return visible_rect.merge(fallback) + + +func safe_position(candidate: Vector2, keep_out: Rect2) -> Vector2: + if not keep_out.has_point(candidate): return candidate - # Push it out to the edge of the bubble rather than dropping it, so the - # field keeps its density near the fleet instead of thinning out. + # Pushed out through the nearest edge rather than dropped, so the field + # keeps its density around the fleet instead of thinning out where the + # player is looking. + var centre: Vector2 = keep_out.get_center() var away: Vector2 = candidate - centre - if away.length() < 0.001: + if absf(away.x) < 0.001 and absf(away.y) < 0.001: away = Vector2.RIGHT.rotated(randf() * TAU) - return centre + away.normalized() * safe_spawn_radius + var half: Vector2 = keep_out.size * 0.5 + # The smallest scaling that puts the point on the boundary along `away`: + # whichever axis it would leave through first decides. + var scale_x: float = half.x / absf(away.x) if absf(away.x) > 0.001 else INF + var scale_y: float = half.y / absf(away.y) if absf(away.y) > 0.001 else INF + var factor: float = minf(scale_x, scale_y) + if is_inf(factor): + return centre + Vector2(half.x + 1.0, 0.0) + return centre + away * factor * 1.02 func _spawn_chunk_asteroids(chunk: Vector2i) -> void: var origin: Vector2 = WorldGen.chunk_origin(chunk) var spawned: Array = [] - var fleet_centre: Vector2 = fleet.get_centroid() + # Measured once per chunk rather than per asteroid: it cannot change + # between two rocks placed in the same frame, and it is a viewport + # transform rather than a field read. + var keep_out: Rect2 = keep_out_rect(fleet.get_centroid()) for i in range(asteroids_per_chunk): var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE) var asteroid: Node2D = asteroid_scene.instantiate() - asteroid.position = _safe_position(origin + local_pos, fleet_centre) + asteroid.position = safe_position(origin + local_pos, keep_out) var angle: float = randf() * TAU var speed: float = randf_range(min_speed, max_speed) asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd new file mode 100644 index 0000000..c30f6b9 --- /dev/null +++ b/world/IntroDirector.gd @@ -0,0 +1,509 @@ +extends Node2D + +# #108: the prologue - the convoy, the ambush, the asteroid belt, and the +# breach that leaves the player with one ship and no way home. +# +# It directs the REAL scene rather than being a scene of its own. It wants +# the real fleet, the real enemies and the real asteroid field, and it makes +# "then the game starts" cost nothing: the director stops steering, clears +# GameState.intro_active, and what is left running is the ordinary game with +# exactly the opening position a new game already has. +# +# The script is a list of BEATS. Each has a deadline as well as its own "am +# I done" test, so a beat whose condition is never met - the player refusing +# to fly at the ambush, say - cannot wedge the prologue; it simply runs out +# and the next one starts. skip() ends the whole thing at any point and +# lands in the same state as playing it through, so there is one way for the +# game to start rather than two that have to be kept in step. +# +# #128: those deadlines are wall clock only while the game is RUNNING. A +# dialog stops the tree, which stops this node too, so a beat's clock never +# ticks past an unread line - the deadline is the way out of a beat the +# player will not finish, not a way past a line they have not acknowledged. + +signal finished + +# Beats, in order. The names are the whole outline. +enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH } + +# Where the shipment was going, and never arrives. +const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0) +# The convoy: mostly holds, a few escorts. Larger than any fleet the player +# can crew yet, which is the point - this is not their fleet. +# Carriers are the game's cargo hull; transporters belong to hubs and are +# not fleet-eligible at all, so a convoy of them would be silently filtered +# down to its escorts. +# +# #125: the order here pairs index-for-index with CONVOY_FORMATION below. +# The holds are the middle six entries and the escorts are the four outer +# ones, so the layout below can put the guns on the outside without either +# array having to know which slot is which - they simply agree. +const CONVOY: PackedStringArray = ["interceptor", "interceptor", + "interceptor", "interceptor", + "carrier", "carrier", "carrier", "carrier", "carrier", "carrier"] + +# #125: "space them a bit more and surround transporters by the default +# vessels". Escorts first, at the four corners of the box; then the holds +# packed in the middle of it. Roughly three times the spread of the fleet's +# own wedge - this is a freight convoy under guard, not a fighting +# formation, and it should not look like one. +const CONVOY_FORMATION: Array[Vector2] = [ + Vector2(-300.0, -260.0), Vector2(300.0, -260.0), + Vector2(-300.0, 260.0), Vector2(300.0, 260.0), + Vector2(-105.0, -120.0), Vector2(105.0, -120.0), + Vector2(-105.0, 0.0), Vector2(105.0, 0.0), + Vector2(-105.0, 120.0), Vector2(105.0, 120.0), +] +const AMBUSH_COUNT: int = 3 +const AMBUSH_AHEAD: float = 1100.0 +# #125: the run was over in about four seconds at cruise. Far enough now +# that the convoy is genuinely under way before anything interrupts it. +const RUN_DISTANCE: float = 3400.0 +# #139: far enough that the near edge of the belt is well past the screen +# even before the keep-out below trims it. The two together, rather than +# either alone: the distance keeps the belt looking laid out ahead rather +# than pinned to the screen edge, and the keep-out is what actually +# guarantees nothing appears in view at any aspect ratio or zoom. +const BELT_AHEAD: float = 2900.0 +const BELT_ROCKS: int = 90 +const BELT_HALF_WIDTH: float = 2600.0 +const BELT_DEPTH: float = 1400.0 +const BREACH_PULL: float = 620.0 +const SHAKE_MAX: float = 34.0 +# How far past the horizon the lens reaches. Generous: the bend is what +# sells the hole, and it falls off to nothing on its own well before this. +const BREACH_LENS_SCALE: float = 7.0 +# #127: who is talking. The guide is the convoy's controller for everything +# up to the breach; after it there is nobody left to talk to, so the closing +# lines are the ship's own log - which is the point being made. +const SHIP_LOG: String = "KESTREL - LOG" +const VORTEX_COLORS: Array = [ + Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8), + Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6), +] + +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") +@onready var narration: Control = get_node("../UI/NarrationBanner") +@onready var asteroid_field: Node2D = get_node("../AsteroidField") +@onready var enemy_spawner: Node2D = get_node("../EnemySpawner") + +var running: bool = false +var beat: int = Beat.BRIEF +var beat_time: float = 0.0 +var start_at: Vector2 = Vector2.ZERO +var belt_at: Vector2 = Vector2.ZERO +var breach_at: Vector2 = Vector2.ZERO +var ambush: Array = [] +var rocks: Array = [] +var shake: float = 0.0 +var vortex_time: float = 0.0 +var survivor: Node2D = null +var breach: Node2D = null +var _done: bool = false + +@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay") + + +func _ready() -> void: + # A NEW-game thing. A player returning from a planet surface, or + # resuming, is already past it. + running = GameState.intro_active + if not running: + queue_free() + return + # Nothing arrives on its own while the prologue runs: every enemy and + # every rock in it is placed by this script. + enemy_spawner.set_process(false) + asteroid_field.set_process(false) + for hazard in get_tree().get_nodes_in_group("asteroids") \ + + get_tree().get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + _begin(Beat.BRIEF) + + +# --- the frame ------------------------------------------------------------ + +func _process(delta: float) -> void: + if not running: + return + beat_time += delta + _tick(delta) + _apply_shake(delta) + if beat_time < _deadline() and not _ready_to_advance(): + return + if beat >= Beat.AFTERMATH: + finish() + return + _begin(beat + 1) + + +func _ready_to_advance() -> bool: + if beat_time < _floor_time(): + return false + if _waits_for_narration() and narration.is_speaking(): + return false + return _beat_complete() or beat_time >= _floor_time() and _floor_only() + + +# Beats with no condition of their own end when their floor is up. Kept +# explicit so a beat that DOES have a condition cannot be advanced early by +# the floor alone. +func _floor_only() -> bool: + return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.BREACH \ + or beat == Beat.VORTEX or beat == Beat.AFTERMATH + + +# Ends the prologue and hands over. The same call ends it whether it was +# played out or skipped. +func finish() -> void: + if _done: + return + _done = true + running = false + _clear_props() + GameState.intro_active = false + GameState.intro_locks_movement = false + GameState.intro_fleet_size = 0 + narration.clear() + camera.offset = Vector2.ZERO + enemy_spawner.set_process(true) + asteroid_field.set_process(true) + # Exactly a new game's opening position: one interceptor, nothing else. + # + # #135: the COUNT is not the whole of that. One surviving carrier is also + # a fleet of one, and it is not the fleet a new game starts with - so the + # types are checked too, and anything else is replaced. + if not _is_starting_fleet(): + fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, fleet.get_centroid(), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + finished.emit() + queue_free() + + +func skip() -> void: + finish() + + +func _is_starting_fleet() -> bool: + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return false + for ship in fleet.ships: + if not is_instance_valid(ship) \ + or String(ship.vessel_type) != GameState.VESSEL_INTERCEPTOR: + return false + return true + + +# #135: the game starts from an interceptor, so an interceptor is what has to +# come out of the vortex. +# +# The convoy's four escorts fly the corners of the box, which is the most +# exposed part of it, and the debris belt can take all four on the way +# through. With none left, the only hulls the whirl could spare were holds - +# and the player flew out of the prologue in a carrier, which is the report. +# +# One is put back into the convoy here, at the top of the vortex, where a +# vessel appearing among ten being thrown around a black hole is not +# something anyone is going to catch. Doing it here rather than only in +# finish() means the ship the player watches survive is the ship they then +# fly, instead of one being swapped for the other at the end. +func _ensure_survivor_hull() -> void: + for ship in fleet.ships: + if is_instance_valid(ship) \ + and String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + return + fleet.add_ship(GameState.VESSEL_INTERCEPTOR) + + +func _apply_convoy_formation() -> void: + for i in range(mini(CONVOY_FORMATION.size(), GameState.formation.size())): + GameState.formation[i] = CONVOY_FORMATION[i] + + +# The world-space half of the breach, plus the screen-space lens over it. +func _open_breach() -> void: + if is_instance_valid(breach): + return + breach = load("res://vfx/DimensionalBreach.tscn").instantiate() + breach.position = breach_at + get_parent().add_child(breach) + + +# Drives both halves from one number. The overlay works in SCREEN pixels, so +# the world point is projected through the canvas transform here - only the +# director knows where the camera is. +func _drive_breach(open_amount: float) -> void: + if is_instance_valid(breach): + breach.grow(open_amount) + if overlay == null: + return + var canvas: Transform2D = get_viewport().get_canvas_transform() + var on_screen: Vector2 = canvas * breach_at + var horizon: float = (breach.horizon_radius() if is_instance_valid(breach) else 0.0) \ + * canvas.get_scale().x + overlay.set_breach(on_screen, horizon, + maxf(horizon * BREACH_LENS_SCALE, 1.0), open_amount) + + +func _clear_props() -> void: + if is_instance_valid(breach): + breach.queue_free() + breach = null + if overlay != null: + overlay.clear_breach() + for prop in ambush + rocks: + if is_instance_valid(prop): + prop.queue_free() + ambush.clear() + rocks.clear() + queue_redraw() + + +# --- the script ----------------------------------------------------------- + +func _begin(next: int) -> void: + beat = next + beat_time = 0.0 + match beat: + Beat.BRIEF: + start_at = fleet.get_centroid() + GameState.intro_fleet_size = CONVOY.size() + # #125: the convoy flies in its own spread-out box, escorts on + # the corners. spawn_ships seats vessel i in formation slot i, + # so writing the layout here - before the spawn - is all it + # takes for CONVOY and CONVOY_FORMATION to line up. + _apply_convoy_formation() + fleet.spawn_ships(CONVOY.size(), start_at, CONVOY) + narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.") + narration.say("Hold your heading and keep the holds intact. We will be watching.") + Beat.RUN: + narration.say("World Ship bears ahead. Fly on.") + Beat.AMBUSH: + _spawn_ambush() + narration.say("Contacts ahead - raiders, a light group. Break them and continue.") + Beat.CLEAR: + narration.say("Clean work, Kestrel. Resume your heading.") + Beat.GAUNTLET: + _spawn_belt() + narration.say("Debris belt across your lane. Take it slow - there is no way around.") + Beat.BREACH: + GameState.intro_locks_movement = true + breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) + _open_breach() + narration.say("Kestrel, your heading is - something is opening ahead of you -", "CONVOY CONTROL") + Beat.VORTEX: + vortex_time = 0.0 + _ensure_survivor_hull() + narration.clear() + Beat.AFTERMATH: + GameState.intro_locks_movement = false + shake = 0.0 + narration.say("It seems you've lost your way through the space, lost and abandoned.", SHIP_LOG) + narration.say("No communication with the World Ship can be heard.", SHIP_LOG) + + +func _deadline() -> float: + match beat: + Beat.BRIEF: return 16.0 + Beat.RUN: return 60.0 + Beat.AMBUSH: return 90.0 + Beat.CLEAR: return 8.0 + Beat.GAUNTLET: return 75.0 + Beat.BREACH: return 9.0 + Beat.VORTEX: return 7.0 + Beat.AFTERMATH: return 15.0 + return 1.0 + + +# #125: "everything happens seconds after the previous thing started." +# +# The deadline above is how long a beat may take; this is how long it must +# take. Two beats ran short for the same reason - their completion test was +# satisfied while the guide was still mid-sentence - so no beat may end +# before its own floor, and a beat that is still TALKING holds regardless. +# One rule, applied in one place, rather than tuning each condition. +func _floor_time() -> float: + match beat: + Beat.BRIEF: return 11.0 + Beat.RUN: return 6.0 + Beat.AMBUSH: return 3.0 + Beat.CLEAR: return 5.0 + Beat.GAUNTLET: return 6.0 + Beat.BREACH: return 8.0 + Beat.VORTEX: return 6.5 + Beat.AFTERMATH: return 12.0 + return 0.0 + + +# Beats where the guide is the point, and cutting the line off would be +# cutting the beat off. The action beats are deliberately not here: a wave +# arriving must not wait for a sentence to finish. +# +# Since #128 stops the whole tree while a line is up, this node is not +# running to ask the question in the first place - so this now says the same +# thing twice. Kept because it is the DIRECTOR's own reason for waiting, and +# it should not quietly depend on how dialogs happen to be implemented. +func _waits_for_narration() -> bool: + return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.AFTERMATH + + +func _beat_complete() -> bool: + match beat: + Beat.RUN: + return start_at.distance_to(fleet.get_centroid()) >= RUN_DISTANCE + Beat.AMBUSH: + return _living(ambush) == 0 + Beat.GAUNTLET: + # Past the belt, rather than merely inside it. + return fleet.get_centroid().y <= belt_at.y - BELT_DEPTH * 0.4 + return false + + +func _tick(delta: float) -> void: + match beat: + Beat.BREACH: + shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX) + # Opens over the whole beat rather than snapping to full size, + # which is the "growing" the ticket asks for. + _drive_breach(clampf(beat_time / maxf(_deadline(), 0.001), 0.0, 1.0)) + # Dragged in, not flown in: the player has already lost the wheel. + var to_breach: Vector2 = breach_at - fleet.get_centroid() + if to_breach.length() > 1.0: + fleet.move_in_direction(to_breach.normalized()) + queue_redraw() + Beat.VORTEX: + vortex_time += delta + shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35) + _drive_breach(1.0) + _whirl(delta) + queue_redraw() + Beat.AFTERMATH: + shake = maxf(shake - delta * SHAKE_MAX, 0.0) + # It closes behind them over the first stretch of the aftermath, + # so the last thing the player sees is the hole sealing rather + # than a cut. + _drive_breach(clampf(1.0 - beat_time / 3.0, 0.0, 1.0)) + queue_redraw() + + +func _apply_shake(delta: float) -> void: + if shake <= 0.01: + if camera.offset != Vector2.ZERO: + camera.offset = Vector2.ZERO + return + # Offset, never position: GameController owns where the camera IS, and a + # shake written into that would be smoothed away by its own follow. + camera.offset = Vector2(randf_range(-shake, shake), randf_range(-shake, shake)) + if beat != Beat.BREACH and beat != Beat.VORTEX: + shake = maxf(shake - delta * SHAKE_MAX, 0.0) + + +# --- the pieces ----------------------------------------------------------- + +func _spawn_ambush() -> void: + for enemy in ambush: + if is_instance_valid(enemy): + enemy.queue_free() + ambush.clear() + var ahead: Vector2 = fleet.get_centroid() + Vector2(0.0, -AMBUSH_AHEAD) + for i in range(AMBUSH_COUNT): + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + # Chasers, and only chasers. "easy enough" is the requirement, and a + # sniper or an artillery piece against a convoy the player cannot + # yet fight properly is neither easy nor a good first impression. + enemy.enemy_id = "chaser" + enemy.position = ahead + Vector2((float(i) - 1.0) * 260.0, randf_range(-120.0, 120.0)) + get_parent().add_child(enemy) + ambush.append(enemy) + + +func _spawn_belt() -> void: + # Idempotent: replaying the beat replaces the belt rather than laying a + # second one on top of it. + for rock in rocks: + if is_instance_valid(rock): + rock.queue_free() + rocks.clear() + belt_at = fleet.get_centroid() + Vector2(0.0, -BELT_AHEAD) + # #139: rocks were popping into existence on screen. The belt is spread + # BELT_DEPTH either side of its centre, so its near edge reached back to + # within a screen of the fleet - and at a wider aspect than the one it + # was eyeballed on, into the view outright. + # + # The rule is the asteroid field's own (#126), borrowed rather than + # restated here: anything that lands in view is pushed out through the + # nearest edge, so the belt keeps its density instead of thinning where + # the player is looking. + var keep_out: Rect2 = asteroid_field.keep_out_rect(fleet.get_centroid()) + var scene: PackedScene = load("res://world/Asteroid.tscn") + for i in range(BELT_ROCKS): + var rock: Node2D = scene.instantiate() + rock.position = asteroid_field.safe_position(belt_at + Vector2( + randf_range(-BELT_HALF_WIDTH, BELT_HALF_WIDTH), + randf_range(-BELT_DEPTH, BELT_DEPTH)), keep_out) + get_parent().add_child(rock) + rocks.append(rock) + + +# The colourful part: the fleet is thrown around the breach and comes apart, +# one vessel at a time, until a single interceptor is left. +func _whirl(delta: float) -> void: + var keep: Node2D = _survivor() + var spin: float = vortex_time * 5.0 + for i in range(fleet.ships.size() - 1, -1, -1): + var ship: Node2D = fleet.ships[i] + if not is_instance_valid(ship): + continue + var angle: float = spin + TAU * float(i) / maxf(float(fleet.ships.size()), 1.0) + var radius: float = lerpf(520.0, 60.0, clampf(vortex_time / 3.0, 0.0, 1.0)) + var want: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * radius + ship.position = ship.position.lerp(want, clampf(delta * 4.0, 0.0, 1.0)) + ship.move_target = ship.position + if ship == keep: + continue + # Staggered rather than all at once, so the fleet visibly comes apart + # instead of blinking out. + if vortex_time >= 0.6 + 0.22 * float(i): + ship.take_damage(1) + + +func _survivor() -> Node2D: + if is_instance_valid(survivor): + return survivor + # The one that lives is the ordinary interceptor - the ship the game + # starts from - not whichever happened to be first in the list. + for ship in fleet.ships: + if is_instance_valid(ship) and String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + survivor = ship + return survivor + survivor = fleet.ships[0] if not fleet.ships.is_empty() else null + return survivor + + +func _living(nodes: Array) -> int: + var n: int = 0 + for node in nodes: + if is_instance_valid(node) and not node.is_queued_for_deletion(): + n += 1 + return n + + +# --- what the prologue draws ---------------------------------------------- + +func _draw() -> void: + # #125: the breach draws itself now - a growing black hole out in the + # world (DimensionalBreach) with a screen-space lens over it + # (BreachOverlay). What is left here is the ribbon of matter being torn + # off the convoy, which belongs to the fleet rather than to the hole. + if not running or beat != Beat.VORTEX: + return + var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0) + for i in range(VORTEX_COLORS.size() * 2): + var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()] + var angle: float = vortex_time * 3.2 + TAU * float(i) / float(VORTEX_COLORS.size() * 2) + var outer: float = lerpf(900.0, 150.0, pull) + var from: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * outer + var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 60.0 + NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.3 + 0.45 * pull), 1.0, 0.8) diff --git a/world/IntroDirector.gd.uid b/world/IntroDirector.gd.uid new file mode 100644 index 0000000..6b574a1 --- /dev/null +++ b/world/IntroDirector.gd.uid @@ -0,0 +1 @@ +uid://j5s70jl5gmxh diff --git a/world/Main.tscn b/world/Main.tscn index 301a200..c6ea6ab 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=31 format=3] +[gd_scene load_steps=34 format=3] [ext_resource type="Script" path="res://world/Starfield.gd" id="1"] [ext_resource type="Script" path="res://vessels/Fleet.gd" id="2"] @@ -20,6 +20,16 @@ [ext_resource type="Script" path="res://ui/NeonNumber.gd" id="19"] [ext_resource type="Script" path="res://ui/NeonPanel.gd" id="20"] [ext_resource type="Script" path="res://ui/SkillTreeView.gd" id="21"] +[ext_resource type="Script" path="res://ui/WaveAlert.gd" id="32"] +[ext_resource type="Script" path="res://ui/GarrisonPanel.gd" id="33"] +[ext_resource type="Script" path="res://vessels/GarrisonField.gd" id="34"] +[ext_resource type="Script" path="res://ui/PlanningPanel.gd" id="35"] +[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="36"] +[ext_resource type="Script" path="res://world/IntroDirector.gd" id="37"] +[ext_resource type="Script" path="res://ui/DevWaveButton.gd" id="38"] +[ext_resource type="Script" path="res://ui/HangarMenuPanel.gd" id="39"] +[ext_resource type="Script" path="res://logistics/RepairDroneField.gd" id="40"] +[ext_resource type="Script" path="res://ui/BreachOverlay.gd" id="41"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] @@ -29,6 +39,9 @@ [ext_resource type="Script" path="res://controllers/GasBombController.gd" id="29"] [ext_resource type="Script" path="res://controllers/LaserController.gd" id="30"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"] +[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] +[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"] +[ext_resource type="Script" path="res://ui/PriceTag.gd" id="price_tag"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -88,9 +101,21 @@ script = ExtResource("11") [node name="BuildingField" type="Node2D" parent="."] script = ExtResource("14") +[node name="IntroDirector" type="Node2D" parent="."] +script = ExtResource("37") + +[node name="QuestDirector" type="Node" parent="."] +script = ExtResource("quest_dir") + +[node name="GarrisonField" type="Node2D" parent="."] +script = ExtResource("34") + [node name="TransporterField" type="Node2D" parent="."] script = ExtResource("23") +[node name="RepairDroneField" type="Node2D" parent="."] +script = ExtResource("40") + [node name="VesselDeliveryField" type="Node2D" parent="."] script = ExtResource("26") @@ -207,6 +232,74 @@ offset_bottom = 172 script = ExtResource("10") icon = 0 +[node name="DevWaveButton" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_right = -24.0 +offset_top = 268.0 +offset_bottom = 384.0 +script = ExtResource("38") + +[node name="Button" type="Control" parent="UI/DevWaveButton"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("10") +icon = 5 +glow_color = Color(1, 0.45, 0.2, 1) + +[node name="BreachOverlay" type="ColorRect" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(1, 1, 1, 1) +script = ExtResource("41") + +[node name="WaveAlert" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -300.0 +offset_right = -24.0 +offset_top = 188.0 +offset_bottom = 252.0 +script = ExtResource("32") + +[node name="QuestButton" type="Control" parent="UI"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -344.0 +offset_right = -196.0 +offset_top = 24.0 +offset_bottom = 172.0 +script = ExtResource("10") +icon = 44 + +[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 24.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = -24.0 +script = ExtResource("quest_panel") + +[node name="CloseButton" type="Control" parent="UI/QuestPanel"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_right = -24.0 +offset_top = 20.0 +offset_bottom = 136.0 +script = ExtResource("10") +icon = 1 + [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("13") @@ -395,19 +488,28 @@ color = Color(0.03, 0.03, 0.08, 0.94) mouse_filter = 2 anchor_right = 1.0 offset_left = 24 -offset_top = 70 +offset_top = 20 offset_right = -24 -offset_bottom = 196 +offset_bottom = 140 text = "Build" horizontal_alignment = 1 +[node name="Bar" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 148.0 +offset_right = -24.0 +offset_bottom = 284.0 +script = ExtResource("24") + [node name="StatusLabel" type="Label" parent="UI/BuildMenu"] mouse_filter = 2 anchor_right = 1.0 offset_left = 24 -offset_top = 206 +offset_top = 292 offset_right = -24 -offset_bottom = 330 +offset_bottom = 372 theme_override_font_sizes/font_size = 40 autowrap_mode = 2 text = "" @@ -451,9 +553,7 @@ offset_left = -200 offset_right = -80 offset_top = -96 offset_bottom = -44 -script = ExtResource("19") -amount = 20 -digit_height = 40.0 +script = ExtResource("price_tag") [node name="CrafterButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -493,9 +593,7 @@ offset_left = 80 offset_right = 200 offset_top = -96 offset_bottom = -44 -script = ExtResource("19") -amount = 12 -digit_height = 40.0 +script = ExtResource("price_tag") [node name="CommandButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -535,9 +633,7 @@ offset_left = -200 offset_right = -80 offset_top = 194 offset_bottom = 246 -script = ExtResource("19") -amount = 15 -digit_height = 40.0 +script = ExtResource("price_tag") [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -577,9 +673,7 @@ offset_left = 80 offset_right = 200 offset_top = 194 offset_bottom = 246 -script = ExtResource("19") -amount = 25 -digit_height = 40.0 +script = ExtResource("price_tag") [node name="TransportHubButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -619,9 +713,7 @@ offset_left = -60 offset_right = 60 offset_top = 484 offset_bottom = 536 -script = ExtResource("19") -amount = 30 -digit_height = 40.0 +script = ExtResource("price_tag") [node name="ShipLabButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -661,9 +753,7 @@ offset_left = 80 offset_right = 200 offset_top = 484 offset_bottom = 536 -script = ExtResource("19") -amount = 35 -digit_height = 40.0 +script = ExtResource("price_tag") [node name="GasBurnerButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -703,9 +793,127 @@ offset_left = -60 offset_right = 60 offset_top = 774 offset_bottom = 826 -script = ExtResource("19") -amount = 30 -digit_height = 40.0 +script = ExtResource("price_tag") + +[node name="OutpostButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 830 +offset_bottom = 958 +script = ExtResource("10") +icon = 40 +glow_color = Color(0.72, 0.86, 0.45, 1) + +[node name="OutpostLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -150 +offset_right = 150 +offset_top = 964 +offset_bottom = 1030 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Outpost" +horizontal_alignment = 1 + +[node name="OutpostCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 1034 +offset_bottom = 1086 +script = ExtResource("price_tag") + +[node name="PlanningButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 1090 +offset_bottom = 1218 +script = ExtResource("10") +icon = 41 +glow_color = Color(0.62, 0.74, 0.92, 1) + +[node name="PlanningLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -150 +offset_right = 150 +offset_top = 1224 +offset_bottom = 1290 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Planning Center" +horizontal_alignment = 1 + +[node name="PlanningCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 1294 +offset_bottom = 1346 +script = ExtResource("price_tag") + +[node name="WorkshopButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 1350 +offset_bottom = 1478 +script = ExtResource("10") +icon = 42 +glow_color = Color(0.5, 0.95, 0.8, 1) + +[node name="WorkshopLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -150 +offset_right = 150 +offset_top = 1484 +offset_bottom = 1550 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Spacial Workshop" +horizontal_alignment = 1 + +[node name="WorkshopCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 1554 +offset_bottom = 1606 +script = ExtResource("price_tag") [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 @@ -774,9 +982,7 @@ offset_left = -70 offset_right = 90 offset_top = -424 offset_bottom = -334 -script = ExtResource("19") -amount = 0 -digit_height = 78.0 +script = ExtResource("price_tag") [node name="BuildButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"] anchor_left = 0.5 @@ -809,6 +1015,7 @@ visible = false mouse_filter = 2 anchor_right = 1.0 anchor_bottom = 1.0 +script = ExtResource("39") [node name="Backdrop" type="ColorRect" parent="UI/HangarMenu"] anchor_right = 1.0 @@ -1294,6 +1501,17 @@ script = ExtResource("10") icon = 37 glow_color = Color(0.7, 0.3, 1, 1) +[node name="VesselH" type="Control" parent="UI/LabMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 320 +offset_right = 380 +offset_top = 300 +offset_bottom = 420 +script = ExtResource("10") +icon = 43 +glow_color = Color(0.5, 0.95, 0.8, 1) + [node name="VesselLabel" type="Label" parent="UI/LabMenu"] visible = false mouse_filter = 2 @@ -1397,6 +1615,19 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "Distorder" +[node name="VesselHLabel" type="Label" parent="UI/LabMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 310 +offset_right = 390 +offset_top = 424 +offset_bottom = 496 +theme_override_font_sizes/font_size = 15 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "Repair Drone" + [node name="VesselUpgradeSeparator" type="ColorRect" parent="UI/LabMenu"] mouse_filter = 2 anchor_right = 1.0 @@ -1617,10 +1848,11 @@ text = "Build a ship" horizontal_alignment = 1 [node name="Bar" type="Control" parent="UI/CraftMenu"] +anchor_right = 1.0 mouse_filter = 2 offset_left = 24 offset_top = 148 -offset_right = 520 +offset_right = -24 offset_bottom = 284 script = ExtResource("24") @@ -2048,6 +2280,119 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) +[node name="PlanningMenu" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 20.0 +offset_right = -200.0 +offset_bottom = 82.0 +text = "Planning Center" +horizontal_alignment = 1 + +[node name="Panel" type="Control" parent="UI/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 40.0 +offset_top = 120.0 +offset_right = -40.0 +offset_bottom = -180.0 +anchor_bottom = 1.0 +script = ExtResource("35") + +[node name="StatusLabel" type="Label" parent="UI/PlanningMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = -160.0 +offset_right = -32.0 +offset_bottom = -24.0 +theme_override_font_sizes/font_size = 28 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="CloseButton" type="Control" parent="UI/PlanningMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_top = 20.0 +offset_right = -24.0 +offset_bottom = 136.0 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.62, 0.74, 0.92, 1) + +[node name="GarrisonMenu" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/GarrisonMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/GarrisonMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 20.0 +offset_right = -200.0 +offset_bottom = 82.0 +text = "Outpost Garrison" +horizontal_alignment = 1 + +[node name="Panel" type="Control" parent="UI/GarrisonMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 40.0 +offset_top = 120.0 +offset_right = -40.0 +offset_bottom = 620.0 +script = ExtResource("33") + +[node name="StatusLabel" type="Label" parent="UI/GarrisonMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = -160.0 +offset_right = -32.0 +offset_bottom = -24.0 +theme_override_font_sizes/font_size = 30 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="CloseButton" type="Control" parent="UI/GarrisonMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_top = 20.0 +offset_right = -24.0 +offset_bottom = 136.0 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.72, 0.86, 0.45, 1) + [node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2 @@ -2060,23 +2405,13 @@ anchor_right = 1.0 anchor_bottom = 1.0 color = Color(0.03, 0.03, 0.08, 0.94) -[node name="TitleLabel" type="Label" parent="UI/SkillMenu"] +[node name="Bar" type="Control" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 offset_left = 24.0 offset_top = 20.0 offset_right = -160.0 -offset_bottom = 82.0 -text = "Research" -horizontal_alignment = 1 - -[node name="Bar" type="Control" parent="UI/SkillMenu"] -mouse_filter = 2 -anchor_right = 1.0 -offset_left = 24.0 -offset_top = 148.0 -offset_right = -24.0 -offset_bottom = 284.0 +offset_bottom = 156.0 script = ExtResource("24") [node name="Tree" type="Control" parent="UI/SkillMenu"] @@ -2085,9 +2420,9 @@ clip_contents = true anchor_right = 1.0 anchor_bottom = 1.0 offset_left = 24.0 -offset_top = 296.0 +offset_top = 168.0 offset_right = -24.0 -offset_bottom = -434.0 +offset_bottom = -280.0 script = ExtResource("21") [node name="DetailLabel" type="Label" parent="UI/SkillMenu"] @@ -2096,26 +2431,26 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -426.0 -offset_right = -32.0 -offset_bottom = -306.0 +offset_top = -272.0 +offset_right = -300.0 +offset_bottom = -152.0 theme_override_font_sizes/font_size = 34 autowrap_mode = 2 -horizontal_alignment = 1 +horizontal_alignment = 0 text = "" [node name="CostNumber" type="Control" parent="UI/SkillMenu"] +alignment = 2 mouse_filter = 2 -anchor_left = 0.5 +anchor_left = 1.0 anchor_top = 1.0 -anchor_right = 0.5 +anchor_right = 1.0 anchor_bottom = 1.0 -offset_left = -64.0 -offset_top = -300.0 -offset_right = 76.0 -offset_bottom = -236.0 -script = ExtResource("19") -digit_height = 52.0 +offset_left = -288.0 +offset_top = -272.0 +offset_right = -32.0 +offset_bottom = -152.0 +script = ExtResource("price_tag") [node name="StatusLabel" type="Label" parent="UI/SkillMenu"] mouse_filter = 2 @@ -2123,9 +2458,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -230.0 +offset_top = -76.0 offset_right = -32.0 -offset_bottom = -178.0 +offset_bottom = -24.0 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 horizontal_alignment = 1 @@ -2141,3 +2476,13 @@ offset_bottom = 136.0 script = ExtResource("10") icon = 1 glow_color = Color(0.55, 0.6, 1, 1) +[node name="NarrationBanner" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 60.0 +offset_right = -60.0 +offset_top = 300.0 +offset_bottom = 428.0 +script = ExtResource("36") + diff --git a/world/QuestDirector.gd b/world/QuestDirector.gd new file mode 100644 index 0000000..a00bcd7 --- /dev/null +++ b/world/QuestDirector.gd @@ -0,0 +1,27 @@ +extends Node + +# Forgejo #132: the voice of the quest chain. +# +# GameState walks the chain and QUEUES what it wants said (see +# GameState.quest_messages); this node is the thing that says it. The split +# is not ceremony: quests advance in the autoload, which keeps running on a +# planet surface where there is no narration banner at all, so a line +# emitted straight at a banner would simply be lost down there. Queued and +# drained, a quest finished on a surface is read out on the way back up. +# +# Draining every waiting line at once is safe: the banner queues lines +# itself and shows them one at a time, and each one stops the game until it +# is acknowledged (#128). + +@onready var narration: Control = get_node_or_null("../UI/NarrationBanner") + + +func _process(_delta: float) -> void: + if narration == null: + return + # The prologue owns the banner while it runs. A quest line arriving in + # the middle of the convoy being torn apart would be read as part of it. + if GameState.intro_active: + return + for message in GameState.take_quest_messages(): + narration.say(String(message.line), String(message.speaker)) diff --git a/world/QuestDirector.gd.uid b/world/QuestDirector.gd.uid new file mode 100644 index 0000000..4416ec5 --- /dev/null +++ b/world/QuestDirector.gd.uid @@ -0,0 +1 @@ +uid://g3dusm3u2tca diff --git a/world/WorldMap.gd b/world/WorldMap.gd index b1f631a..d694157 100644 --- a/world/WorldMap.gd +++ b/world/WorldMap.gd @@ -19,6 +19,29 @@ func _ready() -> void: open_button.pressed.connect(open_map) recenter_button.pressed.connect(recenter_view) close_button.pressed.connect(close_map) + # #128: a dialog stops the tree, and the prologue opens on one - so + # _process's first frame never comes and the button below would sit on + # screen behind the opening line. Applied once here too, where it does + # not depend on the game running. + _apply_intro_lock() + + +func _process(_delta: float) -> void: + _apply_intro_lock() + + +# #122: the prologue leaves movement and nothing else, and the map is a +# button like any other. Owned here rather than by BuildController, which +# hides the rest - this button belongs to the map. +func _apply_intro_lock() -> void: + if GameState.intro_active: + if open_button.visible: + open_button.visible = false + if visible: + close_map() + open_button.visible = false + elif not open_button.visible and not visible: + open_button.visible = true func open_map() -> void: