diff --git a/AsteroidExplosionVFX.gd b/AsteroidExplosionVFX.gd index 9830458..1396edb 100644 --- a/AsteroidExplosionVFX.gd +++ b/AsteroidExplosionVFX.gd @@ -17,21 +17,13 @@ func _random_particle_size() -> float: return randf_range(3.0, 7.0) -# 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: +func _draw_particle(p: Dictionary, alpha: float) -> 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), ]) - visual.draw_callable = func(item: CanvasItem) -> void: - item.draw_colored_polygon(local_pts, COLOR) + 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)) diff --git a/AsteroidField.gd b/AsteroidField.gd index 5b10d7b..efb7b39 100644 --- a/AsteroidField.gd +++ b/AsteroidField.gd @@ -7,10 +7,6 @@ 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 @@ -67,31 +63,14 @@ 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 = _safe_position(origin + local_pos, fleet_centre) + asteroid.position = origin + local_pos 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 ab878fc..1931cae 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -19,6 +19,7 @@ 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") @@ -27,7 +28,6 @@ extends Node2D @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") -@onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") @onready var planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @@ -38,21 +38,8 @@ extends Node2D @onready var craft_menu: Control = get_node("../UI/CraftMenu") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") -@onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton") -@onready var craft_store_button: IconButton = get_node("../UI/CraftMenu/StoreOreButton") -@onready var craft_store_label: Label = get_node("../UI/CraftMenu/StoreOreLabel") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") -@onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton") -@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel") - -@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") @@ -80,10 +67,6 @@ const BUILDING_INFO: Dictionary = { "title": "Command Center", "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", }, - GameState.BUILDING_TRANSPORT_HUB: { - "title": "Transport Hub", - "body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.", - }, GameState.BUILDING_EXTRACTOR: { "title": "Extractor", "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", @@ -93,14 +76,6 @@ const BUILDING_INFO: Dictionary = { # Which structure the popup is currently offering to build, or "" when it is # only explaining something that cannot be built right now. var pending_build_type: String = "" -# Which crafter the craft menu is showing (see _on_action_pressed). -var craft_building_id: int = 0 -# The craft panel refreshes every frame while it is open (so a delivery -# arriving visibly works the queue down). That overwrote any message the -# moment after it was set, which made a working button look dead - the store -# button in particular. Messages therefore have to outlive a refresh. -var craft_message: String = "" -var craft_message_until: int = 0 var current_building: Node2D = null var pending_building: Node2D = null @@ -114,66 +89,41 @@ func _ready() -> void: build_crafter_button.pressed.connect(_on_place_crafter) build_command_button.pressed.connect(_on_place_command_center) build_extractor_button.pressed.connect(_on_place_extractor) - build_hub_button.pressed.connect(_on_place_transport_hub) info_close_button.pressed.connect(_close_info) info_build_button.pressed.connect(_confirm_build) action_button.pressed.connect(_on_action_pressed) craft_interceptor_button.pressed.connect(_on_craft_interceptor) - load_ore_button.pressed.connect(_on_load_ore) - craft_transporter_button.pressed.connect(_on_craft_transporter) - craft_store_button.pressed.connect(_on_store_ore) craft_close_button.pressed.connect(func(): craft_menu.visible = false) formation_close_button.pressed.connect(func(): formation_menu.visible = false) 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 or skill_menu.visible + return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible func _unhandled_input(event: InputEvent) -> void: - # 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: - skill_tree.handle_touch(event) - # Handled either way. A touch that lands on the panel but misses a - # node is still a touch ON THE PANEL - letting it through moved the - # fleet and pinch-zoomed the world behind the menu, because - # GameController sits on the root node and sees whatever this leaves - # unhandled. - get_viewport().set_input_as_handled() + # 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. + if not formation_menu.visible: return - if formation_menu.visible: - if event is InputEventScreenTouch: - formation_editor.handle_touch(event) - elif event is InputEventScreenDrag: - formation_editor.handle_drag(event) + if event is InputEventScreenTouch and formation_editor.handle_touch(event): get_viewport().set_input_as_handled() - return - if build_menu.visible or craft_menu.visible or world_map.visible: - # These panels have no raw-input handler of their own, but the world - # behind them must still not react. + elif event is InputEventScreenDrag and formation_editor.handle_drag(event): get_viewport().set_input_as_handled() @@ -183,24 +133,20 @@ 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 \ - and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + build_extractor_button.enabled = _planet_under_fleet() != null - if craft_menu.visible: - # Live, so a delivery arriving while the menu is open visibly works - # the queue down instead of looking stuck. - _refresh_craft_status() - - if world_map.visible or build_menu.visible or craft_menu.visible \ - or formation_menu.visible or skill_menu.visible: + if world_map.visible or build_menu.visible or craft_menu.visible or formation_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 @@ -213,23 +159,13 @@ 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 = _action_icon_for(current_building.building_type) + 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.queue_redraw() -func _action_icon_for(type: String) -> int: - match type: - GameState.BUILDING_SHIP_CRAFTER: - return IconButton.Icon.SHIP - GameState.BUILDING_TRANSPORT_HUB: - return IconButton.Icon.TRANSPORT_HUB - GameState.BUILDING_MOTHERSHIP: - return IconButton.Icon.MOTHERSHIP - _: - return IconButton.Icon.COMMAND_CENTER - - func _check_fleet_wipe() -> void: if not fleet.ships.is_empty(): return @@ -242,11 +178,7 @@ func _check_fleet_wipe() -> void: func _on_action_pressed() -> void: if current_building == null: return - if current_building.building_type == GameState.BUILDING_MOTHERSHIP: - skill_menu.visible = true - _refresh_load_ore() - _refresh_skill_menu() - elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: + if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true _refresh_craft_status() elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: @@ -267,15 +199,10 @@ func _planet_under_fleet() -> Node2D: func _refresh_build_status() -> void: - # 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_hub_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB) - build_extractor_button.enabled = _planet_under_fleet() != null \ - and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + # 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 # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. @@ -297,10 +224,6 @@ func _on_place_command_center() -> void: _show_building_info(GameState.BUILDING_COMMAND_CENTER) -func _on_place_transport_hub() -> void: - _show_building_info(GameState.BUILDING_TRANSPORT_HUB) - - func _on_place_extractor() -> void: # The option stays pressable while greyed, so this is also how a # not-over-a-planet extractor explains itself. @@ -309,8 +232,6 @@ func _on_place_extractor() -> void: func cost_of(type: String) -> int: match type: - GameState.BUILDING_TRANSPORT_HUB: - return GameState.TRANSPORT_HUB_COST GameState.BUILDING_MOTHERSHIP: return GameState.MOTHERSHIP_COST GameState.BUILDING_SHIP_CRAFTER: @@ -327,15 +248,9 @@ 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 can_build and type == GameState.BUILDING_EXTRACTOR: + if type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") @@ -345,7 +260,7 @@ func _show_building_info(type: String) -> void: can_build = false else: lines.append("Will mine %s." % planet.planet_name) - elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + elif 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): @@ -375,9 +290,6 @@ 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): @@ -412,162 +324,20 @@ 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 _say(message: String) -> void: - craft_message = message - craft_message_until = Time.get_ticks_msec() + 3000 - _refresh_craft_status() - - func _refresh_craft_status() -> void: - # "Here" is the ore delivered to THIS crafter, not the bank: since #16 a - # building builds out of its own stock, and showing the bank would say - # you can afford something the crafter cannot actually make. - var queued: int = GameState.pending_orders(craft_building_id) - var stock: int = GameState.building_stock(craft_building_id) - var queue_text: String = "" if queued == 0 else " Queued %d" % queued - craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [ - GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text, - GameState.transporters_claimed(), GameState.transport_capacity() + craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ + GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size() ] - # Nowhere to station one means the order would be refused, so say so - # rather than letting the button look available and do nothing. - craft_transporter_button.enabled = GameState.has_transport_hub() \ - and GameState.can_station_transporter() - craft_transporter_button.queue_redraw() - - craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo - # Deliberately left ENABLED with an empty hold: a greyed-out button that - # cannot say why is exactly the dead-button problem this panel already - # had. Pressing it explains instead. - craft_store_button.enabled = true - craft_store_button.queue_redraw() - - # A full fleet cannot take another vessel, so the order would be refused. - # Showing that on the button is the difference between "not available yet" - # and a button that looks live and silently does nothing when pressed. - var room: bool = fleet.ships.size() + GameState.ships_owed \ - + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ - < GameState.max_fleet_size() - craft_interceptor_button.enabled = room - craft_interceptor_button.queue_redraw() - # One statement of the fleet count, either way round: the capacity line - # replaces the plain readout rather than being bolted in front of it. - if craft_message != "": - if Time.get_ticks_msec() < craft_message_until: - craft_status.text = "%s\n%s" % [craft_message, craft_status.text] - else: - craft_message = "" - if room: - craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] - else: - craft_status.text = "Fleet full %d/%d - research capacity at the mothership\n%s" % [ - fleet.ships.size(), GameState.max_fleet_size(), craft_status.text - ] func _on_craft_interceptor() -> void: - if craft_building_id == 0: + if fleet.ships.size() >= GameState.formation.size(): + craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size() return - # Still refused when there is nowhere to put the vessel - queueing an - # order that can never be delivered would just bank ships_owed forever. - if fleet.ships.size() + GameState.ships_owed \ - + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ - >= GameState.max_fleet_size(): - _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) - return - # No affordability check: an order the crafter cannot pay for yet is - # QUEUED, and completes by itself once ore is delivered here (#16). - GameState.queue_order(craft_building_id) - _refresh_craft_status() - - -func _refresh_load_ore() -> void: - load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo] - load_ore_button.enabled = GameState.rocks > 0 - load_ore_button.queue_redraw() - - -func _on_load_ore() -> void: - GameState.withdraw_to_fleet() - _refresh_load_ore() - - -func _on_store_ore() -> void: - if craft_building_id == 0: - return - if GameState.fleet_cargo <= 0: - _say("The fleet is carrying nothing - load ore at the mothership first") - return - var moved: int = GameState.unload_into_building(craft_building_id) - _say("Stored %d ore here" % moved) - - -func _on_craft_transporter() -> void: - if craft_building_id == 0: - return - if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): - # Two different refusals, and the player needs to know WHICH. - if not GameState.has_transport_hub(): - _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") - else: - _say("All %d berths are taken - build another hub, or research Freight Yards" % - GameState.transport_capacity()) + if not GameState.spend(GameState.SHIP_COST): + craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] return + fleet.add_ship() _refresh_craft_status() diff --git a/Building.gd b/Building.gd index 54e5d1a..e9fe17a 100644 --- a/Building.gd +++ b/Building.gd @@ -19,28 +19,11 @@ extends Node2D # never changes were a real framerate cost earlier on. @export var building_type: String = "mothership" -# Which GameState.buildings record this node is showing. Set by -# BuildingField when it spawns, so a node can be traced back to the stock -# and production queue that survive the scene swap (#16). -@export var building_id: int = 0 @export var interact_radius: float = 150.0 const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0) -const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85) - -# Transport hub: an octagonal dock ring with berths around it and a freight -# core in the middle. Deliberately radial-but-flat, so it does not read as a -# small mothership - the mothership is the only thing with swept arms. -const HUB_RING_RADIUS: float = 74.0 -const HUB_DECK_RADIUS: float = 54.0 -const HUB_CORE_RADIUS: float = 22.0 -const HUB_BERTH_COUNT: int = 8 -const HUB_BERTH_LENGTH: float = 20.0 -const HUB_DOCKED_COUNT: int = 3 -const HUB_DOCKED_ORBIT: float = 96.0 -const HUB_SPIN_SPEED: float = 0.22 # The vessel on the assembly line is drawn in the fleet's own colour, so it # reads as "one of yours being built" rather than as shipyard machinery. const HULL_COLOR: Color = Color(0.25, 0.85, 1.0) @@ -56,7 +39,6 @@ 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. @@ -84,62 +66,26 @@ 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 = [] -# Rotating holder for the transports stationed at a transport hub (#18). -var _hub_traffic: Node2D = null -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 animate. The command centre is - # entirely static and needs no per-frame work at all. + # 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. 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() - elif building_type == GameState.BUILDING_TRANSPORT_HUB: - _build_hub_pieces() queue_redraw() func _process(delta: float) -> void: anim_time = fmod(anim_time + delta, CYCLE) phase_time += delta - 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() - GameState.BUILDING_TRANSPORT_HUB: - _animate_transport_hub() + queue_redraw() # Where the satellites are, and how open the reactor core is. Both are read @@ -163,27 +109,14 @@ 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_static(self) - GameState.BUILDING_TRANSPORT_HUB: - _draw_transport_hub_static(self) + _draw_mothership() _: - _draw_crafter_static(self) + _draw_crafter() # --- shared helpers ------------------------------------------------------- @@ -206,101 +139,17 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int -# --- 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 - - -# --- transport hub -------------------------------------------------------- - -func _draw_transport_hub_static(target: CanvasItem) -> void: - var c: Color = TRANSPORT_HUB_COLOR - - # Dock ring and the deck nested inside it. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0) - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7) - - # Freight core: a squared-off hold rather than a reactor, so it reads as - # storage and not as another power plant. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95) - - for i in range(HUB_BERTH_COUNT): - var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32) - # Berth arm standing off the ring, where a transporter parks. - NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS, - dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38) - - -func _build_hub_pieces() -> void: - # Stationed transports, as NeonPiece children of one holder that turns. - # Per #10 the geometry is rasterised once and only the holder's rotation - # changes, so the traffic costs no redraws at all. - _hub_traffic = Node2D.new() - add_child(_hub_traffic) - for i in range(HUB_DOCKED_COUNT): - var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU - var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT - var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR) - # A stubby freighter: blunt, boxy, nothing like the fleet's darts. - piece.add_polygon(PackedVector2Array([ - Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5), - ])) - piece.rotation = a + PI / 2.0 - piece.queue_redraw() - - -func _animate_transport_hub() -> void: - _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED - - -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), - ]) - - 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) + # 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), + ]) + 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) # --- mothership: a radial flagship ---------------------------------------- @@ -338,65 +187,49 @@ func _talon(base_angle: float) -> PackedVector2Array: return pts -func _draw_mothership_static(canvas: CanvasItem) -> void: +func _draw_mothership() -> 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(canvas, _talon(base), c, 0.95) + NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95) # Crystal panel set into the gap behind each arm. var pa: float = base - 0.30 - NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([ + NeonDraw.draw_glowing_polygon(self, 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 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) - - -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 = (float(i) / float(SATELLITE_COUNT)) * TAU - var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT - 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: + # 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() - 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() + 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)) + + # Satellites holding station around it, drifting slowly round. + var orbit: float = orbit_angle() + for i in range(SATELLITE_COUNT): + var a: float = orbit + (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) # --- ship crafter: an orbital yard with a hull in the cradle -------------- -func _draw_crafter_static(canvas: CanvasItem) -> void: +func _draw_crafter() -> void: var c: Color = CRAFTER_COLOR + var t: float = anim_time / CYCLE - # 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) + # 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) # Radiator fins above and below the core, slatted like real ones. for dir_y in [-1.0, 1.0]: @@ -404,114 +237,44 @@ func _draw_crafter_static(canvas: CanvasItem) -> 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(canvas, fin, c, 0.75) + NeonDraw.draw_glowing_polygon(self, fin, c, 0.75) for f in [0.35, 0.68]: - NeonDraw.draw_glowing_line(canvas, + NeonDraw.draw_glowing_line(self, 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(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) + 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) for i in range(3): var x0: float = -32.0 + float(i) * 15.0 var x1: float = x0 + 15.0 - 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) + 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) # 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(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) + 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) for side in [-1.0, 1.0]: - NeonDraw.draw_glowing_line(canvas, + NeonDraw.draw_glowing_line(self, 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 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)) + 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) if t < ASSEMBLE_END: - _animate_assembly(t / ASSEMBLE_END) + _draw_assembly(t / ASSEMBLE_END, c) elif t < FLASH_END: - _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) + _draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c) else: - _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) + _draw_launch((t - FLASH_END) / (1.0 - FLASH_END)) # How many pieces are on the hull at this point through the assembly phase. @@ -524,73 +287,78 @@ func attached_part_count(p: float) -> int: return n -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_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 _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) +func _draw_assembly(p: float, c: Color) -> void: + _draw_clamps(c, 0.8) + # Everything welded on so far, newest part still glowing from the seam. var next_part: int = attached_part_count(p) - 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) + 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) # 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_pieces.size(): + if next_part >= _hull_parts.size(): weld.y = RAIL_Y + 16.0 - _crane.position = Vector2(weld.x, RAIL_Y + 13.0) - _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.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) - var welding: bool = next_part < _hull_pieces.size() - _sparks.visible = welding - if welding: + if next_part < _hull_parts.size(): + # Weld sparks: short spokes whose angles jitter frame to frame. var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0)) - _sparks.position = weld - _sparks.rotation = phase_time * 7.0 - _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) - _sparks.modulate.a = flicker + 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) -func _animate_completion_flash(p: float) -> void: +func _draw_completion_flash(p: float, c: Color) -> void: # Whole hull, clamps springing open, and a ring pushing outwards to mark # it as finished. - _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) + _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) -func _animate_launch(p: float) -> void: +func _draw_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) - _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) - for piece in _hull_pieces: - piece.visible = true - piece.modulate.a = 1.0 + for i in range(_hull_parts.size()): + _draw_hull_part(i, centre, fade) # Engine wash, strongest just after it breaks free of the clamps. - _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) + 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) diff --git a/BuildingField.gd b/BuildingField.gd index 4db4fac..cd27afe 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -11,33 +11,12 @@ extends Node2D var building_scene: PackedScene = preload("res://Building.tscn") var spawned: Array = [] -# Whether the fleet was already standing on the mothership last frame - see -# the edge-triggered delivery in _process(). -var _was_on_mothership: bool = false func _ready() -> void: rebuild_all() -func _process(_delta: float) -> void: - # Unloading the fleet's hold (#14). Done here because this is the node - # that already knows where the buildings are and already answers "is the - # fleet standing on one?" - and it uses the fleet CENTROID, the same - # stable single-number test Planet and Building already use, rather than - # per-ship collision that flickers as ships jostle in formation. - # EDGE-triggered on arrival, not continuous. Delivering every frame the - # fleet is parked on the mothership would make withdrawing impossible: - # ore loaded into the hold at the mothership (#17) would be banked again - # the very next frame, and the button would look broken. - var under: Node2D = get_building_fleet_is_on() - var on_mothership: bool = under != null \ - and under.building_type == GameState.BUILDING_MOTHERSHIP - if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0: - GameState.deliver_cargo() - _was_on_mothership = on_mothership - - func rebuild_all() -> void: for b in spawned: if is_instance_valid(b): @@ -64,7 +43,6 @@ func _spawn(record: Dictionary) -> void: var b: Node2D = building_scene.instantiate() b.position = record.position b.building_type = record.type - b.building_id = int(record.get("id", 0)) # Deferred for the same reason as the other field spawners: this can run # from inside a parent's _ready() chain, where a direct add_child() is # rejected while the tree is still setting up children. diff --git a/CargoCounter.gd b/CargoCounter.gd deleted file mode 100644 index bf060f7..0000000 --- a/CargoCounter.gd +++ /dev/null @@ -1,33 +0,0 @@ -extends NeonNumber - -# What the fleet is CARRYING, shown under the banked total (#15, part 2 of -# #12). Since #14 ore is cargo until the fleet delivers it to the mothership, -# so without this, mining visibly does nothing - the banked figure above does -# not move until the fleet flies home, which reads as gathering being broken. -# -# Three things separate it from the banked readout, because colour alone is a -# weak signal on a phone screen in a game that is mostly teal: -# - amber rather than ore-teal, the same amber used elsewhere for ore in -# transit (the extractor derrick, the hauler route) -# - smaller, sitting UNDER the banked figure: subordinate to it, because -# this is the number that is not yours yet -# - present only while actually carrying something, so an empty hold is not -# a permanent zero taking up space next to the real total -# -# Reads GameState directly for the same reason ResourceCounter does: it stays -# correct across the space <-> planet-surface scene swap with nobody having -# to push updates into it. - -const CARGO_GLOW: Color = Color(1.0, 0.78, 0.25) -const CARGO_CORE: Color = Color(0.22, 0.12, 0.0) - - -func _ready() -> void: - glow_color = CARGO_GLOW - core_color = CARGO_CORE - - -func _process(_delta: float) -> void: - visible = GameState.fleet_cargo > 0 - if visible: - set_amount(GameState.fleet_cargo) diff --git a/CargoCounter.gd.uid b/CargoCounter.gd.uid deleted file mode 100644 index dfef251..0000000 --- a/CargoCounter.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmrcja23g5oi6 diff --git a/ExplosionParticle.gd b/ExplosionParticle.gd deleted file mode 100644 index 6a5591f..0000000 --- a/ExplosionParticle.gd +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index ed360bf..0000000 --- a/ExplosionParticle.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dm2m305hlrv6l diff --git a/ExplosionVFXBase.gd b/ExplosionVFXBase.gd index 457c72a..1196585 100644 --- a/ExplosionVFXBase.gd +++ b/ExplosionVFXBase.gd @@ -22,25 +22,14 @@ func _ready() -> void: for i in range(particle_count): var angle: float = randf() * TAU var speed: float = randf_range(min_speed, max_speed) - var p: Dictionary = { + particles.append({ "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(), - } - # 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) + }) + queue_redraw() func _process(delta: float) -> void: @@ -48,35 +37,22 @@ 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 - var visual: ExplosionParticle = p["visual"] - visual.position = p["pos"] - if _particle_spins(): - visual.rotation = p["rot"] + 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) func _random_particle_size() -> float: return randf_range(10.0, 20.0) -# 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: +func _draw_particle(_p: Dictionary, _alpha: float) -> 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 6abccd5..e19a06d 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.max_fleet_size()) + var n: int = clamp(count, 1, GameState.formation.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.max_fleet_size(): + if ships.size() >= GameState.formation.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.max_fleet_size(): + if i >= GameState.formation.size(): break formation_offsets[i] = GameState.formation[i] _assign_slot_targets() @@ -113,9 +113,6 @@ func move_in_direction(direction: Vector2) -> void: func _process(delta: float) -> void: - # Before the steering early-out: a ship the crafter has already built and - # paid for is owed whether or not the fleet happens to be flying (#16). - _claim_owed_ships() if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. @@ -125,17 +122,6 @@ func _process(delta: float) -> void: _assign_slot_targets() -func _claim_owed_ships() -> void: - # Production completes in the GameState autoload, which may well happen - # while the player is on a planet's surface with no Fleet node in - # existence to hand a vessel to. Those ships wait as `ships_owed` until a - # fleet with room can take them. - while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size(): - if not add_ship(): - return - GameState.ships_owed -= 1 - - func _assign_slot_targets() -> void: for i in range(ships.size()): ships[i].set_move_target(fleet_target + formation_offsets[i]) @@ -152,32 +138,12 @@ func stop() -> void: # since that lands inside arrive_radius immediately). fleet_target = get_formation_origin() for ship in ships: - # 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() + var speed: float = ship.velocity.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 + motion.normalized() * braking_distance) + ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance) func get_move_targets() -> Array: diff --git a/FormationEditor.gd b/FormationEditor.gd index 9ecab2a..a4da8bc 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.max_fleet_size()): + for i in range(GameState.formation.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.max_fleet_size()): + for i in range(GameState.formation.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 8728c91..4bc80dd 100644 --- a/GameState.gd +++ b/GameState.gd @@ -22,70 +22,11 @@ var explored_chunks: Dictionary = {} # --- Resources ------------------------------------------------------------- -# Ore that has been BANKED at the mothership. This is the only pool anything -# can be bought with. +# Rocks mined from asteroid-planet surfaces, spent on buildings and ships. # Starts stocked for play-testing so buildings can be tried without mining # first - drop this back to 0 for a real playthrough. var rocks: int = 500 -# Ore the fleet is physically carrying and has not delivered yet (#14). Mining -# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a -# long mining run is a real trip with something to lose rather than instant -# income from anywhere on the map. -# -# Lives here, like everything else that has to survive Main.tscn being torn -# down and rebuilt on every trip to a planet's surface - and cargo mined ON a -# surface would otherwise be dropped the moment the player took off. -var fleet_cargo: int = 0 - -signal cargo_delivered(amount: int) - - -func collect_cargo(amount: int = 1) -> void: - fleet_cargo += amount - - -# One press of "load ore" at the mothership. A batch rather than the whole -# bank: the hold is the part that can be lost, and emptying the bank in one -# tap would also strand research, which still spends from it. -const CARGO_LOAD_BATCH: int = 25 - - -func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int: - # Banked ore back into the hold, so the fleet can carry it to a building - # that is waiting on it (#17). - var taken: int = mini(amount, rocks) - if taken <= 0: - return 0 - rocks -= taken - fleet_cargo += taken - return taken - - -func unload_into_building(id: int) -> int: - # Everything the fleet is carrying, into this building's stock. Returns - # how much moved so a caller can tell the player something happened. - if fleet_cargo <= 0: - return 0 - var moved: int = fleet_cargo - if deposit_to_building(id, moved) <= 0: - return 0 - fleet_cargo = 0 - return moved - - -func deliver_cargo() -> int: - # Everything the fleet is carrying, banked at once. Returns how much moved - # so a caller can react to a delivery actually having happened rather than - # to merely being in range. - if fleet_cargo <= 0: - return 0 - var delivered: int = fleet_cargo - rocks += delivered - fleet_cargo = 0 - cargo_delivered.emit(delivered) - return delivered - const MOTHERSHIP_COST: int = 20 const SHIP_CRAFTER_COST: int = 12 const SHIP_COST: int = 6 @@ -101,304 +42,15 @@ const BUILDING_COMMAND_CENTER: String = "command_center" # Extractors are not placed in space like the others - they are built ON a # planet's surface and tracked separately (see `extractors`). const BUILDING_EXTRACTOR: String = "extractor" -# Hosts the transport vessels that supply other buildings (#18). Built in -# space like the rest; it is the transporters (#19) that do the moving. -const BUILDING_TRANSPORT_HUB: String = "transport_hub" const COMMAND_CENTER_COST: int = 15 const EXTRACTOR_COST: int = 25 -const TRANSPORT_HUB_COST: int = 30 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": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", - "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, - "desc": "Build transport hubs, which supply your other buildings with ore.", - }, - { - "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", - "x": 0.05, "hub_bonus": 1, - "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", - }, - { - "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() - - -# --- building stock and production queue (#16) ----------------------------- - -# Since #12, a building cannot just deduct from the bank at the moment of -# purchase: what it produces has to be paid for out of resources physically -# delivered to it. So each building carries its own `stock`, and an order it -# cannot afford yet is QUEUED rather than refused, and completes by itself -# once the stock turns up. -# -# `id` exists because a building has to be referable across the space <-> -# planet-surface scene swap, when every Building NODE has been destroyed and -# rebuilt. Position would nearly work and then fail on two buildings placed -# on the same spot. -var next_building_id: int = 1 - -# Ships that have been paid for and built but not yet handed to the fleet. -# Production is simulated here, in the autoload, the same way extractor -# haulers are - so an order can complete while the player is down on a -# planet's surface, where there is no Fleet node to hand a ship to. The fleet -# claims them when it next can. -var ships_owed: int = 0 - -signal order_completed(building_id: int, type: String) - - -func add_building(type: String, at_position: Vector2) -> int: - var id: int = next_building_id - next_building_id += 1 - buildings.append({ - "id": id, "type": type, "position": at_position, "stock": 0, "orders": [], - }) - return id - - -func find_building(id: int) -> Dictionary: - for b in buildings: - if b.get("id", 0) == id: - return b - return {} - - -const PRODUCT_SHIP: String = "interceptor" -const PRODUCT_TRANSPORTER: String = "transporter" - -const TRANSPORTER_COST: int = 10 - - -func product_cost(product: String) -> int: - if product == PRODUCT_TRANSPORTER: - return TRANSPORTER_COST - return SHIP_COST - - -func can_produce(type: String) -> bool: - # Only the ship crafter builds anything. The hub STATIONS transporters, - # it does not make them (#12). - return type == BUILDING_SHIP_CRAFTER - - -func building_stock(id: int) -> int: - var b: Dictionary = find_building(id) - return 0 if b.is_empty() else int(b.get("stock", 0)) - - -func pending_orders(id: int) -> int: - var b: Dictionary = find_building(id) - return 0 if b.is_empty() else (b.get("orders", []) as Array).size() - - -func pending_orders_of(id: int, product: String) -> int: - var b: Dictionary = find_building(id) - if b.is_empty(): - return 0 - var n: int = 0 - for o in b.get("orders", []): - if o == product: - n += 1 - return n - - -func outstanding_cost(id: int) -> int: - # What this building still needs delivered to clear everything queued. - var b: Dictionary = find_building(id) - if b.is_empty(): - return 0 - var need: int = 0 - for o in b.get("orders", []): - need += product_cost(o) - return maxi(need - int(b.get("stock", 0)), 0) - - -func deposit_to_building(id: int, amount: int) -> int: - if amount <= 0: - return 0 - var b: Dictionary = find_building(id) - if b.is_empty(): - return 0 - b.stock = int(b.get("stock", 0)) + amount - return amount - - -func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: - var b: Dictionary = find_building(id) - if b.is_empty() or not can_produce(b.type): - return false - if product == PRODUCT_TRANSPORTER and not has_transport_hub(): - # Nowhere to station it. Refused rather than queued, so the player is - # told now instead of watching an order sit forever. - return false - if product == PRODUCT_TRANSPORTER and not can_station_transporter(): - # Every berth is taken or spoken for (#20). - return false - (b.orders as Array).append(product) - return true - - -func _advance_production() -> void: - for b in buildings: - if not can_produce(b.type): - continue - # `while`, not `if`: a delivery big enough to cover several queued - # orders should clear them all rather than dribbling one out per - # frame. - var orders: Array = b.get("orders", []) - while not orders.is_empty(): - var product: String = String(orders[0]) - var cost: int = product_cost(product) - if int(b.get("stock", 0)) < cost: - break - if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity(): - # A hub was demolished after this was ordered, so the berth it - # was going to use is gone. Hold the order rather than - # overfilling what is left. - break - if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): - # Every hub was demolished while this sat in the queue. Leave - # it queued and the stock untouched rather than producing a - # vessel with nowhere to live. - break - b.stock = int(b.stock) - cost - orders.pop_front() - if product == PRODUCT_TRANSPORTER: - _station_transporter(b.position) - else: - ships_owed += 1 - order_completed.emit(int(b.id), product) +func add_building(type: String, at_position: Vector2) -> void: + buildings.append({"type": type, "position": at_position}) func has_mothership() -> bool: @@ -432,13 +84,8 @@ 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() @@ -486,20 +133,12 @@ func has_extractor_at(planet_name: String) -> bool: return false -signal extractor_added(planet_name: String) - - func add_extractor(planet_name: String, at_position: Vector2) -> void: extractors.append({ "position": at_position, "planet_name": planet_name, "progress": 0.0, }) - # An extractor is built from SPACE, with the fleet parked on the planet - - # so the Planet node is already in the tree with its _ready() long since - # run. Without this it kept showing no derrick until it happened to - # despawn and respawn, which reads as the build having done nothing. - extractor_added.emit(planet_name) func get_hauler_route_time(extractor: Dictionary) -> float: @@ -526,11 +165,8 @@ func get_hauler_position(extractor: Dictionary) -> Variant: func _process(delta: float) -> void: - # Runs in the autoload, so extraction keeps earning - and queued orders - # keep completing - while the player is on a planet's surface or nowhere - # near either. - _advance_production() - _advance_transporters(delta) + # Runs in the autoload, so extraction keeps earning while the player is + # on a planet's surface or nowhere near the route. if extractors.is_empty(): return for e in extractors: @@ -547,201 +183,6 @@ func _process(delta: float) -> void: e.progress -= 1.0 -# --- transporters (#19) --------------------------------------------------- - -# Vessels stationed at a transport hub that fetch ore from the mothership and -# deliver it to whichever building is waiting on it. -# -# Simulated HERE rather than by any node, exactly like the extractor haulers -# above and for the same reason: a route the player is nowhere near still has -# to run. `progress` drives both the drawn position and the moment the ore -# actually moves, so what you see and what happens cannot drift apart. -# -# Entries are {"hub_id", "state", "target_id", "carrying", "progress", -# "from", "to"}. -const TRANSPORTER_SPEED: float = 340.0 - -# How many transporters one hub can station. Raised for EVERY hub - built -# already or not - by research, which is the same shape as max_fleet_size() -# rather than a second mechanism doing the same job. -const BASE_HUB_CAPACITY: int = 2 - -const TRANSPORTER_IDLE: String = "idle" -const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership" -const TRANSPORTER_TO_BUILDING: String = "to_building" -const TRANSPORTER_RETURNING: String = "returning" - -var transporters: Array = [] - - -func hub_capacity() -> int: - # Per hub. - var n: int = BASE_HUB_CAPACITY - for id in unlocked: - n += int(skill_node(id).get("hub_bonus", 0)) - return n - - -func hub_count() -> int: - var n: int = 0 - for b in buildings: - if b.type == BUILDING_TRANSPORT_HUB: - n += 1 - return n - - -func transport_capacity() -> int: - # Total berths: more hubs, or more capacity per hub. Either raises it, - # which is exactly the choice #12 asks the player to make. - return hub_count() * hub_capacity() - - -func pending_transporter_orders() -> int: - var n: int = 0 - for b in buildings: - for o in b.get("orders", []): - if o == PRODUCT_TRANSPORTER: - n += 1 - return n - - -func transporters_claimed() -> int: - # Built plus already ordered: an order in the queue has a berth reserved, - # or a player could queue ten of them against two berths and only find out - # when they refused to appear. - return transporters.size() + pending_transporter_orders() - - -func can_station_transporter() -> bool: - return transporters_claimed() < transport_capacity() - - -func has_transport_hub() -> bool: - return not nearest_hub_to(Vector2.ZERO).is_empty() - - -func nearest_hub_to(at: Vector2) -> Dictionary: - var best: Dictionary = {} - var best_d: float = INF - for b in buildings: - if b.type != BUILDING_TRANSPORT_HUB: - continue - var d: float = at.distance_squared_to(b.position) - if d < best_d: - best_d = d - best = b - return best - - -func _station_transporter(built_at: Vector2) -> void: - # Built at the crafter, then it flies itself to the nearest hub and waits - # there for orders (#12). - var hub: Dictionary = nearest_hub_to(built_at) - if hub.is_empty(): - return - transporters.append({ - "hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0, - "carrying": 0, "progress": 0.0, "from": built_at, "to": hub.position, - }) - - -func transporter_position(t: Dictionary) -> Vector2: - return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0)) - - -func _hub_position(hub_id: int) -> Variant: - var hub: Dictionary = find_building(hub_id) - return null if hub.is_empty() else hub.position - - -func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void: - t.from = from - t.to = to - t.state = state - t.progress = 0.0 - - -func _transporter_inbound_to(building_id: int) -> bool: - for t in transporters: - if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id: - return true - return false - - -func _dispatch_transporters() -> void: - var mothership: Variant = get_mothership_position() - if mothership == null: - return # nowhere to load from - for b in buildings: - if outstanding_cost(int(b.id)) <= 0: - continue - # One at a time per building: without this every idle transporter - # would be sent after the same shortfall and they would all arrive - # carrying ore nobody needs any more. - if _transporter_inbound_to(int(b.id)): - continue - var chosen: Dictionary = {} - var best_d: float = INF - for t in transporters: - if t.state != TRANSPORTER_IDLE: - continue - var here: Vector2 = transporter_position(t) - var d: float = here.distance_squared_to(b.position) - if d < best_d: - best_d = d - chosen = t - if chosen.is_empty(): - return # every transporter is busy - chosen.target_id = int(b.id) - _begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP) - - -func _advance_transporters(delta: float) -> void: - if transporters.is_empty(): - return - _dispatch_transporters() - for t in transporters: - if t.state == TRANSPORTER_IDLE: - continue - var distance: float = (t.from as Vector2).distance_to(t.to as Vector2) - var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001) - t.progress += delta / leg - if t.progress < 1.0: - continue - t.progress = 1.0 - _arrive(t) - - -func _arrive(t: Dictionary) -> void: - var here: Vector2 = t.to - var hub_pos: Variant = _hub_position(int(t.hub_id)) - match t.state: - TRANSPORTER_TO_MOTHERSHIP: - var target: Dictionary = find_building(int(t.target_id)) - var want: int = outstanding_cost(int(t.target_id)) - var take: int = mini(want, rocks) - if take <= 0 or target.is_empty(): - # Nothing to carry, or the building is gone. Go home empty. - t.target_id = 0 - _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) - return - rocks -= take - t.carrying = take - _begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING) - TRANSPORTER_TO_BUILDING: - # The ore moves at exactly the moment the vessel arrives. - if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0: - # Building demolished in transit - put the ore back rather - # than quietly destroying it. - rocks += int(t.carrying) - t.carrying = 0 - t.target_id = 0 - _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) - TRANSPORTER_RETURNING: - t.state = TRANSPORTER_IDLE - t.progress = 1.0 - - func can_afford(cost: int) -> bool: return rocks >= cost diff --git a/IconButton.gd b/IconButton.gd index 7dd8ea3..dc91c31 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -15,7 +15,7 @@ enum Icon { # build menu used to pick from the generic set, which left the mothership # and the command centre sharing a folded-map glyph and neither of them # looking anything like the structure they build. - MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, + MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -50,25 +50,7 @@ func _ready() -> void: queue_redraw() -func _blocked_by_modal() -> bool: - # A panel is open and this button is not part of it, so it is sitting - # BEHIND the panel and must not react. - # - # The panels cannot simply swallow GUI input with a mouse_filter of STOP: - # the skill tree and the formation editor are driven from raw - # _unhandled_input (see SkillTreeView), and a Control that consumes - # touches through the GUI system swallows them before they ever get - # there. So the panels stay transparent to input and the buttons check - # instead. - for menu in get_tree().get_nodes_in_group("modal_menus"): - if menu.visible and not menu.is_ancestor_of(self): - return true - return false - - func _gui_input(event: InputEvent) -> void: - if _blocked_by_modal(): - return if event is InputEventScreenTouch: _handle_press(event.pressed, event.position) accept_event() @@ -137,8 +119,6 @@ func _draw() -> void: _draw_command_icon(center, pulse, s) Icon.EXTRACTOR: _draw_extractor_icon(center, pulse, s) - Icon.TRANSPORT_HUB: - _draw_transport_hub_icon(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -224,32 +204,6 @@ func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) -func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void: - # A ring of docking cradles with a freight box in the middle: reads as - # "things are stationed here and go out from here", which is what the hub - # does, and shares no silhouette with the crafter's open cradle. - var ring_r: float = 11.0 * s - var ring: PackedVector2Array = PackedVector2Array() - for i in range(8): - var a: float = (float(i) / 8.0) * TAU + PI / 8.0 - ring.append(center + Vector2(cos(a), sin(a)) * ring_r) - NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) - - var box: float = 4.2 * s - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - center + Vector2(-box, -box), center + Vector2(box, -box), - center + Vector2(box, box), center + Vector2(-box, box), - ]), _active_color(), pulse, core_color) - - # Three stubby berths, so the ring reads as occupied rather than as a - # plain octagon. - for i in range(3): - var a: float = (float(i) / 3.0) * TAU - PI / 2.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - NeonDraw.draw_glowing_line(self, center + dir * (box * 1.5), - center + dir * (ring_r - 1.5 * s), _active_color(), pulse, 0.35 * s, core_color) - - func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: # Reactor core, truss spine, and an open cradle holding a hull. var core_centre: Vector2 = center + Vector2(-11, 0) * s @@ -273,31 +227,18 @@ func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void: - # 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) - + # Diamond with three formation pips - "arrangement". + var d: float = 14.0 * s NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - center + Vector2(0, -hub_r), center + Vector2(hub_r, 0), - center + Vector2(0, hub_r), center + Vector2(-hub_r, 0), + center + Vector2(0, -d), center + Vector2(d, 0), + center + Vector2(0, d), center + Vector2(-d, 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 5a87a8d..63b4f5e 100644 --- a/LightBurstVFX.gd +++ b/LightBurstVFX.gd @@ -7,13 +7,9 @@ extends ExplosionVFXBase var color: Color = Color(1, 1, 1) -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. +func _draw_particle(p: Dictionary, alpha: float) -> void: 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 line_color: Color = color - visual.draw_callable = func(item: CanvasItem) -> void: - NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6) + var pos: Vector2 = p["pos"] + NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6) diff --git a/Main.tscn b/Main.tscn index 88b0a2a..d7e5378 100644 --- a/Main.tscn +++ b/Main.tscn @@ -20,9 +20,6 @@ [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"] -[ext_resource type="Script" path="res://CargoCounter.gd" id="22"] -[ext_resource type="Script" path="res://TransporterField.gd" id="23"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -73,9 +70,6 @@ script = ExtResource("14") [node name="HaulerField" type="Node2D" parent="."] script = ExtResource("18") -[node name="TransporterField" type="Node2D" parent="."] -script = ExtResource("23") - [node name="BuildController" type="Node2D" parent="."] script = ExtResource("15") @@ -89,19 +83,10 @@ layer = 1 mouse_filter = 2 offset_left = 28 offset_top = 24 -offset_right = 320 -offset_bottom = 92 +offset_right = 520 +offset_bottom = 140 script = ExtResource("16") -digit_height = 60.0 - -[node name="CargoCounter" type="Control" parent="UI"] -mouse_filter = 2 -offset_left = 28 -offset_top = 96 -offset_right = 320 -offset_bottom = 146 -script = ExtResource("22") -digit_height = 42.0 +digit_height = 100.0 [node name="PlanetNameLabel" type="Label" parent="UI"] visible = false @@ -144,6 +129,19 @@ 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 @@ -203,7 +201,20 @@ offset_bottom = -24 script = ExtResource("10") icon = 6 -[node name="WorldMap" type="Control" parent="UI" groups=["modal_menus"]] +[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 anchor_bottom = 1.0 @@ -237,7 +248,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="BuildMenu" type="Control" parent="UI" groups=["modal_menus"]] +[node name="BuildMenu" type="Control" parent="UI"] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -274,10 +285,10 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -204 -offset_right = -76 -offset_top = -300 -offset_bottom = -172 +offset_left = -214 +offset_right = -66 +offset_top = -224 +offset_bottom = -76 script = ExtResource("10") icon = 8 glow_color = Color(0.45, 0.9, 0.55, 1) @@ -290,9 +301,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = -166 -offset_bottom = -100 -theme_override_font_sizes/font_size = 32 +offset_top = -70 +offset_bottom = 60 +theme_override_font_sizes/font_size = 44 autowrap_mode = 2 text = "Mothership" horizontal_alignment = 1 @@ -303,23 +314,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -200 -offset_right = -80 -offset_top = -96 -offset_bottom = -44 +offset_left = -220 +offset_right = -60 +offset_top = 70 +offset_bottom = 150 script = ExtResource("19") amount = 20 -digit_height = 40.0 +digit_height = 66.0 [node name="CrafterButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 76 -offset_right = 204 -offset_top = -300 -offset_bottom = -172 +offset_left = 66 +offset_right = 214 +offset_top = -224 +offset_bottom = -76 script = ExtResource("10") icon = 9 glow_color = Color(1, 0.72, 0.25, 1) @@ -332,9 +343,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = -166 -offset_bottom = -100 -theme_override_font_sizes/font_size = 32 +offset_top = -70 +offset_bottom = 60 +theme_override_font_sizes/font_size = 44 autowrap_mode = 2 text = "Ship Crafter" horizontal_alignment = 1 @@ -345,23 +356,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 80 -offset_right = 200 -offset_top = -96 -offset_bottom = -44 +offset_left = 60 +offset_right = 220 +offset_top = 70 +offset_bottom = 150 script = ExtResource("19") amount = 12 -digit_height = 40.0 +digit_height = 66.0 [node name="CommandButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -204 -offset_right = -76 -offset_top = -10 -offset_bottom = 118 +offset_left = -214 +offset_right = -66 +offset_top = 175 +offset_bottom = 323 script = ExtResource("10") icon = 10 glow_color = Color(0.55, 0.6, 1, 1) @@ -374,9 +385,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = 124 -offset_bottom = 190 -theme_override_font_sizes/font_size = 32 +offset_top = 333 +offset_bottom = 463 +theme_override_font_sizes/font_size = 44 autowrap_mode = 2 text = "Command Center" horizontal_alignment = 1 @@ -387,23 +398,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -200 -offset_right = -80 -offset_top = 194 -offset_bottom = 246 +offset_left = -220 +offset_right = -60 +offset_top = 473 +offset_bottom = 553 script = ExtResource("19") amount = 15 -digit_height = 40.0 +digit_height = 66.0 [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 76 -offset_right = 204 -offset_top = -10 -offset_bottom = 118 +offset_left = 66 +offset_right = 214 +offset_top = 175 +offset_bottom = 323 script = ExtResource("10") icon = 11 glow_color = Color(0, 0.62, 0.7, 1) @@ -416,9 +427,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = 124 -offset_bottom = 190 -theme_override_font_sizes/font_size = 32 +offset_top = 333 +offset_bottom = 463 +theme_override_font_sizes/font_size = 44 autowrap_mode = 2 text = "Extractor" horizontal_alignment = 1 @@ -429,55 +440,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 80 -offset_right = 200 -offset_top = 194 -offset_bottom = 246 +offset_left = 60 +offset_right = 220 +offset_top = 473 +offset_bottom = 553 script = ExtResource("19") amount = 25 -digit_height = 40.0 - -[node name="TransportHubButton" type="Control" parent="UI/BuildMenu"] -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = -64 -offset_right = 64 -offset_top = 280 -offset_bottom = 408 -script = ExtResource("10") -icon = 12 -glow_color = Color(0.95, 0.55, 0.85, 1) - -[node name="TransportHubLabel" type="Label" parent="UI/BuildMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = -150 -offset_right = 150 -offset_top = 414 -offset_bottom = 480 -theme_override_font_sizes/font_size = 32 -autowrap_mode = 2 -text = "Transport Hub" -horizontal_alignment = 1 - -[node name="TransportHubCost" type="Control" parent="UI/BuildMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = -60 -offset_right = 60 -offset_top = 484 -offset_bottom = 536 -script = ExtResource("19") -amount = 30 -digit_height = 40.0 +digit_height = 66.0 [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 @@ -489,7 +458,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="InfoPopup" type="Control" parent="UI/BuildMenu" groups=["modal_menus"]] +[node name="InfoPopup" type="Control" parent="UI/BuildMenu"] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -576,7 +545,7 @@ script = ExtResource("10") icon = 1 glow_color = Color(0, 0.62, 0.7, 1) -[node name="CraftMenu" type="Control" parent="UI" groups=["modal_menus"]] +[node name="CraftMenu" type="Control" parent="UI"] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -613,8 +582,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -234 -offset_right = -86 +offset_left = -74 +offset_right = 74 offset_top = -74 offset_bottom = 74 script = ExtResource("10") @@ -626,70 +595,14 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -300 -offset_right = -20 +offset_left = -260 +offset_right = 260 offset_top = 100 offset_bottom = 200 -theme_override_font_sizes/font_size = 32 +theme_override_font_sizes/font_size = 60 text = "Interceptor" horizontal_alignment = 1 -[node name="TransporterButton" type="Control" parent="UI/CraftMenu"] -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = 86 -offset_right = 234 -offset_top = -74 -offset_bottom = 74 -script = ExtResource("10") -icon = 12 -glow_color = Color(0.95, 0.55, 0.85, 1) - -[node name="TransporterLabel" type="Label" parent="UI/CraftMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = 20 -offset_right = 300 -offset_top = 96 -offset_bottom = 176 -theme_override_font_sizes/font_size = 32 -autowrap_mode = 2 -text = "Transporter" -horizontal_alignment = 1 - -[node name="StoreOreButton" type="Control" parent="UI/CraftMenu"] -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -74 -offset_top = -304 -offset_right = 74 -offset_bottom = -156 -script = ExtResource("10") -icon = 3 -glow_color = Color(1, 0.78, 0.25, 1) - -[node name="StoreOreLabel" type="Label" parent="UI/CraftMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -260 -offset_top = -148 -offset_right = 260 -offset_bottom = -24 -theme_override_font_sizes/font_size = 32 -autowrap_mode = 2 -text = "Store ore here" -horizontal_alignment = 1 - [node name="CloseButton" type="Control" parent="UI/CraftMenu"] anchor_left = 1.0 anchor_right = 1.0 @@ -700,7 +613,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]] +[node name="FormationMenu" type="Control" parent="UI"] visible = false mouse_filter = 2 anchor_right = 1.0 @@ -783,128 +696,3 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) - -[node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]] -visible = false -mouse_filter = 2 -anchor_right = 1.0 -anchor_bottom = 1.0 - -[node name="Backdrop" type="ColorRect" parent="UI/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 -anchor_bottom = 1.0 -offset_left = 24.0 -offset_top = 210.0 -offset_right = -24.0 -offset_bottom = -620.0 -script = ExtResource("21") - -[node name="DetailLabel" type="Label" parent="UI/SkillMenu"] -mouse_filter = 2 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = 32.0 -offset_top = -610.0 -offset_right = -32.0 -offset_bottom = -462.0 -theme_override_font_sizes/font_size = 34 -autowrap_mode = 2 -horizontal_alignment = 1 -text = "" - -[node name="CostNumber" type="Control" parent="UI/SkillMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -64.0 -offset_top = -456.0 -offset_right = 76.0 -offset_bottom = -392.0 -script = ExtResource("19") -digit_height = 52.0 - -[node name="StatusLabel" type="Label" parent="UI/SkillMenu"] -mouse_filter = 2 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = 32.0 -offset_top = -386.0 -offset_right = -32.0 -offset_bottom = -318.0 -theme_override_font_sizes/font_size = 32 -autowrap_mode = 2 -horizontal_alignment = 1 -text = "" - -[node name="UnlockButton" type="Control" parent="UI/SkillMenu"] -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -74.0 -offset_top = -304.0 -offset_right = 74.0 -offset_bottom = -156.0 -script = ExtResource("10") -icon = 5 -glow_color = Color(0.45, 0.9, 0.55, 1) - -[node name="LoadOreButton" type="Control" parent="UI/SkillMenu"] -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -256.0 -offset_top = -304.0 -offset_right = -108.0 -offset_bottom = -156.0 -script = ExtResource("10") -icon = 4 -glow_color = Color(1, 0.78, 0.25, 1) - -[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = -330.0 -offset_top = -150.0 -offset_right = -30.0 -offset_bottom = -14.0 -theme_override_font_sizes/font_size = 28 -horizontal_alignment = 1 -autowrap_mode = 2 -text = "Load ore" - -[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 8a0e22a..b18f19e 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, width_scale: float = 1.0) -> void: +static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: var closed: PackedVector2Array = points.duplicate() closed.append(points[0]) - _draw_layers(item, closed, glow_color, pulse, width_scale, core_color) + _draw_layers(item, closed, glow_color, pulse, 1.0, 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 162261a..7333b36 100644 --- a/NeonNumber.gd +++ b/NeonNumber.gd @@ -16,13 +16,6 @@ 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 = [ @@ -67,8 +60,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, 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) + 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) func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void: @@ -85,8 +78,7 @@ 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, stroke, core_color) + NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, 0.3, core_color) diff --git a/NeonPiece.gd b/NeonPiece.gd deleted file mode 100644 index a4fc9b7..0000000 --- a/NeonPiece.gd +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 2b99e96..0000000 --- a/NeonPiece.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8r2lwqg3syw2 diff --git a/Planet.gd b/Planet.gd index f3dbc4a..4882311 100644 --- a/Planet.gd +++ b/Planet.gd @@ -20,23 +20,6 @@ extends Node2D var shape_points: PackedVector2Array = PackedVector2Array() -# An extractor is built down on this planet's SURFACE, in a different scene -# the player is no longer looking at, so from space nothing distinguished a -# planet already being mined from an untouched one - you had to land to find -# out. This is a derrick planted on the rim: the same amber the world map -# uses for its extractor badge, so the two read as the same fact. -const EXTRACTOR_MARKER_COLOR: Color = Color(1.0, 0.85, 0.3) -const DERRICK_HEIGHT: float = 44.0 -const DERRICK_HALF_WIDTH: float = 16.0 - -# Kept in step with GameState.extractor_added rather than polled per frame. -# An extractor is built from SPACE (BuildController._planet_under_fleet(), -# with the fleet parked on the planet), so this node is already in the tree -# with its _ready() long since run when the answer changes - resolving it -# only at _ready() left the planet looking untouched until it happened to -# despawn and respawn. -var shows_extractor: bool = false - func _ready() -> void: add_to_group("planets") @@ -49,60 +32,8 @@ func _ready() -> void: var angle: float = (float(i) / point_count) * TAU var radius: float = rng.randf_range(70.0, 100.0) shape_points.append(Vector2(cos(angle), sin(angle)) * radius) - shows_extractor = GameState.has_extractor_at(planet_name) - GameState.extractor_added.connect(_on_extractor_added) queue_redraw() -func _on_extractor_added(built_on: String) -> void: - # Only the planet it was actually built on, and only a redraw when the - # answer really changed - every other planet in range gets this too. - if built_on != planet_name or shows_extractor: - return - shows_extractor = true - queue_redraw() - - -func extractor_derrick_anchor() -> Vector2: - # The rim vertex the derrick stands on: the shape's own topmost point, - # rather than a fixed radius, so it sits ON the outline whatever lumpy - # shape the seed drew instead of floating off it or sinking inside it. - var base: Vector2 = Vector2(0.0, -100.0) - for p in shape_points: - if p.y < base.y: - base = p - return base - - -func extractor_derrick_tip() -> Vector2: - # Exposed separately from the polygon because "does the derrick clear the - # planet's outline" is a question about the TIP alone. The base corners - # sit a half-width off to either side, so they are sqrt(|base|^2 + w^2) - # from the centre and can clear the rim on their own even when the tower - # points straight into the ground. - var base: Vector2 = extractor_derrick_anchor() - var out: Vector2 = base.normalized() - if out == Vector2.ZERO: - out = Vector2.UP - return base + out * DERRICK_HEIGHT - - -func extractor_derrick_points() -> PackedVector2Array: - var base: Vector2 = extractor_derrick_anchor() - var out: Vector2 = base.normalized() - if out == Vector2.ZERO: - out = Vector2.UP - var side: Vector2 = Vector2(-out.y, out.x) - var tip: Vector2 = extractor_derrick_tip() - return PackedVector2Array([ - base - side * DERRICK_HALF_WIDTH, - tip - side * (DERRICK_HALF_WIDTH * 0.35), - tip + side * (DERRICK_HALF_WIDTH * 0.35), - base + side * DERRICK_HALF_WIDTH, - ]) - - func _draw() -> void: NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0) - if shows_extractor: - NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0) diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index aa4a117..b2a7a16 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -58,10 +58,10 @@ layer = 1 mouse_filter = 2 offset_left = 28 offset_top = 24 -offset_right = 320 -offset_bottom = 92 +offset_right = 520 +offset_bottom = 140 script = ExtResource("9") -digit_height = 60.0 +digit_height = 100.0 glow_color = Color(0, 0.42, 0.48, 1) core_color = Color(0, 0.08, 0.1, 1) @@ -143,6 +143,22 @@ 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 ba7d629..ed05f23 100644 --- a/Projectile.gd +++ b/Projectile.gd @@ -12,13 +12,6 @@ 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: @@ -29,6 +22,14 @@ 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/Rock.gd b/Rock.gd index a5a85bd..bc8ffac 100644 --- a/Rock.gd +++ b/Rock.gd @@ -80,9 +80,7 @@ func _nearest_ship() -> Node2D: func _on_area_entered(area: Area2D) -> void: if area.is_in_group("player_ships"): - # Into the fleet's hold, not the bank (#14). It only counts as - # spendable once the fleet has flown it back to the mothership. - GameState.collect_cargo(1) + GameState.rocks += 1 collected.emit() queue_free() diff --git a/SkillTreeView.gd b/SkillTreeView.gd deleted file mode 100644 index 84def69..0000000 --- a/SkillTreeView.gd +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index b4906ff..0000000 --- a/SkillTreeView.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ob1hc06hf21t diff --git a/Transporter.gd b/Transporter.gd deleted file mode 100644 index 07b5103..0000000 --- a/Transporter.gd +++ /dev/null @@ -1,38 +0,0 @@ -extends Node2D - -# Purely visual: a transport vessel running a hub -> mothership -> building -# supply route. Carries no state of its own; GameState owns the simulation -# and TransporterField creates, positions and frees this to match. - -const HULL_COLOR: Color = Color(0.95, 0.55, 0.85) - -var laden: bool = false - - -func _ready() -> void: - add_to_group("transporters") - z_index = -1 - queue_redraw() - - -func set_laden(value: bool) -> void: - if laden == value: - return - laden = value - queue_redraw() - - -func _draw() -> void: - # Squat and boxy, in the transport hub's own magenta, so it reads as - # "belongs to the hub" and not as an extractor hauler (green) or a fleet - # vessel (sharp cyan triangle). - var hull: PackedVector2Array = PackedVector2Array([ - Vector2(0, -16), Vector2(10, -4), Vector2(10, 14), Vector2(-10, 14), Vector2(-10, -4), - ]) - NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0) - if laden: - # A visible payload, so a loaded run is distinguishable from a - # deadhead back to the hub at a glance. - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - Vector2(-5, 0), Vector2(5, 0), Vector2(5, 9), Vector2(-5, 9), - ]), HULL_COLOR, 0.85) diff --git a/Transporter.gd.uid b/Transporter.gd.uid deleted file mode 100644 index 317eb72..0000000 --- a/Transporter.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://u8bqm1f03l2y diff --git a/Transporter.tscn b/Transporter.tscn deleted file mode 100644 index d6555a6..0000000 --- a/Transporter.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://Transporter.gd" id="1"] - -[node name="Transporter" type="Node2D"] -script = ExtResource("1") diff --git a/TransporterField.gd b/TransporterField.gd deleted file mode 100644 index c47ab7d..0000000 --- a/TransporterField.gd +++ /dev/null @@ -1,51 +0,0 @@ -extends Node2D - -# Draws the transport vessels GameState is simulating, and only while they -# are somewhere the player could see them. Same arrangement as HaulerField, -# and for the same reason: the simulation is authoritative and the visuals -# are optional, so a run happening far away costs nothing but still moves -# ore. Position comes from the very same `progress` that decides when the -# ore actually changes hands. - -@export var visible_radius: float = 2600.0 - -@onready var fleet: Node2D = get_node("../Fleet") - -var transporter_scene: PackedScene = preload("res://Transporter.tscn") -# index into GameState.transporters -> live node -var active: Dictionary = {} - - -func _process(_delta: float) -> void: - var centre: Vector2 = fleet.get_centroid() - - for i in range(GameState.transporters.size()): - var t: Dictionary = GameState.transporters[i] - var pos: Vector2 = GameState.transporter_position(t) - if pos.distance_to(centre) > visible_radius: - _despawn(i) - continue - - var node: Node2D = active.get(i) - if node == null or not is_instance_valid(node): - node = transporter_scene.instantiate() - get_parent().add_child(node) - active[i] = node - - var previous: Vector2 = node.position - node.position = pos - node.set_laden(int(t.carrying) > 0) - var travel: Vector2 = pos - previous - if travel.length() > 0.5: - node.rotation = travel.angle() + PI / 2.0 - - for key in active.keys(): - if key >= GameState.transporters.size(): - _despawn(key) - - -func _despawn(index: int) -> void: - var node: Node2D = active.get(index) - if node != null and is_instance_valid(node): - node.queue_free() - active.erase(index) diff --git a/TransporterField.gd.uid b/TransporterField.gd.uid deleted file mode 100644 index a42478f..0000000 --- a/TransporterField.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://mrx6p58dog66 diff --git a/WorldMapContent.gd b/WorldMapContent.gd index b8b1ad2..b0fe79d 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -2,24 +2,6 @@ extends Node2D const WORLD_TO_LOCAL: float = 0.04 -# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a -# marker on the map reads as "the same building" at a glance. Duplicated -# rather than cross-referenced - the same colour is already independently -# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this -# follows the codebase's existing convention rather than inventing a new one. -const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) -const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) -const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) -const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85) - -# A structure built on a planet's surface (see GameState.extractors) is not -# itself a map marker - it is a small badge on the existing planet marker, -# in a colour distinct from both the planets and the space buildings so it -# reads as "something is mined here" rather than another building type. -const EXTRACTOR_BADGE_COLOR: Color = Color(1.0, 0.85, 0.3) -const EXTRACTOR_BADGE_OFFSET: Vector2 = Vector2(8.0, -8.0) -const EXTRACTOR_BADGE_RADIUS: float = 5.0 - @onready var tracker: Node2D = get_node("../../../ExplorationTracker") @onready var fleet: Node2D = get_node("../../../Fleet") @@ -29,61 +11,6 @@ func _process(_delta: float) -> void: queue_redraw() -# Local-space marker data for every player-placed building (GameState. -# buildings), one entry per building with its colour already resolved. Kept -# as a separate, callable step (rather than inlined in _draw()) so a -# headless test - which has no GPU and cannot check drawn pixels - can -# verify a built building actually produces a marker. -func building_markers() -> Array: - var markers: Array = [] - for b in GameState.buildings: - markers.append({ - "position": b.position * WORLD_TO_LOCAL, - "type": b.type, - "color": _building_marker_color(b.type), - }) - return markers - - -func _building_marker_color(type: String) -> Color: - match type: - GameState.BUILDING_MOTHERSHIP: - return MOTHERSHIP_MARKER_COLOR - GameState.BUILDING_SHIP_CRAFTER: - return CRAFTER_MARKER_COLOR - GameState.BUILDING_TRANSPORT_HUB: - return TRANSPORT_HUB_MARKER_COLOR - _: - return COMMAND_MARKER_COLOR - - -# Whether the planet in this (explored) chunk has an extractor on it, per -# GameState.has_extractor_at(). A separate predicate for the same -# GPU-less-testability reason as building_markers(). -func chunk_has_extractor_badge(chunk: Vector2i) -> bool: - if not WorldGen.has_planet_at_chunk(chunk): - return false - return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).name) - - -func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: - var pts: PackedVector2Array = PackedVector2Array() - for i in range(sides): - var a: float = (float(i) / float(sides)) * TAU + phase - pts.append(center + Vector2(cos(a), sin(a)) * radius) - return pts - - -# Filled shape plus a thin white outline - the same construction the fleet -# marker below already uses, just factored out since buildings now need it -# three times over (once per type) and the extractor badge needs it too. -func _draw_marker_polygon(pts: PackedVector2Array, color: Color) -> void: - draw_colored_polygon(pts, color) - var closed: PackedVector2Array = pts.duplicate() - closed.append(pts[0]) - draw_polyline(closed, Color(1, 1, 1), 1.5, true) - - func _draw() -> void: var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL @@ -105,24 +32,6 @@ func _draw() -> void: draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55)) draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true) - if chunk_has_extractor_badge(chunk): - var badge_pts: PackedVector2Array = _regular_polygon( - planet_local + EXTRACTOR_BADGE_OFFSET, EXTRACTOR_BADGE_RADIUS, 4, PI / 4.0) - _draw_marker_polygon(badge_pts, EXTRACTOR_BADGE_COLOR) - - # Buildings are placed freely in space rather than tied to a chunk, so - # they get their own pass rather than living inside the chunk loop above. - for marker in building_markers(): - match marker.type: - GameState.BUILDING_MOTHERSHIP: - _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) - GameState.BUILDING_SHIP_CRAFTER: - _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) - GameState.BUILDING_TRANSPORT_HUB: - _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color) - _: - _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) - var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ fleet_local + Vector2(0, -10), diff --git a/fonts/CarroisGothicSC-Regular.ttf b/fonts/CarroisGothicSC-Regular.ttf deleted file mode 100644 index c363454..0000000 Binary files a/fonts/CarroisGothicSC-Regular.ttf and /dev/null differ diff --git a/fonts/CarroisGothicSC-Regular.ttf.import b/fonts/CarroisGothicSC-Regular.ttf.import deleted file mode 100644 index 82d1e1f..0000000 --- a/fonts/CarroisGothicSC-Regular.ttf.import +++ /dev/null @@ -1,36 +0,0 @@ -[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 deleted file mode 100644 index d645695..0000000 --- a/fonts/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - 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 deleted file mode 100644 index 13e01d9..0000000 --- a/fonts/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# 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/_build_menu_preview.gd b/tests/_build_menu_preview.gd deleted file mode 100644 index 5132bbf..0000000 --- a/tests/_build_menu_preview.gd +++ /dev/null @@ -1,20 +0,0 @@ -extends Node2D - -# Preview, not a test. Opens the build menu itself, so the three-row layout -# added for the Transport Hub (#18) can be checked for fit on the real phone -# viewport - every button, label and price moved, and a bad offset is -# invisible to a headless test. -# -# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/menu.png 20 720x1280 - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.rocks = 500 - # Everything researched, so nothing is greyed out and the whole menu is - # legible at full strength. - GameState.unlocked = ["shipyards", "logistics", "freight", "coordination"] - var main: Node = load("res://Main.tscn").instantiate() - add_child(main) - main.get_node("UI/BuildMenu").visible = true diff --git a/tests/_build_menu_preview.gd.uid b/tests/_build_menu_preview.gd.uid deleted file mode 100644 index 5292ec5..0000000 --- a/tests/_build_menu_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cuns23tjgn7fg diff --git a/tests/_build_menu_preview.tscn b/tests/_build_menu_preview.tscn deleted file mode 100644 index 419b3ea..0000000 --- a/tests/_build_menu_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_build_menu_preview.gd" id="1"] - -[node name="BuildMenuPreview" type="Node2D"] -script = ExtResource("1") diff --git a/tests/_cargo_readout_preview.gd b/tests/_cargo_readout_preview.gd deleted file mode 100644 index 6e63b47..0000000 --- a/tests/_cargo_readout_preview.gd +++ /dev/null @@ -1,15 +0,0 @@ -extends Node2D - -# Preview, not a test. The resource readouts with the fleet carrying ore, so -# the #15 banked-vs-carried split can actually be looked at - headless draws -# no pixels and cannot say whether two numbers read as two different things. -# -# bash tests/screenshot.sh res://tests/_cargo_readout_preview.tscn /tmp/hud.png 20 720x1280 - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.rocks = 128 - GameState.fleet_cargo = 34 - add_child(load("res://Main.tscn").instantiate()) diff --git a/tests/_cargo_readout_preview.gd.uid b/tests/_cargo_readout_preview.gd.uid deleted file mode 100644 index 8434c7f..0000000 --- a/tests/_cargo_readout_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://djp66g3saubh4 diff --git a/tests/_cargo_readout_preview.tscn b/tests/_cargo_readout_preview.tscn deleted file mode 100644 index 60c3692..0000000 --- a/tests/_cargo_readout_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_cargo_readout_preview.gd" id="1"] - -[node name="CargoReadoutPreview" type="Node2D"] -script = ExtResource("1") diff --git a/tests/_craft_menu_preview.gd b/tests/_craft_menu_preview.gd deleted file mode 100644 index 73dca1f..0000000 --- a/tests/_craft_menu_preview.gd +++ /dev/null @@ -1,27 +0,0 @@ -extends Node2D - -# Preview, not a test. Opens the CraftMenu so its layout can be checked on a -# real viewport - the mothership's "Load ore" label was falling off the bottom -# of shorter screens, which no headless test can see. -# -# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280 -# ... and again at 720x960 to check a shorter screen format. - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.transporters.clear() - GameState.rocks = 180 - GameState.fleet_cargo = 24 - GameState.unlocked = ["shipyards", "logistics", "freight"] - var main: Node = load("res://Main.tscn").instantiate() - add_child(main) - main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0)) - var bc: Node = main.get_node("BuildController") - bc.craft_building_id = 1 - main.get_node("UI/CraftMenu").visible = true - if "Craft" == "Craft": - bc._refresh_craft_status() - else: - bc._refresh_load_ore() diff --git a/tests/_craft_menu_preview.gd.uid b/tests/_craft_menu_preview.gd.uid deleted file mode 100644 index 365ad46..0000000 --- a/tests/_craft_menu_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dlhupvrh333dd diff --git a/tests/_craft_menu_preview.tscn b/tests/_craft_menu_preview.tscn deleted file mode 100644 index 677d6e0..0000000 --- a/tests/_craft_menu_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_craft_menu_preview.gd" id="1"] - -[node name="CraftMenuPreview" type="Node2D"] -script = ExtResource("1") diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd index dc89fe4..9a85a15 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.apply_animation() + b.queue_redraw() 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 24331f5..b03b453 100644 --- a/tests/_font_probe.gd +++ b/tests/_font_probe.gd @@ -19,7 +19,8 @@ func _initialize() -> void: for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel", "UI/BuildMenu/InfoPopup/Panel/MessageLabel", "UI/BuildMenu/MothershipLabel", - "UI/BuildMenu/TitleLabel"]: + "UI/BuildMenu/TitleLabel", + "UI/NavLabel"]: var label: Label = scene.get_node_or_null(path) if label == null: print("%s: MISSING" % path) diff --git a/tests/_ingame_buildings_preview.gd b/tests/_ingame_buildings_preview.gd index d586710..aa7f619 100644 --- a/tests/_ingame_buildings_preview.gd +++ b/tests/_ingame_buildings_preview.gd @@ -13,9 +13,8 @@ func _ready() -> void: GameState.buildings.clear() GameState.extractors.clear() GameState.reset_formation() - GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0)) - GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0)) - GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0)) + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0)) + GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0)) var main: Node = load("res://Main.tscn").instantiate() add_child(main) @@ -25,4 +24,4 @@ func _ready() -> void: # far enough to see them whole. main.get_node("Fleet").move_to(Vector2(0.0, 210.0)) var cam: Camera2D = main.get_node("Camera2D") - cam.zoom = Vector2(1.0, 1.0) + cam.zoom = Vector2(1.35, 1.35) diff --git a/tests/_marker_preview.gd b/tests/_marker_preview.gd deleted file mode 100644 index 465cb20..0000000 --- a/tests/_marker_preview.gd +++ /dev/null @@ -1,45 +0,0 @@ -extends Node2D - -# Preview, not a test (underscore prefix keeps it out of run_tests.sh). -# Renders the world map with buildings placed and an extractor built, so the -# issue #11 building markers and the issue #9 extractor badge can actually be -# LOOKED at. --headless draws no pixels, so no headless test can tell whether -# a marker is visible - see tests/screenshot.sh. -# -# bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280 - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.explored_chunks.clear() - - # Near the origin, where the fleet starts, so everything lands on screen. - GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0)) - GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0)) - GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0)) - - # Reveal a patch of map, and put an extractor on whichever planet is - # nearest the origin so its badge is in frame too. - var best: Vector2i = Vector2i.ZERO - var best_distance: int = 1 << 30 - for x in range(-6, 7): - for y in range(-6, 7): - var chunk: Vector2i = Vector2i(x, y) - GameState.explored_chunks[chunk] = true - if WorldGen.has_planet_at_chunk(chunk): - var d: int = absi(x) + absi(y) - if d < best_distance: - best_distance = d - best = chunk - if best_distance < (1 << 30): - var data: Dictionary = WorldGen.planet_data_at_chunk(best) - GameState.add_extractor(data.name, WorldGen.chunk_origin(best) + data.local_pos) - print("extractor on '%s' at chunk %s" % [data.name, best]) - - var main: Node = load("res://Main.tscn").instantiate() - add_child(main) - # open_map(), not `visible = true` - opening also recentres the view on - # the fleet, and without that the map is drawn from its own origin in the - # corner of the screen with everything near the fleet cropped off. - main.get_node("UI/WorldMap").open_map() diff --git a/tests/_marker_preview.gd.uid b/tests/_marker_preview.gd.uid deleted file mode 100644 index f49b06c..0000000 --- a/tests/_marker_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blbow3jq36fdw diff --git a/tests/_marker_preview.tscn b/tests/_marker_preview.tscn deleted file mode 100644 index 85c61cb..0000000 --- a/tests/_marker_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_marker_preview.gd" id="1"] - -[node name="MarkerPreview" type="Node2D"] -script = ExtResource("1") diff --git a/tests/_planet_derrick_preview.gd b/tests/_planet_derrick_preview.gd deleted file mode 100644 index bfcbc31..0000000 --- a/tests/_planet_derrick_preview.gd +++ /dev/null @@ -1,25 +0,0 @@ -extends Node2D - -# Preview, not a test. Two planets side by side in SPACE - the left one has -# an extractor built on it, the right one does not - so the issue #9 derrick -# can be compared against a plain planet and judged as actually visible. -# -# bash tests/screenshot.sh res://tests/_planet_derrick_preview.tscn /tmp/derrick.png 15 1280x720 - - -func _ready() -> void: - GameState.extractors.clear() - GameState.buildings.clear() - GameState.add_extractor("Mined World", Vector2.ZERO) - - _spawn("Mined World", Vector2(380.0, 380.0), 12345) - _spawn("Untouched World", Vector2(900.0, 380.0), 4242) - - -func _spawn(planet_name: String, at: Vector2, shape_seed: int) -> void: - var planet: Node2D = load("res://Planet.tscn").instantiate() - planet.planet_name = planet_name - planet.shape_seed = shape_seed - planet.position = at - add_child(planet) - print("%s: derrick=%s" % [planet_name, planet.shows_extractor]) diff --git a/tests/_planet_derrick_preview.gd.uid b/tests/_planet_derrick_preview.gd.uid deleted file mode 100644 index 053a54b..0000000 --- a/tests/_planet_derrick_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dl4abkye58bd6 diff --git a/tests/_planet_derrick_preview.tscn b/tests/_planet_derrick_preview.tscn deleted file mode 100644 index cbbfb77..0000000 --- a/tests/_planet_derrick_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_planet_derrick_preview.gd" id="1"] - -[node name="PlanetDerrickPreview" type="Node2D"] -script = ExtResource("1") diff --git a/tests/_profile_startup.gd b/tests/_profile_startup.gd deleted file mode 100644 index 098098b..0000000 --- a/tests/_profile_startup.gd +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 5b4d374..0000000 --- a/tests/_profile_startup.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dvfdj3y0xg3sj diff --git a/tests/_run_inside.sh b/tests/_run_inside.sh deleted file mode 100644 index c6f68e6..0000000 --- a/tests/_run_inside.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/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 deleted file mode 100644 index 6a876b1..0000000 --- a/tests/_skill_menu_preview.gd +++ /dev/null @@ -1,31 +0,0 @@ -extends Node2D - -# Preview, not a test. Opens the SkillMenu so its layout can be checked on a -# real viewport - the mothership's "Load ore" label was falling off the bottom -# of shorter screens, which no headless test can see. -# -# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280 -# ... and again at 720x960 to check a shorter screen format. - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.transporters.clear() - GameState.rocks = 180 - GameState.fleet_cargo = 24 - GameState.unlocked = ["shipyards", "logistics", "freight"] - var main: Node = load("res://Main.tscn").instantiate() - add_child(main) - main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0)) - var bc: Node = main.get_node("BuildController") - bc.craft_building_id = 1 - main.get_node("UI/SkillMenu").visible = true - if "Skill" == "Craft": - bc._refresh_craft_status() - else: - bc._refresh_load_ore() - # Select a node so the description, price and status all have text - - # an empty description cannot collide with anything. - bc.skill_tree.selected_id = "freight" - bc._refresh_skill_menu("freight") diff --git a/tests/_skill_menu_preview.gd.uid b/tests/_skill_menu_preview.gd.uid deleted file mode 100644 index 4160b66..0000000 --- a/tests/_skill_menu_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d3n86yp86b755 diff --git a/tests/_skill_menu_preview.tscn b/tests/_skill_menu_preview.tscn deleted file mode 100644 index fe8181c..0000000 --- a/tests/_skill_menu_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[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/_transporter_preview.gd b/tests/_transporter_preview.gd deleted file mode 100644 index 68d2b55..0000000 --- a/tests/_transporter_preview.gd +++ /dev/null @@ -1,38 +0,0 @@ -extends Node2D - -# Preview, not a test. A transporter mid-run between the mothership and a -# building it is supplying, so the #19 vessel can actually be looked at -# alongside the hub that stations it. Headless draws no pixels. -# -# bash tests/screenshot.sh res://tests/_transporter_preview.tscn /tmp/t.png 25 1280x720 - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.transporters.clear() - GameState.rocks = 200 - GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-420.0, 0.0)) - GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(420.0, 0.0)) - - # One laden, mid-leg, and one running home empty, so both states show. - GameState.transporters.append({ - "hub_id": 2, "state": GameState.TRANSPORTER_TO_BUILDING, "target_id": 2, - "carrying": 8, "progress": 0.45, - "from": Vector2(-420.0, 0.0), "to": Vector2(420.0, 0.0), - }) - GameState.transporters.append({ - "hub_id": 2, "state": GameState.TRANSPORTER_RETURNING, "target_id": 0, - "carrying": 0, "progress": 0.5, - "from": Vector2(-380.0, 150.0), "to": Vector2(420.0, 150.0), - }) - - 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) - # Freeze the simulation so the vessels stay where they were placed. - GameState.set_process(false) - main.get_node("Fleet").move_to(Vector2(0.0, 260.0)) - var cam: Camera2D = main.get_node("Camera2D") - cam.zoom = Vector2(0.85, 0.85) diff --git a/tests/_transporter_preview.gd.uid b/tests/_transporter_preview.gd.uid deleted file mode 100644 index 7fbf0dc..0000000 --- a/tests/_transporter_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cqc5de83g4tr2 diff --git a/tests/_transporter_preview.tscn b/tests/_transporter_preview.tscn deleted file mode 100644 index f3c7618..0000000 --- a/tests/_transporter_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_transporter_preview.gd" id="1"] - -[node name="TransporterPreview" type="Node2D"] -script = ExtResource("1") diff --git a/tests/_vfx_preview.gd b/tests/_vfx_preview.gd deleted file mode 100644 index 55d5cc8..0000000 --- a/tests/_vfx_preview.gd +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index d4f13ac..0000000 --- a/tests/_vfx_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dv32ajs2bxrwd diff --git a/tests/_vfx_preview.tscn b/tests/_vfx_preview.tscn deleted file mode 100644 index 6e87acb..0000000 --- a/tests/_vfx_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[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 6e5e7d8..c252284 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -1,78 +1,20 @@ #!/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. -# -# 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 +# Usage: bash tests/run_tests.sh +set -e PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" -# 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 +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 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/screenshot.sh b/tests/screenshot.sh index 8fa7168..739b163 100644 --- a/tests/screenshot.sh +++ b/tests/screenshot.sh @@ -28,13 +28,6 @@ WAIT_FRAMES="${3:-60}" RESOLUTION="${4:-1280x720}" proot-distro login ubuntu -- bash -c " -# A killed run (or a session that ended mid-screenshot) leaves /tmp/.X99-lock -# behind, and Xvfb then refuses to claim :99 - which surfaces much later and -# far less obviously as Godot's 'X11 Display is not available'. Nothing else -# uses :99 here, so a lock with no live Xvfb behind it is always stale. -if ! pgrep -x Xvfb >/dev/null; then - rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 -fi Xvfb :99 -screen 0 ${RESOLUTION}x24 & XVFB_PID=\$! sleep 2 diff --git a/tests/test_asteroid_safe_spawn.gd b/tests/test_asteroid_safe_spawn.gd deleted file mode 100644 index 700ecf1..0000000 --- a/tests/test_asteroid_safe_spawn.gd +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 5336bb6..0000000 --- a/tests/test_asteroid_safe_spawn.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ci8vodtuvwod7 diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index ef1d708..57f950e 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -39,12 +39,6 @@ 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 deleted file mode 100644 index c083754..0000000 --- a/tests/test_building_redraw_cost.gd +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index a65db84..0000000 --- a/tests/test_building_redraw_cost.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ihnpl5ogoxi1 diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd deleted file mode 100644 index d116fcb..0000000 --- a/tests/test_building_stock_queue.gd +++ /dev/null @@ -1,247 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_building_stock_queue.gd -# Forgejo #16 (part 3 of #12): a building produces out of its OWN stock, and -# an order it cannot pay for yet is queued rather than refused. -# -# Before this, BuildController deducted from the bank at the moment of -# purchase and refused outright with "Not enough rocks", so nothing ever had -# to be delivered anywhere. -# -# Verifies: -# 1. ordering with an empty stock queues instead of failing, and produces -# nothing yet -# 2. it does NOT quietly take the cost out of the bank - the whole point is -# that the building has to be supplied -# 3. delivering enough stock completes the order on its own -# 4. one delivery covering several queued orders clears them all, rather -# than dribbling out one per frame -# 5. a part-delivery is not enough: stock below the cost leaves the order -# queued and the ore untouched -# 6. stock and queue survive the space scene being torn down (the trip to -# a planet's surface) -# 7. production completes with NO fleet in existence, and the fleet picks -# the vessel up when it comes back - orders must not depend on which -# scene the player is standing in - -var game_state: Node -var scene: Node -var fleet: Node -var field: Node -var crafter_id: int = 0 -var stage: int = 0 -var elapsed: float = 0.0 -var ships_at_start: int = 0 -var bank_at_start: int = 0 -var ticks: int = 0 - -const CRAFTER_AT: Vector2 = Vector2(900.0, 0.0) - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.rocks = 500 - game_state.fleet_cargo = 0 - game_state.ships_owed = 0 - # Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet - # starts at exactly 5, so without this every completed order would have - # nowhere to put its vessel and the test would be measuring the cap - # rather than the queue. - game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - 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) - _clear_hazards() - fleet.spawn_ships(2, Vector2.ZERO) - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - crafter_id = int(game_state.buildings[-1].id) - ships_at_start = fleet.ships.size() - bank_at_start = game_state.rocks - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.2: - return false - # --- 1 + 2. an unaffordable order queues, and costs nothing -- - if not game_state.queue_order(crafter_id): - print("TEST_BUILDING_STOCK_QUEUE: FAIL (the crafter refused an order outright)") - quit(1) - return true - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.3: - return false - if game_state.pending_orders(crafter_id) != 1: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (order did not stay queued: %d pending)" % game_state.pending_orders(crafter_id)) - quit(1) - return true - if fleet.ships.size() != ships_at_start: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel appeared with an empty stock)") - quit(1) - return true - if game_state.rocks != bank_at_start: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (queueing took %d out of the bank - the building is supposed to be supplied)" % (bank_at_start - game_state.rocks)) - quit(1) - return true - print("empty stock -> queued=%d ships=%d bank=%d (untouched)" % [ - game_state.pending_orders(crafter_id), fleet.ships.size(), game_state.rocks - ]) - - # --- 5. a part-delivery is not enough ----------------------- - game_state.deposit_to_building(crafter_id, GameState.SHIP_COST - 1) - elapsed = 0.0 - stage = 3 - return false - 3: - if elapsed < 0.3: - return false - if game_state.pending_orders(crafter_id) != 1 or fleet.ships.size() != ships_at_start: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (produced on a part-delivery of %d against a cost of %d)" % [ - GameState.SHIP_COST - 1, GameState.SHIP_COST - ]) - quit(1) - return true - if game_state.building_stock(crafter_id) != GameState.SHIP_COST - 1: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (part-delivered ore was consumed anyway)") - quit(1) - return true - print("part delivery (%d of %d) -> still queued, ore intact" % [ - game_state.building_stock(crafter_id), GameState.SHIP_COST - ]) - - # --- 3. topping it up completes the order ------------------- - game_state.deposit_to_building(crafter_id, 1) - elapsed = 0.0 - stage = 4 - return false - 4: - if elapsed < 0.4: - return false - if game_state.pending_orders(crafter_id) != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (order not completed once the stock arrived)") - quit(1) - return true - if fleet.ships.size() != ships_at_start + 1: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (no vessel delivered: %d -> %d)" % [ - ships_at_start, fleet.ships.size() - ]) - quit(1) - return true - if game_state.building_stock(crafter_id) != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (stock not consumed: %d left)" % game_state.building_stock(crafter_id)) - quit(1) - return true - print("supplied -> ships %d->%d, stock spent" % [ships_at_start, fleet.ships.size()]) - - # --- 4. one delivery clears ALL the orders it covers --------- - # Measured in TICKS, not seconds, and with more orders than the - # ticks allowed: production that handled one order per frame - # would still clear them all inside any generous wall-clock wait, - # so a timed check cannot tell "cleared at once" from "dribbled - # out". Three orders, two ticks - only clearing them together - # passes. - ships_at_start = fleet.ships.size() - game_state.queue_order(crafter_id) - game_state.queue_order(crafter_id) - game_state.queue_order(crafter_id) - game_state.deposit_to_building(crafter_id, GameState.SHIP_COST * 3) - ticks = 0 - stage = 5 - return false - 5: - ticks += 1 - if ticks < 2: - return false - if game_state.pending_orders(crafter_id) != 0 or fleet.ships.size() != ships_at_start + 3: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (a delivery covering 3 orders left %d queued after %d ticks, ships %d->%d)" % [ - game_state.pending_orders(crafter_id), ticks, ships_at_start, fleet.ships.size() - ]) - quit(1) - return true - print("one delivery cleared 3 orders in %d ticks -> ships %d->%d" % [ - ticks, ships_at_start, fleet.ships.size() - ]) - - # --- 6 + 7. no scene, no fleet: production still completes -- - # The scene has to be GONE before the order is placed. With a - # fleet still alive and under its cap it claims the vessel the - # same frame production finishes, which would leave ships_owed - # back at 0 and prove nothing about orders surviving without it. - ships_at_start = fleet.ships.size() - scene.queue_free() - elapsed = 0.0 - stage = 6 - return false - 6: - if elapsed < 0.3: - return false - if is_instance_valid(fleet): - return false - game_state.queue_order(crafter_id) - game_state.deposit_to_building(crafter_id, GameState.SHIP_COST) - elapsed = 0.0 - stage = 61 - return false - 61: - if elapsed < 0.4: - return false - # The queue and stock live in the autoload, so tearing the space - # scene down must not touch them - and the order must complete - # even with no Fleet node anywhere to hand the vessel to. - if game_state.pending_orders(crafter_id) != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)") - quit(1) - return true - if game_state.ships_owed < 1: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)") - quit(1) - return true - print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed) - - # Come back to space: the fleet should claim what it is owed. - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - elapsed = 0.0 - stage = 8 - return false - 8: - if elapsed < 0.5: - return false - if game_state.ships_owed != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed) - quit(1) - return true - print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size()) - print("TEST_BUILDING_STOCK_QUEUE: PASS") - quit(0) - return true - return false - - -func _clear_hazards() -> void: - # set_process(false) on AsteroidField only stops NEW chunks spawning - - # rocks already in the world keep drifting and can destroy a parked ship, - # which silently changes the fleet size this test asserts on. This is a - # logistics test, not a survival one. - for a in get_nodes_in_group("asteroids"): - if is_instance_valid(a): - a.queue_free() - for e in get_nodes_in_group("enemies"): - if is_instance_valid(e): - e.queue_free() diff --git a/tests/test_building_stock_queue.gd.uid b/tests/test_building_stock_queue.gd.uid deleted file mode 100644 index d21c584..0000000 --- a/tests/test_building_stock_queue.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b0364sukgpwow diff --git a/tests/test_cargo_readout.gd b/tests/test_cargo_readout.gd deleted file mode 100644 index 03b2500..0000000 --- a/tests/test_cargo_readout.gd +++ /dev/null @@ -1,117 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_cargo_readout.gd -# Forgejo #15 (part 2 of #12): what the fleet is carrying has to be visible -# separately from what is banked. -# -# Since #14, ore is cargo until the fleet delivers it to the mothership. The -# banked readout does not move on pickup, so without a second readout mining -# looks broken - you gather ore and no number on screen changes. -# -# --headless has no GPU and draws no pixels, so this asserts on the two -# readouts' state, not on their appearance. tests/screenshot.sh covers -# whether they are legible. -# -# Verifies: -# 1. the banked readout keeps showing the BANK, not the total -# 2. the cargo readout shows the hold, and appears only while carrying -# 3. an empty hold hides it rather than parking a permanent 0 beside the -# real total -# 4. delivering moves the number from one readout to the other -# 5. the two are actually told apart on screen, rather than being the same -# number twice in the same colour at the same place - -var game_state: Node -var scene: Node -var banked: Control -var cargo: Control -var stage: int = 0 -var elapsed: float = 0.0 - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.rocks = 42 - game_state.fleet_cargo = 0 - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - banked = scene.get_node("UI/ResourceCounter") - cargo = scene.get_node("UI/CargoCounter") - - -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - if elapsed < 0.2: - return false - # --- 3. nothing carried -> no cargo readout ----------------- - if cargo.visible: - print("TEST_CARGO_READOUT: FAIL (cargo readout showing with an empty hold)") - quit(1) - return true - if banked.amount != 42: - print("TEST_CARGO_READOUT: FAIL (banked readout shows %d, bank is 42)" % banked.amount) - quit(1) - return true - print("empty hold -> cargo readout hidden, banked shows %d" % banked.amount) - - game_state.collect_cargo(7) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.2: - return false - # --- 1 + 2. carried and banked are separate numbers --------- - if not cargo.visible: - print("TEST_CARGO_READOUT: FAIL (carrying 7 and the cargo readout is hidden)") - quit(1) - return true - if cargo.amount != 7: - print("TEST_CARGO_READOUT: FAIL (cargo readout shows %d, hold is 7)" % cargo.amount) - quit(1) - return true - if banked.amount != 42: - print("TEST_CARGO_READOUT: FAIL (banked readout moved to %d on a pickup - it is showing the total, not the bank)" % banked.amount) - quit(1) - return true - print("carrying 7 -> cargo=%d banked=%d" % [cargo.amount, banked.amount]) - - # --- 5. genuinely distinguishable --------------------------- - if cargo.glow_color == banked.glow_color: - print("TEST_CARGO_READOUT: FAIL (both readouts drawn in the same colour)") - quit(1) - return true - if cargo.position == banked.position: - print("TEST_CARGO_READOUT: FAIL (both readouts in the same place)") - quit(1) - return true - print("distinct: colours %s vs %s, at %s vs %s" % [ - cargo.glow_color, banked.glow_color, cargo.position, banked.position - ]) - - # --- 4. delivery moves it across ---------------------------- - game_state.deliver_cargo() - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.2: - return false - if cargo.visible: - print("TEST_CARGO_READOUT: FAIL (cargo readout still showing after delivery)") - quit(1) - return true - if banked.amount != 49: - print("TEST_CARGO_READOUT: FAIL (banked shows %d after delivering 7 into 42)" % banked.amount) - quit(1) - return true - print("delivered -> cargo hidden, banked shows %d" % banked.amount) - print("TEST_CARGO_READOUT: PASS") - quit(0) - return true - return false diff --git a/tests/test_cargo_readout.gd.uid b/tests/test_cargo_readout.gd.uid deleted file mode 100644 index 9d85839..0000000 --- a/tests/test_cargo_readout.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bpldq0su02w1v diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 13b2567..20e9607 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -42,11 +42,6 @@ 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 b559bf5..f11e9b8 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -14,13 +14,7 @@ extends SceneTree var game_state: Node var stage: int = 0 var elapsed: float = 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 mothership_pos: Vector2 = Vector2(4000.0, 0.0) var planet_pos: Vector2 = Vector2.ZERO var rocks_before_arrival: int = 0 diff --git a/tests/test_fleet_cargo.gd b/tests/test_fleet_cargo.gd deleted file mode 100644 index 3b395ec..0000000 --- a/tests/test_fleet_cargo.gd +++ /dev/null @@ -1,179 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_fleet_cargo.gd -# Forgejo #14 (part 1 of #12): mined ore is CARGO the fleet is carrying, not -# instant global income. It only becomes spendable once the fleet flies it -# back to the mothership. -# -# Before this, Rock.gd did `GameState.rocks += 1` on pickup, so ore was -# banked instantly from anywhere on the map and a mining run had nothing to -# lose. -# -# Verifies: -# 1. mining fills the hold and leaves the bank alone -# 2. reaching the mothership banks the lot, and empties the hold -# 3. with no mothership there is nowhere to deliver, so cargo just -# accumulates - it is never quietly banked anyway -# 4. sitting on a DIFFERENT building is not a delivery -# 5. ore is conserved: nothing is created or destroyed in the transfer -# 6. cargo survives the trip to a planet's surface and back, which is -# exactly the trip a mining run is - -var game_state: Node -var scene: Node -var fleet: Node -var field: Node -var stage: int = 0 -var elapsed: float = 0.0 -var banked_before: int = 0 - -const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0) -const CRAFTER_AT: Vector2 = Vector2(-4000.0, 0.0) - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.rocks = 100 - game_state.fleet_cargo = 0 - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - field = scene.get_node("BuildingField") - - -func _park_fleet_on(at: Vector2) -> void: - for ship in fleet.ships: - ship.position = at - fleet.move_to(at) - - -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - - # --- 1. mining fills the hold, not the bank ----------------- - # Through a REAL Rock, flown over by a real ship, rather than by - # calling collect_cargo() directly: the whole change is which - # pool Rock.gd credits on pickup, and a test that calls the new - # API by hand passes just as happily with Rock.gd left untouched. - banked_before = game_state.rocks - var rock: Area2D = load("res://Rock.tscn").instantiate() - rock.build_shape(1) - rock.global_position = fleet.ships[0].global_position - scene.add_child(rock) - elapsed = 0.0 - stage = 5 - return false - 5: - # A tick for the Area2D overlap to register. - if elapsed < 0.25: - return false - if game_state.fleet_cargo != 1: - print("TEST_FLEET_CARGO: FAIL (flying over a rock put %d in the hold)" % game_state.fleet_cargo) - quit(1) - return true - if game_state.rocks != banked_before: - print("TEST_FLEET_CARGO: FAIL (a mined rock was banked instantly: %d -> %d)" % [ - banked_before, game_state.rocks - ]) - quit(1) - return true - print("flew over a real rock -> hold=%d bank=%d (unchanged)" % [ - game_state.fleet_cargo, game_state.rocks - ]) - - game_state.collect_cargo(1) - game_state.collect_cargo(1) - if game_state.fleet_cargo != 3: - print("TEST_FLEET_CARGO: FAIL (mining did not fill the hold: %d)" % game_state.fleet_cargo) - quit(1) - return true - if game_state.rocks != banked_before: - print("TEST_FLEET_CARGO: FAIL (mining banked %d straight away)" % (game_state.rocks - banked_before)) - quit(1) - return true - print("mined 3 -> hold=%d bank=%d (unchanged)" % [game_state.fleet_cargo, game_state.rocks]) - - # --- 3. no mothership means nowhere to deliver -------------- - _park_fleet_on(Vector2.ZERO) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.4: - return false - if game_state.fleet_cargo != 3 or game_state.rocks != banked_before: - print("TEST_FLEET_CARGO: FAIL (delivered with no mothership built)") - quit(1) - return true - print("no mothership -> hold still %d, bank still %d" % [game_state.fleet_cargo, game_state.rocks]) - - # --- 4. a different building is not a delivery point -------- - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) - _park_fleet_on(CRAFTER_AT) - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.4: - return false - if game_state.fleet_cargo != 3: - print("TEST_FLEET_CARGO: FAIL (the ship crafter accepted a delivery)") - quit(1) - return true - print("parked on the crafter -> hold still %d" % game_state.fleet_cargo) - - # --- 2. the mothership banks it ----------------------------- - _park_fleet_on(MOTHERSHIP_AT) - elapsed = 0.0 - stage = 3 - return false - 3: - if elapsed < 0.4: - return false - var total_before: int = banked_before + 3 - print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [ - game_state.fleet_cargo, game_state.rocks, total_before - ]) - if game_state.fleet_cargo != 0: - print("TEST_FLEET_CARGO: FAIL (hold not emptied on delivery)") - quit(1) - return true - # --- 5. conservation ---------------------------------------- - if game_state.rocks != total_before: - print("TEST_FLEET_CARGO: FAIL (ore was created or destroyed in the transfer)") - quit(1) - return true - - # --- 6. cargo survives the scene swap ----------------------- - # Not simulated by loading PlanetSurface.tscn here: the point is - # that cargo lives in the autoload rather than in Main.tscn, so - # tearing Main.tscn down must not touch it. - # Fly off the mothership first, or this new cargo is (correctly) - # banked the moment BuildingField next runs, and the teardown - # check below would be measuring a delivery instead. - _park_fleet_on(CRAFTER_AT) - game_state.collect_cargo(5) - scene.queue_free() - elapsed = 0.0 - stage = 4 - return false - 4: - if elapsed < 0.3: - return false - if game_state.fleet_cargo != 5: - print("TEST_FLEET_CARGO: FAIL (cargo lost when the space scene was torn down: %d)" % game_state.fleet_cargo) - quit(1) - return true - print("cargo survived the scene teardown: hold=%d" % game_state.fleet_cargo) - print("TEST_FLEET_CARGO: PASS") - quit(0) - return true - return false diff --git a/tests/test_fleet_cargo.gd.uid b/tests/test_fleet_cargo.gd.uid deleted file mode 100644 index 3c5062a..0000000 --- a/tests/test_fleet_cargo.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c2y4e2f1rgera diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd deleted file mode 100644 index 46bae11..0000000 --- a/tests/test_hub_capacity.gd +++ /dev/null @@ -1,143 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_hub_capacity.gd -# Forgejo #20 (part 7 of #12): a hub stations a limited number of -# transporters. More hubs, or research, raise it. -# -# Verifies: -# 1. capacity is per hub, so a second hub raises the total -# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket -# is explicit that the player has to be told -# 3. a queued-but-unbuilt transporter still holds its berth, or ten could -# be ordered against two and only fail on arrival -# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not -# just ones placed afterwards -# 5. the extra berth is immediately usable - -var game_state: Node -var scene: Node -var field: Node -var crafter_id: int = 0 -var stage: int = 0 -var elapsed: float = 0.0 - -const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0) -const HUB_A: Vector2 = Vector2(-500.0, 0.0) -const HUB_B: Vector2 = Vector2(-500.0, 600.0) - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.transporters.clear() - game_state.rocks = 900 - game_state.fleet_cargo = 0 - game_state.ships_owed = 0 - game_state.unlocked = ["shipyards", "logistics", "freight"] - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - field = scene.get_node("BuildingField") - - -func _fill_berths(n: int) -> void: - # Queue and immediately fund n transporters. - for i in range(n): - if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) - - -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.ZERO) - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - crafter_id = int(game_state.buildings[-1].id) - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_A) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.3: - return false - var per_hub: int = game_state.hub_capacity() - if game_state.transport_capacity() != per_hub: - print("TEST_HUB_CAPACITY: FAIL (one hub gives %d berths, per-hub capacity is %d)" % [ - game_state.transport_capacity(), per_hub - ]) - quit(1) - return true - print("1 hub -> %d berths (%d per hub)" % [game_state.transport_capacity(), per_hub]) - - # --- 2 + 3. fill it, then try to exceed it ------------------ - _fill_berths(per_hub) - if game_state.transporters_claimed() != per_hub: - print("TEST_HUB_CAPACITY: FAIL (claimed %d berths after ordering %d)" % [ - game_state.transporters_claimed(), per_hub - ]) - quit(1) - return true - # Still only QUEUED at this point - the berth must already count. - if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_HUB_CAPACITY: FAIL (queued a transporter past the %d-berth cap)" % per_hub) - quit(1) - return true - print("cap enforced at %d, including orders not yet built" % per_hub) - - # --- 1. a second hub raises the total ----------------------- - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_B) - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.3: - return false - if game_state.transport_capacity() != game_state.hub_capacity() * 2: - print("TEST_HUB_CAPACITY: FAIL (2 hubs give %d berths, expected %d)" % [ - game_state.transport_capacity(), game_state.hub_capacity() * 2 - ]) - quit(1) - return true - print("2 hubs -> %d berths" % game_state.transport_capacity()) - if not game_state.can_station_transporter(): - print("TEST_HUB_CAPACITY: FAIL (second hub did not free a berth)") - quit(1) - return true - - # Fill the second hub too, so capacity is exhausted again. - _fill_berths(game_state.transport_capacity() - game_state.transporters_claimed()) - if game_state.can_station_transporter(): - print("TEST_HUB_CAPACITY: FAIL (still room after filling every berth)") - quit(1) - return true - - # --- 4. research raises it for hubs ALREADY built ----------- - var before: int = game_state.transport_capacity() - game_state.unlocked.append("freight_yards") - var after: int = game_state.transport_capacity() - print("researched Freight Yards: %d -> %d berths across %d existing hubs" % [ - before, after, game_state.hub_count() - ]) - if after != before + game_state.hub_count(): - print("TEST_HUB_CAPACITY: FAIL (research gave %d berths, expected +1 per existing hub)" % (after - before)) - quit(1) - return true - - # --- 5. and the new berth is usable straight away ----------- - if not game_state.can_station_transporter(): - print("TEST_HUB_CAPACITY: FAIL (research raised the number but left no usable berth)") - quit(1) - return true - if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_HUB_CAPACITY: FAIL (could not order into the berth research just added)") - quit(1) - return true - print("ordered into the newly researched berth") - print("TEST_HUB_CAPACITY: PASS") - quit(0) - return true - return false diff --git a/tests/test_hub_capacity.gd.uid b/tests/test_hub_capacity.gd.uid deleted file mode 100644 index abfc3d4..0000000 --- a/tests/test_hub_capacity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dumdbx4iggydw diff --git a/tests/test_joystick_control.gd b/tests/test_joystick_control.gd index 1a3e41a..855a192 100644 --- a/tests/test_joystick_control.gd +++ b/tests/test_joystick_control.gd @@ -120,17 +120,9 @@ func _process(delta: float) -> bool: for ship in fleet.ships: speed = max(speed, ship.velocity.length()) var stopped: bool = speed < 1.0 - # 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) + var coasted: bool = fleet.get_centroid().x > moving_centroid.x + print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [ + speed, stopped, coasted ]) if stopped and coasted: print("TEST_JOYSTICK_CONTROL: PASS") diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd deleted file mode 100644 index 338fd3e..0000000 --- a/tests/test_manual_supply_route.gd +++ /dev/null @@ -1,205 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_manual_supply_route.gd -# Forgejo #17 (part 4 of #12): the player can supply a building by hand - -# withdraw banked ore into the fleet's hold at the mothership, fly it over, -# and have the building take it. -# -# This is what keeps the game unblockable. #16 lets an order queue waiting -# for ore, and until transporters exist (#19) there is otherwise no way to -# fulfil one - a player with a queued order and no Transport Hub would be -# stuck forever. -# -# Verifies: -# 1. withdrawing moves ore bank -> hold, and conserves the total -# 2. withdrawing is capped by what is actually banked (no overdraft) -# 3. arriving at the mothership banks the hold, but doing so is EDGE -# triggered - ore withdrawn while parked there is not instantly taken -# back, which would make the button look broken -# 4. a building takes the fleet's whole hold when opened -# 5. end to end: a queued order the bank cannot reach is fulfilled using -# only the fleet, with no Transport Hub in existence -# 6. nothing is created or destroyed anywhere along the route - -var game_state: Node -var scene: Node -var fleet: Node -var field: Node -var controller: Node -var crafter_id: int = 0 -var stage: int = 0 -var elapsed: float = 0.0 -var ships_before: int = 0 -var total_before: int = 0 - -const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) -const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0) - - -func _total() -> int: - var sum: int = game_state.rocks + game_state.fleet_cargo - for b in game_state.buildings: - sum += int(b.get("stock", 0)) - return sum - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.rocks = 40 - game_state.fleet_cargo = 0 - game_state.ships_owed = 0 - game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - field = scene.get_node("BuildingField") - controller = scene.get_node("BuildController") - - -func _park_fleet_on(at: Vector2) -> void: - for ship in fleet.ships: - ship.position = at - fleet.move_to(at) - - -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - _clear_hazards() - fleet.spawn_ships(2, MOTHERSHIP_AT) - field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - crafter_id = int(game_state.buildings[-1].id) - total_before = _total() - _park_fleet_on(CRAFTER_AT) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.3: - return false - # --- 5. an order the crafter cannot pay for ----------------- - ships_before = fleet.ships.size() - game_state.queue_order(crafter_id) - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.3: - return false - if game_state.pending_orders(crafter_id) != 1: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order did not queue)") - quit(1) - return true - - # --- 1 + 2. withdraw at the mothership ---------------------- - _park_fleet_on(MOTHERSHIP_AT) - elapsed = 0.0 - stage = 3 - return false - 3: - if elapsed < 0.3: - return false - var bank_before: int = game_state.rocks - var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST) - if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo]) - quit(1) - return true - if game_state.rocks != bank_before - GameState.SHIP_COST: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (bank did not pay for the withdrawal)") - quit(1) - return true - print("withdrew %d at the mothership -> bank=%d hold=%d" % [ - took, game_state.rocks, game_state.fleet_cargo - ]) - elapsed = 0.0 - stage = 4 - return false - 4: - # --- 3. still parked on the mothership ---------------------- - if elapsed < 0.5: - return false - if game_state.fleet_cargo != GameState.SHIP_COST: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)") - quit(1) - return true - print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo) - - # --- 4. carry it to the crafter and open it ----------------- - _park_fleet_on(CRAFTER_AT) - elapsed = 0.0 - stage = 5 - return false - 5: - if elapsed < 0.6: - return false - var moved: int = game_state.unload_into_building(crafter_id) - if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo]) - quit(1) - return true - print("unloaded %d into the crafter" % moved) - elapsed = 0.0 - stage = 6 - return false - 6: - if elapsed < 0.5: - return false - # --- 5. the queued order completed off the fleet's own ore --- - if game_state.pending_orders(crafter_id) != 0: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)") - quit(1) - return true - if fleet.ships.size() != ships_before + 1: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()]) - quit(1) - return true - print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [ - ships_before, fleet.ships.size() - ]) - - # --- 6. conservation ---------------------------------------- - var expected: int = total_before - GameState.SHIP_COST - if _total() != expected: - print("bank=%d hold=%d stocks total=%d, expected %d" % [ - game_state.rocks, game_state.fleet_cargo, _total(), expected - ]) - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore created or destroyed along the route)") - quit(1) - return true - print("ore conserved: %d -> %d, exactly the %d the vessel cost" % [ - total_before, _total(), GameState.SHIP_COST - ]) - - # --- 2. no overdraft ---------------------------------------- - game_state.rocks = 3 - var over: int = game_state.withdraw_to_fleet(999) - if over != 3 or game_state.rocks != 0: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks]) - quit(1) - return true - print("withdrawing 999 from a bank of 3 took %d" % over) - print("TEST_MANUAL_SUPPLY_ROUTE: PASS") - quit(0) - return true - return false - - -func _clear_hazards() -> void: - # set_process(false) on AsteroidField only stops NEW chunks spawning - - # rocks already in the world keep drifting and can destroy a parked ship, - # which silently changes the fleet size this test asserts on. This is a - # logistics test, not a survival one. - for a in get_nodes_in_group("asteroids"): - if is_instance_valid(a): - a.queue_free() - for e in get_nodes_in_group("enemies"): - if is_instance_valid(e): - e.queue_free() diff --git a/tests/test_manual_supply_route.gd.uid b/tests/test_manual_supply_route.gd.uid deleted file mode 100644 index 1b3c8ab..0000000 --- a/tests/test_manual_supply_route.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bjkqin5w5tah2 diff --git a/tests/test_map_building_markers.gd b/tests/test_map_building_markers.gd deleted file mode 100644 index e92320a..0000000 --- a/tests/test_map_building_markers.gd +++ /dev/null @@ -1,99 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_map_building_markers.gd -# Forgejo issue #11: a building the player has placed must appear on the -# world map. Without it the map showed explored chunks, planets and the -# fleet, but nothing the player had actually built - so there was no way to -# find your own base again once you had flown away from it. -# -# Buildings are not tied to a chunk (they are placed freely in space), so -# they cannot ride along with the chunk markers and need their own pass. -# -# --headless has no GPU and draws no pixels, so this cannot assert on what -# was rendered. It asserts on building_markers(), the step that turns -# GameState.buildings into map-local marker data, which is the part that -# _draw() then simply stamps out. -# -# Verifies: -# 1. no buildings -> no markers (so a passing count is not just "always 3") -# 2. every placed building produces exactly one marker -# 3. markers land at the building's world position scaled into map space -# 4. the three building types are told apart by colour, rather than all -# being drawn as the same anonymous dot - -var scene: Node -var game_state: Node -var map_content: Node2D -var stage: int = 0 - -const MOTHERSHIP_AT: Vector2 = Vector2(2000.0, -1200.0) -const CRAFTER_AT: Vector2 = Vector2(-800.0, 400.0) -const COMMAND_AT: Vector2 = Vector2(150.0, 3000.0) - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - map_content = scene.get_node("UI/WorldMap/MapContent") - - -func _process(_delta: float) -> bool: - match stage: - 0: - # Nothing built yet: the map must show no building markers at all. - var empty: Array = map_content.building_markers() - if not empty.is_empty(): - print("TEST_MAP_BUILDING_MARKERS: FAIL (%d markers with nothing built)" % empty.size()) - quit(1) - return true - - game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) - game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - game_state.add_building(GameState.BUILDING_COMMAND_CENTER, COMMAND_AT) - stage = 1 - return false - 1: - var markers: Array = map_content.building_markers() - print("markers=%d for %d buildings" % [markers.size(), game_state.buildings.size()]) - if markers.size() != 3: - print("TEST_MAP_BUILDING_MARKERS: FAIL (expected one marker per building)") - quit(1) - return true - - var scale: float = map_content.WORLD_TO_LOCAL - var expected: Dictionary = { - GameState.BUILDING_MOTHERSHIP: MOTHERSHIP_AT * scale, - GameState.BUILDING_SHIP_CRAFTER: CRAFTER_AT * scale, - GameState.BUILDING_COMMAND_CENTER: COMMAND_AT * scale, - } - var colours: Array = [] - for m in markers: - if not expected.has(m.type): - print("TEST_MAP_BUILDING_MARKERS: FAIL (unexpected marker type %s)" % m.type) - quit(1) - return true - var want: Vector2 = expected[m.type] - if m.position.distance_to(want) > 0.001: - print("%s marker at %s, expected %s" % [m.type, m.position, want]) - print("TEST_MAP_BUILDING_MARKERS: FAIL (marker is not where the building is)") - quit(1) - return true - colours.append(m.color) - - # Three buildings drawn in one indistinguishable colour would - # satisfy every check above and still be useless to read. - if colours[0] == colours[1] or colours[1] == colours[2] or colours[0] == colours[2]: - print("colours=%s" % [colours]) - print("TEST_MAP_BUILDING_MARKERS: FAIL (building types are not visually distinct)") - quit(1) - return true - - print("all 3 markers positioned correctly, colours distinct: %s" % [colours]) - print("TEST_MAP_BUILDING_MARKERS: PASS") - quit(0) - return true - return false diff --git a/tests/test_map_building_markers.gd.uid b/tests/test_map_building_markers.gd.uid deleted file mode 100644 index ecae550..0000000 --- a/tests/test_map_building_markers.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bx3bce8ix4rbg diff --git a/tests/test_modal_blocks_input.gd b/tests/test_modal_blocks_input.gd deleted file mode 100644 index 19c3e78..0000000 --- a/tests/test_modal_blocks_input.gd +++ /dev/null @@ -1,138 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_modal_blocks_input.gd -# With a panel open, nothing behind it may react to a touch. -# -# Two separate leaks, because there are two separate input paths: -# -# 1. GUI clicks. The skill and formation panels cannot block these the -# normal way (mouse_filter = STOP), because the skill tree and the -# formation editor are driven from raw _unhandled_input, and a Control -# that consumes touches through the GUI system swallows them before -# they ever get there. So the panels stay transparent and the buttons -# check whether they are behind one. -# 2. Raw input. A touch landing on the panel but missing a skill node was -# left unhandled, so it fell through to GameController - which sits on -# the ROOT node and therefore sees everything its children ignore - and -# moved the fleet or pinch-zoomed the world behind the menu. -# -# Verifies: -# 1. a button outside an open panel does not fire -# 2. a button INSIDE the open panel still does (or the panel is unusable) -# 3. with every panel closed, that same outside button fires normally, so -# a pass cannot just mean "the button never works" -# 4. a touch on an open panel does not reach the world behind it - -var scene: Node -var game_state: Node -var fleet: Node -var skill_menu: Control -var map_button: Control -var skill_close: Control -var stage: int = 0 -var elapsed: float = 0.0 -var outside_fired: bool = false -var inside_fired: bool = false - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.transporters.clear() - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - skill_menu = scene.get_node("UI/SkillMenu") - map_button = scene.get_node("UI/MapButton") - skill_close = scene.get_node("UI/SkillMenu/CloseButton") - map_button.pressed.connect(func(): outside_fired = true) - skill_close.pressed.connect(func(): inside_fired = true) - - -func _tap(button: Control) -> void: - # Straight at the button's own _gui_input, which is where a real tap - # lands once Godot has routed it. - var centre: Vector2 = button.size / 2.0 - for pressed in [true, false]: - var ev: InputEventScreenTouch = InputEventScreenTouch.new() - ev.pressed = pressed - ev.position = centre - button._gui_input(ev) - - -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - if elapsed < 0.2: - return false - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - - # --- 3. baseline: it works with nothing open ---------------- - outside_fired = false - _tap(map_button) - if not outside_fired: - print("TEST_MODAL_BLOCKS_INPUT: FAIL (the button never fires even with no panel open - the test proves nothing)") - quit(1) - return true - print("no panel open -> outside button fires") - # That tap actually OPENED the world map, which is itself a modal - # panel - leave it open and it correctly blocks the research - # panel's own buttons, which is a true result but not the one - # being measured here. - scene.get_node("UI/WorldMap").visible = false - - skill_menu.visible = true - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.2: - return false - # --- 1. behind an open panel -------------------------------- - outside_fired = false - _tap(map_button) - if outside_fired: - print("TEST_MODAL_BLOCKS_INPUT: FAIL (a button behind the open research panel still fired)") - quit(1) - return true - print("research panel open -> button behind it is inert") - - # --- 2. inside it ------------------------------------------ - inside_fired = false - _tap(skill_close) - if not inside_fired: - print("TEST_MODAL_BLOCKS_INPUT: FAIL (the panel's own close button stopped working)") - quit(1) - return true - print("panel's own button still works") - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.2: - return false - # --- 4. raw input must not reach the world ------------------ - skill_menu.visible = true - var before: Vector2 = fleet.fleet_target - var controller: Node = scene.get_node("BuildController") - var ev: InputEventScreenTouch = InputEventScreenTouch.new() - ev.pressed = true - ev.position = Vector2(360.0, 900.0) - controller._unhandled_input(ev) - if not root.get_viewport().is_input_handled(): - print("TEST_MODAL_BLOCKS_INPUT: FAIL (a touch on the open panel was left unhandled - it falls through to the world behind)") - quit(1) - return true - if fleet.fleet_target != before: - print("TEST_MODAL_BLOCKS_INPUT: FAIL (the fleet moved from a touch on the panel)") - quit(1) - return true - print("touch on the panel consumed, fleet target unchanged") - print("TEST_MODAL_BLOCKS_INPUT: PASS") - quit(0) - return true - return false diff --git a/tests/test_planet_extractor_marker.gd b/tests/test_planet_extractor_marker.gd deleted file mode 100644 index ed4b4cb..0000000 --- a/tests/test_planet_extractor_marker.gd +++ /dev/null @@ -1,155 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_planet_extractor_marker.gd -# Forgejo issue #9: a planet with an extractor built on it must show that -# something is built there. The extractor lives on PlanetSurface.tscn, a -# scene the player is not looking at from space, so an already-mined planet -# was indistinguishable from an untouched one - you had to land to find out. -# -# Covered in both places the player looks at a planet: -# - in space, as a derrick on the planet's own outline (Planet.gd) -# - on the world map, as a badge on the planet marker (WorldMapContent.gd) -# -# --headless has no GPU, so this asserts on the state and geometry that -# _draw() consumes, not on pixels. tests/screenshot.sh covers whether it is -# actually visible. -# -# Verifies: -# 1. a planet with no extractor shows neither derrick nor badge (so a pass -# cannot just mean "always on") -# 2. building one turns both on, keyed by planet NAME - the same key -# GameState.has_extractor_at uses -# 3. the derrick sits outside the planet's outline rather than buried in -# it, which is the difference between visible and not - -var game_state: Node -var scene: Node -var map_content: Node2D -var planet_chunk: Vector2i -var planet_name: String = "" -var stage: int = 0 -var already_spawned: Node2D - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - - var found: bool = false - for x in range(-15, 15): - for y in range(-15, 15): - var chunk: Vector2i = Vector2i(x, y) - if WorldGen.has_planet_at_chunk(chunk): - planet_chunk = chunk - planet_name = WorldGen.planet_data_at_chunk(chunk).name - found = true - break - if found: - break - if not found: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no planet chunk to test with)") - quit(1) - return - - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - map_content = scene.get_node("UI/WorldMap/MapContent") - - -func _spawn_planet() -> Node2D: - var planet: Node2D = load("res://Planet.tscn").instantiate() - planet.planet_name = planet_name - planet.shape_seed = 12345 - scene.add_child(planet) - return planet - - -func _process(_delta: float) -> bool: - match stage: - 0: - # --- nothing built there yet -------------------------------- - var clean: Node2D = _spawn_planet() - if clean.shows_extractor: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick on a planet with no extractor)") - quit(1) - return true - if map_content.chunk_has_extractor_badge(planet_chunk): - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)") - quit(1) - return true - print("no extractor -> no derrick, no badge (planet '%s')" % planet_name) - - # Deliberately keep this planet ALIVE across the build. An - # extractor is built from space with the fleet parked on the - # planet (BuildController._planet_under_fleet()), so the node is - # already in the tree with _ready() long since run. Resolving the - # marker only at _ready() meant the planet kept looking untouched - # until it happened to despawn and respawn - the build read as - # having done nothing at all. Spawning a fresh planet afterwards - # (as the checks below do) cannot catch that. - already_spawned = clean - game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk)) - stage = 1 - return false - 1: - # --- the planet that was already on screen when it was built -- - if not already_spawned.shows_extractor: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (a planet already in the tree never picked up its derrick - the build reads as doing nothing)") - quit(1) - return true - print("planet already in the tree picked up its derrick on build") - already_spawned.queue_free() - - # --- extractor built ---------------------------------------- - var mined: Node2D = _spawn_planet() - if not mined.shows_extractor: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no derrick after building an extractor)") - quit(1) - return true - if not map_content.chunk_has_extractor_badge(planet_chunk): - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no map badge after building an extractor)") - quit(1) - return true - - # The derrick has to clear the outline to be seen at all. Compare - # its tip against the planet's own radius in that direction, not - # against a constant - the outline is a lumpy seeded shape. - var derrick: PackedVector2Array = mined.extractor_derrick_points() - if derrick.size() < 3: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick has no shape)") - quit(1) - return true - # Measure the TIP, not the whole polygon. The base corners sit a - # half-width to either side of the rim vertex, so they are - # sqrt(|base|^2 + w^2) from the centre and clear the rim on their - # own - a max over every point called a derrick pointing straight - # into the ground "visible", which is exactly the bug this check - # exists to catch. - var furthest: float = mined.extractor_derrick_tip().length() - var rim: float = 0.0 - for p in mined.shape_points: - rim = maxf(rim, p.length()) - print("derrick tip reaches %.1f, planet outline reaches %.1f" % [furthest, rim]) - if furthest <= rim: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick buried inside the planet)") - quit(1) - return true - - # A different planet must NOT inherit the marker: the flag is keyed - # by name, and getting that wrong would light up every planet. - var other: Node2D = load("res://Planet.tscn").instantiate() - other.planet_name = planet_name + " II" - other.shape_seed = 999 - scene.add_child(other) - if other.shows_extractor: - print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (marker leaked onto a different planet)") - quit(1) - return true - - print("extractor -> derrick + badge, and only on the right planet") - print("TEST_PLANET_EXTRACTOR_MARKER: PASS") - quit(0) - return true - return false diff --git a/tests/test_planet_extractor_marker.gd.uid b/tests/test_planet_extractor_marker.gd.uid deleted file mode 100644 index f1bd124..0000000 --- a/tests/test_planet_extractor_marker.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dcr7qb1yoewcy diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 6069a1b..a78200c 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -3,9 +3,7 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_resources_and_buildings.gd # Covers the resource/building loop end to end: -# 1. flying a ship over a surface rock loads it into the fleet's hold -# (banking it needs a trip to the mothership - see #14 and -# tests/test_fleet_cargo.gd) +# 1. flying a ship over a surface rock banks it in GameState # 2. buildings are paid for, persist in GameState, and respawn with the # space scene (they must survive the trip to a planet and back) # 3. a ship crafter adds a vessel to the fleet for rocks @@ -23,7 +21,6 @@ var mothership_pos: Vector2 = Vector2(900.0, -400.0) func _initialize() -> void: game_state = root.get_node("GameState") game_state.rocks = 0 - game_state.fleet_cargo = 0 game_state.buildings.clear() game_state.has_return_position = false @@ -52,14 +49,12 @@ func _process(delta: float) -> bool: 1: if elapsed < 1.5: return false - # Into the hold, not the bank: since #14 ore is cargo until the - # fleet delivers it to the mothership. - if game_state.fleet_cargo != 1: - print("fleet_cargo=%d after flying a ship onto one" % game_state.fleet_cargo) + if game_state.rocks != 1: + print("rocks=%d after flying a ship onto one" % game_state.rocks) print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)") quit(1) return true - print("gathered rock -> hold=%d bank=%d" % [game_state.fleet_cargo, game_state.rocks]) + print("gathered rock -> rocks=%d" % game_state.rocks) # Bank enough to build with, then move to the space scene. game_state.rocks = 60 diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd deleted file mode 100644 index 92e818f..0000000 --- a/tests/test_skill_tree.gd +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 92d320c..0000000 --- a/tests/test_skill_tree.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://db2nf67mtlg27 diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd deleted file mode 100644 index b2cc4eb..0000000 --- a/tests/test_transport_hub_building.gd +++ /dev/null @@ -1,157 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_transport_hub_building.gd -# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of -# its own. Only the building here - the vessels that do the hauling are #19. -# -# Verifies: -# 1. it is gated behind its research node, like every other structure -# 2. researching it makes it buildable, and placing it charges its cost -# 3. it is drawn as ITSELF, not as the crafter - Building._draw()'s match -# has a catch-all that renders anything unrecognised as a ship crafter, -# so a missing case is silent and looks almost right -# 4. its geometry is rasterised ONCE however long it animates (#10), and -# its stationed transports really do move -# 5. it has its own build-menu icon and its own map marker rather than -# borrowing another building's - the menu was once unreadable because -# two buildings shared a glyph - -var game_state: Node -var scene: Node -var field: Node -var controller: Node -var map_content: Node2D -var hub: Node2D -var stage: int = 0 -var elapsed: float = 0.0 -var spin_at_start: float = 0.0 - -const HUB_AT: Vector2 = Vector2(700.0, 0.0) - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.reset_progression() - game_state.rocks = 500 - game_state.fleet_cargo = 0 - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - field = scene.get_node("BuildingField") - controller = scene.get_node("BuildController") - map_content = scene.get_node("UI/WorldMap/MapContent") - - -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - - # --- 1. locked until researched ------------------------------ - if game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB): - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (buildable with no research at all)") - quit(1) - return true - var gate: String = game_state.research_for_building(GameState.BUILDING_TRANSPORT_HUB) - if gate == "": - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no research node gates it)") - quit(1) - return true - print("locked behind research: '%s'" % gate) - - # --- 5. its own icon, distinct from every other building ----- - var icons: Dictionary = {} - for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, - GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]: - var icon: int = controller._action_icon_for(type) - if icons.has(icon): - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]]) - quit(1) - return true - icons[icon] = type - print("action icons distinct across %d buildings" % icons.size()) - - # --- 2. research it, then build it --------------------------- - game_state.unlocked = ["shipyards", "logistics", "freight"] - if not game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB): - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate) - quit(1) - return true - var before: int = game_state.rocks - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) - game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB)) - if game_state.rocks != before - GameState.TRANSPORT_HUB_COST: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [ - before - game_state.rocks, GameState.TRANSPORT_HUB_COST - ]) - quit(1) - return true - print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.3: - return false - for b in field.spawned: - if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: - hub = b - if hub == null: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)") - quit(1) - return true - - # --- 3. drawn as itself, not via the crafter catch-all ------- - # _hub_traffic only exists if _ready() took the transport-hub - # branch; a missing case falls through to the crafter and would - # leave it null while still rendering something plausible. - if hub._hub_traffic == null: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (rendered through the crafter catch-all, not as a hub)") - quit(1) - return true - - # --- 5. its own map marker ---------------------------------- - var markers: Array = map_content.building_markers() - var found: bool = false - for m in markers: - if m.type == GameState.BUILDING_TRANSPORT_HUB: - found = true - if m.color == map_content.CRAFTER_MARKER_COLOR \ - or m.color == map_content.MOTHERSHIP_MARKER_COLOR \ - or m.color == map_content.COMMAND_MARKER_COLOR: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)") - quit(1) - return true - if not found: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no map marker - see #11)") - quit(1) - return true - print("has its own map marker") - - spin_at_start = hub._hub_traffic.rotation - elapsed = 0.0 - stage = 2 - return false - 2: - # --- 4. animates without ever redrawing (#10) ---------------- - if elapsed < 2.0: - return false - print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count]) - if hub.draw_count != 1: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count) - quit(1) - return true - var spun: float = absf(hub._hub_traffic.rotation - spin_at_start) - print("stationed transports turned %.3f rad" % spun) - if spun < 0.05: - print("TEST_TRANSPORT_HUB_BUILDING: FAIL (nothing moved - a frozen hub trivially passes the redraw check)") - quit(1) - return true - print("TEST_TRANSPORT_HUB_BUILDING: PASS") - quit(0) - return true - return false diff --git a/tests/test_transport_hub_building.gd.uid b/tests/test_transport_hub_building.gd.uid deleted file mode 100644 index 4599336..0000000 --- a/tests/test_transport_hub_building.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ctplk4adbsi3a diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd deleted file mode 100644 index dbc43e4..0000000 --- a/tests/test_transporter_supply.gd +++ /dev/null @@ -1,207 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_transporter_supply.gd -# Forgejo #19 (part 6 of #12): transporters stationed at a hub fetch ore from -# the mothership and deliver it to whichever building is waiting on it, with -# no player input at all. -# -# Verifies: -# 1. a transporter is built at the CRAFTER and takes itself to the nearest -# hub, rather than appearing at the hub out of nowhere -# 2. a queued order the crafter cannot pay for is fulfilled end to end -# automatically - the whole point of the ticket -# 3. ore is conserved across the entire route (bank + hold + every -# building's stock), at every step, not just at the end -# 4. ore leaves the bank when it is LOADED, not when the order is placed - -# the vessel is carrying something real -# 5. transporters are not ordered when there is no hub to station them at -# 6. no unbounded node accumulation: the fleet of transporters is exactly -# what was built, however many runs they make - -var game_state: Node -var scene: Node -var fleet: Node -var field: Node -var crafter_id: int = 0 -var hub_id: int = 0 -var stage: int = 0 -var elapsed: float = 0.0 -var total_at_start: int = 0 -var bank_when_ordered: int = 0 -var conservation_broken: bool = false -# Ore legitimately consumed by completed production. Conservation means -# "nothing vanishes in TRANSIT", not "the total never falls" - building a -# vessel spends ore, and that is the one way it is allowed to leave. -var consumed: int = 0 - -const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) -const HUB_AT: Vector2 = Vector2(600.0, 200.0) -const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0) - - -func _total() -> int: - var sum: int = game_state.rocks + game_state.fleet_cargo - for b in game_state.buildings: - sum += int(b.get("stock", 0)) - for t in game_state.transporters: - sum += int(t.get("carrying", 0)) - return sum - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.transporters.clear() - game_state.rocks = 200 - game_state.fleet_cargo = 0 - game_state.ships_owed = 0 - game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"] - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - field = scene.get_node("BuildingField") - game_state.order_completed.connect(_on_order_completed) - - -func _on_order_completed(_building_id: int, product: String) -> void: - consumed += game_state.product_cost(product) - - -func _process(delta: float) -> bool: - elapsed += delta - - # --- 3. conservation, checked EVERY tick ------------------------------ - # Ore in flight is the easy thing to lose: a leg that loads without - # debiting, or delivers without crediting, would show up here and nowhere - # else. Checked continuously rather than at the end, so it cannot be - # masked by a later correction. - if stage >= 2 and _total() + consumed != total_at_start: - conservation_broken = true - - match stage: - 0: - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - for a in get_nodes_in_group("asteroids"): - if is_instance_valid(a): - a.queue_free() - - field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - crafter_id = int(game_state.buildings[-1].id) - - # --- 5. no hub yet: a transporter cannot be ordered ---------- - if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_TRANSPORTER_SUPPLY: FAIL (ordered a transporter with no hub to station it at)") - quit(1) - return true - print("no hub -> transporter order refused") - - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) - hub_id = int(game_state.buildings[-1].id) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.2: - return false - # --- 1. build a transporter at the crafter ------------------ - if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_TRANSPORTER_SUPPLY: FAIL (could not order a transporter with a hub built)") - quit(1) - return true - game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) - elapsed = 0.0 - stage = 2 - total_at_start = _total() - return false - 2: - if elapsed < 0.3: - return false - if game_state.transporters.size() != 1: - print("TEST_TRANSPORTER_SUPPLY: FAIL (built %d transporters from one order)" % game_state.transporters.size()) - quit(1) - return true - var t: Dictionary = game_state.transporters[0] - if int(t.hub_id) != hub_id: - print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter did not take the nearest hub)") - quit(1) - return true - # It must fly FROM the crafter TO the hub, not appear at the hub. - # Checked on the leg's endpoints rather than its current position, - # which is already partway there by now - and being partway is - # the correct behaviour. - if (t.from as Vector2).distance_to(CRAFTER_AT) > 1.0: - print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg starts at %s, not at the crafter that built it)" % t.from) - quit(1) - return true - if (t.to as Vector2).distance_to(HUB_AT) > 1.0: - print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg does not end at the hub: %s)" % t.to) - quit(1) - return true - print("transporter built at the crafter, heading for hub %d" % hub_id) - elapsed = 0.0 - stage = 3 - return false - 3: - # Give it time to reach the hub and go idle. - if elapsed < 4.0: - return false - if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE: - print("TEST_TRANSPORTER_SUPPLY: FAIL (never settled at its hub: state=%s)" % game_state.transporters[0].state) - quit(1) - return true - print("stationed at the hub, idle") - - # --- 2 + 4. an order it cannot pay for ---------------------- - game_state.queue_order(crafter_id, GameState.PRODUCT_SHIP) - bank_when_ordered = game_state.rocks - elapsed = 0.0 - stage = 4 - return false - 4: - # One tick: placing the order must not touch the bank. - if elapsed < 0.1: - return false - if game_state.rocks != bank_when_ordered: - print("TEST_TRANSPORTER_SUPPLY: FAIL (bank moved on placing the order, before anything was loaded)") - quit(1) - return true - print("order placed, bank untouched at %d" % game_state.rocks) - elapsed = 0.0 - stage = 5 - return false - 5: - # --- 2. the whole run, with no player input ----------------- - if game_state.pending_orders(crafter_id) > 0: - if elapsed > 25.0: - print("TEST_TRANSPORTER_SUPPLY: FAIL (order never fulfilled: transporter state=%s carrying=%d crafter stock=%d)" % [ - game_state.transporters[0].state, - int(game_state.transporters[0].carrying), - game_state.building_stock(crafter_id), - ]) - quit(1) - return true - return false - if conservation_broken: - print("TEST_TRANSPORTER_SUPPLY: FAIL (ore created or destroyed somewhere along the route)") - quit(1) - return true - print("order fulfilled automatically in %.1fs, no player input" % elapsed) - - # --- 6. no vessel accumulation ------------------------------ - if game_state.transporters.size() != 1: - print("TEST_TRANSPORTER_SUPPLY: FAIL (%d transporters exist after the run, one was built)" % game_state.transporters.size()) - quit(1) - return true - print("still exactly 1 transporter after a full round trip") - print("bank=%d crafter stock=%d total=%d + %d consumed = %d (started %d)" % [ - game_state.rocks, game_state.building_stock(crafter_id), _total(), - consumed, _total() + consumed, total_at_start - ]) - print("TEST_TRANSPORTER_SUPPLY: PASS") - quit(0) - return true - return false diff --git a/tests/test_transporter_supply.gd.uid b/tests/test_transporter_supply.gd.uid deleted file mode 100644 index bfc9fcf..0000000 --- a/tests/test_transporter_supply.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dxu5orxbivfpa diff --git a/ui_theme.tres b/ui_theme.tres index 89bb6a1..38c105d 100644 --- a/ui_theme.tres +++ b/ui_theme.tres @@ -1,8 +1,15 @@ -[gd_resource type="Theme" load_steps=2 format=3] +[gd_resource type="Theme" format=3] -[ext_resource type="FontFile" path="res://fonts/CarroisGothicSC-Regular.ttf" id="1"] +; 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. [resource] -default_font = ExtResource("1") default_font_size = 78 -Label/colors/font_color = Color(0.36, 0.9, 1, 1)