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
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue