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