diff --git a/BuildController.gd b/BuildController.gd index 114575f..b598703 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -1,5 +1,7 @@ extends Node2D +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + # Space-side building UI. Owns: # - the build menu (place a mothership, ship crafter or command centre) # - the shared ACTION button, which adapts to whatever the fleet is @@ -22,14 +24,8 @@ extends Node2D @onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton") @onready var build_menu: Control = get_node("../UI/BuildMenu") -@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton") -@onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton") -@onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton") @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 build_lab_button: IconButton = get_node("../UI/BuildMenu/ShipLabButton") @onready var lab_menu: Control = get_node("../UI/LabMenu") @onready var lab_vessel_buttons: Array = [ get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), @@ -78,20 +74,7 @@ extends Node2D get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetTransporterLabel"), ] -@onready var build_menu_entries: Array = [ - [GameState.BUILDING_MOTHERSHIP, build_mothership_button, - get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], - [GameState.BUILDING_SHIP_CRAFTER, build_crafter_button, - get_node("../UI/BuildMenu/CrafterLabel"), get_node("../UI/BuildMenu/CrafterCost")], - [GameState.BUILDING_COMMAND_CENTER, build_command_button, - get_node("../UI/BuildMenu/CommandLabel"), get_node("../UI/BuildMenu/CommandCost")], - [GameState.BUILDING_EXTRACTOR, build_extractor_button, - get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")], - [GameState.BUILDING_TRANSPORT_HUB, build_hub_button, - get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")], - [GameState.BUILDING_SHIP_LAB, build_lab_button, - get_node("../UI/BuildMenu/ShipLabLabel"), get_node("../UI/BuildMenu/ShipLabCost")], -] +var build_menu_entries: Array = [] # Which vessel the lab screen is showing, and which visible upgrade card is # selected. Each vessel gets its own small set of direct cards; the detail and @@ -148,41 +131,6 @@ var lab_building_id: int = 0 @onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") @onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton") -# What each option is and does, shown before anything is spent. Picking a -# structure is a real commitment - it costs ore, and a mothership placement -# can destroy the one you already have - so no option builds straight from -# the menu; each one explains itself first and waits for a confirmation. -# -# The bodies are kept short on purpose: at the UI's font size the popup holds -# roughly eight lines, and a placement condition or a price warning is -# appended below them and needs some of those lines. -const BUILDING_INFO: Dictionary = { - GameState.BUILDING_MOTHERSHIP: { - "title": "Mothership", - "body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.", - }, - GameState.BUILDING_SHIP_CRAFTER: { - "title": "Ship Crafter", - "body": "Fly the fleet onto it, then use the action button to build extra vessels.", - }, - GameState.BUILDING_COMMAND_CENTER: { - "title": "Command Center", - "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", - }, - GameState.BUILDING_SHIP_LAB: { - "title": "Ship Lab", - "body": "Fly the fleet onto it to upgrade each vessel type, and to unlock the shield vessel.", - }, - 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.", - }, -} - # 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 = "" @@ -210,12 +158,7 @@ var context_actions_expanded: bool = false func _ready() -> void: build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) - build_mothership_button.pressed.connect(_on_place_mothership) - 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) - build_lab_button.pressed.connect(_on_place_ship_lab) + _configure_build_menu_entries() craft_shield_button.pressed.connect(_on_craft_shield) lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_buy_button.pressed.connect(_on_lab_buy) @@ -267,6 +210,21 @@ func _ready() -> void: _layout_context_buttons() +func _configure_build_menu_entries() -> void: + build_menu_entries.clear() + for definition in BUILDING_DEFINITIONS.all(): + if definition.menu_button_name == "": + continue + var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name) + var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name) + var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name) + button.icon = definition.menu_icon + button.pressed.connect(_show_building_info.bind(String(definition.definition_id))) + label.text = definition.display_name + cost_number.amount = definition.cost + build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) + + # 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: @@ -318,9 +276,7 @@ func _process(delta: float) -> void: if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. - build_extractor_button.enabled = _planet_under_fleet() != null \ - and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) - build_extractor_button.queue_redraw() + _refresh_build_option_states() if craft_menu.visible: # Live, so a delivery arriving while the menu is open visibly works @@ -359,10 +315,7 @@ func _process(delta: float) -> void: current_building = pending_building context_actions_expanded = false - # The hangar is where finished vessels are delivered (#23), so it belongs - # at the mothership and nowhere else. - hangar_button.visible = current_building != null \ - and current_building.building_type == GameState.BUILDING_MOTHERSHIP + hangar_button.visible = false # Every building can hold ore, so the transfer button rides alongside the # action button rather than being one of the things the action button # turns into - the two are independent choices at the same structure. @@ -371,7 +324,7 @@ func _process(delta: float) -> void: action_button.visible = current_building != null if current_building != null: var actions: Array = _context_action_buttons() - building_action_button.icon = _action_icon_for(current_building.building_type) + building_action_button.icon = int(current_building.definition.action_icon) building_action_button.queue_redraw() action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU action_button.queue_redraw() @@ -403,13 +356,20 @@ func _layout_context_buttons() -> void: func _context_action_buttons() -> Array: if current_building == null: return [] - var actions: Array = [transfer_button] - if current_building.building_type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, - GameState.BUILDING_SHIP_LAB, GameState.BUILDING_COMMAND_CENTER]: - actions.push_front(building_action_button) - if current_building.building_type == GameState.BUILDING_MOTHERSHIP: - actions.append(hangar_button) - return actions + var buttons: Array = [] + for action in current_building.available_actions(): + var button: IconButton = _button_for_building_action(action) + if button != null and button not in buttons: + buttons.append(button) + return buttons + + +func _button_for_building_action(action: String) -> IconButton: + if action == "transfer": + return transfer_button + if action == "hangar": + return hangar_button + return building_action_button func _on_context_action_pressed() -> void: @@ -433,20 +393,6 @@ func _collapse_context_actions() -> void: _layout_context_buttons() -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_SHIP_LAB: - return IconButton.Icon.SHIP_LAB - GameState.BUILDING_MOTHERSHIP: - return IconButton.Icon.MOTHERSHIP - _: - return IconButton.Icon.COMMAND_CENTER - - func _check_fleet_wipe() -> void: if not fleet.ships.is_empty(): return @@ -465,11 +411,12 @@ func _on_action_pressed() -> void: for bar in resource_bars: bar.building_id = current_building.building_id - if current_building.building_type == GameState.BUILDING_MOTHERSHIP: + var action: String = current_building.primary_action() + if action == "research": skill_menu.visible = true skill_tree.reset_scroll() _refresh_skill_menu() - elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: + elif action == "craft": # Remembered explicitly: _process() clears current_building while any # menu is open, so by the next frame the panel would no longer know # which crafter it belongs to. Losing this line makes every button in @@ -478,11 +425,11 @@ func _on_action_pressed() -> void: craft_building_id = current_building.building_id craft_menu.visible = true _refresh_craft_status() - elif current_building.building_type == GameState.BUILDING_SHIP_LAB: + elif action == "lab": lab_building_id = current_building.building_id lab_menu.visible = true _refresh_lab() - elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: + elif action == "formation": formation_menu.visible = true formation_editor.queue_redraw() @@ -551,8 +498,7 @@ func _refresh_build_status() -> void: # Researched but not usable right here is still shown, greyed: an # extractor needs a planet under the fleet, which is a "not now" rather # than a "not yet". - build_extractor_button.enabled = _planet_under_fleet() != null \ - and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + _refresh_build_option_states() # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. @@ -562,16 +508,14 @@ func _refresh_build_status() -> void: build_status.text = "" -func _on_place_mothership() -> void: - _show_building_info(GameState.BUILDING_MOTHERSHIP) - - -func _on_place_crafter() -> void: - _show_building_info(GameState.BUILDING_SHIP_CRAFTER) - - -func _on_place_command_center() -> void: - _show_building_info(GameState.BUILDING_COMMAND_CENTER) +func _refresh_build_option_states() -> void: + for entry in build_menu_entries: + var definition: Resource = entry[4] + var enabled: bool = GameState.is_building_unlocked(definition.definition_id) + if definition.placement_mode == "planet": + enabled = enabled and _planet_under_fleet() != null + entry[1].enabled = enabled + entry[1].queue_redraw() func _close_build_menu() -> void: @@ -582,40 +526,17 @@ func _close_build_menu() -> void: build_menu.visible = false -func _on_place_ship_lab() -> void: - _show_building_info(GameState.BUILDING_SHIP_LAB) - - -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. - _show_building_info(GameState.BUILDING_EXTRACTOR) - - func cost_of(type: String) -> int: - match type: - GameState.BUILDING_TRANSPORT_HUB: - return GameState.TRANSPORT_HUB_COST - GameState.BUILDING_SHIP_LAB: - return GameState.SHIP_LAB_COST - GameState.BUILDING_MOTHERSHIP: - return GameState.MOTHERSHIP_COST - GameState.BUILDING_SHIP_CRAFTER: - return GameState.SHIP_CRAFTER_COST - GameState.BUILDING_COMMAND_CENTER: - return GameState.COMMAND_CENTER_COST - _: - return GameState.EXTRACTOR_COST + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + return int(definition.cost) if definition != null else 0 func _show_building_info(type: String) -> void: - var info: Dictionary = BUILDING_INFO[type] - var cost: int = cost_of(type) - var lines: Array = [info.body] + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + if definition == null: + return + var cost: int = int(definition.cost) + var lines: Array = [definition.description] var can_build: bool = true # Locked structures still list themselves, greyed, so the player can see @@ -626,7 +547,7 @@ func _show_building_info(type: String) -> void: # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. - if can_build and type == GameState.BUILDING_EXTRACTOR: + if can_build and definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") @@ -635,15 +556,15 @@ func _show_building_info(type: String) -> void: lines.append("%s already has an extractor." % planet.planet_name) can_build = false else: - lines.append("Will mine %s from %s." % ["gas" if planet.resource_type == "gas" else "ore", planet.planet_name]) - elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + lines.append("Will mine %s from %s." % [GameState.resource_name(planet.resource_type), planet.planet_name]) + elif can_build and definition.unique and GameState.has_building_of_type(type): lines.append("WARNING: only one may exist - this destroys your current one.") if can_build and not GameState.can_afford(cost): lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks]) can_build = false - info_title.text = info.title + info_title.text = definition.display_name info_message.text = "\n\n".join(lines) info_cost.amount = cost info_build_button.visible = can_build @@ -668,28 +589,31 @@ func _confirm_build() -> void: # planet an extractor was going to mine. if not GameState.is_building_unlocked(type): return + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + if definition == null: + return - if type == GameState.BUILDING_EXTRACTOR: + if definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_name): build_status.text = "No planet in reach any more" return - if not GameState.spend(GameState.EXTRACTOR_COST): - build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] + if not GameState.spend(definition.cost): + build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks] return GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type) build_menu.visible = false return - if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): - if not GameState.can_afford(GameState.MOTHERSHIP_COST): + if definition.unique and GameState.has_building_of_type(type): + if not GameState.can_afford(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [ - GameState.MOTHERSHIP_COST, GameState.rocks + definition.cost, GameState.rocks ] return # Only now is the old one destroyed - never before the new one is # known to be affordable, or the player would be left with none. - GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP) + GameState.remove_buildings_of_type(type) building_field.rebuild_all() _try_place(type, cost_of(type)) diff --git a/Building.gd b/Building.gd index d4387d7..b9dea7e 100644 --- a/Building.gd +++ b/Building.gd @@ -1,665 +1,42 @@ extends Node2D -# A player-placed structure in space. Three kinds for now: -# mothership - where a wiped-out fleet is rebuilt -# ship_crafter - fly the fleet onto it to buy additional ships -# command_center - reconfigure the fleet formation -# -# Buildings are stationary and harmless; whether the fleet is "on" one is -# decided by BuildingField comparing the fleet CENTROID against -# interact_radius, the same stable single-number test Planet uses (see -# Planet.gd for why per-ship overlap was a poor fit). -# -# Nothing here rests on ground or vents into an atmosphere - these are -# orbital structures, so they are built out of trusses, radiator fins, -# reactor cores and open cradles rather than walls and roofs. -# -# The crafter and the mothership animate, so they run _process. The command -# centre is drawn once and never redrawn - per-frame redraws of art that -# never changes were a real framerate cost earlier on. +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") -@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 definition_id: String = "mothership" @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) -const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) +var definition: Resource +var visual: Node2D -# Ship lab: a test frame with a vessel held in it and instruments sweeping -# over it. Reads as a workshop rather than a factory - the crafter already -# owns "assembly line", so this is measurement, not production. -const LAB_FRAME_W: float = 78.0 -const LAB_FRAME_H: float = 58.0 -const LAB_MOUNT_RADIUS: float = 15.0 -const LAB_SCANNER_ORBIT: float = 96.0 -const LAB_SCAN_SPEED: float = 0.9 - -# 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) - -# One assembly cycle: parts are welded on, the finished hull flashes, then -# it clears the cradle and the line starts over. -const CYCLE: float = 3.6 -const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding -const FLASH_END: float = 0.76 # ... then the completion flash - # ... then launch, to 1.0 - -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. -const SHIP_PARTS: Array = [ - {"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]}, - {"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]}, - {"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]}, - {"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]}, -] - -# The assembly clock, wrapped at CYCLE because the whole build sequence is -# expressed as a fraction of it. -var anim_time: float = 0.0 - -# Free-running seconds for everything that is merely cyclic - reactor pulses, -# orbiting satellites, blinking beacons. These must NOT share anim_time: -# CYCLE is 3.6s, which is not a whole number of periods for any of them, so -# wrapping made every one of them leap at the wrap. The satellites were the -# obvious casualty, snapping back 66 degrees every 3.6 seconds. -# -# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours -# of play the argument to sin() keeps far more precision than a frame needs, -# and any wrap point would reintroduce exactly the discontinuity above. -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 -# Sweeping instrument booms at a ship lab (#5). -var _lab_scanners: Node2D = null -var _flash_ring: NeonPiece = null -var _wash: NeonPiece = null +var building_type: String: + get: return definition_id + set(value): definition_id = value func _ready() -> void: add_to_group("buildings") z_index = -1 - # The crafter runs an assembly line and the mothership holds a live - # reactor with satellites in orbit, so both animate. The command centre is - # entirely static and needs no per-frame work at all. - 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() - elif building_type == GameState.BUILDING_SHIP_LAB: - _build_lab_pieces() - queue_redraw() + definition = BUILDING_DEFINITIONS.get_definition(definition_id) + if definition == null or definition.visual_scene == null: + return + visual = definition.visual_scene.instantiate() + get_node("VisualHost").add_child(visual) -func _process(delta: float) -> void: - anim_time = fmod(anim_time + delta, CYCLE) - phase_time += delta - apply_animation() +func available_actions() -> PackedStringArray: + if visual == null: + return PackedStringArray() + var provider: Node = visual.get_node_or_null("ActionProvider") + return provider.actions if provider != null else PackedStringArray() -# 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() - GameState.BUILDING_SHIP_LAB: - _animate_ship_lab() - - -# Where the satellites are, and how open the reactor core is. Both are read -# by _draw and by the test that checks they stay continuous across the -# assembly clock's wrap. -func orbit_angle() -> float: - return phase_time * ORBIT_SPEED - - -func core_pulse() -> float: - return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED) +func primary_action() -> String: + for action in available_actions(): + if action != "transfer" and action != "hangar": + return action + return "" func get_display_name() -> String: - match building_type: - GameState.BUILDING_MOTHERSHIP: - return "Mothership" - GameState.BUILDING_SHIP_CRAFTER: - return "Ship Crafter" - _: - return "Command Center" - - -# How many times this building's geometry has actually been rasterised. The -# whole point of the NeonPiece decomposition is that this stays at 1 for the -# building's lifetime however long it animates, so it is worth being able to -# assert rather than assume. -var draw_count: int = 0 - - -func _draw() -> void: - draw_count += 1 - # Runs exactly once per building: this is the fixed structure. Everything - # that moves is a NeonPiece child, animated through node properties, so - # nothing here ever asks for a redraw again. - match building_type: - GameState.BUILDING_COMMAND_CENTER: - _draw_command_center() - GameState.BUILDING_MOTHERSHIP: - _draw_mothership_static(self) - GameState.BUILDING_TRANSPORT_HUB: - _draw_transport_hub_static(self) - GameState.BUILDING_SHIP_LAB: - _draw_ship_lab_static(self) - _: - _draw_crafter_static(self) - - -# --- shared helpers ------------------------------------------------------- - -func _ring(centre: 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(centre + Vector2(cos(a), sin(a)) * radius) - return pts - - -func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array: - var pts: PackedVector2Array = PackedVector2Array() - for i in range(steps + 1): - var a: float = lerpf(from_a, to_a, float(i) / float(steps)) - pts.append(centre + Vector2(cos(a), sin(a)) * radius) - return pts - - - - -# --- 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 - - -# --- ship lab ------------------------------------------------------------- - -func _draw_ship_lab_static(target: CanvasItem) -> void: - var c: Color = SHIP_LAB_COLOR - - # Outer test frame, open at the corners so it reads as a rig rather than - # a solid hull. - for sx in [-1.0, 1.0]: - for sy in [-1.0, 1.0]: - var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H) - NeonDraw.draw_glowing_line(target, corner, - corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42) - NeonDraw.draw_glowing_line(target, corner, - corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42) - - # The vessel under test, held nose-up in the middle - deliberately the - # fleet's own silhouette, so it is obvious what the lab is for. - NeonDraw.draw_glowing_polygon(target, PackedVector2Array([ - Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12), - ]), HULL_COLOR, 0.95) - - # Mount ring and its bracing. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5, - NeonDraw.DEFAULT_CORE, 0.7) - for i in range(4): - var a: float = (float(i) / 4.0) * TAU + PI / 4.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3) - - -func _build_lab_pieces() -> void: - # Two instrument booms sweeping round the vessel. One holder, rasterised - # once, rotated per frame (#10). - _lab_scanners = Node2D.new() - add_child(_lab_scanners) - for i in range(2): - var a: float = float(i) * PI - var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT - var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR) - piece.add_polygon(PackedVector2Array([ - Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12), - ])) - piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5) - piece.rotation = a - piece.queue_redraw() - - -func _animate_ship_lab() -> void: - _lab_scanners.rotation = phase_time * LAB_SCAN_SPEED - - -# --- transport hub -------------------------------------------------------- - -func _draw_transport_hub_static(target: CanvasItem) -> void: - var c: Color = TRANSPORT_HUB_COLOR - - # Dock ring and the deck nested inside it. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0) - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7) - - # Freight core: a squared-off hold rather than a reactor, so it reads as - # storage and not as another power plant. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95) - - for i in range(HUB_BERTH_COUNT): - var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32) - # Berth arm standing off the ring, where a transporter parks. - NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS, - dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38) - - -func _build_hub_pieces() -> void: - # Stationed transports, as NeonPiece children of one holder that turns. - # Per #10 the geometry is rasterised once and only the holder's rotation - # changes, so the traffic costs no redraws at all. - _hub_traffic = Node2D.new() - add_child(_hub_traffic) - for i in range(HUB_DOCKED_COUNT): - var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU - var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT - var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR) - # A stubby freighter: blunt, boxy, nothing like the fleet's darts. - piece.add_polygon(PackedVector2Array([ - Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5), - ])) - piece.rotation = a + PI / 2.0 - piece.queue_redraw() - - -func _animate_transport_hub() -> void: - _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED - - -func _diamond(centre: Vector2, r: float) -> PackedVector2Array: - return PackedVector2Array([ - centre + Vector2(0, -r), centre + Vector2(r, 0), - centre + Vector2(0, r), centre + Vector2(-r, 0), - ]) - - -func _draw_command_center() -> void: - var c: Color = COMMAND_COLOR - var readout: Color = Color(0.82, 0.86, 1.0) - - # The rim: a hex table holding the whole layout together, with a fainter - # deck ring nested just inside it - the same "plating has layers" read the - # mothership gets from its nested reactor rings. - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0) - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) - - for i in range(CMD_PORT_COUNT): - var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - # Spoke bracing the hub to the rim. - NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36) - # Socket where the mast plugs into the rim. - NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85) - # Port mast standing proud of the rim; every other one is manned. - var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH) - NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4) - if i % 2 == 0: - NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0) - - # Command core: a diamond hub with a lit readout set inside it. - NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0) - NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) - - -# --- mothership: a radial flagship ---------------------------------------- -# Not a big fighter. A mothership is the thing everything else orbits, so it -# is built radially around a reactor core rather than pointed in a direction: -# six swept talons, crystal panels set between them, and satellites circling -# the whole assembly. A forward-facing hull, however large, just reads as -# another ship. - -const ARM_COUNT: int = 6 -const ARM_INNER: float = 42.0 -const ARM_OUTER: float = 106.0 -const ARM_SWEEP: float = 0.72 -const SATELLITE_COUNT: int = 4 -const SATELLITE_ORBIT: float = 126.0 -const ORBIT_SPEED: float = 0.32 -const PULSE_SPEED: float = 2.4 - - -func _talon(base_angle: float) -> PackedVector2Array: - # One swept claw: the outer edge runs from the core to the tip, and the - # inner edge comes back a little behind it, closing to a point so the arm - # tapers instead of ending in a blunt stub. - var steps: int = 7 - var pts: PackedVector2Array = PackedVector2Array() - for i in range(steps + 1): - var f: float = float(i) / float(steps) - var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) - for i in range(steps, -1, -1): - var f: float = float(i) / float(steps) - var trail: float = 0.34 * (1.0 - f) + 0.04 - var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail - pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) - return pts - - -func _draw_mothership_static(canvas: CanvasItem) -> void: - var c: Color = MOTHERSHIP_COLOR - for i in range(ARM_COUNT): - var base: float = (float(i) / float(ARM_COUNT)) * TAU - NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95) - # Crystal panel set into the gap behind each arm. - var pa: float = base - 0.30 - 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 housing: the nested rings around the cell that breathes. - NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) - NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) - - -const CORE_CELL_RADIUS: float = 12.0 - - -func _build_mothership_pieces() -> void: - var core_color: Color = Color(0.75, 1.0, 0.85) - - # The breathing cell: drawn at a reference size and then scaled, rather - # than rebuilt at a new radius every frame. - _core_cell = NeonPiece.create(self, Vector2.ZERO, core_color) - _core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0)) - - # Satellites sit at fixed offsets inside a holder; the orbit is the - # holder's rotation, so four hexagon pairs become one transform update. - _satellites = Node2D.new() - add_child(_satellites) - for i in range(SATELLITE_COUNT): - var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU - var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT - var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR) - shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85) - var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color) - cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8) - - -func _animate_mothership() -> void: - 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_static(canvas: CanvasItem) -> void: - var c: Color = CRAFTER_COLOR - - # 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]: - var fin: PackedVector2Array = PackedVector2Array([ - CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), - CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), - ]) - NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75) - for f in [0.35, 0.68]: - 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(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(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(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(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 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: - _animate_assembly(t / ASSEMBLE_END) - elif t < FLASH_END: - _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) - else: - _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) - - -# How many pieces are on the hull at this point through the assembly phase. -# Parts attach in order, so this is just how many thresholds have passed. -func attached_part_count(p: float) -> int: - var n: int = 0 - for part in SHIP_PARTS: - if p >= float(part.at): - n += 1 - return n - - -func _place_hull(centre: Vector2, alpha: float) -> void: - # The parts are defined around their own origin, so the holder goes - # straight to the point they should be centred on. - _hull_holder.position = centre - _hull_holder.modulate.a = alpha - - -func _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) - - var next_part: int = attached_part_count(p) - for i in range(_hull_pieces.size()): - var attached: bool = i < next_part - _hull_pieces[i].visible = attached - if attached: - # Freshly attached parts flare, then settle to full brightness. - var age: float = p - float(SHIP_PARTS[i].at) - _hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) - - # The crane rides the rail over whichever seam is being worked, and - # retracts once there is nothing left to attach. - var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) - if next_part >= _hull_pieces.size(): - weld.y = RAIL_Y + 16.0 - _crane.position = Vector2(weld.x, RAIL_Y + 13.0) - _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0) - - var 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)) - _sparks.position = weld - _sparks.rotation = phase_time * 7.0 - _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) - _sparks.modulate.a = flicker - - -func _animate_completion_flash(p: float) -> void: - # Whole hull, clamps springing open, and a ring pushing outwards to mark - # it as finished. - _clamps.modulate.a = 1.0 - p - _sparks.visible = false - _place_hull(BAY_CENTRE, 1.0) - for piece in _hull_pieces: - piece.visible = true - piece.modulate.a = 1.0 - # Retracted, so it does not hang over the finished hull. - _crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0) - _hoist.scale.y = 1.0 - _flash_ring.modulate.a = 1.0 - p - _flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS) - - -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 fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) - _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) - for piece in _hull_pieces: - piece.visible = true - piece.modulate.a = 1.0 - # Engine wash, strongest just after it breaks free of the clamps. - _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) + return String(definition.display_name) if definition != null else definition_id.capitalize() diff --git a/Building.tscn b/Building.tscn index 174c302..3929101 100644 --- a/Building.tscn +++ b/Building.tscn @@ -4,3 +4,5 @@ [node name="Building" type="Node2D"] script = ExtResource("1") + +[node name="VisualHost" type="Node2D" parent="."] diff --git a/BuildingActionProvider.gd b/BuildingActionProvider.gd new file mode 100644 index 0000000..1a9dba8 --- /dev/null +++ b/BuildingActionProvider.gd @@ -0,0 +1,3 @@ +extends Node + +@export var actions: PackedStringArray = [] diff --git a/BuildingActionProvider.gd.uid b/BuildingActionProvider.gd.uid new file mode 100644 index 0000000..e5e07f8 --- /dev/null +++ b/BuildingActionProvider.gd.uid @@ -0,0 +1 @@ +uid://c4dah6xluvenl diff --git a/BuildingDefinition.gd b/BuildingDefinition.gd new file mode 100644 index 0000000..fdc990e --- /dev/null +++ b/BuildingDefinition.gd @@ -0,0 +1,20 @@ +class_name BuildingDefinition +extends Resource + +@export var definition_id: String +@export var display_name: String +@export_multiline var description: String +@export var cost: int +@export var action_icon: int +@export var menu_icon: int +@export var visual_scene: PackedScene +@export var capabilities: PackedStringArray = [] +@export var map_color: Color = Color.WHITE +@export var map_sides: int = 4 +@export var map_radius: float = 7.0 +@export var map_phase: float = 0.0 +@export var menu_button_name: String +@export var menu_label_name: String +@export var menu_cost_name: String +@export_enum("space", "planet") var placement_mode: String = "space" +@export var unique: bool = false diff --git a/BuildingDefinition.gd.uid b/BuildingDefinition.gd.uid new file mode 100644 index 0000000..7473bd0 --- /dev/null +++ b/BuildingDefinition.gd.uid @@ -0,0 +1 @@ +uid://d10s0frtxiw62 diff --git a/BuildingDefinitions.gd b/BuildingDefinitions.gd new file mode 100644 index 0000000..f05bf8e --- /dev/null +++ b/BuildingDefinitions.gd @@ -0,0 +1,21 @@ +class_name BuildingDefinitions +extends RefCounted + +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab"] +const DEFINITIONS: Dictionary = { + "mothership": preload("res://MothershipBuildingDefinition.tres"), + "ship_crafter": preload("res://CrafterBuildingDefinition.tres"), + "command_center": preload("res://CommandBuildingDefinition.tres"), + "extractor": preload("res://ExtractorBuildingDefinition.tres"), + "transport_hub": preload("res://TransportHubBuildingDefinition.tres"), + "ship_lab": preload("res://ShipLabBuildingDefinition.tres"), +} + +static func get_definition(definition_id: String) -> Resource: + return DEFINITIONS.get(definition_id) + +static func all() -> Array: + var result: Array = [] + for definition_id in ORDER: + result.append(DEFINITIONS[definition_id]) + return result diff --git a/BuildingDefinitions.gd.uid b/BuildingDefinitions.gd.uid new file mode 100644 index 0000000..0dead32 --- /dev/null +++ b/BuildingDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cpm0u3gu0exke diff --git a/BuildingField.gd b/BuildingField.gd index 496c26a..09b37bd 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -50,7 +50,7 @@ func get_building_fleet_is_on() -> Node2D: func _spawn(record: Dictionary) -> void: var b: Node2D = building_scene.instantiate() b.position = record.position - b.building_type = record.type + b.definition_id = 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 diff --git a/BuildingVisualBase.gd b/BuildingVisualBase.gd new file mode 100644 index 0000000..cd339b3 --- /dev/null +++ b/BuildingVisualBase.gd @@ -0,0 +1,606 @@ +class_name BuildingVisualBase +extends Node2D + +# A player-placed structure in space. Three kinds for now: +# mothership - where a wiped-out fleet is rebuilt +# ship_crafter - fly the fleet onto it to buy additional ships +# command_center - reconfigure the fleet formation +# +# Buildings are stationary and harmless; whether the fleet is "on" one is +# decided by BuildingField comparing the fleet CENTROID against +# interact_radius, the same stable single-number test Planet uses (see +# Planet.gd for why per-ship overlap was a poor fit). +# +# Nothing here rests on ground or vents into an atmosphere - these are +# orbital structures, so they are built out of trusses, radiator fins, +# reactor cores and open cradles rather than walls and roofs. +# +# The crafter and the mothership animate, so they run _process. The command +# centre is drawn once and never redrawn - per-frame redraws of art that +# never changes were a real framerate cost earlier on. + +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) +const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) + +# Ship lab: a test frame with a vessel held in it and instruments sweeping +# over it. Reads as a workshop rather than a factory - the crafter already +# owns "assembly line", so this is measurement, not production. +const LAB_FRAME_W: float = 78.0 +const LAB_FRAME_H: float = 58.0 +const LAB_MOUNT_RADIUS: float = 15.0 +const LAB_SCANNER_ORBIT: float = 96.0 +const LAB_SCAN_SPEED: float = 0.9 + +# 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) + +# One assembly cycle: parts are welded on, the finished hull flashes, then +# it clears the cradle and the line starts over. +const CYCLE: float = 3.6 +const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding +const FLASH_END: float = 0.76 # ... then the completion flash + # ... then launch, to 1.0 + +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. +const SHIP_PARTS: Array = [ + {"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]}, + {"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]}, + {"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]}, + {"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]}, +] + +# The assembly clock, wrapped at CYCLE because the whole build sequence is +# expressed as a fraction of it. +var anim_time: float = 0.0 + +# Free-running seconds for everything that is merely cyclic - reactor pulses, +# orbiting satellites, blinking beacons. These must NOT share anim_time: +# CYCLE is 3.6s, which is not a whole number of periods for any of them, so +# wrapping made every one of them leap at the wrap. The satellites were the +# obvious casualty, snapping back 66 degrees every 3.6 seconds. +# +# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours +# of play the argument to sin() keeps far more precision than a frame needs, +# and any wrap point would reintroduce exactly the discontinuity above. +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 +# Sweeping instrument booms at a ship lab (#5). +var _lab_scanners: Node2D = null +var _flash_ring: NeonPiece = null +var _wash: NeonPiece = null + + +func _process(delta: float) -> void: + anim_time = fmod(anim_time + delta, CYCLE) + phase_time += delta + apply_animation() + + +# Pushes the current clock onto the movable pieces. Normally driven from +# _process; public so a preview or a test can pin the clock and see exactly +# that frame without having to run the animation up to it in real time. +func apply_animation() -> void: + pass + + +# Where the satellites are, and how open the reactor core is. Both are read +# by _draw and by the test that checks they stay continuous across the +# assembly clock's wrap. +func orbit_angle() -> float: + return phase_time * ORBIT_SPEED + + +func core_pulse() -> float: + return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED) + + +# 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 register_draw() -> void: + draw_count += 1 + + +# --- shared helpers ------------------------------------------------------- + +func _ring(centre: 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(centre + Vector2(cos(a), sin(a)) * radius) + return pts + + +func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(steps + 1): + var a: float = lerpf(from_a, to_a, float(i) / float(steps)) + pts.append(centre + Vector2(cos(a), sin(a)) * radius) + return pts + + + + +# --- 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 + + +# --- ship lab ------------------------------------------------------------- + +func _draw_ship_lab_static(target: CanvasItem) -> void: + var c: Color = SHIP_LAB_COLOR + + # Outer test frame, open at the corners so it reads as a rig rather than + # a solid hull. + for sx in [-1.0, 1.0]: + for sy in [-1.0, 1.0]: + var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H) + NeonDraw.draw_glowing_line(target, corner, + corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42) + NeonDraw.draw_glowing_line(target, corner, + corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42) + + # The vessel under test, held nose-up in the middle - deliberately the + # fleet's own silhouette, so it is obvious what the lab is for. + NeonDraw.draw_glowing_polygon(target, PackedVector2Array([ + Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12), + ]), HULL_COLOR, 0.95) + + # Mount ring and its bracing. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5, + NeonDraw.DEFAULT_CORE, 0.7) + for i in range(4): + var a: float = (float(i) / 4.0) * TAU + PI / 4.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3) + + +func _build_lab_pieces() -> void: + # Two instrument booms sweeping round the vessel. One holder, rasterised + # once, rotated per frame (#10). + _lab_scanners = Node2D.new() + add_child(_lab_scanners) + for i in range(2): + var a: float = float(i) * PI + var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT + var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR) + piece.add_polygon(PackedVector2Array([ + Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12), + ])) + piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5) + piece.rotation = a + piece.queue_redraw() + + +func _animate_ship_lab() -> void: + _lab_scanners.rotation = phase_time * LAB_SCAN_SPEED + + +# --- transport hub -------------------------------------------------------- + +func _draw_transport_hub_static(target: CanvasItem) -> void: + var c: Color = TRANSPORT_HUB_COLOR + + # Dock ring and the deck nested inside it. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0) + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7) + + # Freight core: a squared-off hold rather than a reactor, so it reads as + # storage and not as another power plant. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95) + + for i in range(HUB_BERTH_COUNT): + var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32) + # Berth arm standing off the ring, where a transporter parks. + NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS, + dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38) + + +func _build_hub_pieces() -> void: + # Stationed transports, as NeonPiece children of one holder that turns. + # Per #10 the geometry is rasterised once and only the holder's rotation + # changes, so the traffic costs no redraws at all. + _hub_traffic = Node2D.new() + add_child(_hub_traffic) + for i in range(HUB_DOCKED_COUNT): + var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU + var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT + var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR) + # A stubby freighter: blunt, boxy, nothing like the fleet's darts. + piece.add_polygon(PackedVector2Array([ + Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5), + ])) + piece.rotation = a + PI / 2.0 + piece.queue_redraw() + + +func _animate_transport_hub() -> void: + _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED + + +func _diamond(centre: Vector2, r: float) -> PackedVector2Array: + return PackedVector2Array([ + centre + Vector2(0, -r), centre + Vector2(r, 0), + centre + Vector2(0, r), centre + Vector2(-r, 0), + ]) + + +func _draw_command_center() -> void: + var c: Color = COMMAND_COLOR + var readout: Color = Color(0.82, 0.86, 1.0) + + # The rim: a hex table holding the whole layout together, with a fainter + # deck ring nested just inside it - the same "plating has layers" read the + # mothership gets from its nested reactor rings. + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) + + for i in range(CMD_PORT_COUNT): + var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + # Spoke bracing the hub to the rim. + NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36) + # Socket where the mast plugs into the rim. + NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85) + # Port mast standing proud of the rim; every other one is manned. + var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH) + NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4) + if i % 2 == 0: + NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0) + + # Command core: a diamond hub with a lit readout set inside it. + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) + + +# --- mothership: a radial flagship ---------------------------------------- +# Not a big fighter. A mothership is the thing everything else orbits, so it +# is built radially around a reactor core rather than pointed in a direction: +# six swept talons, crystal panels set between them, and satellites circling +# the whole assembly. A forward-facing hull, however large, just reads as +# another ship. + +const ARM_COUNT: int = 6 +const ARM_INNER: float = 42.0 +const ARM_OUTER: float = 106.0 +const ARM_SWEEP: float = 0.72 +const SATELLITE_COUNT: int = 4 +const SATELLITE_ORBIT: float = 126.0 +const ORBIT_SPEED: float = 0.32 +const PULSE_SPEED: float = 2.4 + + +func _talon(base_angle: float) -> PackedVector2Array: + # One swept claw: the outer edge runs from the core to the tip, and the + # inner edge comes back a little behind it, closing to a point so the arm + # tapers instead of ending in a blunt stub. + var steps: int = 7 + var pts: PackedVector2Array = PackedVector2Array() + for i in range(steps + 1): + var f: float = float(i) / float(steps) + var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) + pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) + for i in range(steps, -1, -1): + var f: float = float(i) / float(steps) + var trail: float = 0.34 * (1.0 - f) + 0.04 + var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail + pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) + return pts + + +func _draw_mothership_static(canvas: CanvasItem) -> void: + var c: Color = MOTHERSHIP_COLOR + for i in range(ARM_COUNT): + var base: float = (float(i) / float(ARM_COUNT)) * TAU + NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95) + # Crystal panel set into the gap behind each arm. + var pa: float = base - 0.30 + 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 housing: the nested rings around the cell that breathes. + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) + + +const CORE_CELL_RADIUS: float = 12.0 + + +func _build_mothership_pieces() -> void: + var core_color: Color = Color(0.75, 1.0, 0.85) + + # The breathing cell: drawn at a reference size and then scaled, rather + # than rebuilt at a new radius every frame. + _core_cell = NeonPiece.create(self, Vector2.ZERO, core_color) + _core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0)) + + # Satellites sit at fixed offsets inside a holder; the orbit is the + # holder's rotation, so four hexagon pairs become one transform update. + _satellites = Node2D.new() + add_child(_satellites) + for i in range(SATELLITE_COUNT): + var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU + var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT + var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR) + shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85) + var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color) + cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8) + + +func _animate_mothership() -> void: + 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_static(canvas: CanvasItem) -> void: + var c: Color = CRAFTER_COLOR + + # 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]: + var fin: PackedVector2Array = PackedVector2Array([ + CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), + CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), + ]) + NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75) + for f in [0.35, 0.68]: + 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(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(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(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(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 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: + _animate_assembly(t / ASSEMBLE_END) + elif t < FLASH_END: + _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) + else: + _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) + + +# How many pieces are on the hull at this point through the assembly phase. +# Parts attach in order, so this is just how many thresholds have passed. +func attached_part_count(p: float) -> int: + var n: int = 0 + for part in SHIP_PARTS: + if p >= float(part.at): + n += 1 + return n + + +func _place_hull(centre: Vector2, alpha: float) -> void: + # The parts are defined around their own origin, so the holder goes + # straight to the point they should be centred on. + _hull_holder.position = centre + _hull_holder.modulate.a = alpha + + +func _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) + + var next_part: int = attached_part_count(p) + for i in range(_hull_pieces.size()): + var attached: bool = i < next_part + _hull_pieces[i].visible = attached + if attached: + # Freshly attached parts flare, then settle to full brightness. + var age: float = p - float(SHIP_PARTS[i].at) + _hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) + + # The crane rides the rail over whichever seam is being worked, and + # retracts once there is nothing left to attach. + var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) + if next_part >= _hull_pieces.size(): + weld.y = RAIL_Y + 16.0 + _crane.position = Vector2(weld.x, RAIL_Y + 13.0) + _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0) + + var 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)) + _sparks.position = weld + _sparks.rotation = phase_time * 7.0 + _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) + _sparks.modulate.a = flicker + + +func _animate_completion_flash(p: float) -> void: + # Whole hull, clamps springing open, and a ring pushing outwards to mark + # it as finished. + _clamps.modulate.a = 1.0 - p + _sparks.visible = false + _place_hull(BAY_CENTRE, 1.0) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 + # Retracted, so it does not hang over the finished hull. + _crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0) + _hoist.scale.y = 1.0 + _flash_ring.modulate.a = 1.0 - p + _flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS) + + +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 fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) + _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 + # Engine wash, strongest just after it breaks free of the clamps. + _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) diff --git a/BuildingVisualBase.gd.uid b/BuildingVisualBase.gd.uid new file mode 100644 index 0000000..8827724 --- /dev/null +++ b/BuildingVisualBase.gd.uid @@ -0,0 +1 @@ +uid://p7vuv1xsksc diff --git a/CommandBuildingDefinition.tres b/CommandBuildingDefinition.tres new file mode 100644 index 0000000..727a6f4 --- /dev/null +++ b/CommandBuildingDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://CommandBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "command_center" +display_name = "Command Center" +description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it." +cost = 15 +action_icon = 10 +menu_icon = 10 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("formation") +map_color = Color(0.55, 0.6, 1, 1) +map_sides = 4 +map_radius = 6.0 +map_phase = 0.785398 +menu_button_name = "CommandButton" +menu_label_name = "CommandLabel" +menu_cost_name = "CommandCost" diff --git a/CommandBuildingVisual.gd b/CommandBuildingVisual.gd new file mode 100644 index 0000000..713b407 --- /dev/null +++ b/CommandBuildingVisual.gd @@ -0,0 +1,9 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_command_center() diff --git a/CommandBuildingVisual.gd.uid b/CommandBuildingVisual.gd.uid new file mode 100644 index 0000000..9a270e2 --- /dev/null +++ b/CommandBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://bpb5pq8g56ywn diff --git a/CommandBuildingVisual.tscn b/CommandBuildingVisual.tscn new file mode 100644 index 0000000..048b3f3 --- /dev/null +++ b/CommandBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://CommandBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("formation", "transfer") diff --git a/CrafterBuildingDefinition.tres b/CrafterBuildingDefinition.tres new file mode 100644 index 0000000..2a3f91f --- /dev/null +++ b/CrafterBuildingDefinition.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://CrafterBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "ship_crafter" +display_name = "Ship Crafter" +description = "Fly the fleet onto it, then use the action button to build extra vessels." +cost = 12 +action_icon = 2 +menu_icon = 9 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("production") +map_color = Color(1, 0.72, 0.25, 1) +map_sides = 4 +map_radius = 7.0 +menu_button_name = "CrafterButton" +menu_label_name = "CrafterLabel" +menu_cost_name = "CrafterCost" diff --git a/CrafterBuildingVisual.gd b/CrafterBuildingVisual.gd new file mode 100644 index 0000000..ddd41a8 --- /dev/null +++ b/CrafterBuildingVisual.gd @@ -0,0 +1,14 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + for part in SHIP_PARTS: + _hull_parts.append(PackedVector2Array(part.points)) + _build_crafter_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_crafter() + +func _draw() -> void: + register_draw() + _draw_crafter_static(self) diff --git a/CrafterBuildingVisual.gd.uid b/CrafterBuildingVisual.gd.uid new file mode 100644 index 0000000..05576b0 --- /dev/null +++ b/CrafterBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://dbwebd1vf7biy diff --git a/CrafterBuildingVisual.tscn b/CrafterBuildingVisual.tscn new file mode 100644 index 0000000..c3bb670 --- /dev/null +++ b/CrafterBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://CrafterBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("craft", "transfer") diff --git a/ExtractorBuildingDefinition.tres b/ExtractorBuildingDefinition.tres new file mode 100644 index 0000000..0584758 --- /dev/null +++ b/ExtractorBuildingDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +definition_id = "extractor" +display_name = "Extractor" +description = "Mines resources into local storage for transport vessels to collect. It keeps extracting while you are away." +cost = 25 +action_icon = 11 +menu_icon = 11 +capabilities = PackedStringArray("extraction") +map_color = Color(1, 0.85, 0.3, 1) +menu_button_name = "ExtractorButton" +menu_label_name = "ExtractorLabel" +menu_cost_name = "ExtractorCost" +placement_mode = "planet" diff --git a/GameState.gd b/GameState.gd index 500ec5d..56f773f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -2,6 +2,7 @@ extends Node const INVENTORY = preload("res://Inventory.gd") const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -104,7 +105,7 @@ func building_inventory(id: int) -> RefCounted: var building: Dictionary = find_building(id) if building.is_empty(): return null - if building.type == BUILDING_MOTHERSHIP: + if building_has_capability(building, "bank"): return bank_inventory return _inventory_for_record(building) @@ -313,11 +314,6 @@ func unlock(id: String) -> bool: 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) @@ -392,6 +388,13 @@ func find_building(id: int) -> Dictionary: return {} +func building_has_capability(building: Dictionary, capability: String) -> bool: + if building.is_empty(): + return false + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type)) + return definition != null and capability in definition.capabilities + + # --- vessel types and their upgrades (#5) --------------------------------- # The three things that can be flying around. The first and last double as @@ -494,7 +497,7 @@ signal vessel_upgraded(upgrade_id: String) func has_ship_lab() -> bool: for b in buildings: - if b.type == BUILDING_SHIP_LAB: + if building_has_capability(b, "vessel_research"): return true return false @@ -627,9 +630,8 @@ func product_cost(product: String) -> int: 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 + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + return definition != null and "production" in definition.capabilities func building_stock(id: int) -> int: @@ -777,7 +779,7 @@ func _advance_production(delta: float) -> void: func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: var lab: Dictionary = find_building(lab_id) - if lab.is_empty() or lab.type != BUILDING_SHIP_LAB or upgrade_id == "": + if not building_has_capability(lab, "vessel_research") or upgrade_id == "": return false var orders: Array = lab.get("research_orders", []) if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)): @@ -795,7 +797,7 @@ func lab_research_order(lab_id: int) -> Dictionary: func _advance_lab_research(delta: float) -> void: for lab in buildings: - if lab.type != BUILDING_SHIP_LAB: + if not building_has_capability(lab, "vessel_research"): continue var orders: Array = lab.get("research_orders", []) if orders.is_empty(): @@ -841,6 +843,13 @@ func has_mothership() -> bool: return get_mothership_position() != null +func has_building_of_type(type: String) -> bool: + for building in buildings: + if building.type == type: + return true + return false + + func remove_buildings_of_type(type: String) -> int: var kept: Array = [] var removed: int = 0 @@ -857,7 +866,7 @@ func get_mothership_position() -> Variant: # Where a wiped-out fleet gets rebuilt. Returns null when no mothership # has been built yet, so the caller can fall back to the world origin. for b in buildings: - if b.type == BUILDING_MOTHERSHIP: + if building_has_capability(b, "bank"): return b.position return null @@ -1017,7 +1026,7 @@ func hub_capacity() -> int: func hub_count() -> int: var n: int = 0 for b in buildings: - if b.type == BUILDING_TRANSPORT_HUB: + if building_has_capability(b, "transport_hub"): n += 1 return n @@ -1056,7 +1065,7 @@ 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: + if not building_has_capability(b, "transport_hub"): continue var d: float = at.distance_squared_to(b.position) if d < best_d: diff --git a/MothershipBuildingDefinition.tres b/MothershipBuildingDefinition.tres new file mode 100644 index 0000000..a032777 --- /dev/null +++ b/MothershipBuildingDefinition.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://MothershipBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "mothership" +display_name = "Mothership" +description = "Fleet home port. A wiped-out fleet rebuilds here, and extractor transports deliver resources here." +cost = 20 +action_icon = 8 +menu_icon = 8 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("bank", "research", "hangar") +map_color = Color(0.45, 0.9, 0.55, 1) +map_sides = 6 +map_radius = 8.0 +map_phase = 0.523599 +menu_button_name = "MothershipButton" +menu_label_name = "MothershipLabel" +menu_cost_name = "MothershipCost" +unique = true diff --git a/MothershipBuildingVisual.gd b/MothershipBuildingVisual.gd new file mode 100644 index 0000000..ba2f617 --- /dev/null +++ b/MothershipBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + _build_mothership_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_mothership() + +func _draw() -> void: + register_draw() + _draw_mothership_static(self) diff --git a/MothershipBuildingVisual.gd.uid b/MothershipBuildingVisual.gd.uid new file mode 100644 index 0000000..7ac3669 --- /dev/null +++ b/MothershipBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://m43bwdwhqgy4 diff --git a/MothershipBuildingVisual.tscn b/MothershipBuildingVisual.tscn new file mode 100644 index 0000000..0cc3615 --- /dev/null +++ b/MothershipBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://MothershipBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("research", "transfer", "hangar") diff --git a/ShipLabBuildingDefinition.tres b/ShipLabBuildingDefinition.tres new file mode 100644 index 0000000..0823f52 --- /dev/null +++ b/ShipLabBuildingDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://ShipLabBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "ship_lab" +display_name = "Ship Lab" +description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs." +cost = 35 +action_icon = 13 +menu_icon = 13 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("vessel_research") +map_color = Color(0.55, 0.95, 0.9, 1) +map_sides = 3 +map_radius = 7.5 +map_phase = -1.570796 +menu_button_name = "ShipLabButton" +menu_label_name = "ShipLabLabel" +menu_cost_name = "ShipLabCost" diff --git a/ShipLabBuildingVisual.gd b/ShipLabBuildingVisual.gd new file mode 100644 index 0000000..9655162 --- /dev/null +++ b/ShipLabBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + _build_lab_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_ship_lab() + +func _draw() -> void: + register_draw() + _draw_ship_lab_static(self) diff --git a/ShipLabBuildingVisual.gd.uid b/ShipLabBuildingVisual.gd.uid new file mode 100644 index 0000000..fbb5203 --- /dev/null +++ b/ShipLabBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://b0p7p0fs0th0b diff --git a/ShipLabBuildingVisual.tscn b/ShipLabBuildingVisual.tscn new file mode 100644 index 0000000..e71fdf2 --- /dev/null +++ b/ShipLabBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ShipLabBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("lab", "transfer") diff --git a/TransportHubBuildingDefinition.tres b/TransportHubBuildingDefinition.tres new file mode 100644 index 0000000..59ad15d --- /dev/null +++ b/TransportHubBuildingDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://TransportHubBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "transport_hub" +display_name = "Transport Hub" +description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials." +cost = 30 +action_icon = 12 +menu_icon = 12 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("transport_hub") +map_color = Color(0.95, 0.55, 0.85, 1) +map_sides = 8 +map_radius = 7.5 +map_phase = 0.392699 +menu_button_name = "TransportHubButton" +menu_label_name = "TransportHubLabel" +menu_cost_name = "TransportHubCost" diff --git a/TransportHubBuildingVisual.gd b/TransportHubBuildingVisual.gd new file mode 100644 index 0000000..a8a0d44 --- /dev/null +++ b/TransportHubBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + _build_hub_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_transport_hub() + +func _draw() -> void: + register_draw() + _draw_transport_hub_static(self) diff --git a/TransportHubBuildingVisual.gd.uid b/TransportHubBuildingVisual.gd.uid new file mode 100644 index 0000000..b1ca4cb --- /dev/null +++ b/TransportHubBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://6qyl5qnhw0ho diff --git a/TransportHubBuildingVisual.tscn b/TransportHubBuildingVisual.tscn new file mode 100644 index 0000000..1426761 --- /dev/null +++ b/TransportHubBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://TransportHubBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("transfer") diff --git a/WorldMapContent.gd b/WorldMapContent.gd index 33212a0..5d4667b 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -1,20 +1,10 @@ extends Node2D const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") 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) -const SHIP_LAB_MARKER_COLOR: Color = Color(0.55, 0.95, 0.9) - # 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 @@ -40,28 +30,20 @@ func _process(_delta: float) -> void: func building_markers() -> Array: var markers: Array = [] for b in GameState.buildings: + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(b.type)) + if definition == null: + continue markers.append({ "position": b.position * WORLD_TO_LOCAL, "type": b.type, - "color": _building_marker_color(b.type), + "color": definition.map_color, + "sides": definition.map_sides, + "radius": definition.map_radius, + "phase": definition.map_phase, }) 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 - GameState.BUILDING_SHIP_LAB: - return SHIP_LAB_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(). @@ -119,17 +101,7 @@ func _draw() -> void: # 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) - GameState.BUILDING_SHIP_LAB: - _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 3, -PI / 2.0), marker.color) - _: - _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) + _draw_marker_polygon(_regular_polygon(marker.position, marker.radius, marker.sides, marker.phase), marker.color) var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ diff --git a/tests/_build_popup_preview.gd b/tests/_build_popup_preview.gd index 3bb938e..d3c6b1d 100644 --- a/tests/_build_popup_preview.gd +++ b/tests/_build_popup_preview.gd @@ -28,4 +28,4 @@ func _ready() -> void: # Flip SHOW_POPUP to check the menu underneath # instead of the popup that covers it. if SHOW_POPUP: - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd index dc89fe4..52888aa 100644 --- a/tests/_crafter_preview.gd +++ b/tests/_crafter_preview.gd @@ -31,9 +31,9 @@ func _ready() -> void: b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0) add_child(b) if cell.has("phase"): - b.set_process(false) - b.anim_time = float(cell.phase) * b.CYCLE - b.apply_animation() + b.visual.set_process(false) + b.visual.anim_time = float(cell.phase) * b.visual.CYCLE + b.visual.apply_animation() var cam: Camera2D = Camera2D.new() cam.zoom = Vector2(1.65, 1.65) diff --git a/tests/test_animation_continuity.gd b/tests/test_animation_continuity.gd index d373a25..3919aa6 100644 --- a/tests/test_animation_continuity.gd +++ b/tests/test_animation_continuity.gd @@ -53,7 +53,7 @@ func _process(delta: float) -> bool: var ship: Node2D = null for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: - ship = b + ship = b.visual if ship == null: print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)") quit(1) diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index ef1d708..c67d45c 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -28,9 +28,9 @@ var elapsed: float = 0.0 # Extractor is excluded: it needs a planet underneath, which # test_extractor_building covers in full. const OPTIONS: Array = [ - {"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"}, - {"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"}, - {"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"}, + {"type": "mothership", "title": "Mothership"}, + {"type": "ship_crafter", "title": "Ship Crafter"}, + {"type": "command_center", "title": "Command Center"}, ] @@ -84,7 +84,7 @@ func _process(delta: float) -> bool: var built_before: int = _count(type) controller._on_build_pressed() - controller.call(option.press) + controller._show_building_info(type) var informed: bool = popup.visible \ and title.text == option.title \ @@ -111,7 +111,7 @@ func _process(delta: float) -> bool: # --- reopening and confirming builds it ------------------ controller._on_build_pressed() - controller.call(option.press) + controller._show_building_info(type) controller._confirm_build() var built: bool = _count(type) == built_before + 1 var charged: bool = game_state.rocks == rocks_before - expected_cost diff --git a/tests/test_building_composition.gd b/tests/test_building_composition.gd new file mode 100644 index 0000000..7cadf5d --- /dev/null +++ b/tests/test_building_composition.gd @@ -0,0 +1,35 @@ +extends SceneTree + +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + + +func _initialize() -> void: + var scene: PackedScene = load("res://Building.tscn") + for definition in BUILDING_DEFINITIONS.all(): + if definition.placement_mode != "space": + continue + var building: Node2D = scene.instantiate() + building.definition_id = definition.definition_id + building._ready() + if building.definition == null \ + or building.definition.definition_id != definition.definition_id \ + or building.visual == null: + _fail("%s did not compose its definition and visual (resolved=%s visual=%s)" % [ + definition.definition_id, + "null" if building.definition == null else building.definition.definition_id, + building.visual, + ]) + return + var provider: Node = building.visual.get_node_or_null("ActionProvider") + if provider == null or not provider.actions.has("transfer"): + _fail("%s has no composed action provider" % definition.definition_id) + return + building.free() + + print("TEST_BUILDING_COMPOSITION: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_BUILDING_COMPOSITION: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_building_redraw_cost.gd b/tests/test_building_redraw_cost.gd index c083754..1c81451 100644 --- a/tests/test_building_redraw_cost.gd +++ b/tests/test_building_redraw_cost.gd @@ -61,11 +61,11 @@ func _process(delta: float) -> bool: continue match b.building_type: GameState.BUILDING_SHIP_CRAFTER: - crafter = b + crafter = b.visual GameState.BUILDING_MOTHERSHIP: - mothership = b + mothership = b.visual GameState.BUILDING_COMMAND_CENTER: - command = b + command = b.visual if crafter == null or mothership == null or command == null: print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)") quit(1) @@ -88,7 +88,7 @@ func _process(delta: float) -> bool: 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 + b.name, b.draw_count ]) quit(1) return true diff --git a/tests/test_crafter_animation.gd b/tests/test_crafter_animation.gd index 7ddab14..9b5d57e 100644 --- a/tests/test_crafter_animation.gd +++ b/tests/test_crafter_animation.gd @@ -57,16 +57,19 @@ func _process(delta: float) -> bool: # Spawning is deferred, so give the tree a frame to attach them. if elapsed < 0.2: return false - crafter = _find(GameState.BUILDING_SHIP_CRAFTER) - if crafter == null: + var crafter_building: Node2D = _find(GameState.BUILDING_SHIP_CRAFTER) + if crafter_building == null: print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)") quit(1) return true + crafter = crafter_building.visual var animated: bool = crafter.is_processing() - var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership_building: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership: Node2D = mothership_building.visual if mothership_building != null else null var mothership_animates: bool = mothership != null and mothership.is_processing() - var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command_building: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command: Node2D = command_building.visual if command_building != null else null var command_static: bool = command != null and not command.is_processing() print("crafter animates=%s mothership animates=%s command centre static=%s" % [ animated, mothership_animates, command_static diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 13b2567..0a4b769 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -69,7 +69,7 @@ func _process(delta: float) -> bool: print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)") quit(1) return true - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower() var offered: bool = popup.get_node("Panel/BuildButton").visible print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [ @@ -118,7 +118,7 @@ func _process(delta: float) -> bool: return true var rocks_before: int = game_state.rocks - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var planet_field2: Node = main_scene.get_node("PlanetField") var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0] @@ -148,7 +148,7 @@ func _process(delta: float) -> bool: # A second one on the same planet must be refused, not charged. var rocks_now: int = game_state.rocks controller._on_build_pressed() - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var re_offered: bool = popup.get_node("Panel/BuildButton").visible controller._confirm_build() # must do nothing even if pressed var still_one: bool = game_state.extractors.size() == 1 and not re_offered diff --git a/tests/test_mothership_replacement.gd b/tests/test_mothership_replacement.gd index 6ebe817..576f039 100644 --- a/tests/test_mothership_replacement.gd +++ b/tests/test_mothership_replacement.gd @@ -67,7 +67,7 @@ func _process(delta: float) -> bool: return false # --- first one: informed, then built ------------------------- controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower() var first_warned: bool = "destroy" in first_text print("first: popup=%s warned=%s built_before_confirm=%d" % [ @@ -86,7 +86,7 @@ func _process(delta: float) -> bool: # --- second: warns, builds nothing --------------------------- _place_fleet(second_pos) controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower() print("second press: warned=%s motherships=%d" % [warned, _count_motherships()]) if not warned or _count_motherships() != 1: @@ -107,7 +107,7 @@ func _process(delta: float) -> bool: # --- confirming replaces ------------------------------------- var rocks_before: int = game_state.rocks - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) controller._confirm_build() var only_one: bool = _count_motherships() == 1 var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 @@ -124,7 +124,7 @@ func _process(delta: float) -> bool: game_state.rocks = GameState.MOTHERSHIP_COST - 1 _place_fleet(Vector2(2500.0, -800.0)) controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) # With nothing affordable there is no confirm button to press; # pressing it anyway must still do nothing. var offered: bool = popup.get_node("Panel/BuildButton").visible diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd index 7b2a887..5c9d21c 100644 --- a/tests/test_skill_tree.gd +++ b/tests/test_skill_tree.gd @@ -72,9 +72,9 @@ func _process(delta: float) -> bool: # Since #21 an unresearched structure is not listed at all, rather # than listed and greyed. It is therefore unreachable from the # menu - but the popup must still refuse if something calls - # _on_place_crafter() by another route. + # the generic definition entry point by another route. var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible - controller._on_place_crafter() + controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER) var offered: bool = popup.get_node("Panel/BuildButton").visible print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered]) if not hidden or offered: diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index b2cc4eb..dcb03a6 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -1,5 +1,7 @@ extends SceneTree +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + # 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 @@ -67,7 +69,7 @@ func _process(delta: float) -> bool: 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) + var icon: int = BUILDING_DEFINITIONS.get_definition(type).action_icon if icons.has(icon): print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]]) quit(1) @@ -99,7 +101,7 @@ func _process(delta: float) -> bool: return false for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: - hub = b + hub = b.visual if hub == null: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)") quit(1) @@ -120,9 +122,9 @@ func _process(delta: float) -> bool: 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: + if m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_SHIP_CRAFTER).map_color \ + or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_MOTHERSHIP).map_color \ + or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_COMMAND_CENTER).map_color: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)") quit(1) return true