Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da8f6a81ea | ||
|
|
f19807618a | ||
|
|
d41128bc43 | ||
|
|
823828f3b3 | ||
|
|
ff162faef5 | ||
|
|
3573ccf44f | ||
|
|
d1b82f4f5a | ||
|
|
fc32e26d1b | ||
|
|
2eb779e5de | ||
|
|
8dd5d7b78f | ||
|
|
b641fa5891 |
99 changed files with 5055 additions and 342 deletions
|
|
@ -17,13 +17,21 @@ func _random_particle_size() -> float:
|
|||
return randf_range(3.0, 7.0)
|
||||
|
||||
|
||||
func _draw_particle(p: Dictionary, alpha: float) -> void:
|
||||
# Debris tumbles independently of its flight path, unlike the light-burst
|
||||
# streaks - each chunk needs its own evolving rotation, so the base class
|
||||
# must keep writing visual.rotation every frame instead of leaving it fixed.
|
||||
func _particle_spins() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void:
|
||||
var s: float = p["size"]
|
||||
# Unrotated local points - the child node's own `rotation` (driven by
|
||||
# the base class from p["rot"]) supplies the tumble that used to be
|
||||
# baked into a fresh pt.rotated(p["rot"]) call every frame.
|
||||
var local_pts: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(-s, -s * 0.6), Vector2(s * 0.7, -s * 0.5),
|
||||
Vector2(s, s * 0.5), Vector2(-s * 0.6, s),
|
||||
])
|
||||
var world_pts: PackedVector2Array = PackedVector2Array()
|
||||
for pt in local_pts:
|
||||
world_pts.append(p["pos"] + pt.rotated(p["rot"]))
|
||||
draw_colored_polygon(world_pts, Color(COLOR.r, COLOR.g, COLOR.b, alpha))
|
||||
visual.draw_callable = func(item: CanvasItem) -> void:
|
||||
item.draw_colored_polygon(local_pts, COLOR)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ extends Node2D
|
|||
|
||||
@export var check_interval: float = 1.0
|
||||
@export var spawn_radius_chunks: int = 2
|
||||
# No asteroid may be created within this distance of the fleet. At cruise
|
||||
# speed this is well over a second of warning, so anything that reaches the
|
||||
# fleet was visible on its way in.
|
||||
@export var safe_spawn_radius: float = 420.0
|
||||
@export var despawn_radius_chunks: int = 4
|
||||
@export var asteroids_per_chunk: int = 6
|
||||
@export var min_speed: float = 12.0
|
||||
|
|
@ -63,14 +67,31 @@ func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int:
|
|||
return max(abs(a.x - b.x), abs(a.y - b.y))
|
||||
|
||||
|
||||
func _safe_position(candidate: Vector2, centre: Vector2) -> Vector2:
|
||||
# Nothing may materialise on top of the fleet. Chunks are populated around
|
||||
# wherever the fleet happens to be - including the chunk it is standing in
|
||||
# - so without this an asteroid could appear directly on a ship and
|
||||
# destroy it with no chance to react. That is worst on the frame a scene
|
||||
# loads: arriving back from a planet could cost a ship instantly.
|
||||
if candidate.distance_to(centre) >= safe_spawn_radius:
|
||||
return candidate
|
||||
# Push it out to the edge of the bubble rather than dropping it, so the
|
||||
# field keeps its density near the fleet instead of thinning out.
|
||||
var away: Vector2 = candidate - centre
|
||||
if away.length() < 0.001:
|
||||
away = Vector2.RIGHT.rotated(randf() * TAU)
|
||||
return centre + away.normalized() * safe_spawn_radius
|
||||
|
||||
|
||||
func _spawn_chunk_asteroids(chunk: Vector2i) -> void:
|
||||
var origin: Vector2 = WorldGen.chunk_origin(chunk)
|
||||
var spawned: Array = []
|
||||
var fleet_centre: Vector2 = fleet.get_centroid()
|
||||
|
||||
for i in range(asteroids_per_chunk):
|
||||
var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE)
|
||||
var asteroid: Node2D = asteroid_scene.instantiate()
|
||||
asteroid.position = origin + local_pos
|
||||
asteroid.position = _safe_position(origin + local_pos, fleet_centre)
|
||||
var angle: float = randf() * TAU
|
||||
var speed: float = randf_range(min_speed, max_speed)
|
||||
asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ extends Node2D
|
|||
@onready var world_map: Control = get_node("../UI/WorldMap")
|
||||
@onready var build_button: IconButton = get_node("../UI/BuildButton")
|
||||
@onready var action_button: IconButton = get_node("../UI/BuildActionButton")
|
||||
@onready var action_label: Label = get_node("../UI/BuildActionLabel")
|
||||
|
||||
@onready var build_menu: Control = get_node("../UI/BuildMenu")
|
||||
@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton")
|
||||
|
|
@ -28,6 +27,7 @@ extends Node2D
|
|||
@onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton")
|
||||
@onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel")
|
||||
@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton")
|
||||
@onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton")
|
||||
@onready var planet_field: Node2D = get_node("../PlanetField")
|
||||
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup")
|
||||
@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
|
||||
|
|
@ -38,8 +38,21 @@ extends Node2D
|
|||
|
||||
@onready var craft_menu: Control = get_node("../UI/CraftMenu")
|
||||
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton")
|
||||
@onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton")
|
||||
@onready var craft_store_button: IconButton = get_node("../UI/CraftMenu/StoreOreButton")
|
||||
@onready var craft_store_label: Label = get_node("../UI/CraftMenu/StoreOreLabel")
|
||||
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
|
||||
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
|
||||
@onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton")
|
||||
@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel")
|
||||
|
||||
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
|
||||
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
|
||||
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
|
||||
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber")
|
||||
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
|
||||
@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton")
|
||||
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
|
||||
|
||||
@onready var formation_menu: Control = get_node("../UI/FormationMenu")
|
||||
@onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor")
|
||||
|
|
@ -67,6 +80,10 @@ const BUILDING_INFO: Dictionary = {
|
|||
"title": "Command Center",
|
||||
"body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.",
|
||||
},
|
||||
GameState.BUILDING_TRANSPORT_HUB: {
|
||||
"title": "Transport Hub",
|
||||
"body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.",
|
||||
},
|
||||
GameState.BUILDING_EXTRACTOR: {
|
||||
"title": "Extractor",
|
||||
"body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.",
|
||||
|
|
@ -76,6 +93,14 @@ const BUILDING_INFO: Dictionary = {
|
|||
# Which structure the popup is currently offering to build, or "" when it is
|
||||
# only explaining something that cannot be built right now.
|
||||
var pending_build_type: String = ""
|
||||
# Which crafter the craft menu is showing (see _on_action_pressed).
|
||||
var craft_building_id: int = 0
|
||||
# The craft panel refreshes every frame while it is open (so a delivery
|
||||
# arriving visibly works the queue down). That overwrote any message the
|
||||
# moment after it was set, which made a working button look dead - the store
|
||||
# button in particular. Messages therefore have to outlive a refresh.
|
||||
var craft_message: String = ""
|
||||
var craft_message_until: int = 0
|
||||
|
||||
var current_building: Node2D = null
|
||||
var pending_building: Node2D = null
|
||||
|
|
@ -89,41 +114,66 @@ func _ready() -> void:
|
|||
build_crafter_button.pressed.connect(_on_place_crafter)
|
||||
build_command_button.pressed.connect(_on_place_command_center)
|
||||
build_extractor_button.pressed.connect(_on_place_extractor)
|
||||
build_hub_button.pressed.connect(_on_place_transport_hub)
|
||||
info_close_button.pressed.connect(_close_info)
|
||||
info_build_button.pressed.connect(_confirm_build)
|
||||
|
||||
action_button.pressed.connect(_on_action_pressed)
|
||||
|
||||
craft_interceptor_button.pressed.connect(_on_craft_interceptor)
|
||||
load_ore_button.pressed.connect(_on_load_ore)
|
||||
craft_transporter_button.pressed.connect(_on_craft_transporter)
|
||||
craft_store_button.pressed.connect(_on_store_ore)
|
||||
craft_close_button.pressed.connect(func(): craft_menu.visible = false)
|
||||
|
||||
formation_close_button.pressed.connect(func(): formation_menu.visible = false)
|
||||
formation_reset_button.pressed.connect(_on_reset_formation)
|
||||
formation_editor.formation_changed.connect(_on_formation_changed)
|
||||
|
||||
skill_close_button.pressed.connect(func(): skill_menu.visible = false)
|
||||
skill_unlock_button.pressed.connect(_on_unlock_pressed)
|
||||
skill_tree.node_picked.connect(_on_skill_node_picked)
|
||||
|
||||
build_menu.visible = false
|
||||
info_popup.visible = false
|
||||
craft_menu.visible = false
|
||||
formation_menu.visible = false
|
||||
skill_menu.visible = false
|
||||
action_button.visible = false
|
||||
action_label.visible = false
|
||||
|
||||
|
||||
# True while a building prompt or panel owns the shared action slot, so the
|
||||
# enter-atmosphere prompt knows to stay out of the way.
|
||||
func is_showing_building_prompt() -> bool:
|
||||
return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible
|
||||
return current_building != null or build_menu.visible or craft_menu.visible \
|
||||
or formation_menu.visible or skill_menu.visible
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# The formation editor is driven from here rather than via _gui_input,
|
||||
# for the same reason as the joystick: its Control must not swallow
|
||||
# touches, or dragging a slot would never reach it.
|
||||
if not formation_menu.visible:
|
||||
return
|
||||
if event is InputEventScreenTouch and formation_editor.handle_touch(event):
|
||||
# The formation editor and the skill tree are driven from here rather than
|
||||
# via _gui_input, for the same reason as the joystick: a Control that
|
||||
# consumes touches through the GUI system swallows them before they get
|
||||
# here, which is what made dragging a formation slot silently do nothing.
|
||||
if skill_menu.visible:
|
||||
if event is InputEventScreenTouch:
|
||||
skill_tree.handle_touch(event)
|
||||
# Handled either way. A touch that lands on the panel but misses a
|
||||
# node is still a touch ON THE PANEL - letting it through moved the
|
||||
# fleet and pinch-zoomed the world behind the menu, because
|
||||
# GameController sits on the root node and sees whatever this leaves
|
||||
# unhandled.
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventScreenDrag and formation_editor.handle_drag(event):
|
||||
return
|
||||
if formation_menu.visible:
|
||||
if event is InputEventScreenTouch:
|
||||
formation_editor.handle_touch(event)
|
||||
elif event is InputEventScreenDrag:
|
||||
formation_editor.handle_drag(event)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if build_menu.visible or craft_menu.visible or world_map.visible:
|
||||
# These panels have no raw-input handler of their own, but the world
|
||||
# behind them must still not react.
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
|
|
@ -133,20 +183,24 @@ func _process(delta: float) -> void:
|
|||
if build_menu.visible:
|
||||
# Keep the greyed state live while the menu is open, so flying onto
|
||||
# a planet lights the option up without reopening it.
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null \
|
||||
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
|
||||
|
||||
if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible:
|
||||
if craft_menu.visible:
|
||||
# Live, so a delivery arriving while the menu is open visibly works
|
||||
# the queue down instead of looking stuck.
|
||||
_refresh_craft_status()
|
||||
|
||||
if world_map.visible or build_menu.visible or craft_menu.visible \
|
||||
or formation_menu.visible or skill_menu.visible:
|
||||
current_building = null
|
||||
pending_building = null
|
||||
action_button.visible = false
|
||||
action_label.visible = false
|
||||
return
|
||||
|
||||
# Debounced exactly like the enter-atmosphere prompt, so the action
|
||||
# button can't flicker while the fleet drifts on the interact boundary.
|
||||
var detected: Node2D = building_field.get_building_fleet_is_on()
|
||||
if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
detected = null # nothing to do at a mothership beyond respawning
|
||||
|
||||
if detected != pending_building:
|
||||
pending_building = detected
|
||||
|
|
@ -159,13 +213,23 @@ func _process(delta: float) -> void:
|
|||
|
||||
current_building = pending_building
|
||||
action_button.visible = current_building != null
|
||||
action_label.visible = current_building != null
|
||||
if current_building != null:
|
||||
action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP
|
||||
action_label.text = current_building.get_display_name()
|
||||
action_button.icon = _action_icon_for(current_building.building_type)
|
||||
action_button.queue_redraw()
|
||||
|
||||
|
||||
func _action_icon_for(type: String) -> int:
|
||||
match type:
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
return IconButton.Icon.SHIP
|
||||
GameState.BUILDING_TRANSPORT_HUB:
|
||||
return IconButton.Icon.TRANSPORT_HUB
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
return IconButton.Icon.MOTHERSHIP
|
||||
_:
|
||||
return IconButton.Icon.COMMAND_CENTER
|
||||
|
||||
|
||||
func _check_fleet_wipe() -> void:
|
||||
if not fleet.ships.is_empty():
|
||||
return
|
||||
|
|
@ -178,7 +242,11 @@ func _check_fleet_wipe() -> void:
|
|||
func _on_action_pressed() -> void:
|
||||
if current_building == null:
|
||||
return
|
||||
if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
skill_menu.visible = true
|
||||
_refresh_load_ore()
|
||||
_refresh_skill_menu()
|
||||
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
craft_menu.visible = true
|
||||
_refresh_craft_status()
|
||||
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
|
||||
|
|
@ -199,10 +267,15 @@ func _planet_under_fleet() -> Node2D:
|
|||
|
||||
|
||||
func _refresh_build_status() -> void:
|
||||
# The extractor option is always listed, but greyed unless the fleet is
|
||||
# actually over a planet - showing it only sometimes would leave the
|
||||
# player wondering whether the game has it at all.
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null
|
||||
# Every option is always listed, greyed when it cannot be used - showing
|
||||
# them only sometimes would leave the player wondering whether the game
|
||||
# has them at all. Greyed buttons still emit `pressed`, so each one can
|
||||
# explain itself in the info popup.
|
||||
build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
|
||||
build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER)
|
||||
build_hub_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB)
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null \
|
||||
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
|
||||
|
||||
# Prices are shown under each option now, so this only needs to say
|
||||
# whether the fleet is somewhere an extractor could go.
|
||||
|
|
@ -224,6 +297,10 @@ func _on_place_command_center() -> void:
|
|||
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
|
||||
|
||||
|
||||
func _on_place_transport_hub() -> void:
|
||||
_show_building_info(GameState.BUILDING_TRANSPORT_HUB)
|
||||
|
||||
|
||||
func _on_place_extractor() -> void:
|
||||
# The option stays pressable while greyed, so this is also how a
|
||||
# not-over-a-planet extractor explains itself.
|
||||
|
|
@ -232,6 +309,8 @@ func _on_place_extractor() -> void:
|
|||
|
||||
func cost_of(type: String) -> int:
|
||||
match type:
|
||||
GameState.BUILDING_TRANSPORT_HUB:
|
||||
return GameState.TRANSPORT_HUB_COST
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
return GameState.MOTHERSHIP_COST
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
|
|
@ -248,9 +327,15 @@ func _show_building_info(type: String) -> void:
|
|||
var lines: Array = [info.body]
|
||||
var can_build: bool = true
|
||||
|
||||
# Locked structures still list themselves, greyed, so the player can see
|
||||
# what exists and what research would open it up.
|
||||
if not GameState.is_building_unlocked(type):
|
||||
lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type))
|
||||
can_build = false
|
||||
|
||||
# Anything that would stop this particular placement is spelled out in
|
||||
# the same popup, rather than silently failing after the press.
|
||||
if type == GameState.BUILDING_EXTRACTOR:
|
||||
if can_build and type == GameState.BUILDING_EXTRACTOR:
|
||||
var planet: Node2D = _planet_under_fleet()
|
||||
if planet == null:
|
||||
lines.append("No planet in reach - fly over one.")
|
||||
|
|
@ -260,7 +345,7 @@ func _show_building_info(type: String) -> void:
|
|||
can_build = false
|
||||
else:
|
||||
lines.append("Will mine %s." % planet.planet_name)
|
||||
elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
|
||||
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
|
||||
lines.append("WARNING: only one may exist - this destroys your current one.")
|
||||
|
||||
if can_build and not GameState.can_afford(cost):
|
||||
|
|
@ -290,6 +375,9 @@ func _confirm_build() -> void:
|
|||
# Everything is re-checked here rather than trusted from when the popup
|
||||
# opened: the fleet keeps flying while it is up, so it can drift off the
|
||||
# planet an extractor was going to mine.
|
||||
if not GameState.is_building_unlocked(type):
|
||||
return
|
||||
|
||||
if type == GameState.BUILDING_EXTRACTOR:
|
||||
var planet: Node2D = _planet_under_fleet()
|
||||
if planet == null or GameState.has_extractor_at(planet.planet_name):
|
||||
|
|
@ -324,20 +412,162 @@ func _try_place(type: String, cost: int) -> void:
|
|||
build_menu.visible = false
|
||||
|
||||
|
||||
# --- research (the mothership's skill tree) --------------------------------
|
||||
|
||||
func _on_skill_node_picked(id: String) -> void:
|
||||
_refresh_skill_menu(id)
|
||||
|
||||
|
||||
func _refresh_skill_menu(id: String = "") -> void:
|
||||
if id == "":
|
||||
id = skill_tree.selected_id
|
||||
if id == "":
|
||||
# Nothing chosen yet: point the player at the tree rather than
|
||||
# leaving the panel blank, which reads as broken.
|
||||
skill_detail.text = "Select a node to see what it unlocks."
|
||||
skill_cost.amount = 0
|
||||
skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size()
|
||||
skill_unlock_button.visible = false
|
||||
return
|
||||
|
||||
var node: Dictionary = GameState.skill_node(id)
|
||||
if node.is_empty():
|
||||
return
|
||||
skill_detail.text = "%s\n%s" % [node.name, node.desc]
|
||||
skill_cost.amount = int(node.cost)
|
||||
|
||||
if GameState.is_unlocked(id):
|
||||
skill_status.text = "Researched"
|
||||
skill_unlock_button.visible = false
|
||||
elif not GameState.prerequisite_met(id):
|
||||
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
|
||||
skill_unlock_button.visible = false
|
||||
elif not GameState.can_afford(int(node.cost)):
|
||||
skill_status.text = "Not enough rocks - you have %d" % GameState.rocks
|
||||
skill_unlock_button.visible = false
|
||||
else:
|
||||
skill_status.text = ""
|
||||
skill_unlock_button.visible = true
|
||||
|
||||
|
||||
func _on_unlock_pressed() -> void:
|
||||
var id: String = skill_tree.selected_id
|
||||
if id == "":
|
||||
return
|
||||
if not GameState.unlock(id):
|
||||
# Re-check rather than trust the button: rocks can be spent elsewhere
|
||||
# while this menu is open, since haulers keep delivering.
|
||||
_refresh_skill_menu(id)
|
||||
return
|
||||
# A capacity upgrade has to reach the fleet that is already flying, not
|
||||
# just ones rebuilt later.
|
||||
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:
|
||||
fleet.apply_formation()
|
||||
skill_tree.queue_redraw()
|
||||
_refresh_skill_menu(id)
|
||||
|
||||
|
||||
func _say(message: String) -> void:
|
||||
craft_message = message
|
||||
craft_message_until = Time.get_ticks_msec() + 3000
|
||||
_refresh_craft_status()
|
||||
|
||||
|
||||
func _refresh_craft_status() -> void:
|
||||
craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [
|
||||
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size()
|
||||
# "Here" is the ore delivered to THIS crafter, not the bank: since #16 a
|
||||
# building builds out of its own stock, and showing the bank would say
|
||||
# you can afford something the crafter cannot actually make.
|
||||
var queued: int = GameState.pending_orders(craft_building_id)
|
||||
var stock: int = GameState.building_stock(craft_building_id)
|
||||
var queue_text: String = "" if queued == 0 else " Queued %d" % queued
|
||||
craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [
|
||||
GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text,
|
||||
GameState.transporters_claimed(), GameState.transport_capacity()
|
||||
]
|
||||
# Nowhere to station one means the order would be refused, so say so
|
||||
# rather than letting the button look available and do nothing.
|
||||
craft_transporter_button.enabled = GameState.has_transport_hub() \
|
||||
and GameState.can_station_transporter()
|
||||
craft_transporter_button.queue_redraw()
|
||||
|
||||
craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo
|
||||
# Deliberately left ENABLED with an empty hold: a greyed-out button that
|
||||
# cannot say why is exactly the dead-button problem this panel already
|
||||
# had. Pressing it explains instead.
|
||||
craft_store_button.enabled = true
|
||||
craft_store_button.queue_redraw()
|
||||
|
||||
# A full fleet cannot take another vessel, so the order would be refused.
|
||||
# Showing that on the button is the difference between "not available yet"
|
||||
# and a button that looks live and silently does nothing when pressed.
|
||||
var room: bool = fleet.ships.size() + GameState.ships_owed \
|
||||
+ GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \
|
||||
< GameState.max_fleet_size()
|
||||
craft_interceptor_button.enabled = room
|
||||
craft_interceptor_button.queue_redraw()
|
||||
# One statement of the fleet count, either way round: the capacity line
|
||||
# replaces the plain readout rather than being bolted in front of it.
|
||||
if craft_message != "":
|
||||
if Time.get_ticks_msec() < craft_message_until:
|
||||
craft_status.text = "%s\n%s" % [craft_message, craft_status.text]
|
||||
else:
|
||||
craft_message = ""
|
||||
if room:
|
||||
craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()]
|
||||
else:
|
||||
craft_status.text = "Fleet full %d/%d - research capacity at the mothership\n%s" % [
|
||||
fleet.ships.size(), GameState.max_fleet_size(), craft_status.text
|
||||
]
|
||||
|
||||
|
||||
func _on_craft_interceptor() -> void:
|
||||
if fleet.ships.size() >= GameState.formation.size():
|
||||
craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size()
|
||||
if craft_building_id == 0:
|
||||
return
|
||||
if not GameState.spend(GameState.SHIP_COST):
|
||||
craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks]
|
||||
# Still refused when there is nowhere to put the vessel - queueing an
|
||||
# order that can never be delivered would just bank ships_owed forever.
|
||||
if fleet.ships.size() + GameState.ships_owed \
|
||||
+ GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \
|
||||
>= GameState.max_fleet_size():
|
||||
_say("Fleet is full (%d slots)" % GameState.max_fleet_size())
|
||||
return
|
||||
# No affordability check: an order the crafter cannot pay for yet is
|
||||
# QUEUED, and completes by itself once ore is delivered here (#16).
|
||||
GameState.queue_order(craft_building_id)
|
||||
_refresh_craft_status()
|
||||
|
||||
|
||||
func _refresh_load_ore() -> void:
|
||||
load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo]
|
||||
load_ore_button.enabled = GameState.rocks > 0
|
||||
load_ore_button.queue_redraw()
|
||||
|
||||
|
||||
func _on_load_ore() -> void:
|
||||
GameState.withdraw_to_fleet()
|
||||
_refresh_load_ore()
|
||||
|
||||
|
||||
func _on_store_ore() -> void:
|
||||
if craft_building_id == 0:
|
||||
return
|
||||
if GameState.fleet_cargo <= 0:
|
||||
_say("The fleet is carrying nothing - load ore at the mothership first")
|
||||
return
|
||||
var moved: int = GameState.unload_into_building(craft_building_id)
|
||||
_say("Stored %d ore here" % moved)
|
||||
|
||||
|
||||
func _on_craft_transporter() -> void:
|
||||
if craft_building_id == 0:
|
||||
return
|
||||
if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER):
|
||||
# Two different refusals, and the player needs to know WHICH.
|
||||
if not GameState.has_transport_hub():
|
||||
_say("Build a Transport Hub first - a transporter needs somewhere to be stationed")
|
||||
else:
|
||||
_say("All %d berths are taken - build another hub, or research Freight Yards" %
|
||||
GameState.transport_capacity())
|
||||
return
|
||||
fleet.add_ship()
|
||||
_refresh_craft_status()
|
||||
|
||||
|
||||
|
|
|
|||
448
Building.gd
448
Building.gd
|
|
@ -19,11 +19,28 @@ extends Node2D
|
|||
# never changes were a real framerate cost earlier on.
|
||||
|
||||
@export var building_type: String = "mothership"
|
||||
# Which GameState.buildings record this node is showing. Set by
|
||||
# BuildingField when it spawns, so a node can be traced back to the stock
|
||||
# and production queue that survive the scene swap (#16).
|
||||
@export var building_id: int = 0
|
||||
@export var interact_radius: float = 150.0
|
||||
|
||||
const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55)
|
||||
const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25)
|
||||
const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0)
|
||||
const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85)
|
||||
|
||||
# Transport hub: an octagonal dock ring with berths around it and a freight
|
||||
# core in the middle. Deliberately radial-but-flat, so it does not read as a
|
||||
# small mothership - the mothership is the only thing with swept arms.
|
||||
const HUB_RING_RADIUS: float = 74.0
|
||||
const HUB_DECK_RADIUS: float = 54.0
|
||||
const HUB_CORE_RADIUS: float = 22.0
|
||||
const HUB_BERTH_COUNT: int = 8
|
||||
const HUB_BERTH_LENGTH: float = 20.0
|
||||
const HUB_DOCKED_COUNT: int = 3
|
||||
const HUB_DOCKED_ORBIT: float = 96.0
|
||||
const HUB_SPIN_SPEED: float = 0.22
|
||||
# The vessel on the assembly line is drawn in the fleet's own colour, so it
|
||||
# reads as "one of yours being built" rather than as shipyard machinery.
|
||||
const HULL_COLOR: Color = Color(0.25, 0.85, 1.0)
|
||||
|
|
@ -39,6 +56,7 @@ const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0)
|
|||
const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0)
|
||||
const RIB_RADIUS: float = 36.0
|
||||
const RAIL_Y: float = -32.0
|
||||
const FLASH_RING_RADIUS: float = 20.0
|
||||
|
||||
# The hull in four pieces, attached in this order. Each carries the point
|
||||
# the crane welds it on at, which is where the sparks fly from.
|
||||
|
|
@ -66,26 +84,62 @@ var phase_time: float = 0.0
|
|||
|
||||
var _hull_parts: Array[PackedVector2Array] = []
|
||||
|
||||
# Every moving element is its own NeonPiece, rasterised once when the
|
||||
# building is created and thereafter only moved, scaled, faded or hidden.
|
||||
# Nothing here ever calls queue_redraw() again: the building's own _draw()
|
||||
# lays down the fixed structure a single time, and the animation is pure
|
||||
# node-property work from then on.
|
||||
var _core_cell: NeonPiece = null
|
||||
var _satellites: Node2D = null
|
||||
var _beacons: Array = []
|
||||
var _clamps: NeonPiece = null
|
||||
var _crane: Node2D = null
|
||||
var _hoist: NeonPiece = null
|
||||
var _sparks: Node2D = null
|
||||
var _hull_holder: Node2D = null
|
||||
var _hull_pieces: Array = []
|
||||
# Rotating holder for the transports stationed at a transport hub (#18).
|
||||
var _hub_traffic: Node2D = null
|
||||
var _flash_ring: NeonPiece = null
|
||||
var _wash: NeonPiece = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("buildings")
|
||||
z_index = -1
|
||||
# The crafter runs an assembly line and the mothership holds a live
|
||||
# reactor with satellites in orbit, so both redraw. The command centre is
|
||||
# static art and is drawn exactly once - per-frame redraws of art that
|
||||
# never changes were a real framerate cost earlier on.
|
||||
# reactor with satellites in orbit, so both animate. The command centre is
|
||||
# entirely static and needs no per-frame work at all.
|
||||
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER
|
||||
set_process(animated)
|
||||
if building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
for part in SHIP_PARTS:
|
||||
_hull_parts.append(PackedVector2Array(part.points))
|
||||
_build_crafter_pieces()
|
||||
elif building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
_build_mothership_pieces()
|
||||
elif building_type == GameState.BUILDING_TRANSPORT_HUB:
|
||||
_build_hub_pieces()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
anim_time = fmod(anim_time + delta, CYCLE)
|
||||
phase_time += delta
|
||||
queue_redraw()
|
||||
apply_animation()
|
||||
|
||||
|
||||
# Pushes the current clock onto the movable pieces. Normally driven from
|
||||
# _process; public so a preview or a test can pin the clock and see exactly
|
||||
# that frame without having to run the animation up to it in real time.
|
||||
func apply_animation() -> void:
|
||||
match building_type:
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
_animate_mothership()
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
_animate_crafter()
|
||||
GameState.BUILDING_TRANSPORT_HUB:
|
||||
_animate_transport_hub()
|
||||
|
||||
|
||||
# Where the satellites are, and how open the reactor core is. Both are read
|
||||
|
|
@ -109,14 +163,27 @@ func get_display_name() -> String:
|
|||
return "Command Center"
|
||||
|
||||
|
||||
# How many times this building's geometry has actually been rasterised. The
|
||||
# whole point of the NeonPiece decomposition is that this stays at 1 for the
|
||||
# building's lifetime however long it animates, so it is worth being able to
|
||||
# assert rather than assume.
|
||||
var draw_count: int = 0
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_count += 1
|
||||
# Runs exactly once per building: this is the fixed structure. Everything
|
||||
# that moves is a NeonPiece child, animated through node properties, so
|
||||
# nothing here ever asks for a redraw again.
|
||||
match building_type:
|
||||
GameState.BUILDING_COMMAND_CENTER:
|
||||
_draw_command_center()
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
_draw_mothership()
|
||||
_draw_mothership_static(self)
|
||||
GameState.BUILDING_TRANSPORT_HUB:
|
||||
_draw_transport_hub_static(self)
|
||||
_:
|
||||
_draw_crafter()
|
||||
_draw_crafter_static(self)
|
||||
|
||||
|
||||
# --- shared helpers -------------------------------------------------------
|
||||
|
|
@ -139,17 +206,101 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int
|
|||
|
||||
|
||||
|
||||
func _draw_command_center() -> void:
|
||||
# Diamond with three formation pips inside - reads as "arrangement".
|
||||
var body: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0),
|
||||
# --- command centre: a tactical plotting hub ------------------------------
|
||||
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
|
||||
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
|
||||
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
|
||||
# readout, and every spoke ends in a formation port - half of them holding a
|
||||
# token, half left open - so the structure itself shows fleet slots waiting
|
||||
# to be filled, which is exactly what the drag-and-drop editor inside does.
|
||||
|
||||
const CMD_HEX_RADIUS: float = 88.0
|
||||
const CMD_DECK_RADIUS: float = 66.0
|
||||
const CMD_HUB_RADIUS: float = 30.0
|
||||
const CMD_SCREEN_RADIUS: float = 14.0
|
||||
const CMD_PORT_COUNT: int = 6
|
||||
const CMD_PORT_LENGTH: float = 16.0
|
||||
const CMD_TOKEN_RADIUS: float = 9.0
|
||||
const CMD_STUD_RADIUS: float = 7.0
|
||||
|
||||
|
||||
# --- transport hub --------------------------------------------------------
|
||||
|
||||
func _draw_transport_hub_static(target: CanvasItem) -> void:
|
||||
var c: Color = TRANSPORT_HUB_COLOR
|
||||
|
||||
# Dock ring and the deck nested inside it.
|
||||
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7)
|
||||
|
||||
# Freight core: a squared-off hold rather than a reactor, so it reads as
|
||||
# storage and not as another power plant.
|
||||
NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95)
|
||||
|
||||
for i in range(HUB_BERTH_COUNT):
|
||||
var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32)
|
||||
# Berth arm standing off the ring, where a transporter parks.
|
||||
NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS,
|
||||
dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38)
|
||||
|
||||
|
||||
func _build_hub_pieces() -> void:
|
||||
# Stationed transports, as NeonPiece children of one holder that turns.
|
||||
# Per #10 the geometry is rasterised once and only the holder's rotation
|
||||
# changes, so the traffic costs no redraws at all.
|
||||
_hub_traffic = Node2D.new()
|
||||
add_child(_hub_traffic)
|
||||
for i in range(HUB_DOCKED_COUNT):
|
||||
var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU
|
||||
var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT
|
||||
var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR)
|
||||
# A stubby freighter: blunt, boxy, nothing like the fleet's darts.
|
||||
piece.add_polygon(PackedVector2Array([
|
||||
Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5),
|
||||
]))
|
||||
piece.rotation = a + PI / 2.0
|
||||
piece.queue_redraw()
|
||||
|
||||
|
||||
func _animate_transport_hub() -> void:
|
||||
_hub_traffic.rotation = phase_time * HUB_SPIN_SPEED
|
||||
|
||||
|
||||
func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
|
||||
return PackedVector2Array([
|
||||
centre + Vector2(0, -r), centre + Vector2(r, 0),
|
||||
centre + Vector2(0, r), centre + Vector2(-r, 0),
|
||||
])
|
||||
NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0)
|
||||
for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]:
|
||||
var pip: PackedVector2Array = PackedVector2Array([
|
||||
p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8),
|
||||
])
|
||||
NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0)
|
||||
|
||||
|
||||
func _draw_command_center() -> void:
|
||||
var c: Color = COMMAND_COLOR
|
||||
var readout: Color = Color(0.82, 0.86, 1.0)
|
||||
|
||||
# The rim: a hex table holding the whole layout together, with a fainter
|
||||
# deck ring nested just inside it - the same "plating has layers" read the
|
||||
# mothership gets from its nested reactor rings.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7)
|
||||
|
||||
for i in range(CMD_PORT_COUNT):
|
||||
var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
# Spoke bracing the hub to the rim.
|
||||
NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36)
|
||||
# Socket where the mast plugs into the rim.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85)
|
||||
# Port mast standing proud of the rim; every other one is manned.
|
||||
var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH)
|
||||
NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4)
|
||||
if i % 2 == 0:
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0)
|
||||
|
||||
# Command core: a diamond hub with a lit readout set inside it.
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
|
||||
|
||||
|
||||
# --- mothership: a radial flagship ----------------------------------------
|
||||
|
|
@ -187,49 +338,65 @@ func _talon(base_angle: float) -> PackedVector2Array:
|
|||
return pts
|
||||
|
||||
|
||||
func _draw_mothership() -> void:
|
||||
func _draw_mothership_static(canvas: CanvasItem) -> void:
|
||||
var c: Color = MOTHERSHIP_COLOR
|
||||
var core_color: Color = Color(0.75, 1.0, 0.85)
|
||||
|
||||
for i in range(ARM_COUNT):
|
||||
var base: float = (float(i) / float(ARM_COUNT)) * TAU
|
||||
NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95)
|
||||
NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95)
|
||||
# Crystal panel set into the gap behind each arm.
|
||||
var pa: float = base - 0.30
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([
|
||||
Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0,
|
||||
Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0,
|
||||
Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0,
|
||||
Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0,
|
||||
]), c, 0.7)
|
||||
|
||||
# Reactor core: nested rings around a cell that breathes.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
|
||||
var breath: float = core_pulse()
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0),
|
||||
core_color, lerpf(0.55, 1.0, breath))
|
||||
# Reactor housing: the nested rings around the cell that breathes.
|
||||
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
|
||||
|
||||
# Satellites holding station around it, drifting slowly round.
|
||||
var orbit: float = orbit_angle()
|
||||
|
||||
const CORE_CELL_RADIUS: float = 12.0
|
||||
|
||||
|
||||
func _build_mothership_pieces() -> void:
|
||||
var core_color: Color = Color(0.75, 1.0, 0.85)
|
||||
|
||||
# The breathing cell: drawn at a reference size and then scaled, rather
|
||||
# than rebuilt at a new radius every frame.
|
||||
_core_cell = NeonPiece.create(self, Vector2.ZERO, core_color)
|
||||
_core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0))
|
||||
|
||||
# Satellites sit at fixed offsets inside a holder; the orbit is the
|
||||
# holder's rotation, so four hexagon pairs become one transform update.
|
||||
_satellites = Node2D.new()
|
||||
add_child(_satellites)
|
||||
for i in range(SATELLITE_COUNT):
|
||||
var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU
|
||||
var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU
|
||||
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8)
|
||||
var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR)
|
||||
shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85)
|
||||
var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color)
|
||||
cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8)
|
||||
|
||||
|
||||
func _animate_mothership() -> void:
|
||||
var breath: float = core_pulse()
|
||||
var radius: float = lerpf(9.0, 15.0, breath)
|
||||
_core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS)
|
||||
_core_cell.modulate.a = lerpf(0.55, 1.0, breath)
|
||||
_satellites.rotation = orbit_angle()
|
||||
|
||||
|
||||
# --- ship crafter: an orbital yard with a hull in the cradle --------------
|
||||
|
||||
func _draw_crafter() -> void:
|
||||
func _draw_crafter_static(canvas: CanvasItem) -> void:
|
||||
var c: Color = CRAFTER_COLOR
|
||||
var t: float = anim_time / CYCLE
|
||||
|
||||
# Fabricator core: a reactor housing whose inner cell pulses as it feeds
|
||||
# the yard. This replaces the chimney - nothing vents smoke in vacuum.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
|
||||
var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow)
|
||||
# Fabricator core housing. This replaces the chimney - nothing vents
|
||||
# smoke in vacuum.
|
||||
NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
|
||||
|
||||
# Radiator fins above and below the core, slatted like real ones.
|
||||
for dir_y in [-1.0, 1.0]:
|
||||
|
|
@ -237,44 +404,114 @@ func _draw_crafter() -> void:
|
|||
CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y),
|
||||
CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y),
|
||||
])
|
||||
NeonDraw.draw_glowing_polygon(self, fin, c, 0.75)
|
||||
NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75)
|
||||
for f in [0.35, 0.68]:
|
||||
NeonDraw.draw_glowing_line(self,
|
||||
NeonDraw.draw_glowing_line(canvas,
|
||||
fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28)
|
||||
|
||||
# Truss spine linking the core to the cradle: two rails with X-bracing,
|
||||
# the standard "this was assembled in orbit" read.
|
||||
var spine_top: float = 4.0
|
||||
var spine_bottom: float = 22.0
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
|
||||
for i in range(3):
|
||||
var x0: float = -32.0 + float(i) * 15.0
|
||||
var x1: float = x0 + 15.0
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
|
||||
|
||||
# Construction cradle: two open ribs holding the hull, with the crane
|
||||
# rail overhead. Open at the top so the finished hull can clear it.
|
||||
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
|
||||
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
|
||||
for side in [-1.0, 1.0]:
|
||||
NeonDraw.draw_glowing_line(self,
|
||||
NeonDraw.draw_glowing_line(canvas,
|
||||
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
|
||||
|
||||
|
||||
|
||||
func _build_crafter_pieces() -> void:
|
||||
var c: Color = CRAFTER_COLOR
|
||||
|
||||
# The core's inner cell: fades with the reactor rather than being redrawn
|
||||
# at a new brightness each frame.
|
||||
_core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6))
|
||||
_core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0))
|
||||
|
||||
# Docking beacons, blinking out of step with each other.
|
||||
for i in range(2):
|
||||
var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0)
|
||||
var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink)
|
||||
var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75))
|
||||
beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4))
|
||||
_beacons.append(beacon)
|
||||
|
||||
# Clamp arms reaching in from each rib. They let go as the hull launches,
|
||||
# which is a fade rather than a redraw.
|
||||
_clamps = NeonPiece.create(self, Vector2.ZERO, c)
|
||||
for side in [-1.0, 1.0]:
|
||||
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
|
||||
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
|
||||
_clamps.add_line(outer, inner, 0.35)
|
||||
_clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3)
|
||||
|
||||
# Crane: the head rides the rail, so only its x moves. The hoist is drawn
|
||||
# one unit long and stretched with scale.y to reach whatever seam is
|
||||
# being welded - stretching a line needs no redraw, redrawing it would.
|
||||
_crane = Node2D.new()
|
||||
add_child(_crane)
|
||||
var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c)
|
||||
head.add_polygon(PackedVector2Array([
|
||||
Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6),
|
||||
]))
|
||||
head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8)
|
||||
_hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5)
|
||||
_hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3)
|
||||
|
||||
# Weld sparks: four spokes on a holder that spins and stretches. The
|
||||
# jitter is genuinely procedural, but rotating and scaling a holder gives
|
||||
# the same flicker without re-tessellating the spokes every frame.
|
||||
_sparks = Node2D.new()
|
||||
add_child(_sparks)
|
||||
for i in range(4):
|
||||
var a: float = float(i) * TAU / 4.0
|
||||
var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7))
|
||||
spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3)
|
||||
|
||||
# The hull under construction. Each part is its own piece so attaching one
|
||||
# is a `visible` flip, and the launch is the holder's position and alpha.
|
||||
_hull_holder = Node2D.new()
|
||||
add_child(_hull_holder)
|
||||
for i in range(_hull_parts.size()):
|
||||
var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR)
|
||||
part.add_polygon(_hull_parts[i])
|
||||
part.visible = false
|
||||
_hull_pieces.append(part)
|
||||
|
||||
# Engine wash under the hull, and the completion ring. The ring is drawn
|
||||
# at a reference radius and scaled outwards.
|
||||
_wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0)
|
||||
for dx in [-4.0, 4.0]:
|
||||
_wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45)
|
||||
|
||||
_flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0)
|
||||
_flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0))
|
||||
|
||||
|
||||
func _animate_crafter() -> void:
|
||||
var t: float = anim_time / CYCLE
|
||||
|
||||
_core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
|
||||
for i in range(_beacons.size()):
|
||||
_beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
|
||||
|
||||
if t < ASSEMBLE_END:
|
||||
_draw_assembly(t / ASSEMBLE_END, c)
|
||||
_animate_assembly(t / ASSEMBLE_END)
|
||||
elif t < FLASH_END:
|
||||
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
|
||||
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
|
||||
else:
|
||||
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
|
||||
_animate_launch((t - FLASH_END) / (1.0 - FLASH_END))
|
||||
|
||||
|
||||
# How many pieces are on the hull at this point through the assembly phase.
|
||||
|
|
@ -287,78 +524,73 @@ func attached_part_count(p: float) -> int:
|
|||
return n
|
||||
|
||||
|
||||
func _draw_clamps(c: Color, pulse: float) -> void:
|
||||
# Arms reaching in from each rib to hold the hull steady. They let go
|
||||
# once it launches, so this is not drawn during the launch phase.
|
||||
for side in [-1.0, 1.0]:
|
||||
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
|
||||
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
|
||||
NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35)
|
||||
NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3)
|
||||
func _place_hull(centre: Vector2, alpha: float) -> void:
|
||||
# The parts are defined around their own origin, so the holder goes
|
||||
# straight to the point they should be centred on.
|
||||
_hull_holder.position = centre
|
||||
_hull_holder.modulate.a = alpha
|
||||
|
||||
|
||||
func _draw_assembly(p: float, c: Color) -> void:
|
||||
_draw_clamps(c, 0.8)
|
||||
func _animate_assembly(p: float) -> void:
|
||||
_clamps.visible = true
|
||||
_clamps.modulate.a = 0.8
|
||||
_flash_ring.modulate.a = 0.0
|
||||
_wash.modulate.a = 0.0
|
||||
_place_hull(BAY_CENTRE, 1.0)
|
||||
|
||||
# Everything welded on so far, newest part still glowing from the seam.
|
||||
var next_part: int = attached_part_count(p)
|
||||
for i in range(next_part):
|
||||
# Freshly attached parts flare, then settle to full brightness.
|
||||
var age: float = p - float(SHIP_PARTS[i].at)
|
||||
var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
|
||||
_draw_hull_part(i, BAY_CENTRE, pulse)
|
||||
for i in range(_hull_pieces.size()):
|
||||
var attached: bool = i < next_part
|
||||
_hull_pieces[i].visible = attached
|
||||
if attached:
|
||||
# Freshly attached parts flare, then settle to full brightness.
|
||||
var age: float = p - float(SHIP_PARTS[i].at)
|
||||
_hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08)
|
||||
|
||||
# The crane rides the rail over whichever seam is being worked, and
|
||||
# retracts once there is nothing left to attach.
|
||||
var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2)
|
||||
if next_part >= _hull_parts.size():
|
||||
if next_part >= _hull_pieces.size():
|
||||
weld.y = RAIL_Y + 16.0
|
||||
var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0)
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
head + Vector2(-10, -7), head + Vector2(10, -7),
|
||||
head + Vector2(6, 6), head + Vector2(-6, 6),
|
||||
]), c, 1.0)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35)
|
||||
NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3)
|
||||
_crane.position = Vector2(weld.x, RAIL_Y + 13.0)
|
||||
_hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0)
|
||||
|
||||
if next_part < _hull_parts.size():
|
||||
# Weld sparks: short spokes whose angles jitter frame to frame.
|
||||
var welding: bool = next_part < _hull_pieces.size()
|
||||
_sparks.visible = welding
|
||||
if welding:
|
||||
var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0))
|
||||
for i in range(4):
|
||||
var a: float = phase_time * 7.0 + float(i) * TAU / 4.0
|
||||
var length: float = 6.0 + 5.0 * flicker
|
||||
NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length,
|
||||
Color(1.0, 0.95, 0.7), flicker, 0.3)
|
||||
_sparks.position = weld
|
||||
_sparks.rotation = phase_time * 7.0
|
||||
_sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker)
|
||||
_sparks.modulate.a = flicker
|
||||
|
||||
|
||||
func _draw_completion_flash(p: float, c: Color) -> void:
|
||||
func _animate_completion_flash(p: float) -> void:
|
||||
# Whole hull, clamps springing open, and a ring pushing outwards to mark
|
||||
# it as finished.
|
||||
_draw_clamps(c, 1.0 - p)
|
||||
for i in range(_hull_parts.size()):
|
||||
_draw_hull_part(i, BAY_CENTRE, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p)
|
||||
_clamps.modulate.a = 1.0 - p
|
||||
_sparks.visible = false
|
||||
_place_hull(BAY_CENTRE, 1.0)
|
||||
for piece in _hull_pieces:
|
||||
piece.visible = true
|
||||
piece.modulate.a = 1.0
|
||||
# Retracted, so it does not hang over the finished hull.
|
||||
_crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0)
|
||||
_hoist.scale.y = 1.0
|
||||
_flash_ring.modulate.a = 1.0 - p
|
||||
_flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS)
|
||||
|
||||
|
||||
func _draw_launch(p: float) -> void:
|
||||
func _animate_launch(p: float) -> void:
|
||||
# Clears the cradle and fades out; the next cycle starts from bare ribs.
|
||||
_clamps.modulate.a = 0.0
|
||||
_sparks.visible = false
|
||||
_flash_ring.modulate.a = 0.0
|
||||
var eased: float = p * p
|
||||
var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased)
|
||||
var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0)
|
||||
for i in range(_hull_parts.size()):
|
||||
_draw_hull_part(i, centre, fade)
|
||||
_place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade)
|
||||
for piece in _hull_pieces:
|
||||
piece.visible = true
|
||||
piece.modulate.a = 1.0
|
||||
# Engine wash, strongest just after it breaks free of the clamps.
|
||||
var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI)))
|
||||
for dx in [-4.0, 4.0]:
|
||||
NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14),
|
||||
centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust),
|
||||
Color(1.0, 0.85, 0.45), thrust, 0.45)
|
||||
|
||||
|
||||
func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void:
|
||||
if pulse <= 0.01:
|
||||
return
|
||||
var moved: PackedVector2Array = PackedVector2Array()
|
||||
for point in _hull_parts[index]:
|
||||
moved.append(centre + point)
|
||||
NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse)
|
||||
_wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI))
|
||||
|
|
|
|||
|
|
@ -11,12 +11,33 @@ extends Node2D
|
|||
|
||||
var building_scene: PackedScene = preload("res://Building.tscn")
|
||||
var spawned: Array = []
|
||||
# Whether the fleet was already standing on the mothership last frame - see
|
||||
# the edge-triggered delivery in _process().
|
||||
var _was_on_mothership: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
rebuild_all()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Unloading the fleet's hold (#14). Done here because this is the node
|
||||
# that already knows where the buildings are and already answers "is the
|
||||
# fleet standing on one?" - and it uses the fleet CENTROID, the same
|
||||
# stable single-number test Planet and Building already use, rather than
|
||||
# per-ship collision that flickers as ships jostle in formation.
|
||||
# EDGE-triggered on arrival, not continuous. Delivering every frame the
|
||||
# fleet is parked on the mothership would make withdrawing impossible:
|
||||
# ore loaded into the hold at the mothership (#17) would be banked again
|
||||
# the very next frame, and the button would look broken.
|
||||
var under: Node2D = get_building_fleet_is_on()
|
||||
var on_mothership: bool = under != null \
|
||||
and under.building_type == GameState.BUILDING_MOTHERSHIP
|
||||
if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0:
|
||||
GameState.deliver_cargo()
|
||||
_was_on_mothership = on_mothership
|
||||
|
||||
|
||||
func rebuild_all() -> void:
|
||||
for b in spawned:
|
||||
if is_instance_valid(b):
|
||||
|
|
@ -43,6 +64,7 @@ func _spawn(record: Dictionary) -> void:
|
|||
var b: Node2D = building_scene.instantiate()
|
||||
b.position = record.position
|
||||
b.building_type = record.type
|
||||
b.building_id = int(record.get("id", 0))
|
||||
# Deferred for the same reason as the other field spawners: this can run
|
||||
# from inside a parent's _ready() chain, where a direct add_child() is
|
||||
# rejected while the tree is still setting up children.
|
||||
|
|
|
|||
33
CargoCounter.gd
Normal file
33
CargoCounter.gd
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
extends NeonNumber
|
||||
|
||||
# What the fleet is CARRYING, shown under the banked total (#15, part 2 of
|
||||
# #12). Since #14 ore is cargo until the fleet delivers it to the mothership,
|
||||
# so without this, mining visibly does nothing - the banked figure above does
|
||||
# not move until the fleet flies home, which reads as gathering being broken.
|
||||
#
|
||||
# Three things separate it from the banked readout, because colour alone is a
|
||||
# weak signal on a phone screen in a game that is mostly teal:
|
||||
# - amber rather than ore-teal, the same amber used elsewhere for ore in
|
||||
# transit (the extractor derrick, the hauler route)
|
||||
# - smaller, sitting UNDER the banked figure: subordinate to it, because
|
||||
# this is the number that is not yours yet
|
||||
# - present only while actually carrying something, so an empty hold is not
|
||||
# a permanent zero taking up space next to the real total
|
||||
#
|
||||
# Reads GameState directly for the same reason ResourceCounter does: it stays
|
||||
# correct across the space <-> planet-surface scene swap with nobody having
|
||||
# to push updates into it.
|
||||
|
||||
const CARGO_GLOW: Color = Color(1.0, 0.78, 0.25)
|
||||
const CARGO_CORE: Color = Color(0.22, 0.12, 0.0)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
glow_color = CARGO_GLOW
|
||||
core_color = CARGO_CORE
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
visible = GameState.fleet_cargo > 0
|
||||
if visible:
|
||||
set_amount(GameState.fleet_cargo)
|
||||
1
CargoCounter.gd.uid
Normal file
1
CargoCounter.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bmrcja23g5oi6
|
||||
22
ExplosionParticle.gd
Normal file
22
ExplosionParticle.gd
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
class_name ExplosionParticle
|
||||
extends Node2D
|
||||
|
||||
# One debris chunk / energy streak inside an explosion. Draws its fixed
|
||||
# local-space shape exactly once (like Ship.gd/Asteroid.gd draw once and
|
||||
# animate via node properties), then ExplosionVFXBase drives it every frame
|
||||
# purely through `position` and `rotation` - no further redraw needed.
|
||||
#
|
||||
# The shape itself is supplied by the owning VFX class via a Callable rather
|
||||
# than being known here, since different explosion styles (glowing streak
|
||||
# vs. rock-chunk polygon) draw completely differently; this class only owns
|
||||
# the "draw once, then just move" mechanics.
|
||||
|
||||
var draw_callable: Callable
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_callable.call(self)
|
||||
1
ExplosionParticle.gd.uid
Normal file
1
ExplosionParticle.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dm2m305hlrv6l
|
||||
|
|
@ -22,14 +22,25 @@ func _ready() -> void:
|
|||
for i in range(particle_count):
|
||||
var angle: float = randf() * TAU
|
||||
var speed: float = randf_range(min_speed, max_speed)
|
||||
particles.append({
|
||||
var p: Dictionary = {
|
||||
"pos": Vector2.ZERO,
|
||||
"vel": Vector2(cos(angle), sin(angle)) * speed,
|
||||
"rot": randf() * TAU,
|
||||
"rot_speed": randf_range(-6.0, 6.0),
|
||||
"size": _random_particle_size(),
|
||||
})
|
||||
queue_redraw()
|
||||
}
|
||||
# Each particle is its own child CanvasItem, drawn once at its own
|
||||
# fixed local shape (subclass decides the shape via
|
||||
# _configure_particle). From here on the "animation" is just this
|
||||
# child's position/rotation, exactly like Ship.gd/Asteroid.gd - no
|
||||
# redraw, no re-tessellating four polylines per particle per frame.
|
||||
var visual: ExplosionParticle = ExplosionParticle.new()
|
||||
_configure_particle(visual, p)
|
||||
if _particle_spins():
|
||||
visual.rotation = p["rot"]
|
||||
add_child(visual)
|
||||
p["visual"] = visual
|
||||
particles.append(p)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
|
@ -37,22 +48,35 @@ func _process(delta: float) -> void:
|
|||
if age >= lifetime:
|
||||
queue_free()
|
||||
return
|
||||
# CanvasItem modulate is inherited down the tree, so setting it once
|
||||
# here fades every particle's already-drawn shape for free - no need
|
||||
# to recompute or re-draw a single one of them.
|
||||
modulate.a = clamp(1.0 - (age / lifetime), 0.0, 1.0)
|
||||
for p in particles:
|
||||
p["pos"] += p["vel"] * delta
|
||||
p["vel"] *= 0.9
|
||||
p["rot"] += p["rot_speed"] * delta
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var alpha: float = clamp(1.0 - (age / lifetime), 0.0, 1.0)
|
||||
for p in particles:
|
||||
_draw_particle(p, alpha)
|
||||
var visual: ExplosionParticle = p["visual"]
|
||||
visual.position = p["pos"]
|
||||
if _particle_spins():
|
||||
visual.rotation = p["rot"]
|
||||
|
||||
|
||||
func _random_particle_size() -> float:
|
||||
return randf_range(10.0, 20.0)
|
||||
|
||||
|
||||
func _draw_particle(_p: Dictionary, _alpha: float) -> void:
|
||||
# Called once per particle at spawn time. Subclasses set visual.draw_callable
|
||||
# to a closure that draws their own fixed local shape (a streak, a rock
|
||||
# chunk, ...) - it must not depend on `p` beyond values fixed at spawn (size,
|
||||
# initial velocity direction), since it never runs again.
|
||||
func _configure_particle(_visual: ExplosionParticle, _p: Dictionary) -> void:
|
||||
pass
|
||||
|
||||
|
||||
# True for particle styles whose shape itself spins independently of its
|
||||
# direction of travel (tumbling debris). Energy streaks are drawn already
|
||||
# aligned to their (fixed) velocity direction, so giving them this unrelated
|
||||
# per-particle spin as well would visibly rotate them off their travel line.
|
||||
func _particle_spins() -> bool:
|
||||
return false
|
||||
|
|
|
|||
44
Fleet.gd
44
Fleet.gd
|
|
@ -39,7 +39,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
|
|||
ships.clear()
|
||||
formation_offsets.clear()
|
||||
|
||||
var n: int = clamp(count, 1, GameState.formation.size())
|
||||
var n: int = clamp(count, 1, GameState.max_fleet_size())
|
||||
for i in range(n):
|
||||
var offset: Vector2 = GameState.formation[i]
|
||||
var ship: Area2D = ship_scene.instantiate()
|
||||
|
|
@ -58,7 +58,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
|
|||
func add_ship() -> bool:
|
||||
# Adds one vessel in the next free formation slot (bought from a ship
|
||||
# crafter). Returns false when the wedge is already full.
|
||||
if ships.size() >= GameState.formation.size():
|
||||
if ships.size() >= GameState.max_fleet_size():
|
||||
return false
|
||||
var offset: Vector2 = GameState.formation[ships.size()]
|
||||
var ship: Area2D = ship_scene.instantiate()
|
||||
|
|
@ -82,7 +82,7 @@ func apply_formation() -> void:
|
|||
# Without this, editing the layout at a command centre (or pivoting it)
|
||||
# only affected ships built later, which read as the edit doing nothing.
|
||||
for i in range(ships.size()):
|
||||
if i >= GameState.formation.size():
|
||||
if i >= GameState.max_fleet_size():
|
||||
break
|
||||
formation_offsets[i] = GameState.formation[i]
|
||||
_assign_slot_targets()
|
||||
|
|
@ -113,6 +113,9 @@ func move_in_direction(direction: Vector2) -> void:
|
|||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Before the steering early-out: a ship the crafter has already built and
|
||||
# paid for is owed whether or not the fleet happens to be flying (#16).
|
||||
_claim_owed_ships()
|
||||
if steer_direction == Vector2.ZERO:
|
||||
return
|
||||
# The anchor - the fleet's formation centre - travels at cruise_speed.
|
||||
|
|
@ -122,6 +125,17 @@ func _process(delta: float) -> void:
|
|||
_assign_slot_targets()
|
||||
|
||||
|
||||
func _claim_owed_ships() -> void:
|
||||
# Production completes in the GameState autoload, which may well happen
|
||||
# while the player is on a planet's surface with no Fleet node in
|
||||
# existence to hand a vessel to. Those ships wait as `ships_owed` until a
|
||||
# fleet with room can take them.
|
||||
while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size():
|
||||
if not add_ship():
|
||||
return
|
||||
GameState.ships_owed -= 1
|
||||
|
||||
|
||||
func _assign_slot_targets() -> void:
|
||||
for i in range(ships.size()):
|
||||
ships[i].set_move_target(fleet_target + formation_offsets[i])
|
||||
|
|
@ -138,12 +152,32 @@ func stop() -> void:
|
|||
# since that lands inside arrive_radius immediately).
|
||||
fleet_target = get_formation_origin()
|
||||
for ship in ships:
|
||||
var speed: float = ship.velocity.length()
|
||||
# A ship's own `velocity` is NOT how fast it is really travelling
|
||||
# while the fleet cruises. Ship snaps to its slot and reports zero
|
||||
# the moment it is within arrive_radius of it, even though the
|
||||
# anchor is dragging that slot forward at cruise_speed underneath
|
||||
# it. Its true motion is its catch-up velocity PLUS the fleet's,
|
||||
# which it already carries as `fleet_velocity` (zero for a
|
||||
# point-to-point move_to, where `velocity` alone is the whole
|
||||
# story).
|
||||
#
|
||||
# Braking from `velocity` alone therefore stopped the fleet dead
|
||||
# on any frame where the ships happened to be seated - and how
|
||||
# often that happens depends on the frame rate, because the anchor
|
||||
# advances cruise_speed * delta per frame. At ~25fps it steps far
|
||||
# enough that ships always lag and always report motion, which is
|
||||
# the only reason releasing the stick looked right.
|
||||
var motion: Vector2 = ship.velocity + ship.fleet_velocity
|
||||
# Context that is no longer true: the fleet is not cruising any
|
||||
# more, so leaving it set would have Ship face a heading it is not
|
||||
# travelling on.
|
||||
ship.fleet_velocity = Vector2.ZERO
|
||||
var speed: float = motion.length()
|
||||
if speed <= 0.001:
|
||||
ship.set_move_target(ship.position)
|
||||
continue
|
||||
var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration)
|
||||
ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance)
|
||||
ship.set_move_target(ship.position + motion.normalized() * braking_distance)
|
||||
|
||||
|
||||
func get_move_targets() -> Array:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool:
|
|||
return true
|
||||
if dragging_index != -1:
|
||||
return false
|
||||
for i in range(GameState.formation.size()):
|
||||
for i in range(GameState.max_fleet_size()):
|
||||
if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4:
|
||||
dragging_index = i
|
||||
drag_touch_index = event.index
|
||||
|
|
@ -126,7 +126,7 @@ func _draw() -> void:
|
|||
NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3)
|
||||
NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3)
|
||||
|
||||
for i in range(GameState.formation.size()):
|
||||
for i in range(GameState.max_fleet_size()):
|
||||
var p: Vector2 = _slot_screen_pos(i)
|
||||
var held: bool = i == dragging_index
|
||||
# Ship glyph, so the layout reads the way it will look in flight.
|
||||
|
|
|
|||
569
GameState.gd
569
GameState.gd
|
|
@ -22,11 +22,70 @@ var explored_chunks: Dictionary = {}
|
|||
|
||||
# --- Resources -------------------------------------------------------------
|
||||
|
||||
# Rocks mined from asteroid-planet surfaces, spent on buildings and ships.
|
||||
# Ore that has been BANKED at the mothership. This is the only pool anything
|
||||
# can be bought with.
|
||||
# Starts stocked for play-testing so buildings can be tried without mining
|
||||
# first - drop this back to 0 for a real playthrough.
|
||||
var rocks: int = 500
|
||||
|
||||
# Ore the fleet is physically carrying and has not delivered yet (#14). Mining
|
||||
# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a
|
||||
# long mining run is a real trip with something to lose rather than instant
|
||||
# income from anywhere on the map.
|
||||
#
|
||||
# Lives here, like everything else that has to survive Main.tscn being torn
|
||||
# down and rebuilt on every trip to a planet's surface - and cargo mined ON a
|
||||
# surface would otherwise be dropped the moment the player took off.
|
||||
var fleet_cargo: int = 0
|
||||
|
||||
signal cargo_delivered(amount: int)
|
||||
|
||||
|
||||
func collect_cargo(amount: int = 1) -> void:
|
||||
fleet_cargo += amount
|
||||
|
||||
|
||||
# One press of "load ore" at the mothership. A batch rather than the whole
|
||||
# bank: the hold is the part that can be lost, and emptying the bank in one
|
||||
# tap would also strand research, which still spends from it.
|
||||
const CARGO_LOAD_BATCH: int = 25
|
||||
|
||||
|
||||
func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int:
|
||||
# Banked ore back into the hold, so the fleet can carry it to a building
|
||||
# that is waiting on it (#17).
|
||||
var taken: int = mini(amount, rocks)
|
||||
if taken <= 0:
|
||||
return 0
|
||||
rocks -= taken
|
||||
fleet_cargo += taken
|
||||
return taken
|
||||
|
||||
|
||||
func unload_into_building(id: int) -> int:
|
||||
# Everything the fleet is carrying, into this building's stock. Returns
|
||||
# how much moved so a caller can tell the player something happened.
|
||||
if fleet_cargo <= 0:
|
||||
return 0
|
||||
var moved: int = fleet_cargo
|
||||
if deposit_to_building(id, moved) <= 0:
|
||||
return 0
|
||||
fleet_cargo = 0
|
||||
return moved
|
||||
|
||||
|
||||
func deliver_cargo() -> int:
|
||||
# Everything the fleet is carrying, banked at once. Returns how much moved
|
||||
# so a caller can react to a delivery actually having happened rather than
|
||||
# to merely being in range.
|
||||
if fleet_cargo <= 0:
|
||||
return 0
|
||||
var delivered: int = fleet_cargo
|
||||
rocks += delivered
|
||||
fleet_cargo = 0
|
||||
cargo_delivered.emit(delivered)
|
||||
return delivered
|
||||
|
||||
const MOTHERSHIP_COST: int = 20
|
||||
const SHIP_CRAFTER_COST: int = 12
|
||||
const SHIP_COST: int = 6
|
||||
|
|
@ -42,15 +101,304 @@ const BUILDING_COMMAND_CENTER: String = "command_center"
|
|||
# Extractors are not placed in space like the others - they are built ON a
|
||||
# planet's surface and tracked separately (see `extractors`).
|
||||
const BUILDING_EXTRACTOR: String = "extractor"
|
||||
# Hosts the transport vessels that supply other buildings (#18). Built in
|
||||
# space like the rest; it is the transporters (#19) that do the moving.
|
||||
const BUILDING_TRANSPORT_HUB: String = "transport_hub"
|
||||
|
||||
const COMMAND_CENTER_COST: int = 15
|
||||
const EXTRACTOR_COST: int = 25
|
||||
const TRANSPORT_HUB_COST: int = 30
|
||||
|
||||
var buildings: Array = []
|
||||
|
||||
# --- Skill tree ------------------------------------------------------------
|
||||
|
||||
func add_building(type: String, at_position: Vector2) -> void:
|
||||
buildings.append({"type": type, "position": at_position})
|
||||
# Researched at the mothership. Two things hang off it: which structures the
|
||||
# player is allowed to build at all, and permanent upgrades that have no
|
||||
# building of their own. The mothership itself is deliberately NOT in the
|
||||
# tree - it is the thing you research AT, so gating it behind research would
|
||||
# be a deadlock.
|
||||
#
|
||||
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
|
||||
# stored, it is derived from how many prerequisites deep the node is, so the
|
||||
# tree always reads root-at-the-bottom and cannot drift out of step with the
|
||||
# dependencies when nodes are added. `requires` is a single prerequisite id
|
||||
# ("" for a root).
|
||||
const SKILL_TREE: Array = [
|
||||
{
|
||||
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
|
||||
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
|
||||
"desc": "Build ship crafters, and buy extra vessels at them.",
|
||||
},
|
||||
{
|
||||
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
|
||||
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
|
||||
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
|
||||
},
|
||||
{
|
||||
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
|
||||
"x": 0.82, "unlocks": BUILDING_COMMAND_CENTER,
|
||||
"desc": "Build command centers, where the fleet formation is arranged.",
|
||||
},
|
||||
{
|
||||
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
|
||||
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
|
||||
"desc": "Build transport hubs, which supply your other buildings with ore.",
|
||||
},
|
||||
{
|
||||
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
|
||||
"x": 0.05, "hub_bonus": 1,
|
||||
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
|
||||
},
|
||||
{
|
||||
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
|
||||
"x": 0.82, "fleet_bonus": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
|
||||
"x": 0.65, "fleet_bonus": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
|
||||
"x": 0.3, "fleet_bonus": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
]
|
||||
|
||||
const BASE_FLEET_SIZE: int = 5
|
||||
|
||||
var unlocked: Array = []
|
||||
|
||||
|
||||
func skill_node(id: String) -> Dictionary:
|
||||
for node in SKILL_TREE:
|
||||
if node.id == id:
|
||||
return node
|
||||
return {}
|
||||
|
||||
|
||||
func skill_depth(id: String) -> int:
|
||||
# How many prerequisites deep this node sits. Drives its height in the
|
||||
# tree view: roots at the bottom, everything that depends on something
|
||||
# above it.
|
||||
var depth: int = 0
|
||||
var node: Dictionary = skill_node(id)
|
||||
while not node.is_empty() and node.requires != "":
|
||||
depth += 1
|
||||
node = skill_node(node.requires)
|
||||
if depth > SKILL_TREE.size():
|
||||
break # a cycle in the data would otherwise hang here
|
||||
return depth
|
||||
|
||||
|
||||
func max_skill_depth() -> int:
|
||||
var deepest: int = 0
|
||||
for node in SKILL_TREE:
|
||||
deepest = maxi(deepest, skill_depth(node.id))
|
||||
return deepest
|
||||
|
||||
|
||||
func is_unlocked(id: String) -> bool:
|
||||
return id in unlocked
|
||||
|
||||
|
||||
func prerequisite_met(id: String) -> bool:
|
||||
var node: Dictionary = skill_node(id)
|
||||
if node.is_empty():
|
||||
return false
|
||||
return node.requires == "" or is_unlocked(node.requires)
|
||||
|
||||
|
||||
func can_unlock(id: String) -> bool:
|
||||
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
|
||||
|
||||
|
||||
func unlock(id: String) -> bool:
|
||||
if not can_unlock(id):
|
||||
return false
|
||||
if not spend(int(skill_node(id).cost)):
|
||||
return false
|
||||
unlocked.append(id)
|
||||
return true
|
||||
|
||||
|
||||
func is_building_unlocked(type: String) -> bool:
|
||||
# The mothership is always buildable: it is where research happens, so
|
||||
# locking it would leave a player with no mothership unable to ever get
|
||||
# one.
|
||||
if type == BUILDING_MOTHERSHIP:
|
||||
return true
|
||||
for node in SKILL_TREE:
|
||||
if node.get("unlocks", "") == type:
|
||||
return is_unlocked(node.id)
|
||||
return true
|
||||
|
||||
|
||||
func research_for_building(type: String) -> String:
|
||||
for node in SKILL_TREE:
|
||||
if node.get("unlocks", "") == type:
|
||||
return node.name
|
||||
return ""
|
||||
|
||||
|
||||
func max_fleet_size() -> int:
|
||||
var n: int = BASE_FLEET_SIZE
|
||||
for id in unlocked:
|
||||
n += int(skill_node(id).get("fleet_bonus", 0))
|
||||
return mini(n, DEFAULT_FORMATION.size())
|
||||
|
||||
|
||||
func reset_progression() -> void:
|
||||
unlocked.clear()
|
||||
|
||||
|
||||
# --- building stock and production queue (#16) -----------------------------
|
||||
|
||||
# Since #12, a building cannot just deduct from the bank at the moment of
|
||||
# purchase: what it produces has to be paid for out of resources physically
|
||||
# delivered to it. So each building carries its own `stock`, and an order it
|
||||
# cannot afford yet is QUEUED rather than refused, and completes by itself
|
||||
# once the stock turns up.
|
||||
#
|
||||
# `id` exists because a building has to be referable across the space <->
|
||||
# planet-surface scene swap, when every Building NODE has been destroyed and
|
||||
# rebuilt. Position would nearly work and then fail on two buildings placed
|
||||
# on the same spot.
|
||||
var next_building_id: int = 1
|
||||
|
||||
# Ships that have been paid for and built but not yet handed to the fleet.
|
||||
# Production is simulated here, in the autoload, the same way extractor
|
||||
# haulers are - so an order can complete while the player is down on a
|
||||
# planet's surface, where there is no Fleet node to hand a ship to. The fleet
|
||||
# claims them when it next can.
|
||||
var ships_owed: int = 0
|
||||
|
||||
signal order_completed(building_id: int, type: String)
|
||||
|
||||
|
||||
func add_building(type: String, at_position: Vector2) -> int:
|
||||
var id: int = next_building_id
|
||||
next_building_id += 1
|
||||
buildings.append({
|
||||
"id": id, "type": type, "position": at_position, "stock": 0, "orders": [],
|
||||
})
|
||||
return id
|
||||
|
||||
|
||||
func find_building(id: int) -> Dictionary:
|
||||
for b in buildings:
|
||||
if b.get("id", 0) == id:
|
||||
return b
|
||||
return {}
|
||||
|
||||
|
||||
const PRODUCT_SHIP: String = "interceptor"
|
||||
const PRODUCT_TRANSPORTER: String = "transporter"
|
||||
|
||||
const TRANSPORTER_COST: int = 10
|
||||
|
||||
|
||||
func product_cost(product: String) -> int:
|
||||
if product == PRODUCT_TRANSPORTER:
|
||||
return TRANSPORTER_COST
|
||||
return SHIP_COST
|
||||
|
||||
|
||||
func can_produce(type: String) -> bool:
|
||||
# Only the ship crafter builds anything. The hub STATIONS transporters,
|
||||
# it does not make them (#12).
|
||||
return type == BUILDING_SHIP_CRAFTER
|
||||
|
||||
|
||||
func building_stock(id: int) -> int:
|
||||
var b: Dictionary = find_building(id)
|
||||
return 0 if b.is_empty() else int(b.get("stock", 0))
|
||||
|
||||
|
||||
func pending_orders(id: int) -> int:
|
||||
var b: Dictionary = find_building(id)
|
||||
return 0 if b.is_empty() else (b.get("orders", []) as Array).size()
|
||||
|
||||
|
||||
func pending_orders_of(id: int, product: String) -> int:
|
||||
var b: Dictionary = find_building(id)
|
||||
if b.is_empty():
|
||||
return 0
|
||||
var n: int = 0
|
||||
for o in b.get("orders", []):
|
||||
if o == product:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func outstanding_cost(id: int) -> int:
|
||||
# What this building still needs delivered to clear everything queued.
|
||||
var b: Dictionary = find_building(id)
|
||||
if b.is_empty():
|
||||
return 0
|
||||
var need: int = 0
|
||||
for o in b.get("orders", []):
|
||||
need += product_cost(o)
|
||||
return maxi(need - int(b.get("stock", 0)), 0)
|
||||
|
||||
|
||||
func deposit_to_building(id: int, amount: int) -> int:
|
||||
if amount <= 0:
|
||||
return 0
|
||||
var b: Dictionary = find_building(id)
|
||||
if b.is_empty():
|
||||
return 0
|
||||
b.stock = int(b.get("stock", 0)) + amount
|
||||
return amount
|
||||
|
||||
|
||||
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
|
||||
var b: Dictionary = find_building(id)
|
||||
if b.is_empty() or not can_produce(b.type):
|
||||
return false
|
||||
if product == PRODUCT_TRANSPORTER and not has_transport_hub():
|
||||
# Nowhere to station it. Refused rather than queued, so the player is
|
||||
# told now instead of watching an order sit forever.
|
||||
return false
|
||||
if product == PRODUCT_TRANSPORTER and not can_station_transporter():
|
||||
# Every berth is taken or spoken for (#20).
|
||||
return false
|
||||
(b.orders as Array).append(product)
|
||||
return true
|
||||
|
||||
|
||||
func _advance_production() -> void:
|
||||
for b in buildings:
|
||||
if not can_produce(b.type):
|
||||
continue
|
||||
# `while`, not `if`: a delivery big enough to cover several queued
|
||||
# orders should clear them all rather than dribbling one out per
|
||||
# frame.
|
||||
var orders: Array = b.get("orders", [])
|
||||
while not orders.is_empty():
|
||||
var product: String = String(orders[0])
|
||||
var cost: int = product_cost(product)
|
||||
if int(b.get("stock", 0)) < cost:
|
||||
break
|
||||
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
|
||||
# A hub was demolished after this was ordered, so the berth it
|
||||
# was going to use is gone. Hold the order rather than
|
||||
# overfilling what is left.
|
||||
break
|
||||
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
|
||||
# Every hub was demolished while this sat in the queue. Leave
|
||||
# it queued and the stock untouched rather than producing a
|
||||
# vessel with nowhere to live.
|
||||
break
|
||||
b.stock = int(b.stock) - cost
|
||||
orders.pop_front()
|
||||
if product == PRODUCT_TRANSPORTER:
|
||||
_station_transporter(b.position)
|
||||
else:
|
||||
ships_owed += 1
|
||||
order_completed.emit(int(b.id), product)
|
||||
|
||||
|
||||
func has_mothership() -> bool:
|
||||
|
|
@ -84,8 +432,13 @@ func get_mothership_position() -> Variant:
|
|||
# centre. Lives here (not as a const on Fleet) so a layout the player has
|
||||
# arranged survives the trip to a planet's surface and back, and so a
|
||||
# rebuilt fleet keeps their arrangement.
|
||||
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
|
||||
# capacity upgrade is researched, but they are defined up front so the wedge
|
||||
# keeps a deliberate shape as it grows rather than having positions invented
|
||||
# at unlock time.
|
||||
const DEFAULT_FORMATION: Array = [
|
||||
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
|
||||
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
|
||||
]
|
||||
|
||||
var formation: Array = DEFAULT_FORMATION.duplicate()
|
||||
|
|
@ -133,12 +486,20 @@ func has_extractor_at(planet_name: String) -> bool:
|
|||
return false
|
||||
|
||||
|
||||
signal extractor_added(planet_name: String)
|
||||
|
||||
|
||||
func add_extractor(planet_name: String, at_position: Vector2) -> void:
|
||||
extractors.append({
|
||||
"position": at_position,
|
||||
"planet_name": planet_name,
|
||||
"progress": 0.0,
|
||||
})
|
||||
# An extractor is built from SPACE, with the fleet parked on the planet -
|
||||
# so the Planet node is already in the tree with its _ready() long since
|
||||
# run. Without this it kept showing no derrick until it happened to
|
||||
# despawn and respawn, which reads as the build having done nothing.
|
||||
extractor_added.emit(planet_name)
|
||||
|
||||
|
||||
func get_hauler_route_time(extractor: Dictionary) -> float:
|
||||
|
|
@ -165,8 +526,11 @@ func get_hauler_position(extractor: Dictionary) -> Variant:
|
|||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Runs in the autoload, so extraction keeps earning while the player is
|
||||
# on a planet's surface or nowhere near the route.
|
||||
# Runs in the autoload, so extraction keeps earning - and queued orders
|
||||
# keep completing - while the player is on a planet's surface or nowhere
|
||||
# near either.
|
||||
_advance_production()
|
||||
_advance_transporters(delta)
|
||||
if extractors.is_empty():
|
||||
return
|
||||
for e in extractors:
|
||||
|
|
@ -183,6 +547,201 @@ func _process(delta: float) -> void:
|
|||
e.progress -= 1.0
|
||||
|
||||
|
||||
# --- transporters (#19) ---------------------------------------------------
|
||||
|
||||
# Vessels stationed at a transport hub that fetch ore from the mothership and
|
||||
# deliver it to whichever building is waiting on it.
|
||||
#
|
||||
# Simulated HERE rather than by any node, exactly like the extractor haulers
|
||||
# above and for the same reason: a route the player is nowhere near still has
|
||||
# to run. `progress` drives both the drawn position and the moment the ore
|
||||
# actually moves, so what you see and what happens cannot drift apart.
|
||||
#
|
||||
# Entries are {"hub_id", "state", "target_id", "carrying", "progress",
|
||||
# "from", "to"}.
|
||||
const TRANSPORTER_SPEED: float = 340.0
|
||||
|
||||
# How many transporters one hub can station. Raised for EVERY hub - built
|
||||
# already or not - by research, which is the same shape as max_fleet_size()
|
||||
# rather than a second mechanism doing the same job.
|
||||
const BASE_HUB_CAPACITY: int = 2
|
||||
|
||||
const TRANSPORTER_IDLE: String = "idle"
|
||||
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
|
||||
const TRANSPORTER_TO_BUILDING: String = "to_building"
|
||||
const TRANSPORTER_RETURNING: String = "returning"
|
||||
|
||||
var transporters: Array = []
|
||||
|
||||
|
||||
func hub_capacity() -> int:
|
||||
# Per hub.
|
||||
var n: int = BASE_HUB_CAPACITY
|
||||
for id in unlocked:
|
||||
n += int(skill_node(id).get("hub_bonus", 0))
|
||||
return n
|
||||
|
||||
|
||||
func hub_count() -> int:
|
||||
var n: int = 0
|
||||
for b in buildings:
|
||||
if b.type == BUILDING_TRANSPORT_HUB:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func transport_capacity() -> int:
|
||||
# Total berths: more hubs, or more capacity per hub. Either raises it,
|
||||
# which is exactly the choice #12 asks the player to make.
|
||||
return hub_count() * hub_capacity()
|
||||
|
||||
|
||||
func pending_transporter_orders() -> int:
|
||||
var n: int = 0
|
||||
for b in buildings:
|
||||
for o in b.get("orders", []):
|
||||
if o == PRODUCT_TRANSPORTER:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func transporters_claimed() -> int:
|
||||
# Built plus already ordered: an order in the queue has a berth reserved,
|
||||
# or a player could queue ten of them against two berths and only find out
|
||||
# when they refused to appear.
|
||||
return transporters.size() + pending_transporter_orders()
|
||||
|
||||
|
||||
func can_station_transporter() -> bool:
|
||||
return transporters_claimed() < transport_capacity()
|
||||
|
||||
|
||||
func has_transport_hub() -> bool:
|
||||
return not nearest_hub_to(Vector2.ZERO).is_empty()
|
||||
|
||||
|
||||
func nearest_hub_to(at: Vector2) -> Dictionary:
|
||||
var best: Dictionary = {}
|
||||
var best_d: float = INF
|
||||
for b in buildings:
|
||||
if b.type != BUILDING_TRANSPORT_HUB:
|
||||
continue
|
||||
var d: float = at.distance_squared_to(b.position)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = b
|
||||
return best
|
||||
|
||||
|
||||
func _station_transporter(built_at: Vector2) -> void:
|
||||
# Built at the crafter, then it flies itself to the nearest hub and waits
|
||||
# there for orders (#12).
|
||||
var hub: Dictionary = nearest_hub_to(built_at)
|
||||
if hub.is_empty():
|
||||
return
|
||||
transporters.append({
|
||||
"hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0,
|
||||
"carrying": 0, "progress": 0.0, "from": built_at, "to": hub.position,
|
||||
})
|
||||
|
||||
|
||||
func transporter_position(t: Dictionary) -> Vector2:
|
||||
return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0))
|
||||
|
||||
|
||||
func _hub_position(hub_id: int) -> Variant:
|
||||
var hub: Dictionary = find_building(hub_id)
|
||||
return null if hub.is_empty() else hub.position
|
||||
|
||||
|
||||
func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void:
|
||||
t.from = from
|
||||
t.to = to
|
||||
t.state = state
|
||||
t.progress = 0.0
|
||||
|
||||
|
||||
func _transporter_inbound_to(building_id: int) -> bool:
|
||||
for t in transporters:
|
||||
if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _dispatch_transporters() -> void:
|
||||
var mothership: Variant = get_mothership_position()
|
||||
if mothership == null:
|
||||
return # nowhere to load from
|
||||
for b in buildings:
|
||||
if outstanding_cost(int(b.id)) <= 0:
|
||||
continue
|
||||
# One at a time per building: without this every idle transporter
|
||||
# would be sent after the same shortfall and they would all arrive
|
||||
# carrying ore nobody needs any more.
|
||||
if _transporter_inbound_to(int(b.id)):
|
||||
continue
|
||||
var chosen: Dictionary = {}
|
||||
var best_d: float = INF
|
||||
for t in transporters:
|
||||
if t.state != TRANSPORTER_IDLE:
|
||||
continue
|
||||
var here: Vector2 = transporter_position(t)
|
||||
var d: float = here.distance_squared_to(b.position)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
chosen = t
|
||||
if chosen.is_empty():
|
||||
return # every transporter is busy
|
||||
chosen.target_id = int(b.id)
|
||||
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
|
||||
|
||||
|
||||
func _advance_transporters(delta: float) -> void:
|
||||
if transporters.is_empty():
|
||||
return
|
||||
_dispatch_transporters()
|
||||
for t in transporters:
|
||||
if t.state == TRANSPORTER_IDLE:
|
||||
continue
|
||||
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
|
||||
var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001)
|
||||
t.progress += delta / leg
|
||||
if t.progress < 1.0:
|
||||
continue
|
||||
t.progress = 1.0
|
||||
_arrive(t)
|
||||
|
||||
|
||||
func _arrive(t: Dictionary) -> void:
|
||||
var here: Vector2 = t.to
|
||||
var hub_pos: Variant = _hub_position(int(t.hub_id))
|
||||
match t.state:
|
||||
TRANSPORTER_TO_MOTHERSHIP:
|
||||
var target: Dictionary = find_building(int(t.target_id))
|
||||
var want: int = outstanding_cost(int(t.target_id))
|
||||
var take: int = mini(want, rocks)
|
||||
if take <= 0 or target.is_empty():
|
||||
# Nothing to carry, or the building is gone. Go home empty.
|
||||
t.target_id = 0
|
||||
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
||||
return
|
||||
rocks -= take
|
||||
t.carrying = take
|
||||
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
|
||||
TRANSPORTER_TO_BUILDING:
|
||||
# The ore moves at exactly the moment the vessel arrives.
|
||||
if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0:
|
||||
# Building demolished in transit - put the ore back rather
|
||||
# than quietly destroying it.
|
||||
rocks += int(t.carrying)
|
||||
t.carrying = 0
|
||||
t.target_id = 0
|
||||
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
|
||||
TRANSPORTER_RETURNING:
|
||||
t.state = TRANSPORTER_IDLE
|
||||
t.progress = 1.0
|
||||
|
||||
|
||||
func can_afford(cost: int) -> bool:
|
||||
return rocks >= cost
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ enum Icon {
|
|||
# build menu used to pick from the generic set, which left the mothership
|
||||
# and the command centre sharing a folded-map glyph and neither of them
|
||||
# looking anything like the structure they build.
|
||||
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR,
|
||||
MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB,
|
||||
}
|
||||
|
||||
# Size the glyphs were originally drawn against. Every glyph dimension is
|
||||
|
|
@ -50,7 +50,25 @@ func _ready() -> void:
|
|||
queue_redraw()
|
||||
|
||||
|
||||
func _blocked_by_modal() -> bool:
|
||||
# A panel is open and this button is not part of it, so it is sitting
|
||||
# BEHIND the panel and must not react.
|
||||
#
|
||||
# The panels cannot simply swallow GUI input with a mouse_filter of STOP:
|
||||
# the skill tree and the formation editor are driven from raw
|
||||
# _unhandled_input (see SkillTreeView), and a Control that consumes
|
||||
# touches through the GUI system swallows them before they ever get
|
||||
# there. So the panels stay transparent to input and the buttons check
|
||||
# instead.
|
||||
for menu in get_tree().get_nodes_in_group("modal_menus"):
|
||||
if menu.visible and not menu.is_ancestor_of(self):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if _blocked_by_modal():
|
||||
return
|
||||
if event is InputEventScreenTouch:
|
||||
_handle_press(event.pressed, event.position)
|
||||
accept_event()
|
||||
|
|
@ -119,6 +137,8 @@ func _draw() -> void:
|
|||
_draw_command_icon(center, pulse, s)
|
||||
Icon.EXTRACTOR:
|
||||
_draw_extractor_icon(center, pulse, s)
|
||||
Icon.TRANSPORT_HUB:
|
||||
_draw_transport_hub_icon(center, pulse, s)
|
||||
|
||||
|
||||
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
|
||||
|
|
@ -204,6 +224,32 @@ func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void:
|
|||
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
|
||||
|
||||
|
||||
func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void:
|
||||
# A ring of docking cradles with a freight box in the middle: reads as
|
||||
# "things are stationed here and go out from here", which is what the hub
|
||||
# does, and shares no silhouette with the crafter's open cradle.
|
||||
var ring_r: float = 11.0 * s
|
||||
var ring: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(8):
|
||||
var a: float = (float(i) / 8.0) * TAU + PI / 8.0
|
||||
ring.append(center + Vector2(cos(a), sin(a)) * ring_r)
|
||||
NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color)
|
||||
|
||||
var box: float = 4.2 * s
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
center + Vector2(-box, -box), center + Vector2(box, -box),
|
||||
center + Vector2(box, box), center + Vector2(-box, box),
|
||||
]), _active_color(), pulse, core_color)
|
||||
|
||||
# Three stubby berths, so the ring reads as occupied rather than as a
|
||||
# plain octagon.
|
||||
for i in range(3):
|
||||
var a: float = (float(i) / 3.0) * TAU - PI / 2.0
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
NeonDraw.draw_glowing_line(self, center + dir * (box * 1.5),
|
||||
center + dir * (ring_r - 1.5 * s), _active_color(), pulse, 0.35 * s, core_color)
|
||||
|
||||
|
||||
func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
|
||||
# Reactor core, truss spine, and an open cradle holding a hull.
|
||||
var core_centre: Vector2 = center + Vector2(-11, 0) * s
|
||||
|
|
@ -227,18 +273,31 @@ func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void:
|
|||
|
||||
|
||||
func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void:
|
||||
# Diamond with three formation pips - "arrangement".
|
||||
var d: float = 14.0 * s
|
||||
# Hex plotting rim with spokes to a diamond hub, echoing the placed
|
||||
# structure's silhouette: half the ports carry a formation token.
|
||||
var hex_r: float = 11.0 * s
|
||||
var hub_r: float = 4.0 * s
|
||||
var hex: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(6):
|
||||
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
|
||||
hex.append(center + Vector2(cos(a), sin(a)) * hex_r)
|
||||
NeonDraw.draw_glowing_polygon(self, hex, _active_color(), pulse, core_color)
|
||||
|
||||
for i in range(6):
|
||||
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
NeonDraw.draw_glowing_line(self, center + dir * hub_r, center + dir * hex_r, _active_color(), pulse, 0.32 * s, core_color)
|
||||
if i % 2 == 0:
|
||||
var tip: Vector2 = center + dir * hex_r
|
||||
var t: float = 2.6 * s
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
tip + Vector2(0, -t), tip + Vector2(t, 0), tip + Vector2(0, t), tip + Vector2(-t, 0),
|
||||
]), _active_color(), pulse, core_color)
|
||||
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
center + Vector2(0, -d), center + Vector2(d, 0),
|
||||
center + Vector2(0, d), center + Vector2(-d, 0),
|
||||
center + Vector2(0, -hub_r), center + Vector2(hub_r, 0),
|
||||
center + Vector2(0, hub_r), center + Vector2(-hub_r, 0),
|
||||
]), _active_color(), pulse, core_color)
|
||||
for p in [Vector2(0, -5), Vector2(-5, 4), Vector2(5, 4)]:
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
center + (p + Vector2(0, -3)) * s,
|
||||
center + (p + Vector2(-2.6, 2.4)) * s,
|
||||
center + (p + Vector2(2.6, 2.4)) * s,
|
||||
]), _active_color(), pulse, core_color)
|
||||
|
||||
|
||||
func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void:
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ extends ExplosionVFXBase
|
|||
var color: Color = Color(1, 1, 1)
|
||||
|
||||
|
||||
func _draw_particle(p: Dictionary, alpha: float) -> void:
|
||||
func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void:
|
||||
# Direction only ever scales (vel *= 0.9 each frame in the base class),
|
||||
# it never turns - so the streak's local orientation is safe to fix
|
||||
# once here rather than recomputed from vel every frame.
|
||||
var vel: Vector2 = p["vel"]
|
||||
var dir: Vector2 = vel.normalized() if vel.length() > 0.01 else Vector2.RIGHT
|
||||
var half: Vector2 = dir * p["size"] * 0.5
|
||||
var pos: Vector2 = p["pos"]
|
||||
NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6)
|
||||
var line_color: Color = color
|
||||
visual.draw_callable = func(item: CanvasItem) -> void:
|
||||
NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6)
|
||||
|
|
|
|||
386
Main.tscn
386
Main.tscn
|
|
@ -20,6 +20,9 @@
|
|||
[ext_resource type="Script" path="res://HaulerField.gd" id="18"]
|
||||
[ext_resource type="Script" path="res://NeonNumber.gd" id="19"]
|
||||
[ext_resource type="Script" path="res://NeonPanel.gd" id="20"]
|
||||
[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"]
|
||||
[ext_resource type="Script" path="res://CargoCounter.gd" id="22"]
|
||||
[ext_resource type="Script" path="res://TransporterField.gd" id="23"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="1"]
|
||||
shader = ExtResource("5")
|
||||
|
|
@ -70,6 +73,9 @@ script = ExtResource("14")
|
|||
[node name="HaulerField" type="Node2D" parent="."]
|
||||
script = ExtResource("18")
|
||||
|
||||
[node name="TransporterField" type="Node2D" parent="."]
|
||||
script = ExtResource("23")
|
||||
|
||||
[node name="BuildController" type="Node2D" parent="."]
|
||||
script = ExtResource("15")
|
||||
|
||||
|
|
@ -83,10 +89,19 @@ layer = 1
|
|||
mouse_filter = 2
|
||||
offset_left = 28
|
||||
offset_top = 24
|
||||
offset_right = 520
|
||||
offset_bottom = 140
|
||||
offset_right = 320
|
||||
offset_bottom = 92
|
||||
script = ExtResource("16")
|
||||
digit_height = 100.0
|
||||
digit_height = 60.0
|
||||
|
||||
[node name="CargoCounter" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 28
|
||||
offset_top = 96
|
||||
offset_right = 320
|
||||
offset_bottom = 146
|
||||
script = ExtResource("22")
|
||||
digit_height = 42.0
|
||||
|
||||
[node name="PlanetNameLabel" type="Label" parent="UI"]
|
||||
visible = false
|
||||
|
|
@ -129,19 +144,6 @@ offset_bottom = -572
|
|||
script = ExtResource("10")
|
||||
icon = 2
|
||||
|
||||
[node name="BuildActionLabel" type="Label" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = 24
|
||||
offset_right = -24
|
||||
offset_top = -566
|
||||
offset_bottom = -440
|
||||
text = ""
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="MapButton" type="Control" parent="UI"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
|
|
@ -201,20 +203,7 @@ offset_bottom = -24
|
|||
script = ExtResource("10")
|
||||
icon = 6
|
||||
|
||||
[node name="NavLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -344
|
||||
offset_right = -24
|
||||
offset_top = -322
|
||||
offset_bottom = -196
|
||||
text = "Pivot"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="WorldMap" type="Control" parent="UI"]
|
||||
[node name="WorldMap" type="Control" parent="UI" groups=["modal_menus"]]
|
||||
visible = false
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
|
@ -248,7 +237,7 @@ offset_bottom = 172
|
|||
script = ExtResource("10")
|
||||
icon = 1
|
||||
|
||||
[node name="BuildMenu" type="Control" parent="UI"]
|
||||
[node name="BuildMenu" type="Control" parent="UI" groups=["modal_menus"]]
|
||||
visible = false
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
|
@ -285,10 +274,10 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -214
|
||||
offset_right = -66
|
||||
offset_top = -224
|
||||
offset_bottom = -76
|
||||
offset_left = -204
|
||||
offset_right = -76
|
||||
offset_top = -300
|
||||
offset_bottom = -172
|
||||
script = ExtResource("10")
|
||||
icon = 8
|
||||
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||
|
|
@ -301,9 +290,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -290
|
||||
offset_right = 10
|
||||
offset_top = -70
|
||||
offset_bottom = 60
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = -166
|
||||
offset_bottom = -100
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Mothership"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -314,23 +303,23 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -220
|
||||
offset_right = -60
|
||||
offset_top = 70
|
||||
offset_bottom = 150
|
||||
offset_left = -200
|
||||
offset_right = -80
|
||||
offset_top = -96
|
||||
offset_bottom = -44
|
||||
script = ExtResource("19")
|
||||
amount = 20
|
||||
digit_height = 66.0
|
||||
digit_height = 40.0
|
||||
|
||||
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 66
|
||||
offset_right = 214
|
||||
offset_top = -224
|
||||
offset_bottom = -76
|
||||
offset_left = 76
|
||||
offset_right = 204
|
||||
offset_top = -300
|
||||
offset_bottom = -172
|
||||
script = ExtResource("10")
|
||||
icon = 9
|
||||
glow_color = Color(1, 0.72, 0.25, 1)
|
||||
|
|
@ -343,9 +332,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -10
|
||||
offset_right = 290
|
||||
offset_top = -70
|
||||
offset_bottom = 60
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = -166
|
||||
offset_bottom = -100
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Ship Crafter"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -356,23 +345,23 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 60
|
||||
offset_right = 220
|
||||
offset_top = 70
|
||||
offset_bottom = 150
|
||||
offset_left = 80
|
||||
offset_right = 200
|
||||
offset_top = -96
|
||||
offset_bottom = -44
|
||||
script = ExtResource("19")
|
||||
amount = 12
|
||||
digit_height = 66.0
|
||||
digit_height = 40.0
|
||||
|
||||
[node name="CommandButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -214
|
||||
offset_right = -66
|
||||
offset_top = 175
|
||||
offset_bottom = 323
|
||||
offset_left = -204
|
||||
offset_right = -76
|
||||
offset_top = -10
|
||||
offset_bottom = 118
|
||||
script = ExtResource("10")
|
||||
icon = 10
|
||||
glow_color = Color(0.55, 0.6, 1, 1)
|
||||
|
|
@ -385,9 +374,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -290
|
||||
offset_right = 10
|
||||
offset_top = 333
|
||||
offset_bottom = 463
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = 124
|
||||
offset_bottom = 190
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Command Center"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -398,23 +387,23 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -220
|
||||
offset_right = -60
|
||||
offset_top = 473
|
||||
offset_bottom = 553
|
||||
offset_left = -200
|
||||
offset_right = -80
|
||||
offset_top = 194
|
||||
offset_bottom = 246
|
||||
script = ExtResource("19")
|
||||
amount = 15
|
||||
digit_height = 66.0
|
||||
digit_height = 40.0
|
||||
|
||||
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 66
|
||||
offset_right = 214
|
||||
offset_top = 175
|
||||
offset_bottom = 323
|
||||
offset_left = 76
|
||||
offset_right = 204
|
||||
offset_top = -10
|
||||
offset_bottom = 118
|
||||
script = ExtResource("10")
|
||||
icon = 11
|
||||
glow_color = Color(0, 0.62, 0.7, 1)
|
||||
|
|
@ -427,9 +416,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -10
|
||||
offset_right = 290
|
||||
offset_top = 333
|
||||
offset_bottom = 463
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = 124
|
||||
offset_bottom = 190
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Extractor"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -440,13 +429,55 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 60
|
||||
offset_right = 220
|
||||
offset_top = 473
|
||||
offset_bottom = 553
|
||||
offset_left = 80
|
||||
offset_right = 200
|
||||
offset_top = 194
|
||||
offset_bottom = 246
|
||||
script = ExtResource("19")
|
||||
amount = 25
|
||||
digit_height = 66.0
|
||||
digit_height = 40.0
|
||||
|
||||
[node name="TransportHubButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -64
|
||||
offset_right = 64
|
||||
offset_top = 280
|
||||
offset_bottom = 408
|
||||
script = ExtResource("10")
|
||||
icon = 12
|
||||
glow_color = Color(0.95, 0.55, 0.85, 1)
|
||||
|
||||
[node name="TransportHubLabel" type="Label" parent="UI/BuildMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -150
|
||||
offset_right = 150
|
||||
offset_top = 414
|
||||
offset_bottom = 480
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Transport Hub"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TransportHubCost" type="Control" parent="UI/BuildMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -60
|
||||
offset_right = 60
|
||||
offset_top = 484
|
||||
offset_bottom = 536
|
||||
script = ExtResource("19")
|
||||
amount = 30
|
||||
digit_height = 40.0
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 1.0
|
||||
|
|
@ -458,7 +489,7 @@ offset_bottom = 172
|
|||
script = ExtResource("10")
|
||||
icon = 1
|
||||
|
||||
[node name="InfoPopup" type="Control" parent="UI/BuildMenu"]
|
||||
[node name="InfoPopup" type="Control" parent="UI/BuildMenu" groups=["modal_menus"]]
|
||||
visible = false
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
|
@ -545,7 +576,7 @@ script = ExtResource("10")
|
|||
icon = 1
|
||||
glow_color = Color(0, 0.62, 0.7, 1)
|
||||
|
||||
[node name="CraftMenu" type="Control" parent="UI"]
|
||||
[node name="CraftMenu" type="Control" parent="UI" groups=["modal_menus"]]
|
||||
visible = false
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
|
@ -582,8 +613,8 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -74
|
||||
offset_right = 74
|
||||
offset_left = -234
|
||||
offset_right = -86
|
||||
offset_top = -74
|
||||
offset_bottom = 74
|
||||
script = ExtResource("10")
|
||||
|
|
@ -595,14 +626,70 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -260
|
||||
offset_right = 260
|
||||
offset_left = -300
|
||||
offset_right = -20
|
||||
offset_top = 100
|
||||
offset_bottom = 200
|
||||
theme_override_font_sizes/font_size = 60
|
||||
theme_override_font_sizes/font_size = 32
|
||||
text = "Interceptor"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TransporterButton" type="Control" parent="UI/CraftMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 86
|
||||
offset_right = 234
|
||||
offset_top = -74
|
||||
offset_bottom = 74
|
||||
script = ExtResource("10")
|
||||
icon = 12
|
||||
glow_color = Color(0.95, 0.55, 0.85, 1)
|
||||
|
||||
[node name="TransporterLabel" type="Label" parent="UI/CraftMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 20
|
||||
offset_right = 300
|
||||
offset_top = 96
|
||||
offset_bottom = 176
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Transporter"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="StoreOreButton" type="Control" parent="UI/CraftMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -74
|
||||
offset_top = -304
|
||||
offset_right = 74
|
||||
offset_bottom = -156
|
||||
script = ExtResource("10")
|
||||
icon = 3
|
||||
glow_color = Color(1, 0.78, 0.25, 1)
|
||||
|
||||
[node name="StoreOreLabel" type="Label" parent="UI/CraftMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -260
|
||||
offset_top = -148
|
||||
offset_right = 260
|
||||
offset_bottom = -24
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
text = "Store ore here"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/CraftMenu"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
|
|
@ -613,7 +700,7 @@ offset_bottom = 172
|
|||
script = ExtResource("10")
|
||||
icon = 1
|
||||
|
||||
[node name="FormationMenu" type="Control" parent="UI"]
|
||||
[node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
|
|
@ -696,3 +783,128 @@ anchor_bottom = 1.0
|
|||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0, 0, 0, 0)
|
||||
|
||||
[node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.03, 0.03, 0.08, 0.94)
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 24.0
|
||||
offset_top = 70.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 196.0
|
||||
text = "Research"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Tree" type="Control" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 24.0
|
||||
offset_top = 210.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = -620.0
|
||||
script = ExtResource("21")
|
||||
|
||||
[node name="DetailLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = -610.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -462.0
|
||||
theme_override_font_sizes/font_size = 34
|
||||
autowrap_mode = 2
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
|
||||
[node name="CostNumber" type="Control" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -64.0
|
||||
offset_top = -456.0
|
||||
offset_right = 76.0
|
||||
offset_bottom = -392.0
|
||||
script = ExtResource("19")
|
||||
digit_height = 52.0
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = -386.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = -318.0
|
||||
theme_override_font_sizes/font_size = 32
|
||||
autowrap_mode = 2
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
|
||||
[node name="UnlockButton" type="Control" parent="UI/SkillMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -74.0
|
||||
offset_top = -304.0
|
||||
offset_right = 74.0
|
||||
offset_bottom = -156.0
|
||||
script = ExtResource("10")
|
||||
icon = 5
|
||||
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||
|
||||
[node name="LoadOreButton" type="Control" parent="UI/SkillMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -256.0
|
||||
offset_top = -304.0
|
||||
offset_right = -108.0
|
||||
offset_bottom = -156.0
|
||||
script = ExtResource("10")
|
||||
icon = 4
|
||||
glow_color = Color(1, 0.78, 0.25, 1)
|
||||
|
||||
[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -330.0
|
||||
offset_top = -150.0
|
||||
offset_right = -30.0
|
||||
offset_bottom = -14.0
|
||||
theme_override_font_sizes/font_size = 28
|
||||
horizontal_alignment = 1
|
||||
autowrap_mode = 2
|
||||
text = "Load ore"
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/SkillMenu"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -172.0
|
||||
offset_top = 24.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 172.0
|
||||
script = ExtResource("10")
|
||||
icon = 1
|
||||
glow_color = Color(0.55, 0.6, 1, 1)
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ extends RefCounted
|
|||
const DEFAULT_CORE: Color = Color(1.0, 1.0, 1.0)
|
||||
|
||||
|
||||
static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:
|
||||
static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE, width_scale: float = 1.0) -> void:
|
||||
var closed: PackedVector2Array = points.duplicate()
|
||||
closed.append(points[0])
|
||||
_draw_layers(item, closed, glow_color, pulse, 1.0, core_color)
|
||||
_draw_layers(item, closed, glow_color, pulse, width_scale, core_color)
|
||||
|
||||
|
||||
static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ extends Control
|
|||
@export var digit_height: float = 30.0
|
||||
@export var digit_gap: float = 9.0
|
||||
|
||||
# Segment weight as a fraction of digit height, so the strokes keep the same
|
||||
# visual heft at any size. This used to be a fixed width regardless of the
|
||||
# digit height, which left large readouts looking hairline next to the solid
|
||||
# ore hexagon beside them.
|
||||
const STROKE_RATIO: float = 0.014
|
||||
const HEX_STROKE_RATIO: float = 0.030
|
||||
|
||||
# Which of the seven segments each digit lights, in the usual order:
|
||||
# top, top-right, bottom-right, bottom, bottom-left, top-left, middle.
|
||||
const DIGIT_SEGMENTS: Array = [
|
||||
|
|
@ -60,8 +67,8 @@ func _draw_hexagon(centre: Vector2, radius: float) -> void:
|
|||
for i in range(6):
|
||||
var a: float = (float(i) / 6.0) * TAU - PI / 2.0
|
||||
pts.append(centre + Vector2(cos(a), sin(a)) * radius)
|
||||
NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color)
|
||||
NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, 0.26, core_color)
|
||||
NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color, radius * HEX_STROKE_RATIO)
|
||||
NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, radius * HEX_STROKE_RATIO * 0.8, core_color)
|
||||
|
||||
|
||||
func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
|
||||
|
|
@ -78,7 +85,8 @@ func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void:
|
|||
[Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left
|
||||
[Vector2(0.0, mid), Vector2(w, mid)], # middle
|
||||
]
|
||||
var stroke: float = h * STROKE_RATIO
|
||||
for i in range(seg.size()):
|
||||
if not on[i]:
|
||||
continue
|
||||
NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, 0.3, core_color)
|
||||
NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, stroke, core_color)
|
||||
|
|
|
|||
47
NeonPiece.gd
Normal file
47
NeonPiece.gd
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
class_name NeonPiece
|
||||
extends Node2D
|
||||
|
||||
# One movable piece of neon art, rasterised ONCE and thereafter animated
|
||||
# only through node properties.
|
||||
#
|
||||
# Godot re-runs _draw() solely when a redraw is requested; changing
|
||||
# position/rotation/scale/modulate/visible reuses the cached geometry
|
||||
# instead. Since every NeonDraw call expands into four antialiased
|
||||
# polylines, redrawing a shape each frame just to move or fade it is the
|
||||
# expensive way to do the cheapest kind of animation. Ship.gd and
|
||||
# Asteroid.gd already work this way - this is the same idea packaged so a
|
||||
# building can be assembled out of independently movable parts.
|
||||
#
|
||||
# Geometry is defined in local space around the piece's own origin, so the
|
||||
# owner can move, spin, scale and fade it without touching the shape.
|
||||
|
||||
var polygons: Array = []
|
||||
var lines: Array = []
|
||||
var glow_color: Color = Color.WHITE
|
||||
var core_color: Color = NeonDraw.DEFAULT_CORE
|
||||
var pulse: float = 1.0
|
||||
|
||||
|
||||
static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0) -> NeonPiece:
|
||||
var piece: NeonPiece = NeonPiece.new()
|
||||
piece.position = at
|
||||
piece.glow_color = colour
|
||||
piece.modulate.a = alpha
|
||||
parent.add_child(piece)
|
||||
return piece
|
||||
|
||||
|
||||
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void:
|
||||
polygons.append({"points": points, "pulse": piece_pulse})
|
||||
|
||||
|
||||
func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0) -> void:
|
||||
lines.append({"from": from, "to": to, "width": width, "pulse": piece_pulse})
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
for poly in polygons:
|
||||
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color)
|
||||
for line in lines:
|
||||
NeonDraw.draw_glowing_line(self, line.from, line.to, glow_color,
|
||||
line.pulse * pulse, line.width, core_color)
|
||||
1
NeonPiece.gd.uid
Normal file
1
NeonPiece.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b8r2lwqg3syw2
|
||||
69
Planet.gd
69
Planet.gd
|
|
@ -20,6 +20,23 @@ extends Node2D
|
|||
|
||||
var shape_points: PackedVector2Array = PackedVector2Array()
|
||||
|
||||
# An extractor is built down on this planet's SURFACE, in a different scene
|
||||
# the player is no longer looking at, so from space nothing distinguished a
|
||||
# planet already being mined from an untouched one - you had to land to find
|
||||
# out. This is a derrick planted on the rim: the same amber the world map
|
||||
# uses for its extractor badge, so the two read as the same fact.
|
||||
const EXTRACTOR_MARKER_COLOR: Color = Color(1.0, 0.85, 0.3)
|
||||
const DERRICK_HEIGHT: float = 44.0
|
||||
const DERRICK_HALF_WIDTH: float = 16.0
|
||||
|
||||
# Kept in step with GameState.extractor_added rather than polled per frame.
|
||||
# An extractor is built from SPACE (BuildController._planet_under_fleet(),
|
||||
# with the fleet parked on the planet), so this node is already in the tree
|
||||
# with its _ready() long since run when the answer changes - resolving it
|
||||
# only at _ready() left the planet looking untouched until it happened to
|
||||
# despawn and respawn.
|
||||
var shows_extractor: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("planets")
|
||||
|
|
@ -32,8 +49,60 @@ func _ready() -> void:
|
|||
var angle: float = (float(i) / point_count) * TAU
|
||||
var radius: float = rng.randf_range(70.0, 100.0)
|
||||
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
shows_extractor = GameState.has_extractor_at(planet_name)
|
||||
GameState.extractor_added.connect(_on_extractor_added)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _on_extractor_added(built_on: String) -> void:
|
||||
# Only the planet it was actually built on, and only a redraw when the
|
||||
# answer really changed - every other planet in range gets this too.
|
||||
if built_on != planet_name or shows_extractor:
|
||||
return
|
||||
shows_extractor = true
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func extractor_derrick_anchor() -> Vector2:
|
||||
# The rim vertex the derrick stands on: the shape's own topmost point,
|
||||
# rather than a fixed radius, so it sits ON the outline whatever lumpy
|
||||
# shape the seed drew instead of floating off it or sinking inside it.
|
||||
var base: Vector2 = Vector2(0.0, -100.0)
|
||||
for p in shape_points:
|
||||
if p.y < base.y:
|
||||
base = p
|
||||
return base
|
||||
|
||||
|
||||
func extractor_derrick_tip() -> Vector2:
|
||||
# Exposed separately from the polygon because "does the derrick clear the
|
||||
# planet's outline" is a question about the TIP alone. The base corners
|
||||
# sit a half-width off to either side, so they are sqrt(|base|^2 + w^2)
|
||||
# from the centre and can clear the rim on their own even when the tower
|
||||
# points straight into the ground.
|
||||
var base: Vector2 = extractor_derrick_anchor()
|
||||
var out: Vector2 = base.normalized()
|
||||
if out == Vector2.ZERO:
|
||||
out = Vector2.UP
|
||||
return base + out * DERRICK_HEIGHT
|
||||
|
||||
|
||||
func extractor_derrick_points() -> PackedVector2Array:
|
||||
var base: Vector2 = extractor_derrick_anchor()
|
||||
var out: Vector2 = base.normalized()
|
||||
if out == Vector2.ZERO:
|
||||
out = Vector2.UP
|
||||
var side: Vector2 = Vector2(-out.y, out.x)
|
||||
var tip: Vector2 = extractor_derrick_tip()
|
||||
return PackedVector2Array([
|
||||
base - side * DERRICK_HALF_WIDTH,
|
||||
tip - side * (DERRICK_HALF_WIDTH * 0.35),
|
||||
tip + side * (DERRICK_HALF_WIDTH * 0.35),
|
||||
base + side * DERRICK_HALF_WIDTH,
|
||||
])
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)
|
||||
if shows_extractor:
|
||||
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0)
|
||||
|
|
|
|||
|
|
@ -58,10 +58,10 @@ layer = 1
|
|||
mouse_filter = 2
|
||||
offset_left = 28
|
||||
offset_top = 24
|
||||
offset_right = 520
|
||||
offset_bottom = 140
|
||||
offset_right = 320
|
||||
offset_bottom = 92
|
||||
script = ExtResource("9")
|
||||
digit_height = 100.0
|
||||
digit_height = 60.0
|
||||
glow_color = Color(0, 0.42, 0.48, 1)
|
||||
core_color = Color(0, 0.08, 0.1, 1)
|
||||
|
||||
|
|
@ -143,22 +143,6 @@ icon = 6
|
|||
glow_color = Color(0.06, 0.32, 0.5, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
|
||||
[node name="NavLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -344
|
||||
offset_right = -24
|
||||
offset_top = -322
|
||||
offset_bottom = -196
|
||||
text = "Pivot"
|
||||
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
|
||||
theme_override_constants/outline_size = 5
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="FadeOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ var age: float = 0.0
|
|||
func _ready() -> void:
|
||||
add_to_group("player_projectiles")
|
||||
area_entered.connect(_on_area_entered)
|
||||
# Draw once. The earlier invisible-bolt bug came from _draw() deriving
|
||||
# the bolt's look from `age` - drawing only at age==0 froze it at
|
||||
# whatever the age==0 case rendered as. _draw() below is now a fixed
|
||||
# shape with no age dependency, so one draw is safe and matches the
|
||||
# Ship.gd/Asteroid.gd pattern: position/rotation are transform
|
||||
# properties Godot applies at render time without re-tessellating.
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func launch(direction: Vector2) -> void:
|
||||
|
|
@ -22,14 +29,6 @@ func launch(direction: Vector2) -> void:
|
|||
func _physics_process(delta: float) -> void:
|
||||
position += velocity * delta
|
||||
age += delta
|
||||
# Redrawn every frame. In principle a fixed shape only needs drawing
|
||||
# once (see Asteroid.gd, where dropping this was a large win), but
|
||||
# doing that here left bolts invisible on screen - verified by
|
||||
# rendering a frame mid-combat and finding zero pixels of the bolt
|
||||
# colour. Rather than ship invisible shots a second time, this keeps
|
||||
# the redraw: projectiles are few and live under two seconds, so the
|
||||
# cost is negligible.
|
||||
queue_redraw()
|
||||
if age >= lifetime:
|
||||
queue_free()
|
||||
|
||||
|
|
|
|||
4
Rock.gd
4
Rock.gd
|
|
@ -80,7 +80,9 @@ func _nearest_ship() -> Node2D:
|
|||
|
||||
func _on_area_entered(area: Area2D) -> void:
|
||||
if area.is_in_group("player_ships"):
|
||||
GameState.rocks += 1
|
||||
# Into the fleet's hold, not the bank (#14). It only counts as
|
||||
# spendable once the fleet has flown it back to the mothership.
|
||||
GameState.collect_cargo(1)
|
||||
collected.emit()
|
||||
queue_free()
|
||||
|
||||
|
|
|
|||
121
SkillTreeView.gd
Normal file
121
SkillTreeView.gd
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
extends Control
|
||||
|
||||
# The mothership's research tree, drawn in the game's own neon style rather
|
||||
# than with Godot UI widgets, for the same reason everything else here is
|
||||
# hand-drawn: a themed Button would look nothing like the rest of the game.
|
||||
#
|
||||
# Nodes are laid out from the normalised `pos` in GameState.SKILL_TREE, so
|
||||
# the tree's shape is data next to the upgrades themselves instead of being
|
||||
# duplicated here.
|
||||
#
|
||||
# Input arrives through BuildController._unhandled_input, NOT _gui_input -
|
||||
# a Control that consumes touches through the GUI system swallows them
|
||||
# before the menu can act on them, which is the bug that made the formation
|
||||
# editor's drag silently do nothing. Everything in this menu must stay
|
||||
# mouse_filter = IGNORE.
|
||||
|
||||
signal node_picked(id: String)
|
||||
|
||||
const LOCKED_COLOR: Color = Color(0.36, 0.39, 0.5)
|
||||
const READY_COLOR: Color = Color(0.55, 0.6, 1.0)
|
||||
const DONE_COLOR: Color = Color(0.45, 0.9, 0.55)
|
||||
const SELECTED_RING: Color = Color(1.0, 0.85, 0.4)
|
||||
# A locked node needs a dark core as well as a dim glow. NeonDraw tops every
|
||||
# shape with a near-white core stroke, which at this size dominates the glow
|
||||
# entirely - so a "dim" colour alone still came out the brightest thing on
|
||||
# screen, making unreachable nodes look like the highlighted ones.
|
||||
const LOCKED_CORE: Color = Color(0.17, 0.19, 0.26)
|
||||
const LOCKED_PULSE: float = 0.5
|
||||
|
||||
@export var node_radius: float = 46.0
|
||||
|
||||
var selected_id: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func node_screen_pos(node: Dictionary) -> Vector2:
|
||||
# Height comes from dependency depth, not from the data: the root sits at
|
||||
# the bottom and each further prerequisite climbs a row, so the tree grows
|
||||
# upwards as it is researched and stays correct when nodes are added.
|
||||
var deepest: int = maxi(GameState.max_skill_depth(), 1)
|
||||
var climb: float = float(GameState.skill_depth(node.id)) / float(deepest)
|
||||
# Inset so a node on row 0 or column 0 is not clipped in half.
|
||||
var margin: float = node_radius * 1.4
|
||||
return Vector2(
|
||||
lerpf(margin, size.x - margin, float(node.x)),
|
||||
lerpf(size.y - margin, margin, climb)
|
||||
)
|
||||
|
||||
|
||||
func _state_color(node: Dictionary) -> Color:
|
||||
if GameState.is_unlocked(node.id):
|
||||
return DONE_COLOR
|
||||
if GameState.prerequisite_met(node.id):
|
||||
return READY_COLOR
|
||||
return LOCKED_COLOR
|
||||
|
||||
|
||||
# Returns true when the press landed on a node, so the caller knows the
|
||||
# touch was consumed.
|
||||
func handle_touch(event: InputEventScreenTouch) -> bool:
|
||||
if not event.pressed or not visible:
|
||||
return false
|
||||
var local: Vector2 = event.position - global_position
|
||||
for node in GameState.SKILL_TREE:
|
||||
if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25:
|
||||
selected_id = node.id
|
||||
queue_redraw()
|
||||
node_picked.emit(node.id)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Links first, so nodes sit on top of them.
|
||||
for node in GameState.SKILL_TREE:
|
||||
if node.requires == "":
|
||||
continue
|
||||
var parent: Dictionary = GameState.skill_node(node.requires)
|
||||
if parent.is_empty():
|
||||
continue
|
||||
var from: Vector2 = node_screen_pos(parent)
|
||||
var to: Vector2 = node_screen_pos(node)
|
||||
# A link reads as "live" only once the step it leads to is actually
|
||||
# reachable, so the eye follows the researched path outwards.
|
||||
var lit: bool = GameState.is_unlocked(node.id)
|
||||
var reachable: bool = GameState.prerequisite_met(node.id)
|
||||
var link_color: Color = DONE_COLOR if lit else (READY_COLOR if reachable else LOCKED_COLOR)
|
||||
var trim: Vector2 = (to - from).normalized() * node_radius
|
||||
NeonDraw.draw_glowing_line(self, from + trim, to - trim, link_color,
|
||||
1.0 if reachable else LOCKED_PULSE, 0.4,
|
||||
NeonDraw.DEFAULT_CORE if reachable else LOCKED_CORE)
|
||||
|
||||
for node in GameState.SKILL_TREE:
|
||||
var centre: Vector2 = node_screen_pos(node)
|
||||
var colour: Color = _state_color(node)
|
||||
var reach: bool = GameState.prerequisite_met(node.id)
|
||||
var pulse: float = 1.0 if reach else LOCKED_PULSE
|
||||
var core_stroke: Color = NeonDraw.DEFAULT_CORE if reach else LOCKED_CORE
|
||||
var hexagon: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(6):
|
||||
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
|
||||
hexagon.append(centre + Vector2(cos(a), sin(a)) * node_radius)
|
||||
NeonDraw.draw_glowing_polygon(self, hexagon, colour, pulse, core_stroke)
|
||||
|
||||
# Researched nodes get a filled-in core; the rest stay hollow.
|
||||
if GameState.is_unlocked(node.id):
|
||||
var core: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(6):
|
||||
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
|
||||
core.append(centre + Vector2(cos(a), sin(a)) * node_radius * 0.42)
|
||||
NeonDraw.draw_glowing_polygon(self, core, colour, 1.0)
|
||||
|
||||
if node.id == selected_id:
|
||||
var ring: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(6):
|
||||
var a: float = (float(i) / 6.0) * TAU
|
||||
ring.append(centre + Vector2(cos(a), sin(a)) * node_radius * 1.32)
|
||||
NeonDraw.draw_glowing_polygon(self, ring, SELECTED_RING, 0.9)
|
||||
1
SkillTreeView.gd.uid
Normal file
1
SkillTreeView.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ob1hc06hf21t
|
||||
38
Transporter.gd
Normal file
38
Transporter.gd
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
extends Node2D
|
||||
|
||||
# Purely visual: a transport vessel running a hub -> mothership -> building
|
||||
# supply route. Carries no state of its own; GameState owns the simulation
|
||||
# and TransporterField creates, positions and frees this to match.
|
||||
|
||||
const HULL_COLOR: Color = Color(0.95, 0.55, 0.85)
|
||||
|
||||
var laden: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("transporters")
|
||||
z_index = -1
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_laden(value: bool) -> void:
|
||||
if laden == value:
|
||||
return
|
||||
laden = value
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Squat and boxy, in the transport hub's own magenta, so it reads as
|
||||
# "belongs to the hub" and not as an extractor hauler (green) or a fleet
|
||||
# vessel (sharp cyan triangle).
|
||||
var hull: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(0, -16), Vector2(10, -4), Vector2(10, 14), Vector2(-10, 14), Vector2(-10, -4),
|
||||
])
|
||||
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0)
|
||||
if laden:
|
||||
# A visible payload, so a loaded run is distinguishable from a
|
||||
# deadhead back to the hub at a glance.
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
Vector2(-5, 0), Vector2(5, 0), Vector2(5, 9), Vector2(-5, 9),
|
||||
]), HULL_COLOR, 0.85)
|
||||
1
Transporter.gd.uid
Normal file
1
Transporter.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://u8bqm1f03l2y
|
||||
6
Transporter.tscn
Normal file
6
Transporter.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://Transporter.gd" id="1"]
|
||||
|
||||
[node name="Transporter" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
51
TransporterField.gd
Normal file
51
TransporterField.gd
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
extends Node2D
|
||||
|
||||
# Draws the transport vessels GameState is simulating, and only while they
|
||||
# are somewhere the player could see them. Same arrangement as HaulerField,
|
||||
# and for the same reason: the simulation is authoritative and the visuals
|
||||
# are optional, so a run happening far away costs nothing but still moves
|
||||
# ore. Position comes from the very same `progress` that decides when the
|
||||
# ore actually changes hands.
|
||||
|
||||
@export var visible_radius: float = 2600.0
|
||||
|
||||
@onready var fleet: Node2D = get_node("../Fleet")
|
||||
|
||||
var transporter_scene: PackedScene = preload("res://Transporter.tscn")
|
||||
# index into GameState.transporters -> live node
|
||||
var active: Dictionary = {}
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
|
||||
for i in range(GameState.transporters.size()):
|
||||
var t: Dictionary = GameState.transporters[i]
|
||||
var pos: Vector2 = GameState.transporter_position(t)
|
||||
if pos.distance_to(centre) > visible_radius:
|
||||
_despawn(i)
|
||||
continue
|
||||
|
||||
var node: Node2D = active.get(i)
|
||||
if node == null or not is_instance_valid(node):
|
||||
node = transporter_scene.instantiate()
|
||||
get_parent().add_child(node)
|
||||
active[i] = node
|
||||
|
||||
var previous: Vector2 = node.position
|
||||
node.position = pos
|
||||
node.set_laden(int(t.carrying) > 0)
|
||||
var travel: Vector2 = pos - previous
|
||||
if travel.length() > 0.5:
|
||||
node.rotation = travel.angle() + PI / 2.0
|
||||
|
||||
for key in active.keys():
|
||||
if key >= GameState.transporters.size():
|
||||
_despawn(key)
|
||||
|
||||
|
||||
func _despawn(index: int) -> void:
|
||||
var node: Node2D = active.get(index)
|
||||
if node != null and is_instance_valid(node):
|
||||
node.queue_free()
|
||||
active.erase(index)
|
||||
1
TransporterField.gd.uid
Normal file
1
TransporterField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://mrx6p58dog66
|
||||
|
|
@ -2,6 +2,24 @@ extends Node2D
|
|||
|
||||
const WORLD_TO_LOCAL: float = 0.04
|
||||
|
||||
# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a
|
||||
# marker on the map reads as "the same building" at a glance. Duplicated
|
||||
# rather than cross-referenced - the same colour is already independently
|
||||
# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this
|
||||
# follows the codebase's existing convention rather than inventing a new one.
|
||||
const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55)
|
||||
const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25)
|
||||
const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0)
|
||||
const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85)
|
||||
|
||||
# A structure built on a planet's surface (see GameState.extractors) is not
|
||||
# itself a map marker - it is a small badge on the existing planet marker,
|
||||
# in a colour distinct from both the planets and the space buildings so it
|
||||
# reads as "something is mined here" rather than another building type.
|
||||
const EXTRACTOR_BADGE_COLOR: Color = Color(1.0, 0.85, 0.3)
|
||||
const EXTRACTOR_BADGE_OFFSET: Vector2 = Vector2(8.0, -8.0)
|
||||
const EXTRACTOR_BADGE_RADIUS: float = 5.0
|
||||
|
||||
@onready var tracker: Node2D = get_node("../../../ExplorationTracker")
|
||||
@onready var fleet: Node2D = get_node("../../../Fleet")
|
||||
|
||||
|
|
@ -11,6 +29,61 @@ func _process(_delta: float) -> void:
|
|||
queue_redraw()
|
||||
|
||||
|
||||
# Local-space marker data for every player-placed building (GameState.
|
||||
# buildings), one entry per building with its colour already resolved. Kept
|
||||
# as a separate, callable step (rather than inlined in _draw()) so a
|
||||
# headless test - which has no GPU and cannot check drawn pixels - can
|
||||
# verify a built building actually produces a marker.
|
||||
func building_markers() -> Array:
|
||||
var markers: Array = []
|
||||
for b in GameState.buildings:
|
||||
markers.append({
|
||||
"position": b.position * WORLD_TO_LOCAL,
|
||||
"type": b.type,
|
||||
"color": _building_marker_color(b.type),
|
||||
})
|
||||
return markers
|
||||
|
||||
|
||||
func _building_marker_color(type: String) -> Color:
|
||||
match type:
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
return MOTHERSHIP_MARKER_COLOR
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
return CRAFTER_MARKER_COLOR
|
||||
GameState.BUILDING_TRANSPORT_HUB:
|
||||
return TRANSPORT_HUB_MARKER_COLOR
|
||||
_:
|
||||
return COMMAND_MARKER_COLOR
|
||||
|
||||
|
||||
# Whether the planet in this (explored) chunk has an extractor on it, per
|
||||
# GameState.has_extractor_at(). A separate predicate for the same
|
||||
# GPU-less-testability reason as building_markers().
|
||||
func chunk_has_extractor_badge(chunk: Vector2i) -> bool:
|
||||
if not WorldGen.has_planet_at_chunk(chunk):
|
||||
return false
|
||||
return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).name)
|
||||
|
||||
|
||||
func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
|
||||
var pts: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(sides):
|
||||
var a: float = (float(i) / float(sides)) * TAU + phase
|
||||
pts.append(center + Vector2(cos(a), sin(a)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
# Filled shape plus a thin white outline - the same construction the fleet
|
||||
# marker below already uses, just factored out since buildings now need it
|
||||
# three times over (once per type) and the extractor badge needs it too.
|
||||
func _draw_marker_polygon(pts: PackedVector2Array, color: Color) -> void:
|
||||
draw_colored_polygon(pts, color)
|
||||
var closed: PackedVector2Array = pts.duplicate()
|
||||
closed.append(pts[0])
|
||||
draw_polyline(closed, Color(1, 1, 1), 1.5, true)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL
|
||||
|
||||
|
|
@ -32,6 +105,24 @@ func _draw() -> void:
|
|||
draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55))
|
||||
draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true)
|
||||
|
||||
if chunk_has_extractor_badge(chunk):
|
||||
var badge_pts: PackedVector2Array = _regular_polygon(
|
||||
planet_local + EXTRACTOR_BADGE_OFFSET, EXTRACTOR_BADGE_RADIUS, 4, PI / 4.0)
|
||||
_draw_marker_polygon(badge_pts, EXTRACTOR_BADGE_COLOR)
|
||||
|
||||
# Buildings are placed freely in space rather than tied to a chunk, so
|
||||
# they get their own pass rather than living inside the chunk loop above.
|
||||
for marker in building_markers():
|
||||
match marker.type:
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
_draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color)
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
_draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color)
|
||||
GameState.BUILDING_TRANSPORT_HUB:
|
||||
_draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color)
|
||||
_:
|
||||
_draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color)
|
||||
|
||||
var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL
|
||||
var marker_points: PackedVector2Array = PackedVector2Array([
|
||||
fleet_local + Vector2(0, -10),
|
||||
|
|
|
|||
BIN
fonts/CarroisGothicSC-Regular.ttf
Normal file
BIN
fonts/CarroisGothicSC-Regular.ttf
Normal file
Binary file not shown.
36
fonts/CarroisGothicSC-Regular.ttf.import
Normal file
36
fonts/CarroisGothicSC-Regular.ttf.import
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://co1qw50ehq2tl"
|
||||
path="res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://fonts/CarroisGothicSC-Regular.ttf"
|
||||
dest_files=["res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
202
fonts/LICENSE.txt
Normal file
202
fonts/LICENSE.txt
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
15
fonts/README.md
Normal file
15
fonts/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Bundled fonts
|
||||
|
||||
## Carrois Gothic SC
|
||||
|
||||
`CarroisGothicSC-Regular.ttf` — designed by Ralph du Carrois, distributed via
|
||||
Google Fonts.
|
||||
|
||||
Licensed under the Apache License, Version 2.0. The full licence text is in
|
||||
`LICENSE.txt` alongside this file; it ships with the game because Apache 2.0
|
||||
requires a copy of the licence to accompany any distribution of the work.
|
||||
|
||||
It is the project-wide UI typeface, wired up through `ui_theme.tres`
|
||||
(`default_font`), which `project.godot` loads as `gui/theme/custom`. Note that
|
||||
the `gui/theme/default_font_size` project setting does **not** reach the
|
||||
default theme — the size lives on the theme resource too.
|
||||
20
tests/_build_menu_preview.gd
Normal file
20
tests/_build_menu_preview.gd
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test. Opens the build menu itself, so the three-row layout
|
||||
# added for the Transport Hub (#18) can be checked for fit on the real phone
|
||||
# viewport - every button, label and price moved, and a bad offset is
|
||||
# invisible to a headless test.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/menu.png 20 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.rocks = 500
|
||||
# Everything researched, so nothing is greyed out and the whole menu is
|
||||
# legible at full strength.
|
||||
GameState.unlocked = ["shipyards", "logistics", "freight", "coordination"]
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("UI/BuildMenu").visible = true
|
||||
1
tests/_build_menu_preview.gd.uid
Normal file
1
tests/_build_menu_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cuns23tjgn7fg
|
||||
6
tests/_build_menu_preview.tscn
Normal file
6
tests/_build_menu_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_build_menu_preview.gd" id="1"]
|
||||
|
||||
[node name="BuildMenuPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
15
tests/_cargo_readout_preview.gd
Normal file
15
tests/_cargo_readout_preview.gd
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test. The resource readouts with the fleet carrying ore, so
|
||||
# the #15 banked-vs-carried split can actually be looked at - headless draws
|
||||
# no pixels and cannot say whether two numbers read as two different things.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_cargo_readout_preview.tscn /tmp/hud.png 20 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.rocks = 128
|
||||
GameState.fleet_cargo = 34
|
||||
add_child(load("res://Main.tscn").instantiate())
|
||||
1
tests/_cargo_readout_preview.gd.uid
Normal file
1
tests/_cargo_readout_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://djp66g3saubh4
|
||||
6
tests/_cargo_readout_preview.tscn
Normal file
6
tests/_cargo_readout_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_cargo_readout_preview.gd" id="1"]
|
||||
|
||||
[node name="CargoReadoutPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
27
tests/_craft_menu_preview.gd
Normal file
27
tests/_craft_menu_preview.gd
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test. Opens the CraftMenu so its layout can be checked on a
|
||||
# real viewport - the mothership's "Load ore" label was falling off the bottom
|
||||
# of shorter screens, which no headless test can see.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280
|
||||
# ... and again at 720x960 to check a shorter screen format.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.transporters.clear()
|
||||
GameState.rocks = 180
|
||||
GameState.fleet_cargo = 24
|
||||
GameState.unlocked = ["shipyards", "logistics", "freight"]
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
|
||||
var bc: Node = main.get_node("BuildController")
|
||||
bc.craft_building_id = 1
|
||||
main.get_node("UI/CraftMenu").visible = true
|
||||
if "Craft" == "Craft":
|
||||
bc._refresh_craft_status()
|
||||
else:
|
||||
bc._refresh_load_ore()
|
||||
1
tests/_craft_menu_preview.gd.uid
Normal file
1
tests/_craft_menu_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dlhupvrh333dd
|
||||
6
tests/_craft_menu_preview.tscn
Normal file
6
tests/_craft_menu_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_craft_menu_preview.gd" id="1"]
|
||||
|
||||
[node name="CraftMenuPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -33,7 +33,7 @@ func _ready() -> void:
|
|||
if cell.has("phase"):
|
||||
b.set_process(false)
|
||||
b.anim_time = float(cell.phase) * b.CYCLE
|
||||
b.queue_redraw()
|
||||
b.apply_animation()
|
||||
|
||||
var cam: Camera2D = Camera2D.new()
|
||||
cam.zoom = Vector2(1.65, 1.65)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ func _initialize() -> void:
|
|||
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
|
||||
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
|
||||
"UI/BuildMenu/MothershipLabel",
|
||||
"UI/BuildMenu/TitleLabel",
|
||||
"UI/NavLabel"]:
|
||||
"UI/BuildMenu/TitleLabel"]:
|
||||
var label: Label = scene.get_node_or_null(path)
|
||||
if label == null:
|
||||
print("%s: MISSING" % path)
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ func _ready() -> void:
|
|||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.reset_formation()
|
||||
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0))
|
||||
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0))
|
||||
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0))
|
||||
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0))
|
||||
GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0))
|
||||
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
|
|
@ -24,4 +25,4 @@ func _ready() -> void:
|
|||
# far enough to see them whole.
|
||||
main.get_node("Fleet").move_to(Vector2(0.0, 210.0))
|
||||
var cam: Camera2D = main.get_node("Camera2D")
|
||||
cam.zoom = Vector2(1.35, 1.35)
|
||||
cam.zoom = Vector2(1.0, 1.0)
|
||||
|
|
|
|||
45
tests/_marker_preview.gd
Normal file
45
tests/_marker_preview.gd
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test (underscore prefix keeps it out of run_tests.sh).
|
||||
# Renders the world map with buildings placed and an extractor built, so the
|
||||
# issue #11 building markers and the issue #9 extractor badge can actually be
|
||||
# LOOKED at. --headless draws no pixels, so no headless test can tell whether
|
||||
# a marker is visible - see tests/screenshot.sh.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.explored_chunks.clear()
|
||||
|
||||
# Near the origin, where the fleet starts, so everything lands on screen.
|
||||
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0))
|
||||
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0))
|
||||
GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0))
|
||||
|
||||
# Reveal a patch of map, and put an extractor on whichever planet is
|
||||
# nearest the origin so its badge is in frame too.
|
||||
var best: Vector2i = Vector2i.ZERO
|
||||
var best_distance: int = 1 << 30
|
||||
for x in range(-6, 7):
|
||||
for y in range(-6, 7):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
GameState.explored_chunks[chunk] = true
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
var d: int = absi(x) + absi(y)
|
||||
if d < best_distance:
|
||||
best_distance = d
|
||||
best = chunk
|
||||
if best_distance < (1 << 30):
|
||||
var data: Dictionary = WorldGen.planet_data_at_chunk(best)
|
||||
GameState.add_extractor(data.name, WorldGen.chunk_origin(best) + data.local_pos)
|
||||
print("extractor on '%s' at chunk %s" % [data.name, best])
|
||||
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
# open_map(), not `visible = true` - opening also recentres the view on
|
||||
# the fleet, and without that the map is drawn from its own origin in the
|
||||
# corner of the screen with everything near the fleet cropped off.
|
||||
main.get_node("UI/WorldMap").open_map()
|
||||
1
tests/_marker_preview.gd.uid
Normal file
1
tests/_marker_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://blbow3jq36fdw
|
||||
6
tests/_marker_preview.tscn
Normal file
6
tests/_marker_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_marker_preview.gd" id="1"]
|
||||
|
||||
[node name="MarkerPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
25
tests/_planet_derrick_preview.gd
Normal file
25
tests/_planet_derrick_preview.gd
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test. Two planets side by side in SPACE - the left one has
|
||||
# an extractor built on it, the right one does not - so the issue #9 derrick
|
||||
# can be compared against a plain planet and judged as actually visible.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_planet_derrick_preview.tscn /tmp/derrick.png 15 1280x720
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.extractors.clear()
|
||||
GameState.buildings.clear()
|
||||
GameState.add_extractor("Mined World", Vector2.ZERO)
|
||||
|
||||
_spawn("Mined World", Vector2(380.0, 380.0), 12345)
|
||||
_spawn("Untouched World", Vector2(900.0, 380.0), 4242)
|
||||
|
||||
|
||||
func _spawn(planet_name: String, at: Vector2, shape_seed: int) -> void:
|
||||
var planet: Node2D = load("res://Planet.tscn").instantiate()
|
||||
planet.planet_name = planet_name
|
||||
planet.shape_seed = shape_seed
|
||||
planet.position = at
|
||||
add_child(planet)
|
||||
print("%s: derrick=%s" % [planet_name, planet.shows_extractor])
|
||||
1
tests/_planet_derrick_preview.gd.uid
Normal file
1
tests/_planet_derrick_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dl4abkye58bd6
|
||||
6
tests/_planet_derrick_preview.tscn
Normal file
6
tests/_planet_derrick_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_planet_derrick_preview.gd" id="1"]
|
||||
|
||||
[node name="PlanetDerrickPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
40
tests/_profile_startup.gd
Normal file
40
tests/_profile_startup.gd
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
extends SceneTree
|
||||
|
||||
# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays
|
||||
# before it can assert anything, so tuning aims at the part that is actually
|
||||
# expensive.
|
||||
# godot --headless -s res://tests/_profile_startup.gd
|
||||
|
||||
var scene: Node
|
||||
var t0: int = 0
|
||||
var frames: int = 0
|
||||
var frame_start: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var boot: int = Time.get_ticks_msec()
|
||||
print("engine boot to _initialize: %d ms" % boot)
|
||||
|
||||
t0 = Time.get_ticks_msec()
|
||||
var packed: PackedScene = load("res://Main.tscn")
|
||||
print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0))
|
||||
|
||||
t0 = Time.get_ticks_msec()
|
||||
scene = packed.instantiate()
|
||||
print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0))
|
||||
|
||||
t0 = Time.get_ticks_msec()
|
||||
root.add_child(scene)
|
||||
print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0))
|
||||
|
||||
frame_start = Time.get_ticks_msec()
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if frames < 60:
|
||||
return false
|
||||
var span: int = Time.get_ticks_msec() - frame_start
|
||||
print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)])
|
||||
quit(0)
|
||||
return true
|
||||
1
tests/_profile_startup.gd.uid
Normal file
1
tests/_profile_startup.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dvfdj3y0xg3sj
|
||||
93
tests/_run_inside.sh
Normal file
93
tests/_run_inside.sh
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/bin/bash
|
||||
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
|
||||
# call tests/run_tests.sh from Termux instead.
|
||||
#
|
||||
# It exists as its own file so the whole suite runs in ONE `proot-distro
|
||||
# login`: entering the container costs ~0.5s, which used to be paid 38 times
|
||||
# over. Everything below is the container-side half of the runner.
|
||||
#
|
||||
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$1"; shift
|
||||
JOBS="$1"; shift
|
||||
VERBOSE="$1"; shift
|
||||
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
cd "$PROJECT_DIR" || exit 2
|
||||
|
||||
tests=("$@")
|
||||
total=${#tests[@]}
|
||||
if (( total == 0 )); then
|
||||
echo "no tests matched"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
outdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$outdir"' EXIT
|
||||
|
||||
# Import once, serially, before anything runs in parallel. Godot writes to
|
||||
# .godot/ while importing; several instances discovering a cold cache at the
|
||||
# same moment would race on it. After this the workers only read it.
|
||||
"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1
|
||||
|
||||
run_one() {
|
||||
local name="$1"
|
||||
local log="$outdir/$name.log"
|
||||
local start end
|
||||
start=$(date +%s%N)
|
||||
# --fixed-fps pins delta to exactly 1/60 instead of measuring the wall
|
||||
# clock. Without it a test's `elapsed` counts REAL seconds, so running
|
||||
# several at once stretches every frame and the gameplay under test sees
|
||||
# deltas it never sees in the game: ships overshoot their braking curve,
|
||||
# settle late, and tests time out for no reason but load. That made
|
||||
# test_camera_settles and test_planet_prompt_map_interaction fail on 2 of
|
||||
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
|
||||
# the phone is doing, and its result no longer depends on how busy the
|
||||
# machine was.
|
||||
"$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1
|
||||
local rc=$?
|
||||
end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
|
||||
# Two independent criteria, because either one alone can lie: a test that
|
||||
# crashes before reaching its verdict exits non-zero without ever printing
|
||||
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
|
||||
# while the log says otherwise.
|
||||
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
|
||||
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
|
||||
else
|
||||
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
|
||||
fi
|
||||
cat "$outdir/$name.status"
|
||||
}
|
||||
|
||||
running=0
|
||||
for name in "${tests[@]}"; do
|
||||
run_one "$name" &
|
||||
running=$(( running + 1 ))
|
||||
if (( running >= JOBS )); then
|
||||
wait -n
|
||||
running=$(( running - 1 ))
|
||||
fi
|
||||
done
|
||||
wait
|
||||
|
||||
failed=0
|
||||
for name in "${tests[@]}"; do
|
||||
if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then
|
||||
failed=$(( failed + 1 ))
|
||||
echo
|
||||
echo "=== $name FAILED, full output ==="
|
||||
cat "$outdir/$name.log" 2>/dev/null
|
||||
elif [[ "$VERBOSE" == "1" ]]; then
|
||||
echo
|
||||
echo "=== $name ==="
|
||||
cat "$outdir/$name.log" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "$(( total - failed ))/$total passed"
|
||||
(( failed == 0 ))
|
||||
31
tests/_skill_menu_preview.gd
Normal file
31
tests/_skill_menu_preview.gd
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test. Opens the SkillMenu so its layout can be checked on a
|
||||
# real viewport - the mothership's "Load ore" label was falling off the bottom
|
||||
# of shorter screens, which no headless test can see.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280
|
||||
# ... and again at 720x960 to check a shorter screen format.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.transporters.clear()
|
||||
GameState.rocks = 180
|
||||
GameState.fleet_cargo = 24
|
||||
GameState.unlocked = ["shipyards", "logistics", "freight"]
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
|
||||
var bc: Node = main.get_node("BuildController")
|
||||
bc.craft_building_id = 1
|
||||
main.get_node("UI/SkillMenu").visible = true
|
||||
if "Skill" == "Craft":
|
||||
bc._refresh_craft_status()
|
||||
else:
|
||||
bc._refresh_load_ore()
|
||||
# Select a node so the description, price and status all have text -
|
||||
# an empty description cannot collide with anything.
|
||||
bc.skill_tree.selected_id = "freight"
|
||||
bc._refresh_skill_menu("freight")
|
||||
1
tests/_skill_menu_preview.gd.uid
Normal file
1
tests/_skill_menu_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3n86yp86b755
|
||||
6
tests/_skill_menu_preview.tscn
Normal file
6
tests/_skill_menu_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_skill_menu_preview.gd" id="1"]
|
||||
|
||||
[node name="SkillMenuPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
38
tests/_transporter_preview.gd
Normal file
38
tests/_transporter_preview.gd
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test. A transporter mid-run between the mothership and a
|
||||
# building it is supplying, so the #19 vessel can actually be looked at
|
||||
# alongside the hub that stations it. Headless draws no pixels.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_transporter_preview.tscn /tmp/t.png 25 1280x720
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.transporters.clear()
|
||||
GameState.rocks = 200
|
||||
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-420.0, 0.0))
|
||||
GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(420.0, 0.0))
|
||||
|
||||
# One laden, mid-leg, and one running home empty, so both states show.
|
||||
GameState.transporters.append({
|
||||
"hub_id": 2, "state": GameState.TRANSPORTER_TO_BUILDING, "target_id": 2,
|
||||
"carrying": 8, "progress": 0.45,
|
||||
"from": Vector2(-420.0, 0.0), "to": Vector2(420.0, 0.0),
|
||||
})
|
||||
GameState.transporters.append({
|
||||
"hub_id": 2, "state": GameState.TRANSPORTER_RETURNING, "target_id": 0,
|
||||
"carrying": 0, "progress": 0.5,
|
||||
"from": Vector2(-380.0, 150.0), "to": Vector2(420.0, 150.0),
|
||||
})
|
||||
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
# Freeze the simulation so the vessels stay where they were placed.
|
||||
GameState.set_process(false)
|
||||
main.get_node("Fleet").move_to(Vector2(0.0, 260.0))
|
||||
var cam: Camera2D = main.get_node("Camera2D")
|
||||
cam.zoom = Vector2(0.85, 0.85)
|
||||
1
tests/_transporter_preview.gd.uid
Normal file
1
tests/_transporter_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cqc5de83g4tr2
|
||||
6
tests/_transporter_preview.tscn
Normal file
6
tests/_transporter_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_transporter_preview.gd" id="1"]
|
||||
|
||||
[node name="TransporterPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
47
tests/_vfx_preview.gd
Normal file
47
tests/_vfx_preview.gd
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders
|
||||
# projectiles and every explosion type so the node-animation conversion
|
||||
# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a
|
||||
# similar optimisation and headless tests did not catch it, because
|
||||
# --headless has no GPU - so this exists to make that failure visible.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20
|
||||
|
||||
const VFX_SCRIPTS: Array = [
|
||||
"res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd",
|
||||
"res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd",
|
||||
]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
|
||||
|
||||
# Bolts at several headings, held still so they can be inspected.
|
||||
var bolt_scene: PackedScene = load("res://Projectile.tscn")
|
||||
for i in range(6):
|
||||
var bolt: Area2D = bolt_scene.instantiate()
|
||||
bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0)
|
||||
bolt.rotation = float(i) * 0.35
|
||||
add_child(bolt)
|
||||
bolt.set_physics_process(false)
|
||||
|
||||
# One of each explosion, stepped forward by hand then frozen. Their real
|
||||
# lifetime is under a second, and a softpipe frame takes far longer than
|
||||
# that in wall-clock, so they would expire before the capture.
|
||||
for i in range(VFX_SCRIPTS.size()):
|
||||
var script: Script = load(VFX_SCRIPTS[i])
|
||||
if script == null:
|
||||
continue
|
||||
var vfx: Node2D = Node2D.new()
|
||||
vfx.set_script(script)
|
||||
vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0)
|
||||
vfx.lifetime = 999.0
|
||||
add_child(vfx)
|
||||
for step in range(8):
|
||||
vfx._process(0.05)
|
||||
vfx.set_process(false)
|
||||
|
||||
var cam: Camera2D = Camera2D.new()
|
||||
add_child(cam)
|
||||
cam.make_current()
|
||||
1
tests/_vfx_preview.gd.uid
Normal file
1
tests/_vfx_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dv32ajs2bxrwd
|
||||
6
tests/_vfx_preview.tscn
Normal file
6
tests/_vfx_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_vfx_preview.gd" id="1"]
|
||||
|
||||
[node name="VFXPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -1,20 +1,78 @@
|
|||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
# Runs the headless GDScript gameplay tests from Termux, via the Godot
|
||||
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
|
||||
# Usage: bash tests/run_tests.sh
|
||||
#
|
||||
# bash tests/run_tests.sh # everything
|
||||
# bash tests/run_tests.sh formation # only tests matching a substring
|
||||
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
|
||||
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
|
||||
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
|
||||
#
|
||||
# Three things make this fast, in descending order of how much they matter
|
||||
# (measured, not guessed - see tests/_profile_startup.gd):
|
||||
#
|
||||
# 1. It runs from a COPY on Termux's own filesystem, not from the project
|
||||
# directory. The project lives under ~/storage/shared, which is Android's
|
||||
# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")`
|
||||
# alone took 8.5s per test, against ~2s from a native path. Nearly every
|
||||
# test loads Main.tscn, and none of that time is gameplay. The copy is
|
||||
# ~1.4s for the whole suite and its .godot import cache is kept between
|
||||
# runs.
|
||||
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
|
||||
# quit() and stamp on the GameState autoload, so they cannot share one -
|
||||
# which makes the suite a pile of independent, single-core-bound jobs on
|
||||
# an 8-core phone.
|
||||
# 3. The whole run happens in ONE `proot-distro login`. Entering the
|
||||
# container costs ~0.5s, which used to be paid once per test.
|
||||
#
|
||||
# Output is buffered per test and printed only for failures; six interleaved
|
||||
# logs are unreadable.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
set -e
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||
|
||||
status=0
|
||||
for test_script in "$PROJECT_DIR"/tests/test_*.gd; do
|
||||
name="$(basename "$test_script")"
|
||||
echo "=== Running $name ==="
|
||||
if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then
|
||||
status=1
|
||||
fi
|
||||
echo
|
||||
# Leave headroom: Godot is memory-hungry and this is a phone, not a build
|
||||
# server. Override with JOBS= if you know better.
|
||||
JOBS="${JOBS:-6}"
|
||||
VERBOSE="${VERBOSE:-0}"
|
||||
IN_PLACE="${IN_PLACE:-0}"
|
||||
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
|
||||
|
||||
filter="${1:-}"
|
||||
tests=()
|
||||
for path in "$PROJECT_DIR"/tests/test_*.gd; do
|
||||
name="$(basename "$path")"
|
||||
if [[ -z "$filter" || "$name" == *"$filter"* ]]; then
|
||||
tests+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#tests[@]} == 0 )); then
|
||||
echo "no tests match '$filter'"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
start=$(date +%s)
|
||||
|
||||
run_dir="$PROJECT_DIR"
|
||||
if [[ "$IN_PLACE" != "1" ]]; then
|
||||
run_dir="$WORK_DIR"
|
||||
mkdir -p "$run_dir"
|
||||
# Clear last run's sources but keep .godot, so imports stay warm. Without
|
||||
# the clear, a test deleted from the project would live on in the copy and
|
||||
# keep being run.
|
||||
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
|
||||
# android/ and builds/ are export artefacts no test touches, and the .apk
|
||||
# is 30MB of nothing useful here.
|
||||
tar -C "$PROJECT_DIR" \
|
||||
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
|
||||
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
|
||||
fi
|
||||
|
||||
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
|
||||
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
|
||||
status=$?
|
||||
|
||||
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
|
||||
exit $status
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ WAIT_FRAMES="${3:-60}"
|
|||
RESOLUTION="${4:-1280x720}"
|
||||
|
||||
proot-distro login ubuntu -- bash -c "
|
||||
# A killed run (or a session that ended mid-screenshot) leaves /tmp/.X99-lock
|
||||
# behind, and Xvfb then refuses to claim :99 - which surfaces much later and
|
||||
# far less obviously as Godot's 'X11 Display is not available'. Nothing else
|
||||
# uses :99 here, so a lock with no live Xvfb behind it is always stale.
|
||||
if ! pgrep -x Xvfb >/dev/null; then
|
||||
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
||||
fi
|
||||
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
|
||||
XVFB_PID=\$!
|
||||
sleep 2
|
||||
|
|
|
|||
114
tests/test_asteroid_safe_spawn.gd
Normal file
114
tests/test_asteroid_safe_spawn.gd
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_asteroid_safe_spawn.gd
|
||||
# Asteroid chunks are populated around wherever the fleet is - including the
|
||||
# chunk it is standing in - and positions used to be uniform within a chunk.
|
||||
# So an asteroid could be created directly on top of a ship and destroy it
|
||||
# with no chance to react. Worst on the frame a scene loads: coming back from
|
||||
# a planet's surface could cost a vessel instantly, which is also what made
|
||||
# test_return_position_matches_planet fail intermittently.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. _safe_position never returns a point inside the bubble, for candidates
|
||||
# all around and including the exact centre. This is checked directly
|
||||
# rather than by looking at where asteroids happened to land: with ~54
|
||||
# asteroids spread over 25 chunks, any single run only rarely produces a
|
||||
# near miss, so an observational check passes just as happily with the
|
||||
# guard removed and proves nothing.
|
||||
# 2. candidates already outside the bubble are left exactly where they are,
|
||||
# so the field is not distorted away from the fleet
|
||||
# 3. the field still spawns a full complement, and a stationary fleet loses
|
||||
# no ships
|
||||
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var ships_at_start: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
root.get_node("GameState").reset_formation()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("AsteroidField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
ships_at_start = fleet.ships.size()
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Let the field populate around the fleet a few times over.
|
||||
if elapsed < 1.5:
|
||||
return false
|
||||
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
var safe: float = field.safe_spawn_radius
|
||||
|
||||
# --- the guard itself, deterministically ----------------------
|
||||
var worst_inside: float = 1e9
|
||||
var moved_an_outside_one: bool = false
|
||||
for i in range(72):
|
||||
var a: float = (float(i) / 72.0) * TAU
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
# Candidates well inside, just inside, and exactly on centre.
|
||||
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
|
||||
var got: Vector2 = field._safe_position(centre + dir * d, centre)
|
||||
worst_inside = minf(worst_inside, got.distance_to(centre))
|
||||
# Candidates already outside must be returned untouched.
|
||||
var outside: Vector2 = centre + dir * (safe * 1.5)
|
||||
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
|
||||
moved_an_outside_one = true
|
||||
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
|
||||
worst_inside, safe
|
||||
])
|
||||
if worst_inside < safe - 0.001:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
|
||||
quit(1)
|
||||
return true
|
||||
if moved_an_outside_one:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var asteroids: Array = get_nodes_in_group("asteroids")
|
||||
var closest: float = 1e9
|
||||
for a in asteroids:
|
||||
if is_instance_valid(a):
|
||||
closest = minf(closest, a.position.distance_to(centre))
|
||||
|
||||
print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [
|
||||
asteroids.size(), closest, safe
|
||||
])
|
||||
if asteroids.is_empty():
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)")
|
||||
quit(1)
|
||||
return true
|
||||
# Asteroids drift, so one may have travelled inward since it was
|
||||
# created. Allow for that, but nothing should be anywhere near
|
||||
# on top of the fleet.
|
||||
if closest < safe * 0.5:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var kept_ships: bool = fleet.ships.size() == ships_at_start
|
||||
print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships])
|
||||
if not kept_ships:
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_ASTEROID_SAFE_SPAWN: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_asteroid_safe_spawn.gd.uid
Normal file
1
tests/test_asteroid_safe_spawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ci8vodtuvwod7
|
||||
|
|
@ -39,6 +39,12 @@ func _initialize() -> void:
|
|||
game_state.rocks = 500
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
# Structures are gated behind the mothership's research tree. That gate
|
||||
# is test_skill_tree's subject, not this one's - here every option needs
|
||||
# to be available so the popup itself can be exercised.
|
||||
game_state.reset_progression()
|
||||
for node in GameState.SKILL_TREE:
|
||||
game_state.unlocked.append(node.id)
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
|
|
|
|||
110
tests/test_building_redraw_cost.gd
Normal file
110
tests/test_building_redraw_cost.gd
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_building_redraw_cost.gd
|
||||
# Animated buildings must not re-run _draw() to animate (Forgejo issues #6
|
||||
# and #10).
|
||||
#
|
||||
# Every NeonDraw call expands into four antialiased polylines, so redrawing
|
||||
# a whole shipyard each frame to advance a crane re-tessellates all of it.
|
||||
# The moving elements are now separate NeonPiece children, rasterised once
|
||||
# and thereafter only moved, scaled, faded or hidden - operations that reuse
|
||||
# the cached geometry. Ship.gd and Asteroid.gd already worked this way.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. an animated building's geometry is rasterised exactly ONCE, however
|
||||
# long it animates
|
||||
# 2. it is genuinely still animating (so this cannot be "passed" by
|
||||
# freezing the animation)
|
||||
# 3. the moving parts really do move - the crane travels and the
|
||||
# mothership's satellites orbit - proving the animation reaches the
|
||||
# nodes rather than being silently dropped
|
||||
# 4. the static command centre needs no per-frame work either
|
||||
|
||||
var scene: Node
|
||||
var field: Node
|
||||
var crafter: Node2D
|
||||
var mothership: Node2D
|
||||
var command: Node2D
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var crane_x_start: float = 0.0
|
||||
var satellite_rotation_start: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var game_state: Node = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
field = scene.get_node("BuildingField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0))
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0))
|
||||
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
for b in field.spawned:
|
||||
if not is_instance_valid(b):
|
||||
continue
|
||||
match b.building_type:
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
crafter = b
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
mothership = b
|
||||
GameState.BUILDING_COMMAND_CENTER:
|
||||
command = b
|
||||
if crafter == null or mothership == null or command == null:
|
||||
print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
crane_x_start = crafter._crane.position.x
|
||||
satellite_rotation_start = mothership._satellites.rotation
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# Long enough to cross several assembly phases and a good arc of
|
||||
# the satellites' orbit.
|
||||
if elapsed < 2.5:
|
||||
return false
|
||||
|
||||
print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [
|
||||
elapsed, crafter.draw_count, mothership.draw_count, command.draw_count
|
||||
])
|
||||
for b in [crafter, mothership, command]:
|
||||
if b.draw_count != 1:
|
||||
print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [
|
||||
b.building_type, b.draw_count
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0
|
||||
var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0
|
||||
var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05
|
||||
print("still animating=%s crane travelled=%s satellites orbited=%s" % [
|
||||
animating, crane_moved, orbit_moved
|
||||
])
|
||||
if not (animating and crane_moved and orbit_moved):
|
||||
print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_BUILDING_REDRAW_COST: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_building_redraw_cost.gd.uid
Normal file
1
tests/test_building_redraw_cost.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ihnpl5ogoxi1
|
||||
247
tests/test_building_stock_queue.gd
Normal file
247
tests/test_building_stock_queue.gd
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_building_stock_queue.gd
|
||||
# Forgejo #16 (part 3 of #12): a building produces out of its OWN stock, and
|
||||
# an order it cannot pay for yet is queued rather than refused.
|
||||
#
|
||||
# Before this, BuildController deducted from the bank at the moment of
|
||||
# purchase and refused outright with "Not enough rocks", so nothing ever had
|
||||
# to be delivered anywhere.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. ordering with an empty stock queues instead of failing, and produces
|
||||
# nothing yet
|
||||
# 2. it does NOT quietly take the cost out of the bank - the whole point is
|
||||
# that the building has to be supplied
|
||||
# 3. delivering enough stock completes the order on its own
|
||||
# 4. one delivery covering several queued orders clears them all, rather
|
||||
# than dribbling out one per frame
|
||||
# 5. a part-delivery is not enough: stock below the cost leaves the order
|
||||
# queued and the ore untouched
|
||||
# 6. stock and queue survive the space scene being torn down (the trip to
|
||||
# a planet's surface)
|
||||
# 7. production completes with NO fleet in existence, and the fleet picks
|
||||
# the vessel up when it comes back - orders must not depend on which
|
||||
# scene the player is standing in
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var crafter_id: int = 0
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var ships_at_start: int = 0
|
||||
var bank_at_start: int = 0
|
||||
var ticks: int = 0
|
||||
|
||||
const CRAFTER_AT: Vector2 = Vector2(900.0, 0.0)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.rocks = 500
|
||||
game_state.fleet_cargo = 0
|
||||
game_state.ships_owed = 0
|
||||
# Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet
|
||||
# starts at exactly 5, so without this every completed order would have
|
||||
# nowhere to put its vessel and the test would be measuring the cap
|
||||
# rather than the queue.
|
||||
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("BuildingField")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
_clear_hazards()
|
||||
fleet.spawn_ships(2, Vector2.ZERO)
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
|
||||
crafter_id = int(game_state.buildings[-1].id)
|
||||
ships_at_start = fleet.ships.size()
|
||||
bank_at_start = game_state.rocks
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- 1 + 2. an unaffordable order queues, and costs nothing --
|
||||
if not game_state.queue_order(crafter_id):
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (the crafter refused an order outright)")
|
||||
quit(1)
|
||||
return true
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if game_state.pending_orders(crafter_id) != 1:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order did not stay queued: %d pending)" % game_state.pending_orders(crafter_id))
|
||||
quit(1)
|
||||
return true
|
||||
if fleet.ships.size() != ships_at_start:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel appeared with an empty stock)")
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.rocks != bank_at_start:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (queueing took %d out of the bank - the building is supposed to be supplied)" % (bank_at_start - game_state.rocks))
|
||||
quit(1)
|
||||
return true
|
||||
print("empty stock -> queued=%d ships=%d bank=%d (untouched)" % [
|
||||
game_state.pending_orders(crafter_id), fleet.ships.size(), game_state.rocks
|
||||
])
|
||||
|
||||
# --- 5. a part-delivery is not enough -----------------------
|
||||
game_state.deposit_to_building(crafter_id, GameState.SHIP_COST - 1)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if game_state.pending_orders(crafter_id) != 1 or fleet.ships.size() != ships_at_start:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (produced on a part-delivery of %d against a cost of %d)" % [
|
||||
GameState.SHIP_COST - 1, GameState.SHIP_COST
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.building_stock(crafter_id) != GameState.SHIP_COST - 1:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (part-delivered ore was consumed anyway)")
|
||||
quit(1)
|
||||
return true
|
||||
print("part delivery (%d of %d) -> still queued, ore intact" % [
|
||||
game_state.building_stock(crafter_id), GameState.SHIP_COST
|
||||
])
|
||||
|
||||
# --- 3. topping it up completes the order -------------------
|
||||
game_state.deposit_to_building(crafter_id, 1)
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
if game_state.pending_orders(crafter_id) != 0:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order not completed once the stock arrived)")
|
||||
quit(1)
|
||||
return true
|
||||
if fleet.ships.size() != ships_at_start + 1:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (no vessel delivered: %d -> %d)" % [
|
||||
ships_at_start, fleet.ships.size()
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.building_stock(crafter_id) != 0:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (stock not consumed: %d left)" % game_state.building_stock(crafter_id))
|
||||
quit(1)
|
||||
return true
|
||||
print("supplied -> ships %d->%d, stock spent" % [ships_at_start, fleet.ships.size()])
|
||||
|
||||
# --- 4. one delivery clears ALL the orders it covers ---------
|
||||
# Measured in TICKS, not seconds, and with more orders than the
|
||||
# ticks allowed: production that handled one order per frame
|
||||
# would still clear them all inside any generous wall-clock wait,
|
||||
# so a timed check cannot tell "cleared at once" from "dribbled
|
||||
# out". Three orders, two ticks - only clearing them together
|
||||
# passes.
|
||||
ships_at_start = fleet.ships.size()
|
||||
game_state.queue_order(crafter_id)
|
||||
game_state.queue_order(crafter_id)
|
||||
game_state.queue_order(crafter_id)
|
||||
game_state.deposit_to_building(crafter_id, GameState.SHIP_COST * 3)
|
||||
ticks = 0
|
||||
stage = 5
|
||||
return false
|
||||
5:
|
||||
ticks += 1
|
||||
if ticks < 2:
|
||||
return false
|
||||
if game_state.pending_orders(crafter_id) != 0 or fleet.ships.size() != ships_at_start + 3:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (a delivery covering 3 orders left %d queued after %d ticks, ships %d->%d)" % [
|
||||
game_state.pending_orders(crafter_id), ticks, ships_at_start, fleet.ships.size()
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
print("one delivery cleared 3 orders in %d ticks -> ships %d->%d" % [
|
||||
ticks, ships_at_start, fleet.ships.size()
|
||||
])
|
||||
|
||||
# --- 6 + 7. no scene, no fleet: production still completes --
|
||||
# The scene has to be GONE before the order is placed. With a
|
||||
# fleet still alive and under its cap it claims the vessel the
|
||||
# same frame production finishes, which would leave ships_owed
|
||||
# back at 0 and prove nothing about orders surviving without it.
|
||||
ships_at_start = fleet.ships.size()
|
||||
scene.queue_free()
|
||||
elapsed = 0.0
|
||||
stage = 6
|
||||
return false
|
||||
6:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if is_instance_valid(fleet):
|
||||
return false
|
||||
game_state.queue_order(crafter_id)
|
||||
game_state.deposit_to_building(crafter_id, GameState.SHIP_COST)
|
||||
elapsed = 0.0
|
||||
stage = 61
|
||||
return false
|
||||
61:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
# The queue and stock live in the autoload, so tearing the space
|
||||
# scene down must not touch them - and the order must complete
|
||||
# even with no Fleet node anywhere to hand the vessel to.
|
||||
if game_state.pending_orders(crafter_id) != 0:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)")
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.ships_owed < 1:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)")
|
||||
quit(1)
|
||||
return true
|
||||
print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed)
|
||||
|
||||
# Come back to space: the fleet should claim what it is owed.
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
elapsed = 0.0
|
||||
stage = 8
|
||||
return false
|
||||
8:
|
||||
if elapsed < 0.5:
|
||||
return false
|
||||
if game_state.ships_owed != 0:
|
||||
print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed)
|
||||
quit(1)
|
||||
return true
|
||||
print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size())
|
||||
print("TEST_BUILDING_STOCK_QUEUE: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _clear_hazards() -> void:
|
||||
# set_process(false) on AsteroidField only stops NEW chunks spawning -
|
||||
# rocks already in the world keep drifting and can destroy a parked ship,
|
||||
# which silently changes the fleet size this test asserts on. This is a
|
||||
# logistics test, not a survival one.
|
||||
for a in get_nodes_in_group("asteroids"):
|
||||
if is_instance_valid(a):
|
||||
a.queue_free()
|
||||
for e in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(e):
|
||||
e.queue_free()
|
||||
1
tests/test_building_stock_queue.gd.uid
Normal file
1
tests/test_building_stock_queue.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b0364sukgpwow
|
||||
117
tests/test_cargo_readout.gd
Normal file
117
tests/test_cargo_readout.gd
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_cargo_readout.gd
|
||||
# Forgejo #15 (part 2 of #12): what the fleet is carrying has to be visible
|
||||
# separately from what is banked.
|
||||
#
|
||||
# Since #14, ore is cargo until the fleet delivers it to the mothership. The
|
||||
# banked readout does not move on pickup, so without a second readout mining
|
||||
# looks broken - you gather ore and no number on screen changes.
|
||||
#
|
||||
# --headless has no GPU and draws no pixels, so this asserts on the two
|
||||
# readouts' state, not on their appearance. tests/screenshot.sh covers
|
||||
# whether they are legible.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. the banked readout keeps showing the BANK, not the total
|
||||
# 2. the cargo readout shows the hold, and appears only while carrying
|
||||
# 3. an empty hold hides it rather than parking a permanent 0 beside the
|
||||
# real total
|
||||
# 4. delivering moves the number from one readout to the other
|
||||
# 5. the two are actually told apart on screen, rather than being the same
|
||||
# number twice in the same colour at the same place
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var banked: Control
|
||||
var cargo: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.rocks = 42
|
||||
game_state.fleet_cargo = 0
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
banked = scene.get_node("UI/ResourceCounter")
|
||||
cargo = scene.get_node("UI/CargoCounter")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- 3. nothing carried -> no cargo readout -----------------
|
||||
if cargo.visible:
|
||||
print("TEST_CARGO_READOUT: FAIL (cargo readout showing with an empty hold)")
|
||||
quit(1)
|
||||
return true
|
||||
if banked.amount != 42:
|
||||
print("TEST_CARGO_READOUT: FAIL (banked readout shows %d, bank is 42)" % banked.amount)
|
||||
quit(1)
|
||||
return true
|
||||
print("empty hold -> cargo readout hidden, banked shows %d" % banked.amount)
|
||||
|
||||
game_state.collect_cargo(7)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- 1 + 2. carried and banked are separate numbers ---------
|
||||
if not cargo.visible:
|
||||
print("TEST_CARGO_READOUT: FAIL (carrying 7 and the cargo readout is hidden)")
|
||||
quit(1)
|
||||
return true
|
||||
if cargo.amount != 7:
|
||||
print("TEST_CARGO_READOUT: FAIL (cargo readout shows %d, hold is 7)" % cargo.amount)
|
||||
quit(1)
|
||||
return true
|
||||
if banked.amount != 42:
|
||||
print("TEST_CARGO_READOUT: FAIL (banked readout moved to %d on a pickup - it is showing the total, not the bank)" % banked.amount)
|
||||
quit(1)
|
||||
return true
|
||||
print("carrying 7 -> cargo=%d banked=%d" % [cargo.amount, banked.amount])
|
||||
|
||||
# --- 5. genuinely distinguishable ---------------------------
|
||||
if cargo.glow_color == banked.glow_color:
|
||||
print("TEST_CARGO_READOUT: FAIL (both readouts drawn in the same colour)")
|
||||
quit(1)
|
||||
return true
|
||||
if cargo.position == banked.position:
|
||||
print("TEST_CARGO_READOUT: FAIL (both readouts in the same place)")
|
||||
quit(1)
|
||||
return true
|
||||
print("distinct: colours %s vs %s, at %s vs %s" % [
|
||||
cargo.glow_color, banked.glow_color, cargo.position, banked.position
|
||||
])
|
||||
|
||||
# --- 4. delivery moves it across ----------------------------
|
||||
game_state.deliver_cargo()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
if cargo.visible:
|
||||
print("TEST_CARGO_READOUT: FAIL (cargo readout still showing after delivery)")
|
||||
quit(1)
|
||||
return true
|
||||
if banked.amount != 49:
|
||||
print("TEST_CARGO_READOUT: FAIL (banked shows %d after delivering 7 into 42)" % banked.amount)
|
||||
quit(1)
|
||||
return true
|
||||
print("delivered -> cargo hidden, banked shows %d" % banked.amount)
|
||||
print("TEST_CARGO_READOUT: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_cargo_readout.gd.uid
Normal file
1
tests/test_cargo_readout.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bpldq0su02w1v
|
||||
|
|
@ -42,6 +42,11 @@ func _initialize() -> void:
|
|||
game_state.rocks = 200
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
# Extractors are gated behind research (see test_skill_tree). This test
|
||||
# is about the planet-proximity rule, so grant the research up front.
|
||||
game_state.reset_progression()
|
||||
for node in GameState.SKILL_TREE:
|
||||
game_state.unlocked.append(node.id)
|
||||
|
||||
main_scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(main_scene)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ extends SceneTree
|
|||
var game_state: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
|
||||
# The test has to sit through a whole there-and-back hauler cycle, and the
|
||||
# cycle length is distance/HAULER_SPEED - so this fixture distance IS the
|
||||
# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s
|
||||
# for the 4000 it used to be. Nothing here asserts on the distance itself,
|
||||
# only on payout and drawn position tracking the same `progress`, so a short
|
||||
# route exercises exactly the same code.
|
||||
var mothership_pos: Vector2 = Vector2(600.0, 0.0)
|
||||
var planet_pos: Vector2 = Vector2.ZERO
|
||||
var rocks_before_arrival: int = 0
|
||||
|
||||
|
|
|
|||
179
tests/test_fleet_cargo.gd
Normal file
179
tests/test_fleet_cargo.gd
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_fleet_cargo.gd
|
||||
# Forgejo #14 (part 1 of #12): mined ore is CARGO the fleet is carrying, not
|
||||
# instant global income. It only becomes spendable once the fleet flies it
|
||||
# back to the mothership.
|
||||
#
|
||||
# Before this, Rock.gd did `GameState.rocks += 1` on pickup, so ore was
|
||||
# banked instantly from anywhere on the map and a mining run had nothing to
|
||||
# lose.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. mining fills the hold and leaves the bank alone
|
||||
# 2. reaching the mothership banks the lot, and empties the hold
|
||||
# 3. with no mothership there is nowhere to deliver, so cargo just
|
||||
# accumulates - it is never quietly banked anyway
|
||||
# 4. sitting on a DIFFERENT building is not a delivery
|
||||
# 5. ore is conserved: nothing is created or destroyed in the transfer
|
||||
# 6. cargo survives the trip to a planet's surface and back, which is
|
||||
# exactly the trip a mining run is
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var banked_before: int = 0
|
||||
|
||||
const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0)
|
||||
const CRAFTER_AT: Vector2 = Vector2(-4000.0, 0.0)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.rocks = 100
|
||||
game_state.fleet_cargo = 0
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("BuildingField")
|
||||
|
||||
|
||||
func _park_fleet_on(at: Vector2) -> void:
|
||||
for ship in fleet.ships:
|
||||
ship.position = at
|
||||
fleet.move_to(at)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
# --- 1. mining fills the hold, not the bank -----------------
|
||||
# Through a REAL Rock, flown over by a real ship, rather than by
|
||||
# calling collect_cargo() directly: the whole change is which
|
||||
# pool Rock.gd credits on pickup, and a test that calls the new
|
||||
# API by hand passes just as happily with Rock.gd left untouched.
|
||||
banked_before = game_state.rocks
|
||||
var rock: Area2D = load("res://Rock.tscn").instantiate()
|
||||
rock.build_shape(1)
|
||||
rock.global_position = fleet.ships[0].global_position
|
||||
scene.add_child(rock)
|
||||
elapsed = 0.0
|
||||
stage = 5
|
||||
return false
|
||||
5:
|
||||
# A tick for the Area2D overlap to register.
|
||||
if elapsed < 0.25:
|
||||
return false
|
||||
if game_state.fleet_cargo != 1:
|
||||
print("TEST_FLEET_CARGO: FAIL (flying over a rock put %d in the hold)" % game_state.fleet_cargo)
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.rocks != banked_before:
|
||||
print("TEST_FLEET_CARGO: FAIL (a mined rock was banked instantly: %d -> %d)" % [
|
||||
banked_before, game_state.rocks
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
print("flew over a real rock -> hold=%d bank=%d (unchanged)" % [
|
||||
game_state.fleet_cargo, game_state.rocks
|
||||
])
|
||||
|
||||
game_state.collect_cargo(1)
|
||||
game_state.collect_cargo(1)
|
||||
if game_state.fleet_cargo != 3:
|
||||
print("TEST_FLEET_CARGO: FAIL (mining did not fill the hold: %d)" % game_state.fleet_cargo)
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.rocks != banked_before:
|
||||
print("TEST_FLEET_CARGO: FAIL (mining banked %d straight away)" % (game_state.rocks - banked_before))
|
||||
quit(1)
|
||||
return true
|
||||
print("mined 3 -> hold=%d bank=%d (unchanged)" % [game_state.fleet_cargo, game_state.rocks])
|
||||
|
||||
# --- 3. no mothership means nowhere to deliver --------------
|
||||
_park_fleet_on(Vector2.ZERO)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
if game_state.fleet_cargo != 3 or game_state.rocks != banked_before:
|
||||
print("TEST_FLEET_CARGO: FAIL (delivered with no mothership built)")
|
||||
quit(1)
|
||||
return true
|
||||
print("no mothership -> hold still %d, bank still %d" % [game_state.fleet_cargo, game_state.rocks])
|
||||
|
||||
# --- 4. a different building is not a delivery point --------
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
|
||||
_park_fleet_on(CRAFTER_AT)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
if game_state.fleet_cargo != 3:
|
||||
print("TEST_FLEET_CARGO: FAIL (the ship crafter accepted a delivery)")
|
||||
quit(1)
|
||||
return true
|
||||
print("parked on the crafter -> hold still %d" % game_state.fleet_cargo)
|
||||
|
||||
# --- 2. the mothership banks it -----------------------------
|
||||
_park_fleet_on(MOTHERSHIP_AT)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.4:
|
||||
return false
|
||||
var total_before: int = banked_before + 3
|
||||
print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [
|
||||
game_state.fleet_cargo, game_state.rocks, total_before
|
||||
])
|
||||
if game_state.fleet_cargo != 0:
|
||||
print("TEST_FLEET_CARGO: FAIL (hold not emptied on delivery)")
|
||||
quit(1)
|
||||
return true
|
||||
# --- 5. conservation ----------------------------------------
|
||||
if game_state.rocks != total_before:
|
||||
print("TEST_FLEET_CARGO: FAIL (ore was created or destroyed in the transfer)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- 6. cargo survives the scene swap -----------------------
|
||||
# Not simulated by loading PlanetSurface.tscn here: the point is
|
||||
# that cargo lives in the autoload rather than in Main.tscn, so
|
||||
# tearing Main.tscn down must not touch it.
|
||||
# Fly off the mothership first, or this new cargo is (correctly)
|
||||
# banked the moment BuildingField next runs, and the teardown
|
||||
# check below would be measuring a delivery instead.
|
||||
_park_fleet_on(CRAFTER_AT)
|
||||
game_state.collect_cargo(5)
|
||||
scene.queue_free()
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if game_state.fleet_cargo != 5:
|
||||
print("TEST_FLEET_CARGO: FAIL (cargo lost when the space scene was torn down: %d)" % game_state.fleet_cargo)
|
||||
quit(1)
|
||||
return true
|
||||
print("cargo survived the scene teardown: hold=%d" % game_state.fleet_cargo)
|
||||
print("TEST_FLEET_CARGO: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_fleet_cargo.gd.uid
Normal file
1
tests/test_fleet_cargo.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c2y4e2f1rgera
|
||||
143
tests/test_hub_capacity.gd
Normal file
143
tests/test_hub_capacity.gd
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_hub_capacity.gd
|
||||
# Forgejo #20 (part 7 of #12): a hub stations a limited number of
|
||||
# transporters. More hubs, or research, raise it.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. capacity is per hub, so a second hub raises the total
|
||||
# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket
|
||||
# is explicit that the player has to be told
|
||||
# 3. a queued-but-unbuilt transporter still holds its berth, or ten could
|
||||
# be ordered against two and only fail on arrival
|
||||
# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not
|
||||
# just ones placed afterwards
|
||||
# 5. the extra berth is immediately usable
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var field: Node
|
||||
var crafter_id: int = 0
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0)
|
||||
const HUB_A: Vector2 = Vector2(-500.0, 0.0)
|
||||
const HUB_B: Vector2 = Vector2(-500.0, 600.0)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.transporters.clear()
|
||||
game_state.rocks = 900
|
||||
game_state.fleet_cargo = 0
|
||||
game_state.ships_owed = 0
|
||||
game_state.unlocked = ["shipyards", "logistics", "freight"]
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
field = scene.get_node("BuildingField")
|
||||
|
||||
|
||||
func _fill_berths(n: int) -> void:
|
||||
# Queue and immediately fund n transporters.
|
||||
for i in range(n):
|
||||
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
|
||||
game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
|
||||
crafter_id = int(game_state.buildings[-1].id)
|
||||
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_A)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
var per_hub: int = game_state.hub_capacity()
|
||||
if game_state.transport_capacity() != per_hub:
|
||||
print("TEST_HUB_CAPACITY: FAIL (one hub gives %d berths, per-hub capacity is %d)" % [
|
||||
game_state.transport_capacity(), per_hub
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
print("1 hub -> %d berths (%d per hub)" % [game_state.transport_capacity(), per_hub])
|
||||
|
||||
# --- 2 + 3. fill it, then try to exceed it ------------------
|
||||
_fill_berths(per_hub)
|
||||
if game_state.transporters_claimed() != per_hub:
|
||||
print("TEST_HUB_CAPACITY: FAIL (claimed %d berths after ordering %d)" % [
|
||||
game_state.transporters_claimed(), per_hub
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
# Still only QUEUED at this point - the berth must already count.
|
||||
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
|
||||
print("TEST_HUB_CAPACITY: FAIL (queued a transporter past the %d-berth cap)" % per_hub)
|
||||
quit(1)
|
||||
return true
|
||||
print("cap enforced at %d, including orders not yet built" % per_hub)
|
||||
|
||||
# --- 1. a second hub raises the total -----------------------
|
||||
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_B)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if game_state.transport_capacity() != game_state.hub_capacity() * 2:
|
||||
print("TEST_HUB_CAPACITY: FAIL (2 hubs give %d berths, expected %d)" % [
|
||||
game_state.transport_capacity(), game_state.hub_capacity() * 2
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
print("2 hubs -> %d berths" % game_state.transport_capacity())
|
||||
if not game_state.can_station_transporter():
|
||||
print("TEST_HUB_CAPACITY: FAIL (second hub did not free a berth)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Fill the second hub too, so capacity is exhausted again.
|
||||
_fill_berths(game_state.transport_capacity() - game_state.transporters_claimed())
|
||||
if game_state.can_station_transporter():
|
||||
print("TEST_HUB_CAPACITY: FAIL (still room after filling every berth)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- 4. research raises it for hubs ALREADY built -----------
|
||||
var before: int = game_state.transport_capacity()
|
||||
game_state.unlocked.append("freight_yards")
|
||||
var after: int = game_state.transport_capacity()
|
||||
print("researched Freight Yards: %d -> %d berths across %d existing hubs" % [
|
||||
before, after, game_state.hub_count()
|
||||
])
|
||||
if after != before + game_state.hub_count():
|
||||
print("TEST_HUB_CAPACITY: FAIL (research gave %d berths, expected +1 per existing hub)" % (after - before))
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- 5. and the new berth is usable straight away -----------
|
||||
if not game_state.can_station_transporter():
|
||||
print("TEST_HUB_CAPACITY: FAIL (research raised the number but left no usable berth)")
|
||||
quit(1)
|
||||
return true
|
||||
if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
|
||||
print("TEST_HUB_CAPACITY: FAIL (could not order into the berth research just added)")
|
||||
quit(1)
|
||||
return true
|
||||
print("ordered into the newly researched berth")
|
||||
print("TEST_HUB_CAPACITY: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_hub_capacity.gd.uid
Normal file
1
tests/test_hub_capacity.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dumdbx4iggydw
|
||||
|
|
@ -120,9 +120,17 @@ func _process(delta: float) -> bool:
|
|||
for ship in fleet.ships:
|
||||
speed = max(speed, ship.velocity.length())
|
||||
var stopped: bool = speed < 1.0
|
||||
var coasted: bool = fleet.get_centroid().x > moving_centroid.x
|
||||
print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [
|
||||
speed, stopped, coasted
|
||||
# Not merely "moved forward at all": the fleet cruises at
|
||||
# cruise_speed and brakes at Ship.acceleration, so the coast is
|
||||
# cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a
|
||||
# real distance is what makes this a test of coasting - the old
|
||||
# `> moving_centroid.x` was satisfied by any float wobble, and
|
||||
# passed happily while the fleet stopped dead.
|
||||
var coast: float = fleet.get_centroid().x - moving_centroid.x
|
||||
var coasted: bool = coast > 40.0
|
||||
print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [
|
||||
speed, stopped, coast,
|
||||
(fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration)
|
||||
])
|
||||
if stopped and coasted:
|
||||
print("TEST_JOYSTICK_CONTROL: PASS")
|
||||
|
|
|
|||
205
tests/test_manual_supply_route.gd
Normal file
205
tests/test_manual_supply_route.gd
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_manual_supply_route.gd
|
||||
# Forgejo #17 (part 4 of #12): the player can supply a building by hand -
|
||||
# withdraw banked ore into the fleet's hold at the mothership, fly it over,
|
||||
# and have the building take it.
|
||||
#
|
||||
# This is what keeps the game unblockable. #16 lets an order queue waiting
|
||||
# for ore, and until transporters exist (#19) there is otherwise no way to
|
||||
# fulfil one - a player with a queued order and no Transport Hub would be
|
||||
# stuck forever.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. withdrawing moves ore bank -> hold, and conserves the total
|
||||
# 2. withdrawing is capped by what is actually banked (no overdraft)
|
||||
# 3. arriving at the mothership banks the hold, but doing so is EDGE
|
||||
# triggered - ore withdrawn while parked there is not instantly taken
|
||||
# back, which would make the button look broken
|
||||
# 4. a building takes the fleet's whole hold when opened
|
||||
# 5. end to end: a queued order the bank cannot reach is fulfilled using
|
||||
# only the fleet, with no Transport Hub in existence
|
||||
# 6. nothing is created or destroyed anywhere along the route
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var controller: Node
|
||||
var crafter_id: int = 0
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var ships_before: int = 0
|
||||
var total_before: int = 0
|
||||
|
||||
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
|
||||
const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0)
|
||||
|
||||
|
||||
func _total() -> int:
|
||||
var sum: int = game_state.rocks + game_state.fleet_cargo
|
||||
for b in game_state.buildings:
|
||||
sum += int(b.get("stock", 0))
|
||||
return sum
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.rocks = 40
|
||||
game_state.fleet_cargo = 0
|
||||
game_state.ships_owed = 0
|
||||
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("BuildingField")
|
||||
controller = scene.get_node("BuildController")
|
||||
|
||||
|
||||
func _park_fleet_on(at: Vector2) -> void:
|
||||
for ship in fleet.ships:
|
||||
ship.position = at
|
||||
fleet.move_to(at)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
_clear_hazards()
|
||||
fleet.spawn_ships(2, MOTHERSHIP_AT)
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
|
||||
crafter_id = int(game_state.buildings[-1].id)
|
||||
total_before = _total()
|
||||
_park_fleet_on(CRAFTER_AT)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
# --- 5. an order the crafter cannot pay for -----------------
|
||||
ships_before = fleet.ships.size()
|
||||
game_state.queue_order(crafter_id)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if game_state.pending_orders(crafter_id) != 1:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order did not queue)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- 1 + 2. withdraw at the mothership ----------------------
|
||||
_park_fleet_on(MOTHERSHIP_AT)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
var bank_before: int = game_state.rocks
|
||||
var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST)
|
||||
if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo])
|
||||
quit(1)
|
||||
return true
|
||||
if game_state.rocks != bank_before - GameState.SHIP_COST:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (bank did not pay for the withdrawal)")
|
||||
quit(1)
|
||||
return true
|
||||
print("withdrew %d at the mothership -> bank=%d hold=%d" % [
|
||||
took, game_state.rocks, game_state.fleet_cargo
|
||||
])
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
# --- 3. still parked on the mothership ----------------------
|
||||
if elapsed < 0.5:
|
||||
return false
|
||||
if game_state.fleet_cargo != GameState.SHIP_COST:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)")
|
||||
quit(1)
|
||||
return true
|
||||
print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo)
|
||||
|
||||
# --- 4. carry it to the crafter and open it -----------------
|
||||
_park_fleet_on(CRAFTER_AT)
|
||||
elapsed = 0.0
|
||||
stage = 5
|
||||
return false
|
||||
5:
|
||||
if elapsed < 0.6:
|
||||
return false
|
||||
var moved: int = game_state.unload_into_building(crafter_id)
|
||||
if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo])
|
||||
quit(1)
|
||||
return true
|
||||
print("unloaded %d into the crafter" % moved)
|
||||
elapsed = 0.0
|
||||
stage = 6
|
||||
return false
|
||||
6:
|
||||
if elapsed < 0.5:
|
||||
return false
|
||||
# --- 5. the queued order completed off the fleet's own ore ---
|
||||
if game_state.pending_orders(crafter_id) != 0:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)")
|
||||
quit(1)
|
||||
return true
|
||||
if fleet.ships.size() != ships_before + 1:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()])
|
||||
quit(1)
|
||||
return true
|
||||
print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [
|
||||
ships_before, fleet.ships.size()
|
||||
])
|
||||
|
||||
# --- 6. conservation ----------------------------------------
|
||||
var expected: int = total_before - GameState.SHIP_COST
|
||||
if _total() != expected:
|
||||
print("bank=%d hold=%d stocks total=%d, expected %d" % [
|
||||
game_state.rocks, game_state.fleet_cargo, _total(), expected
|
||||
])
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore created or destroyed along the route)")
|
||||
quit(1)
|
||||
return true
|
||||
print("ore conserved: %d -> %d, exactly the %d the vessel cost" % [
|
||||
total_before, _total(), GameState.SHIP_COST
|
||||
])
|
||||
|
||||
# --- 2. no overdraft ----------------------------------------
|
||||
game_state.rocks = 3
|
||||
var over: int = game_state.withdraw_to_fleet(999)
|
||||
if over != 3 or game_state.rocks != 0:
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks])
|
||||
quit(1)
|
||||
return true
|
||||
print("withdrawing 999 from a bank of 3 took %d" % over)
|
||||
print("TEST_MANUAL_SUPPLY_ROUTE: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _clear_hazards() -> void:
|
||||
# set_process(false) on AsteroidField only stops NEW chunks spawning -
|
||||
# rocks already in the world keep drifting and can destroy a parked ship,
|
||||
# which silently changes the fleet size this test asserts on. This is a
|
||||
# logistics test, not a survival one.
|
||||
for a in get_nodes_in_group("asteroids"):
|
||||
if is_instance_valid(a):
|
||||
a.queue_free()
|
||||
for e in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(e):
|
||||
e.queue_free()
|
||||
1
tests/test_manual_supply_route.gd.uid
Normal file
1
tests/test_manual_supply_route.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bjkqin5w5tah2
|
||||
99
tests/test_map_building_markers.gd
Normal file
99
tests/test_map_building_markers.gd
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_map_building_markers.gd
|
||||
# Forgejo issue #11: a building the player has placed must appear on the
|
||||
# world map. Without it the map showed explored chunks, planets and the
|
||||
# fleet, but nothing the player had actually built - so there was no way to
|
||||
# find your own base again once you had flown away from it.
|
||||
#
|
||||
# Buildings are not tied to a chunk (they are placed freely in space), so
|
||||
# they cannot ride along with the chunk markers and need their own pass.
|
||||
#
|
||||
# --headless has no GPU and draws no pixels, so this cannot assert on what
|
||||
# was rendered. It asserts on building_markers(), the step that turns
|
||||
# GameState.buildings into map-local marker data, which is the part that
|
||||
# _draw() then simply stamps out.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. no buildings -> no markers (so a passing count is not just "always 3")
|
||||
# 2. every placed building produces exactly one marker
|
||||
# 3. markers land at the building's world position scaled into map space
|
||||
# 4. the three building types are told apart by colour, rather than all
|
||||
# being drawn as the same anonymous dot
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var map_content: Node2D
|
||||
var stage: int = 0
|
||||
|
||||
const MOTHERSHIP_AT: Vector2 = Vector2(2000.0, -1200.0)
|
||||
const CRAFTER_AT: Vector2 = Vector2(-800.0, 400.0)
|
||||
const COMMAND_AT: Vector2 = Vector2(150.0, 3000.0)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
map_content = scene.get_node("UI/WorldMap/MapContent")
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
match stage:
|
||||
0:
|
||||
# Nothing built yet: the map must show no building markers at all.
|
||||
var empty: Array = map_content.building_markers()
|
||||
if not empty.is_empty():
|
||||
print("TEST_MAP_BUILDING_MARKERS: FAIL (%d markers with nothing built)" % empty.size())
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
|
||||
game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
|
||||
game_state.add_building(GameState.BUILDING_COMMAND_CENTER, COMMAND_AT)
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
var markers: Array = map_content.building_markers()
|
||||
print("markers=%d for %d buildings" % [markers.size(), game_state.buildings.size()])
|
||||
if markers.size() != 3:
|
||||
print("TEST_MAP_BUILDING_MARKERS: FAIL (expected one marker per building)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
var scale: float = map_content.WORLD_TO_LOCAL
|
||||
var expected: Dictionary = {
|
||||
GameState.BUILDING_MOTHERSHIP: MOTHERSHIP_AT * scale,
|
||||
GameState.BUILDING_SHIP_CRAFTER: CRAFTER_AT * scale,
|
||||
GameState.BUILDING_COMMAND_CENTER: COMMAND_AT * scale,
|
||||
}
|
||||
var colours: Array = []
|
||||
for m in markers:
|
||||
if not expected.has(m.type):
|
||||
print("TEST_MAP_BUILDING_MARKERS: FAIL (unexpected marker type %s)" % m.type)
|
||||
quit(1)
|
||||
return true
|
||||
var want: Vector2 = expected[m.type]
|
||||
if m.position.distance_to(want) > 0.001:
|
||||
print("%s marker at %s, expected %s" % [m.type, m.position, want])
|
||||
print("TEST_MAP_BUILDING_MARKERS: FAIL (marker is not where the building is)")
|
||||
quit(1)
|
||||
return true
|
||||
colours.append(m.color)
|
||||
|
||||
# Three buildings drawn in one indistinguishable colour would
|
||||
# satisfy every check above and still be useless to read.
|
||||
if colours[0] == colours[1] or colours[1] == colours[2] or colours[0] == colours[2]:
|
||||
print("colours=%s" % [colours])
|
||||
print("TEST_MAP_BUILDING_MARKERS: FAIL (building types are not visually distinct)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("all 3 markers positioned correctly, colours distinct: %s" % [colours])
|
||||
print("TEST_MAP_BUILDING_MARKERS: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_map_building_markers.gd.uid
Normal file
1
tests/test_map_building_markers.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bx3bce8ix4rbg
|
||||
138
tests/test_modal_blocks_input.gd
Normal file
138
tests/test_modal_blocks_input.gd
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_modal_blocks_input.gd
|
||||
# With a panel open, nothing behind it may react to a touch.
|
||||
#
|
||||
# Two separate leaks, because there are two separate input paths:
|
||||
#
|
||||
# 1. GUI clicks. The skill and formation panels cannot block these the
|
||||
# normal way (mouse_filter = STOP), because the skill tree and the
|
||||
# formation editor are driven from raw _unhandled_input, and a Control
|
||||
# that consumes touches through the GUI system swallows them before
|
||||
# they ever get there. So the panels stay transparent and the buttons
|
||||
# check whether they are behind one.
|
||||
# 2. Raw input. A touch landing on the panel but missing a skill node was
|
||||
# left unhandled, so it fell through to GameController - which sits on
|
||||
# the ROOT node and therefore sees everything its children ignore - and
|
||||
# moved the fleet or pinch-zoomed the world behind the menu.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. a button outside an open panel does not fire
|
||||
# 2. a button INSIDE the open panel still does (or the panel is unusable)
|
||||
# 3. with every panel closed, that same outside button fires normally, so
|
||||
# a pass cannot just mean "the button never works"
|
||||
# 4. a touch on an open panel does not reach the world behind it
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var fleet: Node
|
||||
var skill_menu: Control
|
||||
var map_button: Control
|
||||
var skill_close: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var outside_fired: bool = false
|
||||
var inside_fired: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.transporters.clear()
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
skill_menu = scene.get_node("UI/SkillMenu")
|
||||
map_button = scene.get_node("UI/MapButton")
|
||||
skill_close = scene.get_node("UI/SkillMenu/CloseButton")
|
||||
map_button.pressed.connect(func(): outside_fired = true)
|
||||
skill_close.pressed.connect(func(): inside_fired = true)
|
||||
|
||||
|
||||
func _tap(button: Control) -> void:
|
||||
# Straight at the button's own _gui_input, which is where a real tap
|
||||
# lands once Godot has routed it.
|
||||
var centre: Vector2 = button.size / 2.0
|
||||
for pressed in [true, false]:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.pressed = pressed
|
||||
ev.position = centre
|
||||
button._gui_input(ev)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
# --- 3. baseline: it works with nothing open ----------------
|
||||
outside_fired = false
|
||||
_tap(map_button)
|
||||
if not outside_fired:
|
||||
print("TEST_MODAL_BLOCKS_INPUT: FAIL (the button never fires even with no panel open - the test proves nothing)")
|
||||
quit(1)
|
||||
return true
|
||||
print("no panel open -> outside button fires")
|
||||
# That tap actually OPENED the world map, which is itself a modal
|
||||
# panel - leave it open and it correctly blocks the research
|
||||
# panel's own buttons, which is a true result but not the one
|
||||
# being measured here.
|
||||
scene.get_node("UI/WorldMap").visible = false
|
||||
|
||||
skill_menu.visible = true
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- 1. behind an open panel --------------------------------
|
||||
outside_fired = false
|
||||
_tap(map_button)
|
||||
if outside_fired:
|
||||
print("TEST_MODAL_BLOCKS_INPUT: FAIL (a button behind the open research panel still fired)")
|
||||
quit(1)
|
||||
return true
|
||||
print("research panel open -> button behind it is inert")
|
||||
|
||||
# --- 2. inside it ------------------------------------------
|
||||
inside_fired = false
|
||||
_tap(skill_close)
|
||||
if not inside_fired:
|
||||
print("TEST_MODAL_BLOCKS_INPUT: FAIL (the panel's own close button stopped working)")
|
||||
quit(1)
|
||||
return true
|
||||
print("panel's own button still works")
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- 4. raw input must not reach the world ------------------
|
||||
skill_menu.visible = true
|
||||
var before: Vector2 = fleet.fleet_target
|
||||
var controller: Node = scene.get_node("BuildController")
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.pressed = true
|
||||
ev.position = Vector2(360.0, 900.0)
|
||||
controller._unhandled_input(ev)
|
||||
if not root.get_viewport().is_input_handled():
|
||||
print("TEST_MODAL_BLOCKS_INPUT: FAIL (a touch on the open panel was left unhandled - it falls through to the world behind)")
|
||||
quit(1)
|
||||
return true
|
||||
if fleet.fleet_target != before:
|
||||
print("TEST_MODAL_BLOCKS_INPUT: FAIL (the fleet moved from a touch on the panel)")
|
||||
quit(1)
|
||||
return true
|
||||
print("touch on the panel consumed, fleet target unchanged")
|
||||
print("TEST_MODAL_BLOCKS_INPUT: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
155
tests/test_planet_extractor_marker.gd
Normal file
155
tests/test_planet_extractor_marker.gd
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_planet_extractor_marker.gd
|
||||
# Forgejo issue #9: a planet with an extractor built on it must show that
|
||||
# something is built there. The extractor lives on PlanetSurface.tscn, a
|
||||
# scene the player is not looking at from space, so an already-mined planet
|
||||
# was indistinguishable from an untouched one - you had to land to find out.
|
||||
#
|
||||
# Covered in both places the player looks at a planet:
|
||||
# - in space, as a derrick on the planet's own outline (Planet.gd)
|
||||
# - on the world map, as a badge on the planet marker (WorldMapContent.gd)
|
||||
#
|
||||
# --headless has no GPU, so this asserts on the state and geometry that
|
||||
# _draw() consumes, not on pixels. tests/screenshot.sh covers whether it is
|
||||
# actually visible.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. a planet with no extractor shows neither derrick nor badge (so a pass
|
||||
# cannot just mean "always on")
|
||||
# 2. building one turns both on, keyed by planet NAME - the same key
|
||||
# GameState.has_extractor_at uses
|
||||
# 3. the derrick sits outside the planet's outline rather than buried in
|
||||
# it, which is the difference between visible and not
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var map_content: Node2D
|
||||
var planet_chunk: Vector2i
|
||||
var planet_name: String = ""
|
||||
var stage: int = 0
|
||||
var already_spawned: Node2D
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
var found: bool = false
|
||||
for x in range(-15, 15):
|
||||
for y in range(-15, 15):
|
||||
var chunk: Vector2i = Vector2i(x, y)
|
||||
if WorldGen.has_planet_at_chunk(chunk):
|
||||
planet_chunk = chunk
|
||||
planet_name = WorldGen.planet_data_at_chunk(chunk).name
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
break
|
||||
if not found:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no planet chunk to test with)")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
map_content = scene.get_node("UI/WorldMap/MapContent")
|
||||
|
||||
|
||||
func _spawn_planet() -> Node2D:
|
||||
var planet: Node2D = load("res://Planet.tscn").instantiate()
|
||||
planet.planet_name = planet_name
|
||||
planet.shape_seed = 12345
|
||||
scene.add_child(planet)
|
||||
return planet
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
match stage:
|
||||
0:
|
||||
# --- nothing built there yet --------------------------------
|
||||
var clean: Node2D = _spawn_planet()
|
||||
if clean.shows_extractor:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick on a planet with no extractor)")
|
||||
quit(1)
|
||||
return true
|
||||
if map_content.chunk_has_extractor_badge(planet_chunk):
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)")
|
||||
quit(1)
|
||||
return true
|
||||
print("no extractor -> no derrick, no badge (planet '%s')" % planet_name)
|
||||
|
||||
# Deliberately keep this planet ALIVE across the build. An
|
||||
# extractor is built from space with the fleet parked on the
|
||||
# planet (BuildController._planet_under_fleet()), so the node is
|
||||
# already in the tree with _ready() long since run. Resolving the
|
||||
# marker only at _ready() meant the planet kept looking untouched
|
||||
# until it happened to despawn and respawn - the build read as
|
||||
# having done nothing at all. Spawning a fresh planet afterwards
|
||||
# (as the checks below do) cannot catch that.
|
||||
already_spawned = clean
|
||||
game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk))
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# --- the planet that was already on screen when it was built --
|
||||
if not already_spawned.shows_extractor:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (a planet already in the tree never picked up its derrick - the build reads as doing nothing)")
|
||||
quit(1)
|
||||
return true
|
||||
print("planet already in the tree picked up its derrick on build")
|
||||
already_spawned.queue_free()
|
||||
|
||||
# --- extractor built ----------------------------------------
|
||||
var mined: Node2D = _spawn_planet()
|
||||
if not mined.shows_extractor:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no derrick after building an extractor)")
|
||||
quit(1)
|
||||
return true
|
||||
if not map_content.chunk_has_extractor_badge(planet_chunk):
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no map badge after building an extractor)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# The derrick has to clear the outline to be seen at all. Compare
|
||||
# its tip against the planet's own radius in that direction, not
|
||||
# against a constant - the outline is a lumpy seeded shape.
|
||||
var derrick: PackedVector2Array = mined.extractor_derrick_points()
|
||||
if derrick.size() < 3:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick has no shape)")
|
||||
quit(1)
|
||||
return true
|
||||
# Measure the TIP, not the whole polygon. The base corners sit a
|
||||
# half-width to either side of the rim vertex, so they are
|
||||
# sqrt(|base|^2 + w^2) from the centre and clear the rim on their
|
||||
# own - a max over every point called a derrick pointing straight
|
||||
# into the ground "visible", which is exactly the bug this check
|
||||
# exists to catch.
|
||||
var furthest: float = mined.extractor_derrick_tip().length()
|
||||
var rim: float = 0.0
|
||||
for p in mined.shape_points:
|
||||
rim = maxf(rim, p.length())
|
||||
print("derrick tip reaches %.1f, planet outline reaches %.1f" % [furthest, rim])
|
||||
if furthest <= rim:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick buried inside the planet)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# A different planet must NOT inherit the marker: the flag is keyed
|
||||
# by name, and getting that wrong would light up every planet.
|
||||
var other: Node2D = load("res://Planet.tscn").instantiate()
|
||||
other.planet_name = planet_name + " II"
|
||||
other.shape_seed = 999
|
||||
scene.add_child(other)
|
||||
if other.shows_extractor:
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (marker leaked onto a different planet)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("extractor -> derrick + badge, and only on the right planet")
|
||||
print("TEST_PLANET_EXTRACTOR_MARKER: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_planet_extractor_marker.gd.uid
Normal file
1
tests/test_planet_extractor_marker.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dcr7qb1yoewcy
|
||||
|
|
@ -3,7 +3,9 @@ extends SceneTree
|
|||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_resources_and_buildings.gd
|
||||
# Covers the resource/building loop end to end:
|
||||
# 1. flying a ship over a surface rock banks it in GameState
|
||||
# 1. flying a ship over a surface rock loads it into the fleet's hold
|
||||
# (banking it needs a trip to the mothership - see #14 and
|
||||
# tests/test_fleet_cargo.gd)
|
||||
# 2. buildings are paid for, persist in GameState, and respawn with the
|
||||
# space scene (they must survive the trip to a planet and back)
|
||||
# 3. a ship crafter adds a vessel to the fleet for rocks
|
||||
|
|
@ -21,6 +23,7 @@ var mothership_pos: Vector2 = Vector2(900.0, -400.0)
|
|||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 0
|
||||
game_state.fleet_cargo = 0
|
||||
game_state.buildings.clear()
|
||||
game_state.has_return_position = false
|
||||
|
||||
|
|
@ -49,12 +52,14 @@ func _process(delta: float) -> bool:
|
|||
1:
|
||||
if elapsed < 1.5:
|
||||
return false
|
||||
if game_state.rocks != 1:
|
||||
print("rocks=%d after flying a ship onto one" % game_state.rocks)
|
||||
# Into the hold, not the bank: since #14 ore is cargo until the
|
||||
# fleet delivers it to the mothership.
|
||||
if game_state.fleet_cargo != 1:
|
||||
print("fleet_cargo=%d after flying a ship onto one" % game_state.fleet_cargo)
|
||||
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)")
|
||||
quit(1)
|
||||
return true
|
||||
print("gathered rock -> rocks=%d" % game_state.rocks)
|
||||
print("gathered rock -> hold=%d bank=%d" % [game_state.fleet_cargo, game_state.rocks])
|
||||
|
||||
# Bank enough to build with, then move to the space scene.
|
||||
game_state.rocks = 60
|
||||
|
|
|
|||
169
tests/test_skill_tree.gd
Normal file
169
tests/test_skill_tree.gd
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_skill_tree.gd
|
||||
# The mothership's research tree (Forgejo issue #4). It gates which
|
||||
# structures may be built at all, and carries permanent upgrades that have
|
||||
# no building of their own.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. from a fresh start only the mothership is buildable - the others are
|
||||
# greyed, and pressing one explains which research opens it rather than
|
||||
# silently failing
|
||||
# 2. a locked option cannot be built even if the confirm is forced
|
||||
# 3. research costs rocks, needs its prerequisite, and unlocks its building
|
||||
# 4. the mothership's action button opens the tree (it used to have no
|
||||
# action at all)
|
||||
# 5. fleet capacity upgrades raise the cap, and the extra vessel can
|
||||
# actually be crafted afterwards
|
||||
|
||||
var scene: Node
|
||||
var game_state: Node
|
||||
var controller: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var popup: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.rocks = 500
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.reset_formation()
|
||||
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
controller = scene.get_node("BuildController")
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("BuildingField")
|
||||
popup = scene.get_node("UI/BuildMenu/InfoPopup")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
|
||||
# --- everything but the mothership starts locked --------------
|
||||
var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP)
|
||||
var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
|
||||
print("fresh start: mothership buildable=%s crafter locked=%s" % [
|
||||
mothership_free, crafter_locked
|
||||
])
|
||||
if not (mothership_free and crafter_locked):
|
||||
print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
controller._on_build_pressed()
|
||||
var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled
|
||||
controller._on_place_crafter()
|
||||
var explains: bool = popup.visible \
|
||||
and "research" in popup.get_node("Panel/MessageLabel").text.to_lower()
|
||||
var offered: bool = popup.get_node("Panel/BuildButton").visible
|
||||
print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [
|
||||
greyed, explains, offered
|
||||
])
|
||||
if not (greyed and explains) or offered:
|
||||
print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# Forcing the confirm must still build nothing.
|
||||
var before: int = game_state.buildings.size()
|
||||
controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER
|
||||
controller._confirm_build()
|
||||
if game_state.buildings.size() != before:
|
||||
print("TEST_SKILL_TREE: FAIL (built a locked structure)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._close_info()
|
||||
|
||||
# --- the mothership opens the tree ----------------------------
|
||||
var mothership: Node2D = null
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
mothership = b
|
||||
controller.current_building = mothership
|
||||
controller._on_action_pressed()
|
||||
var tree_open: bool = scene.get_node("UI/SkillMenu").visible
|
||||
print("mothership action opens research: %s" % tree_open)
|
||||
if not tree_open:
|
||||
print("TEST_SKILL_TREE: FAIL (mothership has no research action)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- prerequisites --------------------------------------------
|
||||
if game_state.can_unlock("wing"):
|
||||
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- research costs rocks and unlocks its building ------------
|
||||
var rocks_before: int = game_state.rocks
|
||||
var cost: int = int(game_state.skill_node("shipyards").cost)
|
||||
var ok: bool = game_state.unlock("shipyards")
|
||||
var charged: bool = game_state.rocks == rocks_before - cost
|
||||
var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
|
||||
print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [
|
||||
ok, rocks_before, game_state.rocks, charged, now_buildable
|
||||
])
|
||||
if not (ok and charged and now_buildable):
|
||||
print("TEST_SKILL_TREE: FAIL (research did not unlock its building)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# The menu must show it as buildable now, without reopening.
|
||||
controller._on_build_pressed()
|
||||
if not scene.get_node("UI/BuildMenu/CrafterButton").enabled:
|
||||
print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)")
|
||||
quit(1)
|
||||
return true
|
||||
controller._close_info()
|
||||
scene.get_node("UI/BuildMenu").visible = false
|
||||
|
||||
# --- fleet capacity -------------------------------------------
|
||||
var base_cap: int = game_state.max_fleet_size()
|
||||
game_state.unlock("coordination")
|
||||
var wing_ok: bool = game_state.unlock("wing")
|
||||
var raised_cap: int = game_state.max_fleet_size()
|
||||
print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [
|
||||
base_cap, raised_cap, wing_ok
|
||||
])
|
||||
if not (wing_ok and raised_cap == base_cap + 1):
|
||||
print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# And the extra vessel must actually be craftable.
|
||||
fleet.spawn_ships(base_cap, Vector2.ZERO)
|
||||
var added: bool = fleet.add_ship()
|
||||
print("fleet %d ships, add one more -> %s (now %d)" % [
|
||||
base_cap, added, fleet.ships.size()
|
||||
])
|
||||
if not (added and fleet.ships.size() == base_cap + 1):
|
||||
print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)")
|
||||
quit(1)
|
||||
return true
|
||||
if fleet.add_ship():
|
||||
print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
print("TEST_SKILL_TREE: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_skill_tree.gd.uid
Normal file
1
tests/test_skill_tree.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://db2nf67mtlg27
|
||||
157
tests/test_transport_hub_building.gd
Normal file
157
tests/test_transport_hub_building.gd
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_transport_hub_building.gd
|
||||
# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of
|
||||
# its own. Only the building here - the vessels that do the hauling are #19.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. it is gated behind its research node, like every other structure
|
||||
# 2. researching it makes it buildable, and placing it charges its cost
|
||||
# 3. it is drawn as ITSELF, not as the crafter - Building._draw()'s match
|
||||
# has a catch-all that renders anything unrecognised as a ship crafter,
|
||||
# so a missing case is silent and looks almost right
|
||||
# 4. its geometry is rasterised ONCE however long it animates (#10), and
|
||||
# its stationed transports really do move
|
||||
# 5. it has its own build-menu icon and its own map marker rather than
|
||||
# borrowing another building's - the menu was once unreadable because
|
||||
# two buildings shared a glyph
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var field: Node
|
||||
var controller: Node
|
||||
var map_content: Node2D
|
||||
var hub: Node2D
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var spin_at_start: float = 0.0
|
||||
|
||||
const HUB_AT: Vector2 = Vector2(700.0, 0.0)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.rocks = 500
|
||||
game_state.fleet_cargo = 0
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
field = scene.get_node("BuildingField")
|
||||
controller = scene.get_node("BuildController")
|
||||
map_content = scene.get_node("UI/WorldMap/MapContent")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
# --- 1. locked until researched ------------------------------
|
||||
if game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (buildable with no research at all)")
|
||||
quit(1)
|
||||
return true
|
||||
var gate: String = game_state.research_for_building(GameState.BUILDING_TRANSPORT_HUB)
|
||||
if gate == "":
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no research node gates it)")
|
||||
quit(1)
|
||||
return true
|
||||
print("locked behind research: '%s'" % gate)
|
||||
|
||||
# --- 5. its own icon, distinct from every other building -----
|
||||
var icons: Dictionary = {}
|
||||
for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
|
||||
GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]:
|
||||
var icon: int = controller._action_icon_for(type)
|
||||
if icons.has(icon):
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]])
|
||||
quit(1)
|
||||
return true
|
||||
icons[icon] = type
|
||||
print("action icons distinct across %d buildings" % icons.size())
|
||||
|
||||
# --- 2. research it, then build it ---------------------------
|
||||
game_state.unlocked = ["shipyards", "logistics", "freight"]
|
||||
if not game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB):
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate)
|
||||
quit(1)
|
||||
return true
|
||||
var before: int = game_state.rocks
|
||||
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
|
||||
game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB))
|
||||
if game_state.rocks != before - GameState.TRANSPORT_HUB_COST:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [
|
||||
before - game_state.rocks, GameState.TRANSPORT_HUB_COST
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
for b in field.spawned:
|
||||
if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB:
|
||||
hub = b
|
||||
if hub == null:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- 3. drawn as itself, not via the crafter catch-all -------
|
||||
# _hub_traffic only exists if _ready() took the transport-hub
|
||||
# branch; a missing case falls through to the crafter and would
|
||||
# leave it null while still rendering something plausible.
|
||||
if hub._hub_traffic == null:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (rendered through the crafter catch-all, not as a hub)")
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
# --- 5. its own map marker ----------------------------------
|
||||
var markers: Array = map_content.building_markers()
|
||||
var found: bool = false
|
||||
for m in markers:
|
||||
if m.type == GameState.BUILDING_TRANSPORT_HUB:
|
||||
found = true
|
||||
if m.color == map_content.CRAFTER_MARKER_COLOR \
|
||||
or m.color == map_content.MOTHERSHIP_MARKER_COLOR \
|
||||
or m.color == map_content.COMMAND_MARKER_COLOR:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)")
|
||||
quit(1)
|
||||
return true
|
||||
if not found:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no map marker - see #11)")
|
||||
quit(1)
|
||||
return true
|
||||
print("has its own map marker")
|
||||
|
||||
spin_at_start = hub._hub_traffic.rotation
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# --- 4. animates without ever redrawing (#10) ----------------
|
||||
if elapsed < 2.0:
|
||||
return false
|
||||
print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count])
|
||||
if hub.draw_count != 1:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count)
|
||||
quit(1)
|
||||
return true
|
||||
var spun: float = absf(hub._hub_traffic.rotation - spin_at_start)
|
||||
print("stationed transports turned %.3f rad" % spun)
|
||||
if spun < 0.05:
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (nothing moved - a frozen hub trivially passes the redraw check)")
|
||||
quit(1)
|
||||
return true
|
||||
print("TEST_TRANSPORT_HUB_BUILDING: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_transport_hub_building.gd.uid
Normal file
1
tests/test_transport_hub_building.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctplk4adbsi3a
|
||||
207
tests/test_transporter_supply.gd
Normal file
207
tests/test_transporter_supply.gd
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_transporter_supply.gd
|
||||
# Forgejo #19 (part 6 of #12): transporters stationed at a hub fetch ore from
|
||||
# the mothership and deliver it to whichever building is waiting on it, with
|
||||
# no player input at all.
|
||||
#
|
||||
# Verifies:
|
||||
# 1. a transporter is built at the CRAFTER and takes itself to the nearest
|
||||
# hub, rather than appearing at the hub out of nowhere
|
||||
# 2. a queued order the crafter cannot pay for is fulfilled end to end
|
||||
# automatically - the whole point of the ticket
|
||||
# 3. ore is conserved across the entire route (bank + hold + every
|
||||
# building's stock), at every step, not just at the end
|
||||
# 4. ore leaves the bank when it is LOADED, not when the order is placed -
|
||||
# the vessel is carrying something real
|
||||
# 5. transporters are not ordered when there is no hub to station them at
|
||||
# 6. no unbounded node accumulation: the fleet of transporters is exactly
|
||||
# what was built, however many runs they make
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var fleet: Node
|
||||
var field: Node
|
||||
var crafter_id: int = 0
|
||||
var hub_id: int = 0
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var total_at_start: int = 0
|
||||
var bank_when_ordered: int = 0
|
||||
var conservation_broken: bool = false
|
||||
# Ore legitimately consumed by completed production. Conservation means
|
||||
# "nothing vanishes in TRANSIT", not "the total never falls" - building a
|
||||
# vessel spends ore, and that is the one way it is allowed to leave.
|
||||
var consumed: int = 0
|
||||
|
||||
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
|
||||
const HUB_AT: Vector2 = Vector2(600.0, 200.0)
|
||||
const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0)
|
||||
|
||||
|
||||
func _total() -> int:
|
||||
var sum: int = game_state.rocks + game_state.fleet_cargo
|
||||
for b in game_state.buildings:
|
||||
sum += int(b.get("stock", 0))
|
||||
for t in game_state.transporters:
|
||||
sum += int(t.get("carrying", 0))
|
||||
return sum
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.transporters.clear()
|
||||
game_state.rocks = 200
|
||||
game_state.fleet_cargo = 0
|
||||
game_state.ships_owed = 0
|
||||
game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"]
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
fleet = scene.get_node("Fleet")
|
||||
field = scene.get_node("BuildingField")
|
||||
game_state.order_completed.connect(_on_order_completed)
|
||||
|
||||
|
||||
func _on_order_completed(_building_id: int, product: String) -> void:
|
||||
consumed += game_state.product_cost(product)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
|
||||
# --- 3. conservation, checked EVERY tick ------------------------------
|
||||
# Ore in flight is the easy thing to lose: a leg that loads without
|
||||
# debiting, or delivers without crediting, would show up here and nowhere
|
||||
# else. Checked continuously rather than at the end, so it cannot be
|
||||
# masked by a later correction.
|
||||
if stage >= 2 and _total() + consumed != total_at_start:
|
||||
conservation_broken = true
|
||||
|
||||
match stage:
|
||||
0:
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
for a in get_nodes_in_group("asteroids"):
|
||||
if is_instance_valid(a):
|
||||
a.queue_free()
|
||||
|
||||
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
|
||||
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
|
||||
crafter_id = int(game_state.buildings[-1].id)
|
||||
|
||||
# --- 5. no hub yet: a transporter cannot be ordered ----------
|
||||
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (ordered a transporter with no hub to station it at)")
|
||||
quit(1)
|
||||
return true
|
||||
print("no hub -> transporter order refused")
|
||||
|
||||
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
|
||||
hub_id = int(game_state.buildings[-1].id)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
# --- 1. build a transporter at the crafter ------------------
|
||||
if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (could not order a transporter with a hub built)")
|
||||
quit(1)
|
||||
return true
|
||||
game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST)
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
total_at_start = _total()
|
||||
return false
|
||||
2:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
if game_state.transporters.size() != 1:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (built %d transporters from one order)" % game_state.transporters.size())
|
||||
quit(1)
|
||||
return true
|
||||
var t: Dictionary = game_state.transporters[0]
|
||||
if int(t.hub_id) != hub_id:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter did not take the nearest hub)")
|
||||
quit(1)
|
||||
return true
|
||||
# It must fly FROM the crafter TO the hub, not appear at the hub.
|
||||
# Checked on the leg's endpoints rather than its current position,
|
||||
# which is already partway there by now - and being partway is
|
||||
# the correct behaviour.
|
||||
if (t.from as Vector2).distance_to(CRAFTER_AT) > 1.0:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg starts at %s, not at the crafter that built it)" % t.from)
|
||||
quit(1)
|
||||
return true
|
||||
if (t.to as Vector2).distance_to(HUB_AT) > 1.0:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg does not end at the hub: %s)" % t.to)
|
||||
quit(1)
|
||||
return true
|
||||
print("transporter built at the crafter, heading for hub %d" % hub_id)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
# Give it time to reach the hub and go idle.
|
||||
if elapsed < 4.0:
|
||||
return false
|
||||
if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (never settled at its hub: state=%s)" % game_state.transporters[0].state)
|
||||
quit(1)
|
||||
return true
|
||||
print("stationed at the hub, idle")
|
||||
|
||||
# --- 2 + 4. an order it cannot pay for ----------------------
|
||||
game_state.queue_order(crafter_id, GameState.PRODUCT_SHIP)
|
||||
bank_when_ordered = game_state.rocks
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
# One tick: placing the order must not touch the bank.
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
if game_state.rocks != bank_when_ordered:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (bank moved on placing the order, before anything was loaded)")
|
||||
quit(1)
|
||||
return true
|
||||
print("order placed, bank untouched at %d" % game_state.rocks)
|
||||
elapsed = 0.0
|
||||
stage = 5
|
||||
return false
|
||||
5:
|
||||
# --- 2. the whole run, with no player input -----------------
|
||||
if game_state.pending_orders(crafter_id) > 0:
|
||||
if elapsed > 25.0:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (order never fulfilled: transporter state=%s carrying=%d crafter stock=%d)" % [
|
||||
game_state.transporters[0].state,
|
||||
int(game_state.transporters[0].carrying),
|
||||
game_state.building_stock(crafter_id),
|
||||
])
|
||||
quit(1)
|
||||
return true
|
||||
return false
|
||||
if conservation_broken:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (ore created or destroyed somewhere along the route)")
|
||||
quit(1)
|
||||
return true
|
||||
print("order fulfilled automatically in %.1fs, no player input" % elapsed)
|
||||
|
||||
# --- 6. no vessel accumulation ------------------------------
|
||||
if game_state.transporters.size() != 1:
|
||||
print("TEST_TRANSPORTER_SUPPLY: FAIL (%d transporters exist after the run, one was built)" % game_state.transporters.size())
|
||||
quit(1)
|
||||
return true
|
||||
print("still exactly 1 transporter after a full round trip")
|
||||
print("bank=%d crafter stock=%d total=%d + %d consumed = %d (started %d)" % [
|
||||
game_state.rocks, game_state.building_stock(crafter_id), _total(),
|
||||
consumed, _total() + consumed, total_at_start
|
||||
])
|
||||
print("TEST_TRANSPORTER_SUPPLY: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
1
tests/test_transporter_supply.gd.uid
Normal file
1
tests/test_transporter_supply.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dxu5orxbivfpa
|
||||
|
|
@ -1,15 +1,8 @@
|
|||
[gd_resource type="Theme" format=3]
|
||||
[gd_resource type="Theme" load_steps=2 format=3]
|
||||
|
||||
; Project-wide default font size for every Control.
|
||||
;
|
||||
; This exists because the `gui/theme/default_font_size` project setting does
|
||||
; NOT reach the default theme in this build - labels without an explicit
|
||||
; override resolve to the engine fallback of 16 no matter what that setting
|
||||
; says, which is why earlier attempts to enlarge the UI text did nothing.
|
||||
; A custom Theme assigned to `gui/theme/custom` does apply.
|
||||
;
|
||||
; Individual labels that cannot fit their own text at this size carry a
|
||||
; smaller theme_override_font_sizes/font_size in the scene.
|
||||
[ext_resource type="FontFile" path="res://fonts/CarroisGothicSC-Regular.ttf" id="1"]
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1")
|
||||
default_font_size = 78
|
||||
Label/colors/font_color = Color(0.36, 0.9, 1, 1)
|
||||
|
|
|
|||
Loading…
Reference in a new issue