From b641fa58918a5c9e381515f96a6ad915a3894c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 15:00:41 +0200 Subject: [PATCH] Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 --- AsteroidExplosionVFX.gd | 18 +- AsteroidField.gd | 23 +- BuildController.gd | 143 +++++++-- Building.gd | 379 ++++++++++++++++------- ExplosionParticle.gd | 22 ++ ExplosionParticle.gd.uid | 1 + ExplosionVFXBase.gd | 46 ++- Fleet.gd | 30 +- FormationEditor.gd | 4 +- GameState.gd | 137 ++++++++ IconButton.gd | 33 +- LightBurstVFX.gd | 10 +- Main.tscn | 203 +++++++----- NeonDraw.gd | 4 +- NeonNumber.gd | 14 +- NeonPiece.gd | 47 +++ NeonPiece.gd.uid | 1 + PlanetSurface.tscn | 22 +- Projectile.gd | 15 +- SkillTreeView.gd | 121 ++++++++ SkillTreeView.gd.uid | 1 + fonts/CarroisGothicSC-Regular.ttf | Bin 0 -> 40356 bytes fonts/CarroisGothicSC-Regular.ttf.import | 36 +++ fonts/LICENSE.txt | 202 ++++++++++++ fonts/README.md | 15 + tests/_crafter_preview.gd | 2 +- tests/_font_probe.gd | 3 +- tests/_profile_startup.gd | 40 +++ tests/_profile_startup.gd.uid | 1 + tests/_run_inside.sh | 93 ++++++ tests/_skill_menu_preview.gd | 29 ++ tests/_skill_menu_preview.gd.uid | 1 + tests/_skill_menu_preview.tscn | 6 + tests/_vfx_preview.gd | 47 +++ tests/_vfx_preview.gd.uid | 1 + tests/_vfx_preview.tscn | 6 + tests/run_tests.sh | 80 ++++- tests/test_asteroid_safe_spawn.gd | 114 +++++++ tests/test_asteroid_safe_spawn.gd.uid | 1 + tests/test_build_info_popup.gd | 6 + tests/test_building_redraw_cost.gd | 110 +++++++ tests/test_building_redraw_cost.gd.uid | 1 + tests/test_extractor_building.gd | 5 + tests/test_extractor_income.gd | 8 +- tests/test_joystick_control.gd | 14 +- tests/test_skill_tree.gd | 169 ++++++++++ tests/test_skill_tree.gd.uid | 1 + ui_theme.tres | 15 +- 48 files changed, 1981 insertions(+), 299 deletions(-) create mode 100644 ExplosionParticle.gd create mode 100644 ExplosionParticle.gd.uid create mode 100644 NeonPiece.gd create mode 100644 NeonPiece.gd.uid create mode 100644 SkillTreeView.gd create mode 100644 SkillTreeView.gd.uid create mode 100644 fonts/CarroisGothicSC-Regular.ttf create mode 100644 fonts/CarroisGothicSC-Regular.ttf.import create mode 100644 fonts/LICENSE.txt create mode 100644 fonts/README.md create mode 100644 tests/_profile_startup.gd create mode 100644 tests/_profile_startup.gd.uid create mode 100644 tests/_run_inside.sh create mode 100644 tests/_skill_menu_preview.gd create mode 100644 tests/_skill_menu_preview.gd.uid create mode 100644 tests/_skill_menu_preview.tscn create mode 100644 tests/_vfx_preview.gd create mode 100644 tests/_vfx_preview.gd.uid create mode 100644 tests/_vfx_preview.tscn create mode 100644 tests/test_asteroid_safe_spawn.gd create mode 100644 tests/test_asteroid_safe_spawn.gd.uid create mode 100644 tests/test_building_redraw_cost.gd create mode 100644 tests/test_building_redraw_cost.gd.uid create mode 100644 tests/test_skill_tree.gd create mode 100644 tests/test_skill_tree.gd.uid diff --git a/AsteroidExplosionVFX.gd b/AsteroidExplosionVFX.gd index 1396edb..9830458 100644 --- a/AsteroidExplosionVFX.gd +++ b/AsteroidExplosionVFX.gd @@ -17,13 +17,21 @@ func _random_particle_size() -> float: return randf_range(3.0, 7.0) -func _draw_particle(p: Dictionary, alpha: float) -> void: +# Debris tumbles independently of its flight path, unlike the light-burst +# streaks - each chunk needs its own evolving rotation, so the base class +# must keep writing visual.rotation every frame instead of leaving it fixed. +func _particle_spins() -> bool: + return true + + +func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void: var s: float = p["size"] + # Unrotated local points - the child node's own `rotation` (driven by + # the base class from p["rot"]) supplies the tumble that used to be + # baked into a fresh pt.rotated(p["rot"]) call every frame. var local_pts: PackedVector2Array = PackedVector2Array([ Vector2(-s, -s * 0.6), Vector2(s * 0.7, -s * 0.5), Vector2(s, s * 0.5), Vector2(-s * 0.6, s), ]) - var world_pts: PackedVector2Array = PackedVector2Array() - for pt in local_pts: - world_pts.append(p["pos"] + pt.rotated(p["rot"])) - draw_colored_polygon(world_pts, Color(COLOR.r, COLOR.g, COLOR.b, alpha)) + visual.draw_callable = func(item: CanvasItem) -> void: + item.draw_colored_polygon(local_pts, COLOR) diff --git a/AsteroidField.gd b/AsteroidField.gd index efb7b39..5b10d7b 100644 --- a/AsteroidField.gd +++ b/AsteroidField.gd @@ -7,6 +7,10 @@ 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. +@export var safe_spawn_radius: float = 420.0 @export var despawn_radius_chunks: int = 4 @export var asteroids_per_chunk: int = 6 @export var min_speed: float = 12.0 @@ -63,14 +67,31 @@ 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: + 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. + var away: Vector2 = candidate - centre + if away.length() < 0.001: + away = Vector2.RIGHT.rotated(randf() * TAU) + return centre + away.normalized() * safe_spawn_radius + + func _spawn_chunk_asteroids(chunk: Vector2i) -> void: var origin: Vector2 = WorldGen.chunk_origin(chunk) var spawned: Array = [] + var fleet_centre: Vector2 = 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 = origin + local_pos + asteroid.position = _safe_position(origin + local_pos, fleet_centre) 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/BuildController.gd b/BuildController.gd index 1931cae..1872226 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -19,7 +19,6 @@ extends Node2D @onready var world_map: Control = get_node("../UI/WorldMap") @onready var build_button: IconButton = get_node("../UI/BuildButton") @onready var action_button: IconButton = get_node("../UI/BuildActionButton") -@onready var action_label: Label = get_node("../UI/BuildActionLabel") @onready var build_menu: Control = get_node("../UI/BuildMenu") @onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton") @@ -41,6 +40,14 @@ extends Node2D @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") +@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_status: Label = get_node("../UI/SkillMenu/StatusLabel") +@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton") +@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton") + @onready var formation_menu: Control = get_node("../UI/FormationMenu") @onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor") @onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") @@ -101,24 +108,34 @@ func _ready() -> void: formation_reset_button.pressed.connect(_on_reset_formation) formation_editor.formation_changed.connect(_on_formation_changed) + skill_close_button.pressed.connect(func(): skill_menu.visible = false) + skill_unlock_button.pressed.connect(_on_unlock_pressed) + skill_tree.node_picked.connect(_on_skill_node_picked) + build_menu.visible = false info_popup.visible = false craft_menu.visible = false formation_menu.visible = false + skill_menu.visible = false action_button.visible = false - action_label.visible = false # True while a building prompt or panel owns the shared action slot, so the # enter-atmosphere prompt knows to stay out of the way. func is_showing_building_prompt() -> bool: - return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible + return current_building != null or build_menu.visible or craft_menu.visible \ + or formation_menu.visible or skill_menu.visible func _unhandled_input(event: InputEvent) -> void: - # The formation editor is driven from here rather than via _gui_input, - # for the same reason as the joystick: its Control must not swallow - # touches, or dragging a slot would never reach it. + # The formation editor and the skill tree are driven from here rather than + # via _gui_input, for the same reason as the joystick: a Control that + # consumes touches through the GUI system swallows them before they get + # here, which is what made dragging a formation slot silently do nothing. + if skill_menu.visible: + if event is InputEventScreenTouch and skill_tree.handle_touch(event): + get_viewport().set_input_as_handled() + return if not formation_menu.visible: return if event is InputEventScreenTouch and formation_editor.handle_touch(event): @@ -133,20 +150,19 @@ func _process(delta: float) -> void: 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. - build_extractor_button.enabled = _planet_under_fleet() != null + build_extractor_button.enabled = _planet_under_fleet() != null \ + and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) - if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible: + if world_map.visible or build_menu.visible or craft_menu.visible \ + or formation_menu.visible or skill_menu.visible: current_building = null pending_building = null action_button.visible = false - action_label.visible = false return # Debounced exactly like the enter-atmosphere prompt, so the action # button can't flicker while the fleet drifts on the interact boundary. var detected: Node2D = building_field.get_building_fleet_is_on() - if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP: - detected = null # nothing to do at a mothership beyond respawning if detected != pending_building: pending_building = detected @@ -159,13 +175,21 @@ func _process(delta: float) -> void: current_building = pending_building action_button.visible = current_building != null - action_label.visible = current_building != null if current_building != null: - action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP - action_label.text = current_building.get_display_name() + action_button.icon = _action_icon_for(current_building.building_type) action_button.queue_redraw() +func _action_icon_for(type: String) -> int: + match type: + GameState.BUILDING_SHIP_CRAFTER: + return IconButton.Icon.SHIP + GameState.BUILDING_MOTHERSHIP: + return IconButton.Icon.MOTHERSHIP + _: + return IconButton.Icon.COMMAND_CENTER + + func _check_fleet_wipe() -> void: if not fleet.ships.is_empty(): return @@ -178,7 +202,10 @@ func _check_fleet_wipe() -> void: func _on_action_pressed() -> void: if current_building == null: return - if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: + if current_building.building_type == GameState.BUILDING_MOTHERSHIP: + skill_menu.visible = true + _refresh_skill_menu() + elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true _refresh_craft_status() elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: @@ -199,10 +226,14 @@ func _planet_under_fleet() -> Node2D: func _refresh_build_status() -> void: - # The extractor option is always listed, but greyed unless the fleet is - # actually over a planet - showing it only sometimes would leave the - # player wondering whether the game has it at all. - build_extractor_button.enabled = _planet_under_fleet() != null + # Every option is always listed, greyed when it cannot be used - showing + # them only sometimes would leave the player wondering whether the game + # has them at all. Greyed buttons still emit `pressed`, so each one can + # explain itself in the info popup. + build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) + build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER) + build_extractor_button.enabled = _planet_under_fleet() != null \ + and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. @@ -248,9 +279,15 @@ func _show_building_info(type: String) -> void: var lines: Array = [info.body] var can_build: bool = true + # Locked structures still list themselves, greyed, so the player can see + # what exists and what research would open it up. + if not GameState.is_building_unlocked(type): + lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type)) + can_build = false + # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. - if type == GameState.BUILDING_EXTRACTOR: + if can_build and type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") @@ -260,7 +297,7 @@ func _show_building_info(type: String) -> void: can_build = false else: lines.append("Will mine %s." % planet.planet_name) - elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): lines.append("WARNING: only one may exist - this destroys your current one.") if can_build and not GameState.can_afford(cost): @@ -290,6 +327,9 @@ func _confirm_build() -> void: # Everything is re-checked here rather than trusted from when the popup # opened: the fleet keeps flying while it is up, so it can drift off the # planet an extractor was going to mine. + if not GameState.is_building_unlocked(type): + return + if type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_name): @@ -324,15 +364,70 @@ func _try_place(type: String, cost: int) -> void: build_menu.visible = false +# --- research (the mothership's skill tree) -------------------------------- + +func _on_skill_node_picked(id: String) -> void: + _refresh_skill_menu(id) + + +func _refresh_skill_menu(id: String = "") -> void: + if id == "": + id = skill_tree.selected_id + if id == "": + # 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_status.text = "Fleet capacity %d" % GameState.max_fleet_size() + skill_unlock_button.visible = false + return + + var node: Dictionary = GameState.skill_node(id) + if node.is_empty(): + return + skill_detail.text = "%s\n%s" % [node.name, node.desc] + skill_cost.amount = int(node.cost) + + if GameState.is_unlocked(id): + skill_status.text = "Researched" + skill_unlock_button.visible = false + elif not GameState.prerequisite_met(id): + skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name + skill_unlock_button.visible = false + elif not GameState.can_afford(int(node.cost)): + skill_status.text = "Not enough rocks - you have %d" % GameState.rocks + skill_unlock_button.visible = false + else: + skill_status.text = "" + skill_unlock_button.visible = true + + +func _on_unlock_pressed() -> void: + var id: String = skill_tree.selected_id + if id == "": + return + if not GameState.unlock(id): + # Re-check rather than trust the button: rocks can be spent elsewhere + # while this menu is open, since haulers keep delivering. + _refresh_skill_menu(id) + return + # A capacity upgrade has to reach the fleet that is already flying, not + # just ones rebuilt later. + if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: + fleet.apply_formation() + skill_tree.queue_redraw() + _refresh_skill_menu(id) + + func _refresh_craft_status() -> void: craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ - GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size() + GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.max_fleet_size() ] func _on_craft_interceptor() -> void: - if fleet.ships.size() >= GameState.formation.size(): - craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size() + if fleet.ships.size() >= GameState.max_fleet_size(): + craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size() return if not GameState.spend(GameState.SHIP_COST): craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] diff --git a/Building.gd b/Building.gd index e9fe17a..5164b70 100644 --- a/Building.gd +++ b/Building.gd @@ -39,6 +39,7 @@ const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0) const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0) const RIB_RADIUS: float = 36.0 const RAIL_Y: float = -32.0 +const FLASH_RING_RADIUS: float = 20.0 # The hull in four pieces, attached in this order. Each carries the point # the crane welds it on at, which is where the sparks fly from. @@ -66,26 +67,56 @@ var phase_time: float = 0.0 var _hull_parts: Array[PackedVector2Array] = [] +# Every moving element is its own NeonPiece, rasterised once when the +# building is created and thereafter only moved, scaled, faded or hidden. +# Nothing here ever calls queue_redraw() again: the building's own _draw() +# lays down the fixed structure a single time, and the animation is pure +# node-property work from then on. +var _core_cell: NeonPiece = null +var _satellites: Node2D = null +var _beacons: Array = [] +var _clamps: NeonPiece = null +var _crane: Node2D = null +var _hoist: NeonPiece = null +var _sparks: Node2D = null +var _hull_holder: Node2D = null +var _hull_pieces: Array = [] +var _flash_ring: NeonPiece = null +var _wash: NeonPiece = null + func _ready() -> void: add_to_group("buildings") z_index = -1 # The crafter runs an assembly line and the mothership holds a live - # reactor with satellites in orbit, so both redraw. The command centre is - # static art and is drawn exactly once - per-frame redraws of art that - # never changes were a real framerate cost earlier on. + # reactor with satellites in orbit, so both animate. The command centre is + # entirely static and needs no per-frame work at all. var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER set_process(animated) if building_type == GameState.BUILDING_SHIP_CRAFTER: for part in SHIP_PARTS: _hull_parts.append(PackedVector2Array(part.points)) + _build_crafter_pieces() + elif building_type == GameState.BUILDING_MOTHERSHIP: + _build_mothership_pieces() queue_redraw() func _process(delta: float) -> void: anim_time = fmod(anim_time + delta, CYCLE) phase_time += delta - queue_redraw() + apply_animation() + + +# Pushes the current clock onto the movable pieces. Normally driven from +# _process; public so a preview or a test can pin the clock and see exactly +# that frame without having to run the animation up to it in real time. +func apply_animation() -> void: + match building_type: + GameState.BUILDING_MOTHERSHIP: + _animate_mothership() + GameState.BUILDING_SHIP_CRAFTER: + _animate_crafter() # Where the satellites are, and how open the reactor core is. Both are read @@ -109,14 +140,25 @@ func get_display_name() -> String: return "Command Center" +# How many times this building's geometry has actually been rasterised. The +# whole point of the NeonPiece decomposition is that this stays at 1 for the +# building's lifetime however long it animates, so it is worth being able to +# assert rather than assume. +var draw_count: int = 0 + + func _draw() -> void: + draw_count += 1 + # Runs exactly once per building: this is the fixed structure. Everything + # that moves is a NeonPiece child, animated through node properties, so + # nothing here ever asks for a redraw again. match building_type: GameState.BUILDING_COMMAND_CENTER: _draw_command_center() GameState.BUILDING_MOTHERSHIP: - _draw_mothership() + _draw_mothership_static(self) _: - _draw_crafter() + _draw_crafter_static(self) # --- shared helpers ------------------------------------------------------- @@ -139,17 +181,57 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int -func _draw_command_center() -> void: - # Diamond with three formation pips inside - reads as "arrangement". - var body: PackedVector2Array = PackedVector2Array([ - Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0), +# --- command centre: a tactical plotting hub ------------------------------ +# The mothership is radial-and-swept, the crafter is an asymmetric assembly +# line, so the command centre reads best as the odd one out: rigid, hexagonal, +# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond +# readout, and every spoke ends in a formation port - half of them holding a +# token, half left open - so the structure itself shows fleet slots waiting +# to be filled, which is exactly what the drag-and-drop editor inside does. + +const CMD_HEX_RADIUS: float = 88.0 +const CMD_DECK_RADIUS: float = 66.0 +const CMD_HUB_RADIUS: float = 30.0 +const CMD_SCREEN_RADIUS: float = 14.0 +const CMD_PORT_COUNT: int = 6 +const CMD_PORT_LENGTH: float = 16.0 +const CMD_TOKEN_RADIUS: float = 9.0 +const CMD_STUD_RADIUS: float = 7.0 + + +func _diamond(centre: Vector2, r: float) -> PackedVector2Array: + return PackedVector2Array([ + centre + Vector2(0, -r), centre + Vector2(r, 0), + centre + Vector2(0, r), centre + Vector2(-r, 0), ]) - NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0) - for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]: - var pip: PackedVector2Array = PackedVector2Array([ - p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8), - ]) - NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0) + + +func _draw_command_center() -> void: + var c: Color = COMMAND_COLOR + var readout: Color = Color(0.82, 0.86, 1.0) + + # The rim: a hex table holding the whole layout together, with a fainter + # deck ring nested just inside it - the same "plating has layers" read the + # mothership gets from its nested reactor rings. + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) + + for i in range(CMD_PORT_COUNT): + var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + # Spoke bracing the hub to the rim. + NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36) + # Socket where the mast plugs into the rim. + NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85) + # Port mast standing proud of the rim; every other one is manned. + var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH) + NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4) + if i % 2 == 0: + NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0) + + # Command core: a diamond hub with a lit readout set inside it. + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) # --- mothership: a radial flagship ---------------------------------------- @@ -187,49 +269,65 @@ func _talon(base_angle: float) -> PackedVector2Array: return pts -func _draw_mothership() -> void: +func _draw_mothership_static(canvas: CanvasItem) -> void: var c: Color = MOTHERSHIP_COLOR - var core_color: Color = Color(0.75, 1.0, 0.85) - for i in range(ARM_COUNT): var base: float = (float(i) / float(ARM_COUNT)) * TAU - NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95) + NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95) # Crystal panel set into the gap behind each arm. var pa: float = base - 0.30 - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([ Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0, Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0, Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0, Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0, ]), c, 0.7) - # Reactor core: nested rings around a cell that breathes. - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) - var breath: float = core_pulse() - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0), - core_color, lerpf(0.55, 1.0, breath)) + # Reactor housing: the nested rings around the cell that breathes. + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) - # Satellites holding station around it, drifting slowly round. - var orbit: float = orbit_angle() + +const CORE_CELL_RADIUS: float = 12.0 + + +func _build_mothership_pieces() -> void: + var core_color: Color = Color(0.75, 1.0, 0.85) + + # The breathing cell: drawn at a reference size and then scaled, rather + # than rebuilt at a new radius every frame. + _core_cell = NeonPiece.create(self, Vector2.ZERO, core_color) + _core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0)) + + # Satellites sit at fixed offsets inside a holder; the orbit is the + # holder's rotation, so four hexagon pairs become one transform update. + _satellites = Node2D.new() + add_child(_satellites) for i in range(SATELLITE_COUNT): - var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU + var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT - NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85) - NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8) + var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR) + shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85) + var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color) + cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8) + + +func _animate_mothership() -> void: + var breath: float = core_pulse() + var radius: float = lerpf(9.0, 15.0, breath) + _core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS) + _core_cell.modulate.a = lerpf(0.55, 1.0, breath) + _satellites.rotation = orbit_angle() # --- ship crafter: an orbital yard with a hull in the cradle -------------- -func _draw_crafter() -> void: +func _draw_crafter_static(canvas: CanvasItem) -> void: var c: Color = CRAFTER_COLOR - var t: float = anim_time / CYCLE - # Fabricator core: a reactor housing whose inner cell pulses as it feeds - # the yard. This replaces the chimney - nothing vents smoke in vacuum. - NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) - var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) - NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow) + # Fabricator core housing. This replaces the chimney - nothing vents + # smoke in vacuum. + NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) # Radiator fins above and below the core, slatted like real ones. for dir_y in [-1.0, 1.0]: @@ -237,44 +335,114 @@ func _draw_crafter() -> void: CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), ]) - NeonDraw.draw_glowing_polygon(self, fin, c, 0.75) + NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75) for f in [0.35, 0.68]: - NeonDraw.draw_glowing_line(self, + NeonDraw.draw_glowing_line(canvas, fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28) # Truss spine linking the core to the cradle: two rails with X-bracing, # the standard "this was assembled in orbit" read. var spine_top: float = 4.0 var spine_bottom: float = 22.0 - NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) - NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) + NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) + NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) for i in range(3): var x0: float = -32.0 + float(i) * 15.0 var x1: float = x0 + 15.0 - NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) - NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) + NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) + NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) # Construction cradle: two open ribs holding the hull, with the crane # rail overhead. Open at the top so the finished hull can clear it. - NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) - NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) - NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) + NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) for side in [-1.0, 1.0]: - NeonDraw.draw_glowing_line(self, + NeonDraw.draw_glowing_line(canvas, BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3) + + +func _build_crafter_pieces() -> void: + var c: Color = CRAFTER_COLOR + + # The core's inner cell: fades with the reactor rather than being redrawn + # at a new brightness each frame. + _core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6)) + _core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0)) + # Docking beacons, blinking out of step with each other. for i in range(2): var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0) - var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) - NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink) + var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75)) + beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4)) + _beacons.append(beacon) + + # Clamp arms reaching in from each rib. They let go as the hull launches, + # which is a fade rather than a redraw. + _clamps = NeonPiece.create(self, Vector2.ZERO, c) + for side in [-1.0, 1.0]: + var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) + var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) + _clamps.add_line(outer, inner, 0.35) + _clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3) + + # Crane: the head rides the rail, so only its x moves. The hoist is drawn + # one unit long and stretched with scale.y to reach whatever seam is + # being welded - stretching a line needs no redraw, redrawing it would. + _crane = Node2D.new() + add_child(_crane) + var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c) + head.add_polygon(PackedVector2Array([ + Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6), + ])) + head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8) + _hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5) + _hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3) + + # Weld sparks: four spokes on a holder that spins and stretches. The + # jitter is genuinely procedural, but rotating and scaling a holder gives + # the same flicker without re-tessellating the spokes every frame. + _sparks = Node2D.new() + add_child(_sparks) + for i in range(4): + var a: float = float(i) * TAU / 4.0 + var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7)) + spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3) + + # The hull under construction. Each part is its own piece so attaching one + # is a `visible` flip, and the launch is the holder's position and alpha. + _hull_holder = Node2D.new() + add_child(_hull_holder) + for i in range(_hull_parts.size()): + var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR) + part.add_polygon(_hull_parts[i]) + part.visible = false + _hull_pieces.append(part) + + # Engine wash under the hull, and the completion ring. The ring is drawn + # at a reference radius and scaled outwards. + _wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0) + for dx in [-4.0, 4.0]: + _wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45) + + _flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0) + _flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0)) + + +func _animate_crafter() -> void: + var t: float = anim_time / CYCLE + + _core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) + for i in range(_beacons.size()): + _beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) if t < ASSEMBLE_END: - _draw_assembly(t / ASSEMBLE_END, c) + _animate_assembly(t / ASSEMBLE_END) elif t < FLASH_END: - _draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c) + _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) else: - _draw_launch((t - FLASH_END) / (1.0 - FLASH_END)) + _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) # How many pieces are on the hull at this point through the assembly phase. @@ -287,78 +455,73 @@ func attached_part_count(p: float) -> int: return n -func _draw_clamps(c: Color, pulse: float) -> void: - # Arms reaching in from each rib to hold the hull steady. They let go - # once it launches, so this is not drawn during the launch phase. - for side in [-1.0, 1.0]: - var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) - var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) - NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35) - NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3) +func _place_hull(centre: Vector2, alpha: float) -> void: + # The parts are defined around their own origin, so the holder goes + # straight to the point they should be centred on. + _hull_holder.position = centre + _hull_holder.modulate.a = alpha -func _draw_assembly(p: float, c: Color) -> void: - _draw_clamps(c, 0.8) +func _animate_assembly(p: float) -> void: + _clamps.visible = true + _clamps.modulate.a = 0.8 + _flash_ring.modulate.a = 0.0 + _wash.modulate.a = 0.0 + _place_hull(BAY_CENTRE, 1.0) - # Everything welded on so far, newest part still glowing from the seam. var next_part: int = attached_part_count(p) - for i in range(next_part): - # Freshly attached parts flare, then settle to full brightness. - var age: float = p - float(SHIP_PARTS[i].at) - var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) - _draw_hull_part(i, BAY_CENTRE, pulse) + for i in range(_hull_pieces.size()): + var attached: bool = i < next_part + _hull_pieces[i].visible = attached + if attached: + # Freshly attached parts flare, then settle to full brightness. + var age: float = p - float(SHIP_PARTS[i].at) + _hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) # The crane rides the rail over whichever seam is being worked, and # retracts once there is nothing left to attach. var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) - if next_part >= _hull_parts.size(): + if next_part >= _hull_pieces.size(): weld.y = RAIL_Y + 16.0 - var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0) - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - head + Vector2(-10, -7), head + Vector2(10, -7), - head + Vector2(6, 6), head + Vector2(-6, 6), - ]), c, 1.0) - NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35) - NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3) + _crane.position = Vector2(weld.x, RAIL_Y + 13.0) + _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0) - if next_part < _hull_parts.size(): - # Weld sparks: short spokes whose angles jitter frame to frame. + var welding: bool = next_part < _hull_pieces.size() + _sparks.visible = welding + if welding: var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0)) - for i in range(4): - var a: float = phase_time * 7.0 + float(i) * TAU / 4.0 - var length: float = 6.0 + 5.0 * flicker - NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length, - Color(1.0, 0.95, 0.7), flicker, 0.3) + _sparks.position = weld + _sparks.rotation = phase_time * 7.0 + _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) + _sparks.modulate.a = flicker -func _draw_completion_flash(p: float, c: Color) -> void: +func _animate_completion_flash(p: float) -> void: # Whole hull, clamps springing open, and a ring pushing outwards to mark # it as finished. - _draw_clamps(c, 1.0 - p) - for i in range(_hull_parts.size()): - _draw_hull_part(i, BAY_CENTRE, 1.0) - NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p) + _clamps.modulate.a = 1.0 - p + _sparks.visible = false + _place_hull(BAY_CENTRE, 1.0) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 + # Retracted, so it does not hang over the finished hull. + _crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0) + _hoist.scale.y = 1.0 + _flash_ring.modulate.a = 1.0 - p + _flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS) -func _draw_launch(p: float) -> void: +func _animate_launch(p: float) -> void: # Clears the cradle and fades out; the next cycle starts from bare ribs. + _clamps.modulate.a = 0.0 + _sparks.visible = false + _flash_ring.modulate.a = 0.0 var eased: float = p * p - var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased) var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) - for i in range(_hull_parts.size()): - _draw_hull_part(i, centre, fade) + _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 # Engine wash, strongest just after it breaks free of the clamps. - var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI))) - for dx in [-4.0, 4.0]: - NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14), - centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust), - Color(1.0, 0.85, 0.45), thrust, 0.45) - - -func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void: - if pulse <= 0.01: - return - var moved: PackedVector2Array = PackedVector2Array() - for point in _hull_parts[index]: - moved.append(centre + point) - NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse) + _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) diff --git a/ExplosionParticle.gd b/ExplosionParticle.gd new file mode 100644 index 0000000..6a5591f --- /dev/null +++ b/ExplosionParticle.gd @@ -0,0 +1,22 @@ +class_name ExplosionParticle +extends Node2D + +# One debris chunk / energy streak inside an explosion. Draws its fixed +# local-space shape exactly once (like Ship.gd/Asteroid.gd draw once and +# animate via node properties), then ExplosionVFXBase drives it every frame +# purely through `position` and `rotation` - no further redraw needed. +# +# The shape itself is supplied by the owning VFX class via a Callable rather +# than being known here, since different explosion styles (glowing streak +# vs. rock-chunk polygon) draw completely differently; this class only owns +# the "draw once, then just move" mechanics. + +var draw_callable: Callable + + +func _ready() -> void: + queue_redraw() + + +func _draw() -> void: + draw_callable.call(self) diff --git a/ExplosionParticle.gd.uid b/ExplosionParticle.gd.uid new file mode 100644 index 0000000..ed360bf --- /dev/null +++ b/ExplosionParticle.gd.uid @@ -0,0 +1 @@ +uid://dm2m305hlrv6l diff --git a/ExplosionVFXBase.gd b/ExplosionVFXBase.gd index 1196585..457c72a 100644 --- a/ExplosionVFXBase.gd +++ b/ExplosionVFXBase.gd @@ -22,14 +22,25 @@ func _ready() -> void: for i in range(particle_count): var angle: float = randf() * TAU var speed: float = randf_range(min_speed, max_speed) - particles.append({ + var p: Dictionary = { "pos": Vector2.ZERO, "vel": Vector2(cos(angle), sin(angle)) * speed, "rot": randf() * TAU, "rot_speed": randf_range(-6.0, 6.0), "size": _random_particle_size(), - }) - queue_redraw() + } + # Each particle is its own child CanvasItem, drawn once at its own + # fixed local shape (subclass decides the shape via + # _configure_particle). From here on the "animation" is just this + # child's position/rotation, exactly like Ship.gd/Asteroid.gd - no + # redraw, no re-tessellating four polylines per particle per frame. + var visual: ExplosionParticle = ExplosionParticle.new() + _configure_particle(visual, p) + if _particle_spins(): + visual.rotation = p["rot"] + add_child(visual) + p["visual"] = visual + particles.append(p) func _process(delta: float) -> void: @@ -37,22 +48,35 @@ func _process(delta: float) -> void: if age >= lifetime: queue_free() return + # CanvasItem modulate is inherited down the tree, so setting it once + # here fades every particle's already-drawn shape for free - no need + # to recompute or re-draw a single one of them. + modulate.a = clamp(1.0 - (age / lifetime), 0.0, 1.0) for p in particles: p["pos"] += p["vel"] * delta p["vel"] *= 0.9 p["rot"] += p["rot_speed"] * delta - queue_redraw() - - -func _draw() -> void: - var alpha: float = clamp(1.0 - (age / lifetime), 0.0, 1.0) - for p in particles: - _draw_particle(p, alpha) + var visual: ExplosionParticle = p["visual"] + visual.position = p["pos"] + if _particle_spins(): + visual.rotation = p["rot"] func _random_particle_size() -> float: return randf_range(10.0, 20.0) -func _draw_particle(_p: Dictionary, _alpha: float) -> void: +# Called once per particle at spawn time. Subclasses set visual.draw_callable +# to a closure that draws their own fixed local shape (a streak, a rock +# chunk, ...) - it must not depend on `p` beyond values fixed at spawn (size, +# initial velocity direction), since it never runs again. +func _configure_particle(_visual: ExplosionParticle, _p: Dictionary) -> void: pass + + +# True for particle styles whose shape itself spins independently of its +# direction of travel (tumbling debris). Energy streaks are drawn already +# aligned to their (fixed) velocity direction, so giving them this unrelated +# per-particle spin as well would visibly rotate them off their travel line. +func _particle_spins() -> bool: + return false diff --git a/Fleet.gd b/Fleet.gd index e19a06d..8b4475b 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -39,7 +39,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: ships.clear() formation_offsets.clear() - var n: int = clamp(count, 1, GameState.formation.size()) + var n: int = clamp(count, 1, GameState.max_fleet_size()) for i in range(n): var offset: Vector2 = GameState.formation[i] var ship: Area2D = ship_scene.instantiate() @@ -58,7 +58,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: func add_ship() -> bool: # Adds one vessel in the next free formation slot (bought from a ship # crafter). Returns false when the wedge is already full. - if ships.size() >= GameState.formation.size(): + if ships.size() >= GameState.max_fleet_size(): return false var offset: Vector2 = GameState.formation[ships.size()] var ship: Area2D = ship_scene.instantiate() @@ -82,7 +82,7 @@ func apply_formation() -> void: # Without this, editing the layout at a command centre (or pivoting it) # only affected ships built later, which read as the edit doing nothing. for i in range(ships.size()): - if i >= GameState.formation.size(): + if i >= GameState.max_fleet_size(): break formation_offsets[i] = GameState.formation[i] _assign_slot_targets() @@ -138,12 +138,32 @@ func stop() -> void: # since that lands inside arrive_radius immediately). fleet_target = get_formation_origin() for ship in ships: - var speed: float = ship.velocity.length() + # A ship's own `velocity` is NOT how fast it is really travelling + # while the fleet cruises. Ship snaps to its slot and reports zero + # the moment it is within arrive_radius of it, even though the + # anchor is dragging that slot forward at cruise_speed underneath + # it. Its true motion is its catch-up velocity PLUS the fleet's, + # which it already carries as `fleet_velocity` (zero for a + # point-to-point move_to, where `velocity` alone is the whole + # story). + # + # Braking from `velocity` alone therefore stopped the fleet dead + # on any frame where the ships happened to be seated - and how + # often that happens depends on the frame rate, because the anchor + # advances cruise_speed * delta per frame. At ~25fps it steps far + # enough that ships always lag and always report motion, which is + # the only reason releasing the stick looked right. + var motion: Vector2 = ship.velocity + ship.fleet_velocity + # Context that is no longer true: the fleet is not cruising any + # more, so leaving it set would have Ship face a heading it is not + # travelling on. + ship.fleet_velocity = Vector2.ZERO + var speed: float = motion.length() if speed <= 0.001: ship.set_move_target(ship.position) continue var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration) - ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance) + ship.set_move_target(ship.position + motion.normalized() * braking_distance) func get_move_targets() -> Array: diff --git a/FormationEditor.gd b/FormationEditor.gd index a4da8bc..9ecab2a 100644 --- a/FormationEditor.gd +++ b/FormationEditor.gd @@ -67,7 +67,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool: return true if dragging_index != -1: return false - for i in range(GameState.formation.size()): + 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 @@ -126,7 +126,7 @@ 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) - for i in range(GameState.formation.size()): + 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. diff --git a/GameState.gd b/GameState.gd index 4bc80dd..dfd45e2 100644 --- a/GameState.gd +++ b/GameState.gd @@ -48,6 +48,138 @@ const EXTRACTOR_COST: int = 25 var buildings: Array = [] +# --- Skill tree ------------------------------------------------------------ + +# Researched at the mothership. Two things hang off it: which structures the +# player is allowed to build at all, and permanent upgrades that have no +# building of their own. The mothership itself is deliberately NOT in the +# tree - it is the thing you research AT, so gating it behind research would +# be a deadlock. +# +# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT +# stored, it is derived from how many prerequisites deep the node is, so the +# tree always reads root-at-the-bottom and cannot drift out of step with the +# dependencies when nodes are added. `requires` is a single prerequisite id +# ("" for a root). +const SKILL_TREE: Array = [ + { + "id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "", + "x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER, + "desc": "Build ship crafters, and buy extra vessels at them.", + }, + { + "id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards", + "x": 0.18, "unlocks": BUILDING_EXTRACTOR, + "desc": "Build extractors on planets. They mine ore whether or not you are nearby.", + }, + { + "id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards", + "x": 0.82, "unlocks": BUILDING_COMMAND_CENTER, + "desc": "Build command centers, where the fleet formation is arranged.", + }, + { + "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", + "x": 0.82, "fleet_bonus": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, + { + "id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing", + "x": 0.65, "fleet_bonus": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, + { + "id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron", + "x": 0.3, "fleet_bonus": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, +] + +const BASE_FLEET_SIZE: int = 5 + +var unlocked: Array = [] + + +func skill_node(id: String) -> Dictionary: + for node in SKILL_TREE: + if node.id == id: + return node + return {} + + +func skill_depth(id: String) -> int: + # How many prerequisites deep this node sits. Drives its height in the + # tree view: roots at the bottom, everything that depends on something + # above it. + var depth: int = 0 + var node: Dictionary = skill_node(id) + while not node.is_empty() and node.requires != "": + depth += 1 + node = skill_node(node.requires) + if depth > SKILL_TREE.size(): + break # a cycle in the data would otherwise hang here + return depth + + +func max_skill_depth() -> int: + var deepest: int = 0 + for node in SKILL_TREE: + deepest = maxi(deepest, skill_depth(node.id)) + return deepest + + +func is_unlocked(id: String) -> bool: + return id in unlocked + + +func prerequisite_met(id: String) -> bool: + var node: Dictionary = skill_node(id) + if node.is_empty(): + return false + return node.requires == "" or is_unlocked(node.requires) + + +func can_unlock(id: String) -> bool: + return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost)) + + +func unlock(id: String) -> bool: + if not can_unlock(id): + return false + if not spend(int(skill_node(id).cost)): + return false + unlocked.append(id) + return true + + +func is_building_unlocked(type: String) -> bool: + # The mothership is always buildable: it is where research happens, so + # locking it would leave a player with no mothership unable to ever get + # one. + if type == BUILDING_MOTHERSHIP: + return true + for node in SKILL_TREE: + if node.get("unlocks", "") == type: + return is_unlocked(node.id) + return true + + +func research_for_building(type: String) -> String: + for node in SKILL_TREE: + if node.get("unlocks", "") == type: + return node.name + return "" + + +func max_fleet_size() -> int: + var n: int = BASE_FLEET_SIZE + for id in unlocked: + n += int(skill_node(id).get("fleet_bonus", 0)) + return mini(n, DEFAULT_FORMATION.size()) + + +func reset_progression() -> void: + unlocked.clear() + func add_building(type: String, at_position: Vector2) -> void: buildings.append({"type": type, "position": at_position}) @@ -84,8 +216,13 @@ func get_mothership_position() -> Variant: # centre. Lives here (not as a const on Fleet) so a layout the player has # arranged survives the trip to a planet's surface and back, and so a # rebuilt fleet keeps their arrangement. +# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet +# capacity upgrade is researched, but they are defined up front so the wedge +# keeps a deliberate shape as it grows rather than having positions invented +# at unlock time. const DEFAULT_FORMATION: Array = [ Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32), + Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56), ] var formation: Array = DEFAULT_FORMATION.duplicate() diff --git a/IconButton.gd b/IconButton.gd index dc91c31..93cc525 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -227,18 +227,31 @@ func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void: - # Diamond with three formation pips - "arrangement". - var d: float = 14.0 * s + # Hex plotting rim with spokes to a diamond hub, echoing the placed + # structure's silhouette: half the ports carry a formation token. + var hex_r: float = 11.0 * s + var hub_r: float = 4.0 * s + var hex: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + hex.append(center + Vector2(cos(a), sin(a)) * hex_r) + NeonDraw.draw_glowing_polygon(self, hex, _active_color(), pulse, core_color) + + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(self, center + dir * hub_r, center + dir * hex_r, _active_color(), pulse, 0.32 * s, core_color) + if i % 2 == 0: + var tip: Vector2 = center + dir * hex_r + var t: float = 2.6 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + tip + Vector2(0, -t), tip + Vector2(t, 0), tip + Vector2(0, t), tip + Vector2(-t, 0), + ]), _active_color(), pulse, core_color) + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - center + Vector2(0, -d), center + Vector2(d, 0), - center + Vector2(0, d), center + Vector2(-d, 0), + center + Vector2(0, -hub_r), center + Vector2(hub_r, 0), + center + Vector2(0, hub_r), center + Vector2(-hub_r, 0), ]), _active_color(), pulse, core_color) - for p in [Vector2(0, -5), Vector2(-5, 4), Vector2(5, 4)]: - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - center + (p + Vector2(0, -3)) * s, - center + (p + Vector2(-2.6, 2.4)) * s, - center + (p + Vector2(2.6, 2.4)) * s, - ]), _active_color(), pulse, core_color) func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void: diff --git a/LightBurstVFX.gd b/LightBurstVFX.gd index 63b4f5e..5a87a8d 100644 --- a/LightBurstVFX.gd +++ b/LightBurstVFX.gd @@ -7,9 +7,13 @@ extends ExplosionVFXBase var color: Color = Color(1, 1, 1) -func _draw_particle(p: Dictionary, alpha: float) -> void: +func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void: + # Direction only ever scales (vel *= 0.9 each frame in the base class), + # it never turns - so the streak's local orientation is safe to fix + # once here rather than recomputed from vel every frame. var vel: Vector2 = p["vel"] var dir: Vector2 = vel.normalized() if vel.length() > 0.01 else Vector2.RIGHT var half: Vector2 = dir * p["size"] * 0.5 - var pos: Vector2 = p["pos"] - NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6) + var line_color: Color = color + visual.draw_callable = func(item: CanvasItem) -> void: + NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6) diff --git a/Main.tscn b/Main.tscn index d7e5378..7cf3a8e 100644 --- a/Main.tscn +++ b/Main.tscn @@ -20,6 +20,7 @@ [ext_resource type="Script" path="res://HaulerField.gd" id="18"] [ext_resource type="Script" path="res://NeonNumber.gd" id="19"] [ext_resource type="Script" path="res://NeonPanel.gd" id="20"] +[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -83,10 +84,10 @@ layer = 1 mouse_filter = 2 offset_left = 28 offset_top = 24 -offset_right = 520 -offset_bottom = 140 +offset_right = 320 +offset_bottom = 92 script = ExtResource("16") -digit_height = 100.0 +digit_height = 60.0 [node name="PlanetNameLabel" type="Label" parent="UI"] visible = false @@ -129,19 +130,6 @@ offset_bottom = -572 script = ExtResource("10") icon = 2 -[node name="BuildActionLabel" type="Label" parent="UI"] -visible = false -mouse_filter = 2 -anchor_top = 1.0 -anchor_bottom = 1.0 -anchor_right = 1.0 -offset_left = 24 -offset_right = -24 -offset_top = -566 -offset_bottom = -440 -text = "" -horizontal_alignment = 2 - [node name="MapButton" type="Control" parent="UI"] anchor_left = 1.0 anchor_right = 1.0 @@ -201,19 +189,6 @@ offset_bottom = -24 script = ExtResource("10") icon = 6 -[node name="NavLabel" type="Label" parent="UI"] -mouse_filter = 2 -anchor_left = 1.0 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = -344 -offset_right = -24 -offset_top = -322 -offset_bottom = -196 -text = "Pivot" -horizontal_alignment = 1 - [node name="WorldMap" type="Control" parent="UI"] visible = false anchor_right = 1.0 @@ -287,8 +262,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -214 offset_right = -66 -offset_top = -224 -offset_bottom = -76 +offset_top = -254 +offset_bottom = -106 script = ExtResource("10") icon = 8 glow_color = Color(0.45, 0.9, 0.55, 1) @@ -301,9 +276,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = -70 -offset_bottom = 60 -theme_override_font_sizes/font_size = 44 +offset_top = -96 +offset_bottom = 4 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Mothership" horizontal_alignment = 1 @@ -314,13 +289,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -220 -offset_right = -60 -offset_top = 70 -offset_bottom = 150 +offset_left = -204 +offset_right = -64 +offset_top = 14 +offset_bottom = 78 script = ExtResource("19") amount = 20 -digit_height = 66.0 +digit_height = 52.0 [node name="CrafterButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -329,8 +304,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = 66 offset_right = 214 -offset_top = -224 -offset_bottom = -76 +offset_top = -254 +offset_bottom = -106 script = ExtResource("10") icon = 9 glow_color = Color(1, 0.72, 0.25, 1) @@ -343,9 +318,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = -70 -offset_bottom = 60 -theme_override_font_sizes/font_size = 44 +offset_top = -96 +offset_bottom = 4 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Ship Crafter" horizontal_alignment = 1 @@ -356,13 +331,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 60 -offset_right = 220 -offset_top = 70 -offset_bottom = 150 +offset_left = 76 +offset_right = 216 +offset_top = 14 +offset_bottom = 78 script = ExtResource("19") amount = 12 -digit_height = 66.0 +digit_height = 52.0 [node name="CommandButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -371,8 +346,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -214 offset_right = -66 -offset_top = 175 -offset_bottom = 323 +offset_top = 118 +offset_bottom = 266 script = ExtResource("10") icon = 10 glow_color = Color(0.55, 0.6, 1, 1) @@ -385,9 +360,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = 333 -offset_bottom = 463 -theme_override_font_sizes/font_size = 44 +offset_top = 276 +offset_bottom = 376 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Command Center" horizontal_alignment = 1 @@ -398,13 +373,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -220 -offset_right = -60 -offset_top = 473 -offset_bottom = 553 +offset_left = -204 +offset_right = -64 +offset_top = 386 +offset_bottom = 450 script = ExtResource("19") amount = 15 -digit_height = 66.0 +digit_height = 52.0 [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -413,8 +388,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = 66 offset_right = 214 -offset_top = 175 -offset_bottom = 323 +offset_top = 118 +offset_bottom = 266 script = ExtResource("10") icon = 11 glow_color = Color(0, 0.62, 0.7, 1) @@ -427,9 +402,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = 333 -offset_bottom = 463 -theme_override_font_sizes/font_size = 44 +offset_top = 276 +offset_bottom = 376 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Extractor" horizontal_alignment = 1 @@ -440,13 +415,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 60 -offset_right = 220 -offset_top = 473 -offset_bottom = 553 +offset_left = 76 +offset_right = 216 +offset_top = 386 +offset_bottom = 450 script = ExtResource("19") amount = 25 -digit_height = 66.0 +digit_height = 52.0 [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 @@ -696,3 +671,93 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) + +[node name="SkillMenu" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/SkillMenu"] +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/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 70.0 +offset_right = -24.0 +offset_bottom = 196.0 +text = "Research" +horizontal_alignment = 1 + +[node name="Tree" type="Control" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 210.0 +offset_right = -24.0 +offset_bottom = 760.0 +script = ExtResource("21") + +[node name="DetailLabel" type="Label" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 780.0 +offset_right = -32.0 +offset_bottom = 926.0 +theme_override_font_sizes/font_size = 40 +autowrap_mode = 2 +text = "" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="CostNumber" type="Control" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -64.0 +offset_top = 936.0 +offset_right = 76.0 +offset_bottom = 1000.0 +script = ExtResource("19") +amount = 0 +digit_height = 52.0 + +[node name="StatusLabel" type="Label" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 1006.0 +offset_right = -32.0 +offset_bottom = 1070.0 +theme_override_font_sizes/font_size = 36 +autowrap_mode = 2 +text = "" +horizontal_alignment = 1 + +[node name="UnlockButton" type="Control" parent="UI/SkillMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -74.0 +offset_top = 1090.0 +offset_right = 74.0 +offset_bottom = 1238.0 +script = ExtResource("10") +icon = 5 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="CloseButton" type="Control" parent="UI/SkillMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = 172.0 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.55, 0.6, 1, 1) diff --git a/NeonDraw.gd b/NeonDraw.gd index b18f19e..8a0e22a 100644 --- a/NeonDraw.gd +++ b/NeonDraw.gd @@ -16,10 +16,10 @@ extends RefCounted const DEFAULT_CORE: Color = Color(1.0, 1.0, 1.0) -static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: +static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE, width_scale: float = 1.0) -> void: var closed: PackedVector2Array = points.duplicate() closed.append(points[0]) - _draw_layers(item, closed, glow_color, pulse, 1.0, core_color) + _draw_layers(item, closed, glow_color, pulse, width_scale, core_color) static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: diff --git a/NeonNumber.gd b/NeonNumber.gd index 7333b36..162261a 100644 --- a/NeonNumber.gd +++ b/NeonNumber.gd @@ -16,6 +16,13 @@ extends Control @export var digit_height: float = 30.0 @export var digit_gap: float = 9.0 +# Segment weight as a fraction of digit height, so the strokes keep the same +# visual heft at any size. This used to be a fixed width regardless of the +# digit height, which left large readouts looking hairline next to the solid +# ore hexagon beside them. +const STROKE_RATIO: float = 0.014 +const HEX_STROKE_RATIO: float = 0.030 + # Which of the seven segments each digit lights, in the usual order: # top, top-right, bottom-right, bottom, bottom-left, top-left, middle. const DIGIT_SEGMENTS: Array = [ @@ -60,8 +67,8 @@ func _draw_hexagon(centre: Vector2, radius: float) -> void: for i in range(6): var a: float = (float(i) / 6.0) * TAU - PI / 2.0 pts.append(centre + Vector2(cos(a), sin(a)) * radius) - NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color) - NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, 0.26, core_color) + NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color, radius * HEX_STROKE_RATIO) + NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, radius * HEX_STROKE_RATIO * 0.8, core_color) func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void: @@ -78,7 +85,8 @@ func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void: [Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left [Vector2(0.0, mid), Vector2(w, mid)], # middle ] + var stroke: float = h * STROKE_RATIO for i in range(seg.size()): if not on[i]: continue - NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, 0.3, core_color) + NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, stroke, core_color) diff --git a/NeonPiece.gd b/NeonPiece.gd new file mode 100644 index 0000000..a4fc9b7 --- /dev/null +++ b/NeonPiece.gd @@ -0,0 +1,47 @@ +class_name NeonPiece +extends Node2D + +# One movable piece of neon art, rasterised ONCE and thereafter animated +# only through node properties. +# +# Godot re-runs _draw() solely when a redraw is requested; changing +# position/rotation/scale/modulate/visible reuses the cached geometry +# instead. Since every NeonDraw call expands into four antialiased +# polylines, redrawing a shape each frame just to move or fade it is the +# expensive way to do the cheapest kind of animation. Ship.gd and +# Asteroid.gd already work this way - this is the same idea packaged so a +# building can be assembled out of independently movable parts. +# +# Geometry is defined in local space around the piece's own origin, so the +# owner can move, spin, scale and fade it without touching the shape. + +var polygons: Array = [] +var lines: Array = [] +var glow_color: Color = Color.WHITE +var core_color: Color = NeonDraw.DEFAULT_CORE +var pulse: float = 1.0 + + +static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0) -> NeonPiece: + var piece: NeonPiece = NeonPiece.new() + piece.position = at + piece.glow_color = colour + piece.modulate.a = alpha + parent.add_child(piece) + return piece + + +func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void: + polygons.append({"points": points, "pulse": piece_pulse}) + + +func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0) -> void: + lines.append({"from": from, "to": to, "width": width, "pulse": piece_pulse}) + + +func _draw() -> void: + for poly in polygons: + NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color) + for line in lines: + NeonDraw.draw_glowing_line(self, line.from, line.to, glow_color, + line.pulse * pulse, line.width, core_color) diff --git a/NeonPiece.gd.uid b/NeonPiece.gd.uid new file mode 100644 index 0000000..2b99e96 --- /dev/null +++ b/NeonPiece.gd.uid @@ -0,0 +1 @@ +uid://b8r2lwqg3syw2 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index b2a7a16..aa4a117 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -58,10 +58,10 @@ layer = 1 mouse_filter = 2 offset_left = 28 offset_top = 24 -offset_right = 520 -offset_bottom = 140 +offset_right = 320 +offset_bottom = 92 script = ExtResource("9") -digit_height = 100.0 +digit_height = 60.0 glow_color = Color(0, 0.42, 0.48, 1) core_color = Color(0, 0.08, 0.1, 1) @@ -143,22 +143,6 @@ icon = 6 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) -[node name="NavLabel" type="Label" parent="UI"] -mouse_filter = 2 -anchor_left = 1.0 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = -344 -offset_right = -24 -offset_top = -322 -offset_bottom = -196 -text = "Pivot" -theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1) -theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85) -theme_override_constants/outline_size = 5 -horizontal_alignment = 1 - [node name="FadeOverlay" type="CanvasLayer" parent="."] layer = 10 diff --git a/Projectile.gd b/Projectile.gd index ed05f23..ba7d629 100644 --- a/Projectile.gd +++ b/Projectile.gd @@ -12,6 +12,13 @@ var age: float = 0.0 func _ready() -> void: add_to_group("player_projectiles") area_entered.connect(_on_area_entered) + # Draw once. The earlier invisible-bolt bug came from _draw() deriving + # the bolt's look from `age` - drawing only at age==0 froze it at + # whatever the age==0 case rendered as. _draw() below is now a fixed + # shape with no age dependency, so one draw is safe and matches the + # Ship.gd/Asteroid.gd pattern: position/rotation are transform + # properties Godot applies at render time without re-tessellating. + queue_redraw() func launch(direction: Vector2) -> void: @@ -22,14 +29,6 @@ func launch(direction: Vector2) -> void: func _physics_process(delta: float) -> void: position += velocity * delta age += delta - # Redrawn every frame. In principle a fixed shape only needs drawing - # once (see Asteroid.gd, where dropping this was a large win), but - # doing that here left bolts invisible on screen - verified by - # rendering a frame mid-combat and finding zero pixels of the bolt - # colour. Rather than ship invisible shots a second time, this keeps - # the redraw: projectiles are few and live under two seconds, so the - # cost is negligible. - queue_redraw() if age >= lifetime: queue_free() diff --git a/SkillTreeView.gd b/SkillTreeView.gd new file mode 100644 index 0000000..84def69 --- /dev/null +++ b/SkillTreeView.gd @@ -0,0 +1,121 @@ +extends Control + +# The mothership's research tree, drawn in the game's own neon style rather +# than with Godot UI widgets, for the same reason everything else here is +# hand-drawn: a themed Button would look nothing like the rest of the game. +# +# Nodes are laid out from the normalised `pos` in GameState.SKILL_TREE, so +# the tree's shape is data next to the upgrades themselves instead of being +# duplicated here. +# +# Input arrives through BuildController._unhandled_input, NOT _gui_input - +# a Control that consumes touches through the GUI system swallows them +# 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. + +signal node_picked(id: String) + +const LOCKED_COLOR: Color = Color(0.36, 0.39, 0.5) +const READY_COLOR: Color = Color(0.55, 0.6, 1.0) +const DONE_COLOR: Color = Color(0.45, 0.9, 0.55) +const SELECTED_RING: Color = Color(1.0, 0.85, 0.4) +# A locked node needs a dark core as well as a dim glow. NeonDraw tops every +# shape with a near-white core stroke, which at this size dominates the glow +# entirely - so a "dim" colour alone still came out the brightest thing on +# screen, making unreachable nodes look like the highlighted ones. +const LOCKED_CORE: Color = Color(0.17, 0.19, 0.26) +const LOCKED_PULSE: float = 0.5 + +@export var node_radius: float = 46.0 + +var selected_id: String = "" + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func node_screen_pos(node: Dictionary) -> Vector2: + # Height comes from dependency depth, not from the data: the root sits at + # the bottom and each further prerequisite climbs a row, so the tree grows + # upwards as it is researched and stays correct when nodes are added. + var deepest: int = maxi(GameState.max_skill_depth(), 1) + var climb: float = float(GameState.skill_depth(node.id)) / float(deepest) + # Inset so a node on row 0 or column 0 is not clipped in half. + var margin: float = node_radius * 1.4 + return Vector2( + lerpf(margin, size.x - margin, float(node.x)), + lerpf(size.y - margin, margin, climb) + ) + + +func _state_color(node: Dictionary) -> Color: + if GameState.is_unlocked(node.id): + return DONE_COLOR + if GameState.prerequisite_met(node.id): + return READY_COLOR + return LOCKED_COLOR + + +# Returns true when the press landed on a node, so the caller knows the +# touch was consumed. +func handle_touch(event: InputEventScreenTouch) -> bool: + if not event.pressed or not visible: + return false + var local: Vector2 = event.position - global_position + for node in GameState.SKILL_TREE: + if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25: + selected_id = node.id + queue_redraw() + node_picked.emit(node.id) + return true + return false + + +func _draw() -> void: + # Links first, so nodes sit on top of them. + for node in GameState.SKILL_TREE: + if node.requires == "": + continue + var parent: Dictionary = GameState.skill_node(node.requires) + if parent.is_empty(): + continue + var from: Vector2 = node_screen_pos(parent) + var to: Vector2 = node_screen_pos(node) + # A link reads as "live" only once the step it leads to is actually + # reachable, so the eye follows the researched path outwards. + var lit: bool = GameState.is_unlocked(node.id) + var reachable: bool = GameState.prerequisite_met(node.id) + var link_color: Color = DONE_COLOR if lit else (READY_COLOR if reachable else LOCKED_COLOR) + var trim: Vector2 = (to - from).normalized() * node_radius + NeonDraw.draw_glowing_line(self, from + trim, to - trim, link_color, + 1.0 if reachable else LOCKED_PULSE, 0.4, + NeonDraw.DEFAULT_CORE if reachable else LOCKED_CORE) + + for node in GameState.SKILL_TREE: + var centre: Vector2 = node_screen_pos(node) + var colour: Color = _state_color(node) + var reach: bool = GameState.prerequisite_met(node.id) + var pulse: float = 1.0 if reach else LOCKED_PULSE + var core_stroke: Color = NeonDraw.DEFAULT_CORE if reach else LOCKED_CORE + var hexagon: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + hexagon.append(centre + Vector2(cos(a), sin(a)) * node_radius) + NeonDraw.draw_glowing_polygon(self, hexagon, colour, pulse, core_stroke) + + # Researched nodes get a filled-in core; the rest stay hollow. + if GameState.is_unlocked(node.id): + var core: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + core.append(centre + Vector2(cos(a), sin(a)) * node_radius * 0.42) + NeonDraw.draw_glowing_polygon(self, core, colour, 1.0) + + if node.id == selected_id: + var ring: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + ring.append(centre + Vector2(cos(a), sin(a)) * node_radius * 1.32) + NeonDraw.draw_glowing_polygon(self, ring, SELECTED_RING, 0.9) diff --git a/SkillTreeView.gd.uid b/SkillTreeView.gd.uid new file mode 100644 index 0000000..b4906ff --- /dev/null +++ b/SkillTreeView.gd.uid @@ -0,0 +1 @@ +uid://ob1hc06hf21t diff --git a/fonts/CarroisGothicSC-Regular.ttf b/fonts/CarroisGothicSC-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c3634546cbfbddfacc1a87500df4ec0618144b0c GIT binary patch literal 40356 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 a@vZAFD_ZJf_&j?DyWpO9OZ+$f>i>V#*qOlq literal 0 HcmV?d00001 diff --git a/fonts/CarroisGothicSC-Regular.ttf.import b/fonts/CarroisGothicSC-Regular.ttf.import new file mode 100644 index 0000000..82d1e1f --- /dev/null +++ b/fonts/CarroisGothicSC-Regular.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://co1qw50ehq2tl" +path="res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata" + +[deps] + +source_file="res://fonts/CarroisGothicSC-Regular.ttf" +dest_files=["res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/fonts/LICENSE.txt b/fonts/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/fonts/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/fonts/README.md b/fonts/README.md new file mode 100644 index 0000000..13e01d9 --- /dev/null +++ b/fonts/README.md @@ -0,0 +1,15 @@ +# Bundled fonts + +## Carrois Gothic SC + +`CarroisGothicSC-Regular.ttf` — designed by Ralph du Carrois, distributed via +Google Fonts. + +Licensed under the Apache License, Version 2.0. The full licence text is in +`LICENSE.txt` alongside this file; it ships with the game because Apache 2.0 +requires a copy of the licence to accompany any distribution of the work. + +It is the project-wide UI typeface, wired up through `ui_theme.tres` +(`default_font`), which `project.godot` loads as `gui/theme/custom`. Note that +the `gui/theme/default_font_size` project setting does **not** reach the +default theme — the size lives on the theme resource too. diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd index 9a85a15..dc89fe4 100644 --- a/tests/_crafter_preview.gd +++ b/tests/_crafter_preview.gd @@ -33,7 +33,7 @@ func _ready() -> void: if cell.has("phase"): b.set_process(false) b.anim_time = float(cell.phase) * b.CYCLE - b.queue_redraw() + b.apply_animation() var cam: Camera2D = Camera2D.new() cam.zoom = Vector2(1.65, 1.65) diff --git a/tests/_font_probe.gd b/tests/_font_probe.gd index b03b453..24331f5 100644 --- a/tests/_font_probe.gd +++ b/tests/_font_probe.gd @@ -19,8 +19,7 @@ func _initialize() -> void: for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel", "UI/BuildMenu/InfoPopup/Panel/MessageLabel", "UI/BuildMenu/MothershipLabel", - "UI/BuildMenu/TitleLabel", - "UI/NavLabel"]: + "UI/BuildMenu/TitleLabel"]: var label: Label = scene.get_node_or_null(path) if label == null: print("%s: MISSING" % path) diff --git a/tests/_profile_startup.gd b/tests/_profile_startup.gd new file mode 100644 index 0000000..098098b --- /dev/null +++ b/tests/_profile_startup.gd @@ -0,0 +1,40 @@ +extends SceneTree + +# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays +# before it can assert anything, so tuning aims at the part that is actually +# expensive. +# godot --headless -s res://tests/_profile_startup.gd + +var scene: Node +var t0: int = 0 +var frames: int = 0 +var frame_start: int = 0 + + +func _initialize() -> void: + var boot: int = Time.get_ticks_msec() + print("engine boot to _initialize: %d ms" % boot) + + t0 = Time.get_ticks_msec() + var packed: PackedScene = load("res://Main.tscn") + print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0)) + + t0 = Time.get_ticks_msec() + scene = packed.instantiate() + print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0)) + + t0 = Time.get_ticks_msec() + root.add_child(scene) + print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0)) + + frame_start = Time.get_ticks_msec() + + +func _process(_delta: float) -> bool: + frames += 1 + if frames < 60: + return false + var span: int = Time.get_ticks_msec() - frame_start + print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)]) + quit(0) + return true diff --git a/tests/_profile_startup.gd.uid b/tests/_profile_startup.gd.uid new file mode 100644 index 0000000..5b4d374 --- /dev/null +++ b/tests/_profile_startup.gd.uid @@ -0,0 +1 @@ +uid://dvfdj3y0xg3sj diff --git a/tests/_run_inside.sh b/tests/_run_inside.sh new file mode 100644 index 0000000..c6f68e6 --- /dev/null +++ b/tests/_run_inside.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Runs INSIDE the proot-distro Ubuntu container - do not call this directly, +# call tests/run_tests.sh from Termux 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. +# +# Args: ... + +set -uo pipefail + +PROJECT_DIR="$1"; shift +JOBS="$1"; shift +VERBOSE="$1"; shift + +GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" +cd "$PROJECT_DIR" || exit 2 + +tests=("$@") +total=${#tests[@]} +if (( total == 0 )); then + echo "no tests matched" + exit 2 +fi + +outdir="$(mktemp -d)" +trap 'rm -rf "$outdir"' EXIT + +# Import once, serially, before anything runs in parallel. Godot writes to +# .godot/ while importing; several instances discovering a cold cache at the +# same moment would race on it. After this the workers only read it. +"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1 + +run_one() { + local name="$1" + local log="$outdir/$name.log" + local start end + start=$(date +%s%N) + # --fixed-fps pins delta to exactly 1/60 instead of measuring the wall + # clock. Without it a test's `elapsed` counts REAL seconds, so running + # several at once stretches every frame and the gameplay under test sees + # deltas it never sees in the game: ships overshoot their braking curve, + # settle late, and tests time out for no reason but load. That made + # test_camera_settles and test_planet_prompt_map_interaction fail on 2 of + # 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. + "$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1 + local rc=$? + end=$(date +%s%N) + local ms=$(( (end - start) / 1000000 )) + + # 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" + else + printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status" + fi + cat "$outdir/$name.status" +} + +running=0 +for name in "${tests[@]}"; do + run_one "$name" & + running=$(( running + 1 )) + if (( running >= JOBS )); then + wait -n + running=$(( running - 1 )) + fi +done +wait + +failed=0 +for name in "${tests[@]}"; do + if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then + failed=$(( failed + 1 )) + echo + echo "=== $name FAILED, full output ===" + cat "$outdir/$name.log" 2>/dev/null + elif [[ "$VERBOSE" == "1" ]]; then + echo + echo "=== $name ===" + cat "$outdir/$name.log" 2>/dev/null + fi +done + +echo +echo "$(( total - failed ))/$total passed" +(( failed == 0 )) diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd new file mode 100644 index 0000000..2cb092d --- /dev/null +++ b/tests/_skill_menu_preview.gd @@ -0,0 +1,29 @@ +extends Node2D + +# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the +# mothership's research tree with one branch already bought and a node +# selected, so the laid-out tree, its state colours and the detail panel can +# all be checked in one render. +# +# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.reset_progression() + GameState.rocks = 60 + # One branch researched, so the render shows all three node states at + # once: done, reachable, and still locked further out. + GameState.unlock("shipyards") + GameState.unlock("coordination") + + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + + var controller: Node = main.get_node("BuildController") + controller.skill_menu.visible = true + controller.skill_tree.selected_id = "wing" + controller._refresh_skill_menu("wing") diff --git a/tests/_skill_menu_preview.gd.uid b/tests/_skill_menu_preview.gd.uid new file mode 100644 index 0000000..4160b66 --- /dev/null +++ b/tests/_skill_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://d3n86yp86b755 diff --git a/tests/_skill_menu_preview.tscn b/tests/_skill_menu_preview.tscn new file mode 100644 index 0000000..fe8181c --- /dev/null +++ b/tests/_skill_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_skill_menu_preview.gd" id="1"] + +[node name="SkillMenuPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_vfx_preview.gd b/tests/_vfx_preview.gd new file mode 100644 index 0000000..55d5cc8 --- /dev/null +++ b/tests/_vfx_preview.gd @@ -0,0 +1,47 @@ +extends Node2D + +# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders +# projectiles and every explosion type so the node-animation conversion +# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a +# similar optimisation and headless tests did not catch it, because +# --headless has no GPU - so this exists to make that failure visible. +# +# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20 + +const VFX_SCRIPTS: Array = [ + "res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd", + "res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd", +] + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06)) + + # Bolts at several headings, held still so they can be inspected. + var bolt_scene: PackedScene = load("res://Projectile.tscn") + for i in range(6): + var bolt: Area2D = bolt_scene.instantiate() + bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0) + bolt.rotation = float(i) * 0.35 + add_child(bolt) + bolt.set_physics_process(false) + + # One of each explosion, stepped forward by hand then frozen. Their real + # lifetime is under a second, and a softpipe frame takes far longer than + # that in wall-clock, so they would expire before the capture. + for i in range(VFX_SCRIPTS.size()): + var script: Script = load(VFX_SCRIPTS[i]) + if script == null: + continue + var vfx: Node2D = Node2D.new() + vfx.set_script(script) + vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0) + vfx.lifetime = 999.0 + add_child(vfx) + for step in range(8): + vfx._process(0.05) + vfx.set_process(false) + + var cam: Camera2D = Camera2D.new() + add_child(cam) + cam.make_current() diff --git a/tests/_vfx_preview.gd.uid b/tests/_vfx_preview.gd.uid new file mode 100644 index 0000000..d4f13ac --- /dev/null +++ b/tests/_vfx_preview.gd.uid @@ -0,0 +1 @@ +uid://dv32ajs2bxrwd diff --git a/tests/_vfx_preview.tscn b/tests/_vfx_preview.tscn new file mode 100644 index 0000000..6e87acb --- /dev/null +++ b/tests/_vfx_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_vfx_preview.gd" id="1"] + +[node name="VFXPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/run_tests.sh b/tests/run_tests.sh index c252284..6e5e7d8 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -1,20 +1,78 @@ #!/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. -# Usage: bash tests/run_tests.sh +# +# 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 +# +# 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 +# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")` +# 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. +# 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 +# an 8-core phone. +# 3. The whole run happens in ONE `proot-distro login`. Entering the +# container costs ~0.5s, which used to be paid once per test. +# +# Output is buffered per test and printed only for failures; six interleaved +# logs are unreadable. + +set -uo pipefail -set -e PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" -status=0 -for test_script in "$PROJECT_DIR"/tests/test_*.gd; do - name="$(basename "$test_script")" - echo "=== Running $name ===" - if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then - status=1 - fi - echo +# Leave headroom: Godot is memory-hungry and this is a phone, not a build +# server. Override with JOBS= if you know better. +JOBS="${JOBS:-6}" +VERBOSE="${VERBOSE:-0}" +IN_PLACE="${IN_PLACE:-0}" +WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}" + +filter="${1:-}" +tests=() +for path in "$PROJECT_DIR"/tests/test_*.gd; do + name="$(basename "$path")" + if [[ -z "$filter" || "$name" == *"$filter"* ]]; then + tests+=("$name") + fi done +if (( ${#tests[@]} == 0 )); then + echo "no tests match '$filter'" + exit 2 +fi + +start=$(date +%s) + +run_dir="$PROJECT_DIR" +if [[ "$IN_PLACE" != "1" ]]; then + run_dir="$WORK_DIR" + mkdir -p "$run_dir" + # Clear last run's sources but keep .godot, so imports stay warm. Without + # the clear, a test deleted from the project would live on in the copy and + # 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. + tar -C "$PROJECT_DIR" \ + --exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \ + -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[@]}" +status=$? + +echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS" exit $status diff --git a/tests/test_asteroid_safe_spawn.gd b/tests/test_asteroid_safe_spawn.gd new file mode 100644 index 0000000..700ecf1 --- /dev/null +++ b/tests/test_asteroid_safe_spawn.gd @@ -0,0 +1,114 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_asteroid_safe_spawn.gd +# Asteroid chunks are populated around wherever the fleet is - including the +# chunk it is standing in - and positions used to be uniform within a chunk. +# So an asteroid could be created directly on top of a ship and destroy it +# with no chance to react. Worst on the frame a scene loads: coming back from +# a planet's surface could cost a vessel instantly, which is also what made +# 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 + +var scene: Node +var fleet: Node +var field: Node +var stage: int = 0 +var elapsed: float = 0.0 +var ships_at_start: int = 0 + + +func _initialize() -> void: + root.get_node("GameState").reset_formation() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("AsteroidField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("EnemySpawner").set_process(false) + ships_at_start = fleet.ships.size() + elapsed = 0.0 + stage = 1 + return false + 1: + # Let the field populate around the fleet a few times over. + if elapsed < 1.5: + return false + + var centre: Vector2 = fleet.get_centroid() + var safe: float = field.safe_spawn_radius + + # --- the guard itself, deterministically ---------------------- + var worst_inside: float = 1e9 + 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)) + # 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: + 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)") + 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 + + var asteroids: Array = get_nodes_in_group("asteroids") + var closest: float = 1e9 + for a in asteroids: + if is_instance_valid(a): + closest = minf(closest, a.position.distance_to(centre)) + + print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [ + asteroids.size(), closest, safe + ]) + if asteroids.is_empty(): + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)") + quit(1) + return true + # Asteroids drift, so one may have travelled inward since it was + # created. Allow for that, but nothing should be anywhere near + # on top of the fleet. + if closest < safe * 0.5: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)") + quit(1) + return true + + var kept_ships: bool = fleet.ships.size() == ships_at_start + print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships]) + if not kept_ships: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)") + quit(1) + return true + + print("TEST_ASTEROID_SAFE_SPAWN: PASS") + quit(0) + return true + return false diff --git a/tests/test_asteroid_safe_spawn.gd.uid b/tests/test_asteroid_safe_spawn.gd.uid new file mode 100644 index 0000000..5336bb6 --- /dev/null +++ b/tests/test_asteroid_safe_spawn.gd.uid @@ -0,0 +1 @@ +uid://ci8vodtuvwod7 diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index 57f950e..ef1d708 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -39,6 +39,12 @@ func _initialize() -> void: game_state.rocks = 500 game_state.buildings.clear() game_state.extractors.clear() + # Structures are gated behind the mothership's research tree. That gate + # is test_skill_tree's subject, not this one's - here every option needs + # to be available so the popup itself can be exercised. + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) scene = load("res://Main.tscn").instantiate() root.add_child(scene) diff --git a/tests/test_building_redraw_cost.gd b/tests/test_building_redraw_cost.gd new file mode 100644 index 0000000..c083754 --- /dev/null +++ b/tests/test_building_redraw_cost.gd @@ -0,0 +1,110 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_building_redraw_cost.gd +# Animated buildings must not re-run _draw() to animate (Forgejo issues #6 +# and #10). +# +# Every NeonDraw call expands into four antialiased polylines, so redrawing +# a whole shipyard each frame to advance a crane re-tessellates all of it. +# The moving elements are now separate NeonPiece children, rasterised once +# and thereafter only moved, scaled, faded or hidden - operations that reuse +# the cached geometry. Ship.gd and Asteroid.gd already worked this way. +# +# Verifies: +# 1. an animated building's geometry is rasterised exactly ONCE, however +# long it animates +# 2. it is genuinely still animating (so this cannot be "passed" by +# freezing the animation) +# 3. the moving parts really do move - the crane travels and the +# mothership's satellites orbit - proving the animation reaches the +# nodes rather than being silently dropped +# 4. the static command centre needs no per-frame work either + +var scene: Node +var field: Node +var crafter: Node2D +var mothership: Node2D +var command: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var crane_x_start: float = 0.0 +var satellite_rotation_start: float = 0.0 + + +func _initialize() -> void: + var game_state: Node = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + field = scene.get_node("BuildingField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0)) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0)) + field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + for b in field.spawned: + if not is_instance_valid(b): + continue + match b.building_type: + GameState.BUILDING_SHIP_CRAFTER: + crafter = b + GameState.BUILDING_MOTHERSHIP: + mothership = b + GameState.BUILDING_COMMAND_CENTER: + command = b + if crafter == null or mothership == null or command == null: + print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)") + quit(1) + return true + + crane_x_start = crafter._crane.position.x + satellite_rotation_start = mothership._satellites.rotation + elapsed = 0.0 + stage = 2 + return false + 2: + # Long enough to cross several assembly phases and a good arc of + # the satellites' orbit. + if elapsed < 2.5: + return false + + print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [ + elapsed, crafter.draw_count, mothership.draw_count, command.draw_count + ]) + for b in [crafter, mothership, command]: + if b.draw_count != 1: + print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [ + b.building_type, b.draw_count + ]) + quit(1) + return true + + var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0 + var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0 + var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05 + print("still animating=%s crane travelled=%s satellites orbited=%s" % [ + animating, crane_moved, orbit_moved + ]) + if not (animating and crane_moved and orbit_moved): + print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)") + quit(1) + return true + + print("TEST_BUILDING_REDRAW_COST: PASS") + quit(0) + return true + return false diff --git a/tests/test_building_redraw_cost.gd.uid b/tests/test_building_redraw_cost.gd.uid new file mode 100644 index 0000000..a65db84 --- /dev/null +++ b/tests/test_building_redraw_cost.gd.uid @@ -0,0 +1 @@ +uid://ihnpl5ogoxi1 diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 20e9607..13b2567 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -42,6 +42,11 @@ func _initialize() -> void: game_state.rocks = 200 game_state.buildings.clear() game_state.extractors.clear() + # Extractors are gated behind research (see test_skill_tree). This test + # is about the planet-proximity rule, so grant the research up front. + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) main_scene = load("res://Main.tscn").instantiate() root.add_child(main_scene) diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index f11e9b8..b559bf5 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -14,7 +14,13 @@ extends SceneTree var game_state: Node var stage: int = 0 var elapsed: float = 0.0 -var mothership_pos: Vector2 = Vector2(4000.0, 0.0) +# The test has to sit through a whole there-and-back hauler cycle, and the +# cycle length is distance/HAULER_SPEED - so this fixture distance IS the +# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s +# for the 4000 it used to be. Nothing here asserts on the distance itself, +# only on payout and drawn position tracking the same `progress`, so a short +# route exercises exactly the same code. +var mothership_pos: Vector2 = Vector2(600.0, 0.0) var planet_pos: Vector2 = Vector2.ZERO var rocks_before_arrival: int = 0 diff --git a/tests/test_joystick_control.gd b/tests/test_joystick_control.gd index 855a192..1a3e41a 100644 --- a/tests/test_joystick_control.gd +++ b/tests/test_joystick_control.gd @@ -120,9 +120,17 @@ func _process(delta: float) -> bool: for ship in fleet.ships: speed = max(speed, ship.velocity.length()) var stopped: bool = speed < 1.0 - var coasted: bool = fleet.get_centroid().x > moving_centroid.x - print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [ - speed, stopped, coasted + # Not merely "moved forward at all": the fleet cruises at + # cruise_speed and brakes at Ship.acceleration, so the coast is + # cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a + # real distance is what makes this a test of coasting - the old + # `> moving_centroid.x` was satisfied by any float wobble, and + # passed happily while the fleet stopped dead. + var coast: float = fleet.get_centroid().x - moving_centroid.x + var coasted: bool = coast > 40.0 + print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [ + speed, stopped, coast, + (fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration) ]) if stopped and coasted: print("TEST_JOYSTICK_CONTROL: PASS") diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd new file mode 100644 index 0000000..92e818f --- /dev/null +++ b/tests/test_skill_tree.gd @@ -0,0 +1,169 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_skill_tree.gd +# The mothership's research tree (Forgejo issue #4). It gates which +# structures may be built at all, and carries permanent upgrades that have +# no building of their own. +# +# Verifies: +# 1. from a fresh start only the mothership is buildable - the others are +# greyed, and pressing one explains which research opens it rather than +# silently failing +# 2. a locked option cannot be built even if the confirm is forced +# 3. research costs rocks, needs its prerequisite, and unlocks its building +# 4. the mothership's action button opens the tree (it used to have no +# action at all) +# 5. fleet capacity upgrades raise the cap, and the extra vessel can +# actually be crafted afterwards + +var scene: Node +var game_state: Node +var controller: Node +var fleet: Node +var field: Node +var popup: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 500 + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_formation() + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + popup = scene.get_node("UI/BuildMenu/InfoPopup") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + + # --- everything but the mothership starts locked -------------- + var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP) + var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) + print("fresh start: mothership buildable=%s crafter locked=%s" % [ + mothership_free, crafter_locked + ]) + if not (mothership_free and crafter_locked): + print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)") + quit(1) + return true + + controller._on_build_pressed() + var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled + controller._on_place_crafter() + var explains: bool = popup.visible \ + and "research" in popup.get_node("Panel/MessageLabel").text.to_lower() + var offered: bool = popup.get_node("Panel/BuildButton").visible + print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [ + greyed, explains, offered + ]) + if not (greyed and explains) or offered: + print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)") + quit(1) + return true + + # Forcing the confirm must still build nothing. + var before: int = game_state.buildings.size() + controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER + controller._confirm_build() + if game_state.buildings.size() != before: + print("TEST_SKILL_TREE: FAIL (built a locked structure)") + quit(1) + return true + controller._close_info() + + # --- the mothership opens the tree ---------------------------- + var mothership: Node2D = null + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: + mothership = b + controller.current_building = mothership + controller._on_action_pressed() + var tree_open: bool = scene.get_node("UI/SkillMenu").visible + print("mothership action opens research: %s" % tree_open) + if not tree_open: + print("TEST_SKILL_TREE: FAIL (mothership has no research action)") + quit(1) + return true + + # --- prerequisites -------------------------------------------- + if game_state.can_unlock("wing"): + print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)") + quit(1) + return true + + # --- research costs rocks and unlocks its building ------------ + var rocks_before: int = game_state.rocks + var cost: int = int(game_state.skill_node("shipyards").cost) + var ok: bool = game_state.unlock("shipyards") + var charged: bool = game_state.rocks == rocks_before - cost + var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) + print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [ + ok, rocks_before, game_state.rocks, charged, now_buildable + ]) + if not (ok and charged and now_buildable): + print("TEST_SKILL_TREE: FAIL (research did not unlock its building)") + quit(1) + return true + + # The menu must show it as buildable now, without reopening. + controller._on_build_pressed() + if not scene.get_node("UI/BuildMenu/CrafterButton").enabled: + print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)") + quit(1) + return true + controller._close_info() + scene.get_node("UI/BuildMenu").visible = false + + # --- fleet capacity ------------------------------------------- + var base_cap: int = game_state.max_fleet_size() + game_state.unlock("coordination") + var wing_ok: bool = game_state.unlock("wing") + var raised_cap: int = game_state.max_fleet_size() + print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [ + base_cap, raised_cap, wing_ok + ]) + if not (wing_ok and raised_cap == base_cap + 1): + print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)") + quit(1) + return true + + # And the extra vessel must actually be craftable. + fleet.spawn_ships(base_cap, Vector2.ZERO) + var added: bool = fleet.add_ship() + print("fleet %d ships, add one more -> %s (now %d)" % [ + base_cap, added, fleet.ships.size() + ]) + if not (added and fleet.ships.size() == base_cap + 1): + print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)") + quit(1) + return true + if fleet.add_ship(): + print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)") + quit(1) + return true + + print("TEST_SKILL_TREE: PASS") + quit(0) + return true + return false diff --git a/tests/test_skill_tree.gd.uid b/tests/test_skill_tree.gd.uid new file mode 100644 index 0000000..92d320c --- /dev/null +++ b/tests/test_skill_tree.gd.uid @@ -0,0 +1 @@ +uid://db2nf67mtlg27 diff --git a/ui_theme.tres b/ui_theme.tres index 38c105d..89bb6a1 100644 --- a/ui_theme.tres +++ b/ui_theme.tres @@ -1,15 +1,8 @@ -[gd_resource type="Theme" format=3] +[gd_resource type="Theme" load_steps=2 format=3] -; Project-wide default font size for every Control. -; -; This exists because the `gui/theme/default_font_size` project setting does -; NOT reach the default theme in this build - labels without an explicit -; override resolve to the engine fallback of 16 no matter what that setting -; says, which is why earlier attempts to enlarge the UI text did nothing. -; A custom Theme assigned to `gui/theme/custom` does apply. -; -; Individual labels that cannot fit their own text at this size carry a -; smaller theme_override_font_sizes/font_size in the scene. +[ext_resource type="FontFile" path="res://fonts/CarroisGothicSC-Regular.ttf" id="1"] [resource] +default_font = ExtResource("1") default_font_size = 78 +Label/colors/font_color = Color(0.36, 0.9, 1, 1)