Skill tree, node-based building animation, bigger UI, and a 19x faster test suite
Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
85814ce88c
commit
b641fa5891
48 changed files with 1985 additions and 303 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")
|
||||
|
|
@ -41,6 +40,14 @@ extends Node2D
|
|||
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
|
||||
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
|
||||
|
||||
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
|
||||
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
|
||||
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
|
||||
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber")
|
||||
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
|
||||
@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton")
|
||||
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
|
||||
|
||||
@onready var formation_menu: Control = get_node("../UI/FormationMenu")
|
||||
@onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor")
|
||||
@onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton")
|
||||
|
|
@ -101,24 +108,34 @@ func _ready() -> void:
|
|||
formation_reset_button.pressed.connect(_on_reset_formation)
|
||||
formation_editor.formation_changed.connect(_on_formation_changed)
|
||||
|
||||
skill_close_button.pressed.connect(func(): skill_menu.visible = false)
|
||||
skill_unlock_button.pressed.connect(_on_unlock_pressed)
|
||||
skill_tree.node_picked.connect(_on_skill_node_picked)
|
||||
|
||||
build_menu.visible = false
|
||||
info_popup.visible = false
|
||||
craft_menu.visible = false
|
||||
formation_menu.visible = false
|
||||
skill_menu.visible = false
|
||||
action_button.visible = false
|
||||
action_label.visible = false
|
||||
|
||||
|
||||
# True while a building prompt or panel owns the shared action slot, so the
|
||||
# enter-atmosphere prompt knows to stay out of the way.
|
||||
func is_showing_building_prompt() -> bool:
|
||||
return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible
|
||||
return current_building != null or build_menu.visible or craft_menu.visible \
|
||||
or formation_menu.visible or skill_menu.visible
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# The formation editor is driven from here rather than via _gui_input,
|
||||
# for the same reason as the joystick: its Control must not swallow
|
||||
# touches, or dragging a slot would never reach it.
|
||||
# The formation editor and the skill tree are driven from here rather than
|
||||
# via _gui_input, for the same reason as the joystick: a Control that
|
||||
# consumes touches through the GUI system swallows them before they get
|
||||
# here, which is what made dragging a formation slot silently do nothing.
|
||||
if skill_menu.visible:
|
||||
if event is InputEventScreenTouch and skill_tree.handle_touch(event):
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if not formation_menu.visible:
|
||||
return
|
||||
if event is InputEventScreenTouch and formation_editor.handle_touch(event):
|
||||
|
|
@ -133,20 +150,19 @@ func _process(delta: float) -> void:
|
|||
if build_menu.visible:
|
||||
# Keep the greyed state live while the menu is open, so flying onto
|
||||
# a planet lights the option up without reopening it.
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null \
|
||||
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
|
||||
|
||||
if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible:
|
||||
if world_map.visible or build_menu.visible or craft_menu.visible \
|
||||
or formation_menu.visible or skill_menu.visible:
|
||||
current_building = null
|
||||
pending_building = null
|
||||
action_button.visible = false
|
||||
action_label.visible = false
|
||||
return
|
||||
|
||||
# Debounced exactly like the enter-atmosphere prompt, so the action
|
||||
# button can't flicker while the fleet drifts on the interact boundary.
|
||||
var detected: Node2D = building_field.get_building_fleet_is_on()
|
||||
if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
detected = null # nothing to do at a mothership beyond respawning
|
||||
|
||||
if detected != pending_building:
|
||||
pending_building = detected
|
||||
|
|
@ -159,13 +175,21 @@ func _process(delta: float) -> void:
|
|||
|
||||
current_building = pending_building
|
||||
action_button.visible = current_building != null
|
||||
action_label.visible = current_building != null
|
||||
if current_building != null:
|
||||
action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP
|
||||
action_label.text = current_building.get_display_name()
|
||||
action_button.icon = _action_icon_for(current_building.building_type)
|
||||
action_button.queue_redraw()
|
||||
|
||||
|
||||
func _action_icon_for(type: String) -> int:
|
||||
match type:
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
return IconButton.Icon.SHIP
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
return IconButton.Icon.MOTHERSHIP
|
||||
_:
|
||||
return IconButton.Icon.COMMAND_CENTER
|
||||
|
||||
|
||||
func _check_fleet_wipe() -> void:
|
||||
if not fleet.ships.is_empty():
|
||||
return
|
||||
|
|
@ -178,7 +202,10 @@ func _check_fleet_wipe() -> void:
|
|||
func _on_action_pressed() -> void:
|
||||
if current_building == null:
|
||||
return
|
||||
if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
skill_menu.visible = true
|
||||
_refresh_skill_menu()
|
||||
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
craft_menu.visible = true
|
||||
_refresh_craft_status()
|
||||
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
|
||||
|
|
@ -199,10 +226,14 @@ func _planet_under_fleet() -> Node2D:
|
|||
|
||||
|
||||
func _refresh_build_status() -> void:
|
||||
# The extractor option is always listed, but greyed unless the fleet is
|
||||
# actually over a planet - showing it only sometimes would leave the
|
||||
# player wondering whether the game has it at all.
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null
|
||||
# Every option is always listed, greyed when it cannot be used - showing
|
||||
# them only sometimes would leave the player wondering whether the game
|
||||
# has them at all. Greyed buttons still emit `pressed`, so each one can
|
||||
# explain itself in the info popup.
|
||||
build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
|
||||
build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER)
|
||||
build_extractor_button.enabled = _planet_under_fleet() != null \
|
||||
and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR)
|
||||
|
||||
# Prices are shown under each option now, so this only needs to say
|
||||
# whether the fleet is somewhere an extractor could go.
|
||||
|
|
@ -248,9 +279,15 @@ func _show_building_info(type: String) -> void:
|
|||
var lines: Array = [info.body]
|
||||
var can_build: bool = true
|
||||
|
||||
# Locked structures still list themselves, greyed, so the player can see
|
||||
# what exists and what research would open it up.
|
||||
if not GameState.is_building_unlocked(type):
|
||||
lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type))
|
||||
can_build = false
|
||||
|
||||
# Anything that would stop this particular placement is spelled out in
|
||||
# the same popup, rather than silently failing after the press.
|
||||
if type == GameState.BUILDING_EXTRACTOR:
|
||||
if can_build and type == GameState.BUILDING_EXTRACTOR:
|
||||
var planet: Node2D = _planet_under_fleet()
|
||||
if planet == null:
|
||||
lines.append("No planet in reach - fly over one.")
|
||||
|
|
@ -260,7 +297,7 @@ func _show_building_info(type: String) -> void:
|
|||
can_build = false
|
||||
else:
|
||||
lines.append("Will mine %s." % planet.planet_name)
|
||||
elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
|
||||
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
|
||||
lines.append("WARNING: only one may exist - this destroys your current one.")
|
||||
|
||||
if can_build and not GameState.can_afford(cost):
|
||||
|
|
@ -290,6 +327,9 @@ func _confirm_build() -> void:
|
|||
# Everything is re-checked here rather than trusted from when the popup
|
||||
# opened: the fleet keeps flying while it is up, so it can drift off the
|
||||
# planet an extractor was going to mine.
|
||||
if not GameState.is_building_unlocked(type):
|
||||
return
|
||||
|
||||
if type == GameState.BUILDING_EXTRACTOR:
|
||||
var planet: Node2D = _planet_under_fleet()
|
||||
if planet == null or GameState.has_extractor_at(planet.planet_name):
|
||||
|
|
@ -324,15 +364,70 @@ func _try_place(type: String, cost: int) -> void:
|
|||
build_menu.visible = false
|
||||
|
||||
|
||||
# --- research (the mothership's skill tree) --------------------------------
|
||||
|
||||
func _on_skill_node_picked(id: String) -> void:
|
||||
_refresh_skill_menu(id)
|
||||
|
||||
|
||||
func _refresh_skill_menu(id: String = "") -> void:
|
||||
if id == "":
|
||||
id = skill_tree.selected_id
|
||||
if id == "":
|
||||
# Nothing chosen yet: point the player at the tree rather than
|
||||
# leaving the panel blank, which reads as broken.
|
||||
skill_detail.text = "Select a node to see what it unlocks."
|
||||
skill_cost.amount = 0
|
||||
skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size()
|
||||
skill_unlock_button.visible = false
|
||||
return
|
||||
|
||||
var node: Dictionary = GameState.skill_node(id)
|
||||
if node.is_empty():
|
||||
return
|
||||
skill_detail.text = "%s\n%s" % [node.name, node.desc]
|
||||
skill_cost.amount = int(node.cost)
|
||||
|
||||
if GameState.is_unlocked(id):
|
||||
skill_status.text = "Researched"
|
||||
skill_unlock_button.visible = false
|
||||
elif not GameState.prerequisite_met(id):
|
||||
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
|
||||
skill_unlock_button.visible = false
|
||||
elif not GameState.can_afford(int(node.cost)):
|
||||
skill_status.text = "Not enough rocks - you have %d" % GameState.rocks
|
||||
skill_unlock_button.visible = false
|
||||
else:
|
||||
skill_status.text = ""
|
||||
skill_unlock_button.visible = true
|
||||
|
||||
|
||||
func _on_unlock_pressed() -> void:
|
||||
var id: String = skill_tree.selected_id
|
||||
if id == "":
|
||||
return
|
||||
if not GameState.unlock(id):
|
||||
# Re-check rather than trust the button: rocks can be spent elsewhere
|
||||
# while this menu is open, since haulers keep delivering.
|
||||
_refresh_skill_menu(id)
|
||||
return
|
||||
# A capacity upgrade has to reach the fleet that is already flying, not
|
||||
# just ones rebuilt later.
|
||||
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:
|
||||
fleet.apply_formation()
|
||||
skill_tree.queue_redraw()
|
||||
_refresh_skill_menu(id)
|
||||
|
||||
|
||||
func _refresh_craft_status() -> void:
|
||||
craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [
|
||||
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size()
|
||||
GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.max_fleet_size()
|
||||
]
|
||||
|
||||
|
||||
func _on_craft_interceptor() -> void:
|
||||
if fleet.ships.size() >= GameState.formation.size():
|
||||
craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size()
|
||||
if fleet.ships.size() >= GameState.max_fleet_size():
|
||||
craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size()
|
||||
return
|
||||
if not GameState.spend(GameState.SHIP_COST):
|
||||
craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks]
|
||||
|
|
|
|||
375
Building.gd
375
Building.gd
|
|
@ -39,6 +39,7 @@ const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0)
|
|||
const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0)
|
||||
const RIB_RADIUS: float = 36.0
|
||||
const RAIL_Y: float = -32.0
|
||||
const FLASH_RING_RADIUS: float = 20.0
|
||||
|
||||
# The hull in four pieces, attached in this order. Each carries the point
|
||||
# the crane welds it on at, which is where the sparks fly from.
|
||||
|
|
@ -66,26 +67,56 @@ var phase_time: float = 0.0
|
|||
|
||||
var _hull_parts: Array[PackedVector2Array] = []
|
||||
|
||||
# Every moving element is its own NeonPiece, rasterised once when the
|
||||
# building is created and thereafter only moved, scaled, faded or hidden.
|
||||
# Nothing here ever calls queue_redraw() again: the building's own _draw()
|
||||
# lays down the fixed structure a single time, and the animation is pure
|
||||
# node-property work from then on.
|
||||
var _core_cell: NeonPiece = null
|
||||
var _satellites: Node2D = null
|
||||
var _beacons: Array = []
|
||||
var _clamps: NeonPiece = null
|
||||
var _crane: Node2D = null
|
||||
var _hoist: NeonPiece = null
|
||||
var _sparks: Node2D = null
|
||||
var _hull_holder: Node2D = null
|
||||
var _hull_pieces: Array = []
|
||||
var _flash_ring: NeonPiece = null
|
||||
var _wash: NeonPiece = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("buildings")
|
||||
z_index = -1
|
||||
# The crafter runs an assembly line and the mothership holds a live
|
||||
# reactor with satellites in orbit, so both redraw. The command centre is
|
||||
# static art and is drawn exactly once - per-frame redraws of art that
|
||||
# never changes were a real framerate cost earlier on.
|
||||
# reactor with satellites in orbit, so both animate. The command centre is
|
||||
# entirely static and needs no per-frame work at all.
|
||||
var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER
|
||||
set_process(animated)
|
||||
if building_type == GameState.BUILDING_SHIP_CRAFTER:
|
||||
for part in SHIP_PARTS:
|
||||
_hull_parts.append(PackedVector2Array(part.points))
|
||||
_build_crafter_pieces()
|
||||
elif building_type == GameState.BUILDING_MOTHERSHIP:
|
||||
_build_mothership_pieces()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
anim_time = fmod(anim_time + delta, CYCLE)
|
||||
phase_time += delta
|
||||
queue_redraw()
|
||||
apply_animation()
|
||||
|
||||
|
||||
# Pushes the current clock onto the movable pieces. Normally driven from
|
||||
# _process; public so a preview or a test can pin the clock and see exactly
|
||||
# that frame without having to run the animation up to it in real time.
|
||||
func apply_animation() -> void:
|
||||
match building_type:
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
_animate_mothership()
|
||||
GameState.BUILDING_SHIP_CRAFTER:
|
||||
_animate_crafter()
|
||||
|
||||
|
||||
# Where the satellites are, and how open the reactor core is. Both are read
|
||||
|
|
@ -109,14 +140,25 @@ func get_display_name() -> String:
|
|||
return "Command Center"
|
||||
|
||||
|
||||
# How many times this building's geometry has actually been rasterised. The
|
||||
# whole point of the NeonPiece decomposition is that this stays at 1 for the
|
||||
# building's lifetime however long it animates, so it is worth being able to
|
||||
# assert rather than assume.
|
||||
var draw_count: int = 0
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_count += 1
|
||||
# Runs exactly once per building: this is the fixed structure. Everything
|
||||
# that moves is a NeonPiece child, animated through node properties, so
|
||||
# nothing here ever asks for a redraw again.
|
||||
match building_type:
|
||||
GameState.BUILDING_COMMAND_CENTER:
|
||||
_draw_command_center()
|
||||
GameState.BUILDING_MOTHERSHIP:
|
||||
_draw_mothership()
|
||||
_draw_mothership_static(self)
|
||||
_:
|
||||
_draw_crafter()
|
||||
_draw_crafter_static(self)
|
||||
|
||||
|
||||
# --- shared helpers -------------------------------------------------------
|
||||
|
|
@ -139,17 +181,57 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int
|
|||
|
||||
|
||||
|
||||
# --- command centre: a tactical plotting hub ------------------------------
|
||||
# The mothership is radial-and-swept, the crafter is an asymmetric assembly
|
||||
# line, so the command centre reads best as the odd one out: rigid, hexagonal,
|
||||
# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond
|
||||
# readout, and every spoke ends in a formation port - half of them holding a
|
||||
# token, half left open - so the structure itself shows fleet slots waiting
|
||||
# to be filled, which is exactly what the drag-and-drop editor inside does.
|
||||
|
||||
const CMD_HEX_RADIUS: float = 88.0
|
||||
const CMD_DECK_RADIUS: float = 66.0
|
||||
const CMD_HUB_RADIUS: float = 30.0
|
||||
const CMD_SCREEN_RADIUS: float = 14.0
|
||||
const CMD_PORT_COUNT: int = 6
|
||||
const CMD_PORT_LENGTH: float = 16.0
|
||||
const CMD_TOKEN_RADIUS: float = 9.0
|
||||
const CMD_STUD_RADIUS: float = 7.0
|
||||
|
||||
|
||||
func _diamond(centre: Vector2, r: float) -> PackedVector2Array:
|
||||
return PackedVector2Array([
|
||||
centre + Vector2(0, -r), centre + Vector2(r, 0),
|
||||
centre + Vector2(0, r), centre + Vector2(-r, 0),
|
||||
])
|
||||
|
||||
|
||||
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),
|
||||
])
|
||||
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)
|
||||
var c: Color = COMMAND_COLOR
|
||||
var readout: Color = Color(0.82, 0.86, 1.0)
|
||||
|
||||
# The rim: a hex table holding the whole layout together, with a fainter
|
||||
# deck ring nested just inside it - the same "plating has layers" read the
|
||||
# mothership gets from its nested reactor rings.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7)
|
||||
|
||||
for i in range(CMD_PORT_COUNT):
|
||||
var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0
|
||||
var dir: Vector2 = Vector2(cos(a), sin(a))
|
||||
# Spoke bracing the hub to the rim.
|
||||
NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36)
|
||||
# Socket where the mast plugs into the rim.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85)
|
||||
# Port mast standing proud of the rim; every other one is manned.
|
||||
var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH)
|
||||
NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4)
|
||||
if i % 2 == 0:
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0)
|
||||
|
||||
# Command core: a diamond hub with a lit readout set inside it.
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9)
|
||||
|
||||
|
||||
# --- mothership: a radial flagship ----------------------------------------
|
||||
|
|
@ -187,49 +269,65 @@ func _talon(base_angle: float) -> PackedVector2Array:
|
|||
return pts
|
||||
|
||||
|
||||
func _draw_mothership() -> void:
|
||||
func _draw_mothership_static(canvas: CanvasItem) -> void:
|
||||
var c: Color = MOTHERSHIP_COLOR
|
||||
var core_color: Color = Color(0.75, 1.0, 0.85)
|
||||
|
||||
for i in range(ARM_COUNT):
|
||||
var base: float = (float(i) / float(ARM_COUNT)) * TAU
|
||||
NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95)
|
||||
NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95)
|
||||
# Crystal panel set into the gap behind each arm.
|
||||
var pa: float = base - 0.30
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([
|
||||
Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0,
|
||||
Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0,
|
||||
Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0,
|
||||
Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0,
|
||||
]), c, 0.7)
|
||||
|
||||
# Reactor core: nested rings around a cell that breathes.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
|
||||
var breath: float = core_pulse()
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0),
|
||||
core_color, lerpf(0.55, 1.0, breath))
|
||||
# Reactor housing: the nested rings around the cell that breathes.
|
||||
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0)
|
||||
NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85)
|
||||
|
||||
# Satellites holding station around it, drifting slowly round.
|
||||
var orbit: float = orbit_angle()
|
||||
|
||||
const CORE_CELL_RADIUS: float = 12.0
|
||||
|
||||
|
||||
func _build_mothership_pieces() -> void:
|
||||
var core_color: Color = Color(0.75, 1.0, 0.85)
|
||||
|
||||
# The breathing cell: drawn at a reference size and then scaled, rather
|
||||
# than rebuilt at a new radius every frame.
|
||||
_core_cell = NeonPiece.create(self, Vector2.ZERO, core_color)
|
||||
_core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0))
|
||||
|
||||
# Satellites sit at fixed offsets inside a holder; the orbit is the
|
||||
# holder's rotation, so four hexagon pairs become one transform update.
|
||||
_satellites = Node2D.new()
|
||||
add_child(_satellites)
|
||||
for i in range(SATELLITE_COUNT):
|
||||
var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU
|
||||
var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU
|
||||
var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85)
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8)
|
||||
var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR)
|
||||
shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85)
|
||||
var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color)
|
||||
cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8)
|
||||
|
||||
|
||||
func _animate_mothership() -> void:
|
||||
var breath: float = core_pulse()
|
||||
var radius: float = lerpf(9.0, 15.0, breath)
|
||||
_core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS)
|
||||
_core_cell.modulate.a = lerpf(0.55, 1.0, breath)
|
||||
_satellites.rotation = orbit_angle()
|
||||
|
||||
|
||||
# --- ship crafter: an orbital yard with a hull in the cradle --------------
|
||||
|
||||
func _draw_crafter() -> void:
|
||||
func _draw_crafter_static(canvas: CanvasItem) -> void:
|
||||
var c: Color = CRAFTER_COLOR
|
||||
var t: float = anim_time / CYCLE
|
||||
|
||||
# Fabricator core: a reactor housing whose inner cell pulses as it feeds
|
||||
# the yard. This replaces the chimney - nothing vents smoke in vacuum.
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
|
||||
var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow)
|
||||
# Fabricator core housing. This replaces the chimney - nothing vents
|
||||
# smoke in vacuum.
|
||||
NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0)
|
||||
|
||||
# Radiator fins above and below the core, slatted like real ones.
|
||||
for dir_y in [-1.0, 1.0]:
|
||||
|
|
@ -237,44 +335,114 @@ func _draw_crafter() -> void:
|
|||
CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y),
|
||||
CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y),
|
||||
])
|
||||
NeonDraw.draw_glowing_polygon(self, fin, c, 0.75)
|
||||
NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75)
|
||||
for f in [0.35, 0.68]:
|
||||
NeonDraw.draw_glowing_line(self,
|
||||
NeonDraw.draw_glowing_line(canvas,
|
||||
fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28)
|
||||
|
||||
# Truss spine linking the core to the cradle: two rails with X-bracing,
|
||||
# the standard "this was assembled in orbit" read.
|
||||
var spine_top: float = 4.0
|
||||
var spine_bottom: float = 22.0
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4)
|
||||
for i in range(3):
|
||||
var x0: float = -32.0 + float(i) * 15.0
|
||||
var x1: float = x0 + 15.0
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28)
|
||||
|
||||
# Construction cradle: two open ribs holding the hull, with the crane
|
||||
# rail overhead. Open at the top so the finished hull can clear it.
|
||||
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
|
||||
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7)
|
||||
NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55)
|
||||
for side in [-1.0, 1.0]:
|
||||
NeonDraw.draw_glowing_line(self,
|
||||
NeonDraw.draw_glowing_line(canvas,
|
||||
BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3)
|
||||
|
||||
|
||||
|
||||
func _build_crafter_pieces() -> void:
|
||||
var c: Color = CRAFTER_COLOR
|
||||
|
||||
# The core's inner cell: fades with the reactor rather than being redrawn
|
||||
# at a new brightness each frame.
|
||||
_core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6))
|
||||
_core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0))
|
||||
|
||||
# Docking beacons, blinking out of step with each other.
|
||||
for i in range(2):
|
||||
var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0)
|
||||
var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
|
||||
NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink)
|
||||
var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75))
|
||||
beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4))
|
||||
_beacons.append(beacon)
|
||||
|
||||
# Clamp arms reaching in from each rib. They let go as the hull launches,
|
||||
# which is a fade rather than a redraw.
|
||||
_clamps = NeonPiece.create(self, Vector2.ZERO, c)
|
||||
for side in [-1.0, 1.0]:
|
||||
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
|
||||
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
|
||||
_clamps.add_line(outer, inner, 0.35)
|
||||
_clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3)
|
||||
|
||||
# Crane: the head rides the rail, so only its x moves. The hoist is drawn
|
||||
# one unit long and stretched with scale.y to reach whatever seam is
|
||||
# being welded - stretching a line needs no redraw, redrawing it would.
|
||||
_crane = Node2D.new()
|
||||
add_child(_crane)
|
||||
var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c)
|
||||
head.add_polygon(PackedVector2Array([
|
||||
Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6),
|
||||
]))
|
||||
head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8)
|
||||
_hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5)
|
||||
_hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3)
|
||||
|
||||
# Weld sparks: four spokes on a holder that spins and stretches. The
|
||||
# jitter is genuinely procedural, but rotating and scaling a holder gives
|
||||
# the same flicker without re-tessellating the spokes every frame.
|
||||
_sparks = Node2D.new()
|
||||
add_child(_sparks)
|
||||
for i in range(4):
|
||||
var a: float = float(i) * TAU / 4.0
|
||||
var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7))
|
||||
spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3)
|
||||
|
||||
# The hull under construction. Each part is its own piece so attaching one
|
||||
# is a `visible` flip, and the launch is the holder's position and alpha.
|
||||
_hull_holder = Node2D.new()
|
||||
add_child(_hull_holder)
|
||||
for i in range(_hull_parts.size()):
|
||||
var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR)
|
||||
part.add_polygon(_hull_parts[i])
|
||||
part.visible = false
|
||||
_hull_pieces.append(part)
|
||||
|
||||
# Engine wash under the hull, and the completion ring. The ring is drawn
|
||||
# at a reference radius and scaled outwards.
|
||||
_wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0)
|
||||
for dx in [-4.0, 4.0]:
|
||||
_wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45)
|
||||
|
||||
_flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0)
|
||||
_flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0))
|
||||
|
||||
|
||||
func _animate_crafter() -> void:
|
||||
var t: float = anim_time / CYCLE
|
||||
|
||||
_core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0))
|
||||
for i in range(_beacons.size()):
|
||||
_beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI))
|
||||
|
||||
if t < ASSEMBLE_END:
|
||||
_draw_assembly(t / ASSEMBLE_END, c)
|
||||
_animate_assembly(t / ASSEMBLE_END)
|
||||
elif t < FLASH_END:
|
||||
_draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c)
|
||||
_animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END))
|
||||
else:
|
||||
_draw_launch((t - FLASH_END) / (1.0 - FLASH_END))
|
||||
_animate_launch((t - FLASH_END) / (1.0 - FLASH_END))
|
||||
|
||||
|
||||
# How many pieces are on the hull at this point through the assembly phase.
|
||||
|
|
@ -287,78 +455,73 @@ func attached_part_count(p: float) -> int:
|
|||
return n
|
||||
|
||||
|
||||
func _draw_clamps(c: Color, pulse: float) -> void:
|
||||
# Arms reaching in from each rib to hold the hull steady. They let go
|
||||
# once it launches, so this is not drawn during the launch phase.
|
||||
for side in [-1.0, 1.0]:
|
||||
var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0)
|
||||
var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0)
|
||||
NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35)
|
||||
NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3)
|
||||
func _place_hull(centre: Vector2, alpha: float) -> void:
|
||||
# The parts are defined around their own origin, so the holder goes
|
||||
# straight to the point they should be centred on.
|
||||
_hull_holder.position = centre
|
||||
_hull_holder.modulate.a = alpha
|
||||
|
||||
|
||||
func _draw_assembly(p: float, c: Color) -> void:
|
||||
_draw_clamps(c, 0.8)
|
||||
func _animate_assembly(p: float) -> void:
|
||||
_clamps.visible = true
|
||||
_clamps.modulate.a = 0.8
|
||||
_flash_ring.modulate.a = 0.0
|
||||
_wash.modulate.a = 0.0
|
||||
_place_hull(BAY_CENTRE, 1.0)
|
||||
|
||||
# Everything welded on so far, newest part still glowing from the seam.
|
||||
var next_part: int = attached_part_count(p)
|
||||
for i in range(next_part):
|
||||
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)
|
||||
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)
|
||||
_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))
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
30
Fleet.gd
30
Fleet.gd
|
|
@ -39,7 +39,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
|
|||
ships.clear()
|
||||
formation_offsets.clear()
|
||||
|
||||
var n: int = clamp(count, 1, GameState.formation.size())
|
||||
var n: int = clamp(count, 1, GameState.max_fleet_size())
|
||||
for i in range(n):
|
||||
var offset: Vector2 = GameState.formation[i]
|
||||
var ship: Area2D = ship_scene.instantiate()
|
||||
|
|
@ -58,7 +58,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void:
|
|||
func add_ship() -> bool:
|
||||
# Adds one vessel in the next free formation slot (bought from a ship
|
||||
# crafter). Returns false when the wedge is already full.
|
||||
if ships.size() >= GameState.formation.size():
|
||||
if ships.size() >= GameState.max_fleet_size():
|
||||
return false
|
||||
var offset: Vector2 = GameState.formation[ships.size()]
|
||||
var ship: Area2D = ship_scene.instantiate()
|
||||
|
|
@ -82,7 +82,7 @@ func apply_formation() -> void:
|
|||
# Without this, editing the layout at a command centre (or pivoting it)
|
||||
# only affected ships built later, which read as the edit doing nothing.
|
||||
for i in range(ships.size()):
|
||||
if i >= GameState.formation.size():
|
||||
if i >= GameState.max_fleet_size():
|
||||
break
|
||||
formation_offsets[i] = GameState.formation[i]
|
||||
_assign_slot_targets()
|
||||
|
|
@ -138,12 +138,32 @@ func stop() -> void:
|
|||
# since that lands inside arrive_radius immediately).
|
||||
fleet_target = get_formation_origin()
|
||||
for ship in ships:
|
||||
var speed: float = ship.velocity.length()
|
||||
# A ship's own `velocity` is NOT how fast it is really travelling
|
||||
# while the fleet cruises. Ship snaps to its slot and reports zero
|
||||
# the moment it is within arrive_radius of it, even though the
|
||||
# anchor is dragging that slot forward at cruise_speed underneath
|
||||
# it. Its true motion is its catch-up velocity PLUS the fleet's,
|
||||
# which it already carries as `fleet_velocity` (zero for a
|
||||
# point-to-point move_to, where `velocity` alone is the whole
|
||||
# story).
|
||||
#
|
||||
# Braking from `velocity` alone therefore stopped the fleet dead
|
||||
# on any frame where the ships happened to be seated - and how
|
||||
# often that happens depends on the frame rate, because the anchor
|
||||
# advances cruise_speed * delta per frame. At ~25fps it steps far
|
||||
# enough that ships always lag and always report motion, which is
|
||||
# the only reason releasing the stick looked right.
|
||||
var motion: Vector2 = ship.velocity + ship.fleet_velocity
|
||||
# Context that is no longer true: the fleet is not cruising any
|
||||
# more, so leaving it set would have Ship face a heading it is not
|
||||
# travelling on.
|
||||
ship.fleet_velocity = Vector2.ZERO
|
||||
var speed: float = motion.length()
|
||||
if speed <= 0.001:
|
||||
ship.set_move_target(ship.position)
|
||||
continue
|
||||
var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration)
|
||||
ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance)
|
||||
ship.set_move_target(ship.position + motion.normalized() * braking_distance)
|
||||
|
||||
|
||||
func get_move_targets() -> Array:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
137
GameState.gd
137
GameState.gd
|
|
@ -48,6 +48,138 @@ const EXTRACTOR_COST: int = 25
|
|||
|
||||
var buildings: Array = []
|
||||
|
||||
# --- Skill tree ------------------------------------------------------------
|
||||
|
||||
# Researched at the mothership. Two things hang off it: which structures the
|
||||
# player is allowed to build at all, and permanent upgrades that have no
|
||||
# building of their own. The mothership itself is deliberately NOT in the
|
||||
# tree - it is the thing you research AT, so gating it behind research would
|
||||
# be a deadlock.
|
||||
#
|
||||
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
|
||||
# stored, it is derived from how many prerequisites deep the node is, so the
|
||||
# tree always reads root-at-the-bottom and cannot drift out of step with the
|
||||
# dependencies when nodes are added. `requires` is a single prerequisite id
|
||||
# ("" for a root).
|
||||
const SKILL_TREE: Array = [
|
||||
{
|
||||
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
|
||||
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
|
||||
"desc": "Build ship crafters, and buy extra vessels at them.",
|
||||
},
|
||||
{
|
||||
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
|
||||
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
|
||||
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
|
||||
},
|
||||
{
|
||||
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
|
||||
"x": 0.82, "unlocks": BUILDING_COMMAND_CENTER,
|
||||
"desc": "Build command centers, where the fleet formation is arranged.",
|
||||
},
|
||||
{
|
||||
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
|
||||
"x": 0.82, "fleet_bonus": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
|
||||
"x": 0.65, "fleet_bonus": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
{
|
||||
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
|
||||
"x": 0.3, "fleet_bonus": 1,
|
||||
"desc": "Permanent: +1 vessel in the fleet.",
|
||||
},
|
||||
]
|
||||
|
||||
const BASE_FLEET_SIZE: int = 5
|
||||
|
||||
var unlocked: Array = []
|
||||
|
||||
|
||||
func skill_node(id: String) -> Dictionary:
|
||||
for node in SKILL_TREE:
|
||||
if node.id == id:
|
||||
return node
|
||||
return {}
|
||||
|
||||
|
||||
func skill_depth(id: String) -> int:
|
||||
# How many prerequisites deep this node sits. Drives its height in the
|
||||
# tree view: roots at the bottom, everything that depends on something
|
||||
# above it.
|
||||
var depth: int = 0
|
||||
var node: Dictionary = skill_node(id)
|
||||
while not node.is_empty() and node.requires != "":
|
||||
depth += 1
|
||||
node = skill_node(node.requires)
|
||||
if depth > SKILL_TREE.size():
|
||||
break # a cycle in the data would otherwise hang here
|
||||
return depth
|
||||
|
||||
|
||||
func max_skill_depth() -> int:
|
||||
var deepest: int = 0
|
||||
for node in SKILL_TREE:
|
||||
deepest = maxi(deepest, skill_depth(node.id))
|
||||
return deepest
|
||||
|
||||
|
||||
func is_unlocked(id: String) -> bool:
|
||||
return id in unlocked
|
||||
|
||||
|
||||
func prerequisite_met(id: String) -> bool:
|
||||
var node: Dictionary = skill_node(id)
|
||||
if node.is_empty():
|
||||
return false
|
||||
return node.requires == "" or is_unlocked(node.requires)
|
||||
|
||||
|
||||
func can_unlock(id: String) -> bool:
|
||||
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
|
||||
|
||||
|
||||
func unlock(id: String) -> bool:
|
||||
if not can_unlock(id):
|
||||
return false
|
||||
if not spend(int(skill_node(id).cost)):
|
||||
return false
|
||||
unlocked.append(id)
|
||||
return true
|
||||
|
||||
|
||||
func is_building_unlocked(type: String) -> bool:
|
||||
# The mothership is always buildable: it is where research happens, so
|
||||
# locking it would leave a player with no mothership unable to ever get
|
||||
# one.
|
||||
if type == BUILDING_MOTHERSHIP:
|
||||
return true
|
||||
for node in SKILL_TREE:
|
||||
if node.get("unlocks", "") == type:
|
||||
return is_unlocked(node.id)
|
||||
return true
|
||||
|
||||
|
||||
func research_for_building(type: String) -> String:
|
||||
for node in SKILL_TREE:
|
||||
if node.get("unlocks", "") == type:
|
||||
return node.name
|
||||
return ""
|
||||
|
||||
|
||||
func max_fleet_size() -> int:
|
||||
var n: int = BASE_FLEET_SIZE
|
||||
for id in unlocked:
|
||||
n += int(skill_node(id).get("fleet_bonus", 0))
|
||||
return mini(n, DEFAULT_FORMATION.size())
|
||||
|
||||
|
||||
func reset_progression() -> void:
|
||||
unlocked.clear()
|
||||
|
||||
|
||||
func add_building(type: String, at_position: Vector2) -> void:
|
||||
buildings.append({"type": type, "position": at_position})
|
||||
|
|
@ -84,8 +216,13 @@ func get_mothership_position() -> Variant:
|
|||
# centre. Lives here (not as a const on Fleet) so a layout the player has
|
||||
# arranged survives the trip to a planet's surface and back, and so a
|
||||
# rebuilt fleet keeps their arrangement.
|
||||
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
|
||||
# capacity upgrade is researched, but they are defined up front so the wedge
|
||||
# keeps a deliberate shape as it grows rather than having positions invented
|
||||
# at unlock time.
|
||||
const DEFAULT_FORMATION: Array = [
|
||||
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
|
||||
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
|
||||
]
|
||||
|
||||
var formation: Array = DEFAULT_FORMATION.duplicate()
|
||||
|
|
|
|||
|
|
@ -227,17 +227,30 @@ 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([
|
||||
center + Vector2(0, -d), center + Vector2(d, 0),
|
||||
center + Vector2(0, d), center + Vector2(-d, 0),
|
||||
tip + Vector2(0, -t), tip + Vector2(t, 0), tip + Vector2(0, t), tip + Vector2(-t, 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,
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
203
Main.tscn
203
Main.tscn
|
|
@ -20,6 +20,7 @@
|
|||
[ext_resource type="Script" path="res://HaulerField.gd" id="18"]
|
||||
[ext_resource type="Script" path="res://NeonNumber.gd" id="19"]
|
||||
[ext_resource type="Script" path="res://NeonPanel.gd" id="20"]
|
||||
[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="1"]
|
||||
shader = ExtResource("5")
|
||||
|
|
@ -83,10 +84,10 @@ layer = 1
|
|||
mouse_filter = 2
|
||||
offset_left = 28
|
||||
offset_top = 24
|
||||
offset_right = 520
|
||||
offset_bottom = 140
|
||||
offset_right = 320
|
||||
offset_bottom = 92
|
||||
script = ExtResource("16")
|
||||
digit_height = 100.0
|
||||
digit_height = 60.0
|
||||
|
||||
[node name="PlanetNameLabel" type="Label" parent="UI"]
|
||||
visible = false
|
||||
|
|
@ -129,19 +130,6 @@ offset_bottom = -572
|
|||
script = ExtResource("10")
|
||||
icon = 2
|
||||
|
||||
[node name="BuildActionLabel" type="Label" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = 24
|
||||
offset_right = -24
|
||||
offset_top = -566
|
||||
offset_bottom = -440
|
||||
text = ""
|
||||
horizontal_alignment = 2
|
||||
|
||||
[node name="MapButton" type="Control" parent="UI"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
|
|
@ -201,19 +189,6 @@ offset_bottom = -24
|
|||
script = ExtResource("10")
|
||||
icon = 6
|
||||
|
||||
[node name="NavLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -344
|
||||
offset_right = -24
|
||||
offset_top = -322
|
||||
offset_bottom = -196
|
||||
text = "Pivot"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="WorldMap" type="Control" parent="UI"]
|
||||
visible = false
|
||||
anchor_right = 1.0
|
||||
|
|
@ -287,8 +262,8 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -214
|
||||
offset_right = -66
|
||||
offset_top = -224
|
||||
offset_bottom = -76
|
||||
offset_top = -254
|
||||
offset_bottom = -106
|
||||
script = ExtResource("10")
|
||||
icon = 8
|
||||
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||
|
|
@ -301,9 +276,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -290
|
||||
offset_right = 10
|
||||
offset_top = -70
|
||||
offset_bottom = 60
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = -96
|
||||
offset_bottom = 4
|
||||
theme_override_font_sizes/font_size = 36
|
||||
autowrap_mode = 2
|
||||
text = "Mothership"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -314,13 +289,13 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -220
|
||||
offset_right = -60
|
||||
offset_top = 70
|
||||
offset_bottom = 150
|
||||
offset_left = -204
|
||||
offset_right = -64
|
||||
offset_top = 14
|
||||
offset_bottom = 78
|
||||
script = ExtResource("19")
|
||||
amount = 20
|
||||
digit_height = 66.0
|
||||
digit_height = 52.0
|
||||
|
||||
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
|
|
@ -329,8 +304,8 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = 66
|
||||
offset_right = 214
|
||||
offset_top = -224
|
||||
offset_bottom = -76
|
||||
offset_top = -254
|
||||
offset_bottom = -106
|
||||
script = ExtResource("10")
|
||||
icon = 9
|
||||
glow_color = Color(1, 0.72, 0.25, 1)
|
||||
|
|
@ -343,9 +318,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -10
|
||||
offset_right = 290
|
||||
offset_top = -70
|
||||
offset_bottom = 60
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = -96
|
||||
offset_bottom = 4
|
||||
theme_override_font_sizes/font_size = 36
|
||||
autowrap_mode = 2
|
||||
text = "Ship Crafter"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -356,13 +331,13 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 60
|
||||
offset_right = 220
|
||||
offset_top = 70
|
||||
offset_bottom = 150
|
||||
offset_left = 76
|
||||
offset_right = 216
|
||||
offset_top = 14
|
||||
offset_bottom = 78
|
||||
script = ExtResource("19")
|
||||
amount = 12
|
||||
digit_height = 66.0
|
||||
digit_height = 52.0
|
||||
|
||||
[node name="CommandButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
|
|
@ -371,8 +346,8 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -214
|
||||
offset_right = -66
|
||||
offset_top = 175
|
||||
offset_bottom = 323
|
||||
offset_top = 118
|
||||
offset_bottom = 266
|
||||
script = ExtResource("10")
|
||||
icon = 10
|
||||
glow_color = Color(0.55, 0.6, 1, 1)
|
||||
|
|
@ -385,9 +360,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -290
|
||||
offset_right = 10
|
||||
offset_top = 333
|
||||
offset_bottom = 463
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = 276
|
||||
offset_bottom = 376
|
||||
theme_override_font_sizes/font_size = 36
|
||||
autowrap_mode = 2
|
||||
text = "Command Center"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -398,13 +373,13 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -220
|
||||
offset_right = -60
|
||||
offset_top = 473
|
||||
offset_bottom = 553
|
||||
offset_left = -204
|
||||
offset_right = -64
|
||||
offset_top = 386
|
||||
offset_bottom = 450
|
||||
script = ExtResource("19")
|
||||
amount = 15
|
||||
digit_height = 66.0
|
||||
digit_height = 52.0
|
||||
|
||||
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 0.5
|
||||
|
|
@ -413,8 +388,8 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = 66
|
||||
offset_right = 214
|
||||
offset_top = 175
|
||||
offset_bottom = 323
|
||||
offset_top = 118
|
||||
offset_bottom = 266
|
||||
script = ExtResource("10")
|
||||
icon = 11
|
||||
glow_color = Color(0, 0.62, 0.7, 1)
|
||||
|
|
@ -427,9 +402,9 @@ anchor_top = 0.5
|
|||
anchor_bottom = 0.5
|
||||
offset_left = -10
|
||||
offset_right = 290
|
||||
offset_top = 333
|
||||
offset_bottom = 463
|
||||
theme_override_font_sizes/font_size = 44
|
||||
offset_top = 276
|
||||
offset_bottom = 376
|
||||
theme_override_font_sizes/font_size = 36
|
||||
autowrap_mode = 2
|
||||
text = "Extractor"
|
||||
horizontal_alignment = 1
|
||||
|
|
@ -440,13 +415,13 @@ anchor_left = 0.5
|
|||
anchor_right = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = 60
|
||||
offset_right = 220
|
||||
offset_top = 473
|
||||
offset_bottom = 553
|
||||
offset_left = 76
|
||||
offset_right = 216
|
||||
offset_top = 386
|
||||
offset_bottom = 450
|
||||
script = ExtResource("19")
|
||||
amount = 25
|
||||
digit_height = 66.0
|
||||
digit_height = 52.0
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/BuildMenu"]
|
||||
anchor_left = 1.0
|
||||
|
|
@ -696,3 +671,93 @@ anchor_bottom = 1.0
|
|||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0, 0, 0, 0)
|
||||
|
||||
[node name="SkillMenu" type="Control" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.03, 0.03, 0.08, 0.94)
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 24.0
|
||||
offset_top = 70.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 196.0
|
||||
text = "Research"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Tree" type="Control" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 24.0
|
||||
offset_top = 210.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 760.0
|
||||
script = ExtResource("21")
|
||||
|
||||
[node name="DetailLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 780.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = 926.0
|
||||
theme_override_font_sizes/font_size = 40
|
||||
autowrap_mode = 2
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CostNumber" type="Control" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -64.0
|
||||
offset_top = 936.0
|
||||
offset_right = 76.0
|
||||
offset_bottom = 1000.0
|
||||
script = ExtResource("19")
|
||||
amount = 0
|
||||
digit_height = 52.0
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="UI/SkillMenu"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 32.0
|
||||
offset_top = 1006.0
|
||||
offset_right = -32.0
|
||||
offset_bottom = 1070.0
|
||||
theme_override_font_sizes/font_size = 36
|
||||
autowrap_mode = 2
|
||||
text = ""
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="UnlockButton" type="Control" parent="UI/SkillMenu"]
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -74.0
|
||||
offset_top = 1090.0
|
||||
offset_right = 74.0
|
||||
offset_bottom = 1238.0
|
||||
script = ExtResource("10")
|
||||
icon = 5
|
||||
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/SkillMenu"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -172.0
|
||||
offset_top = 24.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 172.0
|
||||
script = ExtResource("10")
|
||||
icon = 1
|
||||
glow_color = Color(0.55, 0.6, 1, 1)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
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
|
||||
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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
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 ))
|
||||
29
tests/_skill_menu_preview.gd
Normal file
29
tests/_skill_menu_preview.gd
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
|
||||
# mothership's research tree with one branch already bought and a node
|
||||
# selected, so the laid-out tree, its state colours and the detail panel can
|
||||
# all be checked in one render.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.buildings.clear()
|
||||
GameState.extractors.clear()
|
||||
GameState.reset_progression()
|
||||
GameState.rocks = 60
|
||||
# One branch researched, so the render shows all three node states at
|
||||
# once: done, reachable, and still locked further out.
|
||||
GameState.unlock("shipyards")
|
||||
GameState.unlock("coordination")
|
||||
|
||||
var main: Node = load("res://Main.tscn").instantiate()
|
||||
add_child(main)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
|
||||
var controller: Node = main.get_node("BuildController")
|
||||
controller.skill_menu.visible = true
|
||||
controller.skill_tree.selected_id = "wing"
|
||||
controller._refresh_skill_menu("wing")
|
||||
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")
|
||||
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
|
||||
# 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
|
||||
echo
|
||||
done
|
||||
|
||||
if (( ${#tests[@]} == 0 )); then
|
||||
echo "no tests match '$filter'"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
start=$(date +%s)
|
||||
|
||||
run_dir="$PROJECT_DIR"
|
||||
if [[ "$IN_PLACE" != "1" ]]; then
|
||||
run_dir="$WORK_DIR"
|
||||
mkdir -p "$run_dir"
|
||||
# Clear last run's sources but keep .godot, so imports stay warm. Without
|
||||
# the clear, a test deleted from the project would live on in the copy and
|
||||
# keep being run.
|
||||
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
|
||||
# android/ and builds/ are export artefacts no test touches, and the .apk
|
||||
# is 30MB of nothing useful here.
|
||||
tar -C "$PROJECT_DIR" \
|
||||
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
|
||||
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
|
||||
fi
|
||||
|
||||
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
|
||||
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
|
||||
status=$?
|
||||
|
||||
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
|
||||
exit $status
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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