Resolve pending gameplay tickets

This commit is contained in:
Loïc Wisniewski 2026-09-02 13:43:56 +02:00
commit c2ebbfaa2d
28 changed files with 618 additions and 188 deletions

View file

@ -15,54 +15,11 @@ extends Area2D
# for. # for.
const STRIKE_INTERVAL: float = 0.7 const STRIKE_INTERVAL: float = 0.7
# #165: how fast a friendly vessel has to be going before touching a structure
# counts as hitting it. Comfortably above the speed a fleet drifts onto its own
# mothership to unload at, and below cruise - so ordinary docking is free and
# flying INTO a building is not.
const RAM_SPEED: float = 420.0
const RAM_DAMAGE: int = 4
const RAM_DAMAGE_MAX: int = 12
var _strike_timer: float = 0.0 var _strike_timer: float = 0.0
func _ready() -> void: func _ready() -> void:
add_to_group("player_buildings") add_to_group("player_buildings")
area_entered.connect(_on_area_entered)
# #165: a vessel of the player's own, arriving fast enough, damages what it
# hits.
#
# On ENTRY, not on the strike tick above. The tick is right for a siege - an
# enemy parked on a structure is besieging it - but wrong for a collision: a
# vessel is only moving fast for the instant it arrives, and by the time a
# 0.7s tick came round it had already braked to nothing. Measured: a ship sent
# in at 900 did no damage at all until this moved to entry.
#
# Below the threshold it is docking, not a collision. The fleet sits on its own
# mothership constantly to bank ore, and if resting there hurt, the economy
# loop would grind the player's own buildings down.
func _on_area_entered(area: Area2D) -> void:
if not is_instance_valid(area) or not area.is_in_group("player_ships"):
return
# A vessel stationed at an outpost is not ramming it. It launches from
# there, patrols, and comes back - repeatedly, and at speed. Only the
# fleet can collide with a structure.
if area.is_in_group("garrison_ships"):
return
var speed: float = float(area.velocity.length()) if "velocity" in area else 0.0
if speed < RAM_SPEED:
return
var id: int = building_id()
if id == 0:
return
# Scaled by how much faster than the threshold it was going, so a glancing
# touch above the line is not a full-speed charge, and capped so no engine
# upgrade turns a vessel into a wrecking ball.
var impact: int = mini(int(round(RAM_DAMAGE * speed / RAM_SPEED)), RAM_DAMAGE_MAX)
GameState.damage_building(id, impact)
area.take_damage(1)
func building_id() -> int: func building_id() -> int:

View file

@ -383,7 +383,7 @@ func _show_building_info(type: String) -> void:
GameState.resource_name(planet.resource_type), planet.planet_name]) GameState.resource_name(planet.resource_type), planet.planet_name])
elif can_build and definition.unique and GameState.has_building_of_type(type): elif can_build and definition.unique and GameState.has_building_of_type(type):
lines.append("WARNING: only one may exist - this destroys your current one.") lines.append("WARNING: only one may exist - this destroys your current one.")
if can_build and not GameState.can_afford_resources(costs): if can_build and not GameState.can_afford_resources(costs, fleet.get_centroid()):
can_build = false can_build = false
pending_build_type = type if can_build else "" pending_build_type = type if can_build else ""
build_menu.show_info(definition.display_name, "\n\n".join(lines), costs, can_build) build_menu.show_info(definition.display_name, "\n\n".join(lines), costs, can_build)
@ -416,7 +416,7 @@ func _confirm_build() -> void:
if String(planet.resource_type) == "": if String(planet.resource_type) == "":
build_menu.set_status("%s has nothing to extract" % planet.planet_name) build_menu.set_status("%s has nothing to extract" % planet.planet_name)
return return
if not GameState.spend_resources(GameState.building_costs(type)): if not GameState.spend_resources(GameState.building_costs(type), fleet.get_centroid()):
build_menu.set_status(_cannot_afford(type)) build_menu.set_status(_cannot_afford(type))
return return
GameState.add_extractor(planet.planet_name, planet.position, GameState.add_extractor(planet.planet_name, planet.position,
@ -424,7 +424,7 @@ func _confirm_build() -> void:
build_menu.close_menu() build_menu.close_menu()
return return
if definition.unique and GameState.has_building_of_type(type): if definition.unique and GameState.has_building_of_type(type):
if not GameState.can_afford_resources(GameState.building_costs(type)): if not GameState.can_afford_resources(GameState.building_costs(type), fleet.get_centroid()):
build_menu.set_status(_cannot_afford(type)) build_menu.set_status(_cannot_afford(type))
return return
GameState.remove_buildings_of_type(type) GameState.remove_buildings_of_type(type)
@ -437,14 +437,14 @@ func _cannot_afford(type: String) -> String:
for resource_type in GameState.building_costs(type): for resource_type in GameState.building_costs(type):
var id: String = String(resource_type) var id: String = String(resource_type)
var want: int = int(GameState.building_costs(type)[resource_type]) var want: int = int(GameState.building_costs(type)[resource_type])
var have: int = GameState.available_resource(id) var have: int = GameState.available_resource_at(id, fleet.get_centroid())
if have < want: if have < want:
missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have]) missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have])
return "" if missing.is_empty() else "Not enough %s" % " and ".join(missing) return "" if missing.is_empty() else "Not enough %s" % " and ".join(missing)
func _try_place(type: String) -> void: func _try_place(type: String) -> void:
if not GameState.spend_resources(GameState.building_costs(type)): if not GameState.spend_resources(GameState.building_costs(type), fleet.get_centroid()):
build_menu.set_status(_cannot_afford(type)) build_menu.set_status(_cannot_afford(type))
return return
building_field.place(type, fleet.get_centroid()) building_field.place(type, fleet.get_centroid())
@ -452,7 +452,12 @@ func _try_place(type: String) -> void:
func _on_unlock_requested(id: String) -> void: func _on_unlock_requested(id: String) -> void:
if not GameState.unlock(id): var research_building: Dictionary = GameState.find_building(skill_menu.building_id)
if research_building.is_empty():
skill_menu.set_status("The research building is no longer available")
return
var payment_position: Variant = research_building.get("position", null)
if not GameState.unlock(id, payment_position):
skill_menu.refresh(id) skill_menu.refresh(id)
return return
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:

View file

@ -0,0 +1,66 @@
class_name DashController
extends "res://controllers/AimedFleetAbilityController.gd"
# Energy Propulser active ability (#179): hold, aim a direction, release, and
# the complete fleet surges along that heading. Cooldown belongs to the
# particular Propulser selected by the shared ability controller, so adding a
# second hull adds a second independently recharging dash.
const DASH_COLOR := Color(0.85, 0.95, 0.15)
const DASH_DISTANCE: float = 520.0
const ENERGY_PER_DASH: int = 15
const TRAIL_SECONDS: float = 0.45
var trail_from: Vector2 = Vector2.ZERO
var trail_time: float = 0.0
func _button_path() -> NodePath: return "../UI/DashButton"
func _status_path() -> NodePath: return "../UI/DashStatus"
func _component_id() -> String: return "distorder"
func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY
func _ammo_cost() -> int: return ENERGY_PER_DASH
func _aim_padding() -> float: return 36.0
func _aim_max_range() -> float: return DASH_DISTANCE
func _has_effect_visuals() -> bool: return trail_time > 0.0 or fleet.is_dashing()
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
return origin + direction * DASH_DISTANCE
func _consume_shot(component: Node) -> bool:
return component.consume_dash()
func _perform_shot(_ship: Node2D, _component: Node) -> void:
var direction: Vector2 = aim_target - _aim_origin()
if direction.length() < 0.001:
return
trail_from = fleet.get_centroid()
trail_time = TRAIL_SECONDS
fleet.dash(direction, DASH_DISTANCE)
func _advance_effects(delta: float) -> void:
trail_time = maxf(trail_time - delta, 0.0)
func _draw() -> void:
if aiming:
var origin: Vector2 = _aim_origin()
NeonDraw.draw_glowing_line(self, origin, aim_target, DASH_COLOR, 0.65, 0.65)
var direction: Vector2 = (aim_target - origin).normalized()
var side: Vector2 = direction.orthogonal() * 18.0
NeonDraw.draw_glowing_path(self, PackedVector2Array([
aim_target - direction * 38.0 + side, aim_target,
aim_target - direction * 38.0 - side,
]), DASH_COLOR, 0.9, 0.55)
if trail_time > 0.0:
var alpha: float = trail_time / TRAIL_SECONDS
var now: Vector2 = fleet.get_centroid()
for offset in [-20.0, 0.0, 20.0]:
var across: Vector2 = (now - trail_from).normalized().orthogonal() * offset
NeonDraw.draw_glowing_line(self, trail_from + across, now + across,
DASH_COLOR, alpha * 0.6, 0.35)

View file

@ -0,0 +1 @@
uid://bk38ws0glrkyk

View file

@ -31,6 +31,7 @@ extends Node2D
# indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd # indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd
# preview - for a class_name this new. Node + duck-typed calls sidesteps it. # preview - for a class_name this new. Node + duck-typed calls sidesteps it.
@onready var laser_controller: Node = get_node_or_null("LaserController") @onready var laser_controller: Node = get_node_or_null("LaserController")
@onready var dash_controller: Node = get_node_or_null("DashController")
@onready var speed_fader: Node = get_node_or_null("UI/SpeedFader") @onready var speed_fader: Node = get_node_or_null("UI/SpeedFader")
var touches: Dictionary = {} var touches: Dictionary = {}
@ -367,6 +368,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void:
return return
if laser_controller != null and laser_controller.handle_touch(event): if laser_controller != null and laser_controller.handle_touch(event):
return return
if dash_controller != null and dash_controller.handle_touch(event):
return
if speed_fader != null and speed_fader.handle_touch(event): if speed_fader != null and speed_fader.handle_touch(event):
return return
if _handle_pivot_touch(event): if _handle_pivot_touch(event):
@ -399,6 +402,8 @@ func _handle_drag(event: InputEventScreenDrag) -> void:
return return
if laser_controller != null and laser_controller.handle_drag(event): if laser_controller != null and laser_controller.handle_drag(event):
return return
if dash_controller != null and dash_controller.handle_drag(event):
return
if speed_fader != null and speed_fader.handle_drag(event): if speed_fader != null and speed_fader.handle_drag(event):
return return
if joystick != null and joystick.visible and joystick.handle_drag(event): if joystick != null and joystick.visible and joystick.handle_drag(event):

View file

@ -23,7 +23,7 @@ extends Node2D
# A literal, not PackedStringArray(...): a constructor call is not a constant # A literal, not PackedStringArray(...): a constructor call is not a constant
# expression, and the script fails to PARSE rather than to run - which shows up # expression, and the script fails to PARSE rather than to run - which shows up
# as "nonexistent function" on a node that looks fine in the scene. # as "nonexistent function" on a node that looks fine in the scene.
const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController"] const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController", "DashController"]
@onready var fleet: Node2D = get_node("../Fleet") @onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D") @onready var camera: Camera2D = get_node("../Camera2D")

View file

@ -657,10 +657,10 @@ func skill_costs(id: String) -> Dictionary:
return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {} return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {}
func can_unlock(id: String) -> bool: func can_unlock(id: String, payment_position: Variant = null) -> bool:
var node: Dictionary = skill_node(id) var node: Dictionary = skill_node(id)
return not is_unlocked(id) and prerequisite_met(id) \ return not is_unlocked(id) and prerequisite_met(id) \
and can_afford_resources(skill_costs(id)) \ and can_afford_resources(skill_costs(id), payment_position) \
and research_points >= skill_research_point_cost(id) and research_points >= skill_research_point_cost(id)
@ -668,11 +668,11 @@ func skill_research_point_cost(id: String) -> int:
return int(skill_node(id).get("research_points", 0)) return int(skill_node(id).get("research_points", 0))
func unlock(id: String) -> bool: func unlock(id: String, payment_position: Variant = null) -> bool:
if not can_unlock(id): if not can_unlock(id, payment_position):
return false return false
var node: Dictionary = skill_node(id) var node: Dictionary = skill_node(id)
if not spend_resources(skill_costs(id)): if not spend_resources(skill_costs(id), payment_position):
return false return false
var points_needed: int = skill_research_point_cost(id) var points_needed: int = skill_research_point_cost(id)
if points_needed > 0: if points_needed > 0:
@ -1314,13 +1314,13 @@ func vessel_for_unlock(unlock_id: String) -> Resource:
return null return null
func unlock_vessel(vessel: String) -> bool: func unlock_vessel(vessel: String, payment_position: Variant = null) -> bool:
var definition: Resource = vessel_definition(vessel) var definition: Resource = vessel_definition(vessel)
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab(): if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false return false
var costs: Dictionary = research_costs(definition.unlock_id) var costs: Dictionary = research_costs(definition.unlock_id)
var points_needed: int = int(definition.research_point_cost) var points_needed: int = int(definition.research_point_cost)
if research_points < points_needed or not spend_resources(costs): if research_points < points_needed or not spend_resources(costs, payment_position):
return false return false
if points_needed > 0: if points_needed > 0:
research_points -= points_needed research_points -= points_needed
@ -1370,15 +1370,16 @@ func upgrade_cost(id: String) -> int:
return int(d.cost) * (upgrade_level(id) + 1) return int(d.cost) * (upgrade_level(id) + 1)
func can_buy_upgrade(id: String) -> bool: func can_buy_upgrade(id: String, payment_position: Variant = null) -> bool:
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \ return has_ship_lab() and not upgrade_maxed(id) \
and can_afford(upgrade_cost(id), payment_position) \
and research_points >= research_point_cost(id) and research_points >= research_point_cost(id)
func buy_upgrade(id: String) -> bool: func buy_upgrade(id: String, payment_position: Variant = null) -> bool:
if not can_buy_upgrade(id): if not can_buy_upgrade(id, payment_position):
return false return false
if not spend(upgrade_cost(id)): if not spend(upgrade_cost(id), payment_position):
return false return false
var points_needed: int = research_point_cost(id) var points_needed: int = research_point_cost(id)
if points_needed > 0: if points_needed > 0:
@ -1601,7 +1602,12 @@ const LOCAL_PAY_RANGE: float = 150.0
func fleet_is_at(building: Dictionary) -> bool: func fleet_is_at(building: Dictionary) -> bool:
if building.is_empty(): if building.is_empty():
return false return false
return fleet_position.distance_to(building.position) <= LOCAL_PAY_RANGE return fleet_can_pay_at(building.position)
func fleet_can_pay_at(payment_position: Variant) -> bool:
return payment_position is Vector2 \
and fleet_position.distance_to(payment_position as Vector2) <= LOCAL_PAY_RANGE
func local_payment_available(building: Dictionary, inventory: RefCounted, func local_payment_available(building: Dictionary, inventory: RefCounted,
@ -3332,10 +3338,10 @@ func _advance_quests() -> void:
# --- paying for things ------------------------------------------------------ # --- paying for things ------------------------------------------------------
# #
# EVERY payment draws on the bank AND on what the ships are carrying. The # Payments draw on the bank and may also draw on what the ships are carrying
# player can already move resources between the two by hand at any building # when the caller supplies the concrete place being paid. The player can move
# that takes them, so refusing a purchase that a couple of taps would have # resources between the two by hand, so using nearby cargo avoids ceremony;
# made affordable was pure ceremony. # #180 makes "nearby" part of the API instead of an assumption in the caller.
# #
# In one case it was worse than ceremony. A fresh game starts with an empty # In one case it was worse than ceremony. A fresh game starts with an empty
# bank, ore is mined into the fleet hold, and the only way to bank it is to # bank, ore is mined into the fleet hold, and the only way to bank it is to
@ -3343,9 +3349,8 @@ func _advance_quests() -> void:
# made the first mothership cost ore that could not yet exist. Mine forever, # made the first mothership cost ore that could not yet exist. Mine forever,
# build nothing. # build nothing.
# #
# Bank first, hold second. The hold is also the fleet's mining capacity, so # Bank first, nearby hold second. A call with no position is deliberately
# leaving it as full as possible means the next trip out can still carry # bank-only: missing context must fail safe instead of teleporting cargo.
# something back.
# #
# The one deliberate exception is AMMO - see AimedFleetAbilityController, # The one deliberate exception is AMMO - see AimedFleetAbilityController,
# which checks fleet_resource() directly. A gas bomb is fired from what the # which checks fleet_resource() directly. A gas bomb is fired from what the
@ -3357,36 +3362,50 @@ func available_resource(resource_type: String) -> int:
return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type) return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type)
# What can pay at one concrete place. Banked resources remain globally
# available; cargo in the fleet hold joins them only when the fleet is close
# enough to physically hand it to the building or construction site (#180).
# A null position deliberately means "no local payer", so a forgotten context
# can never reach into the hold from across the map.
func available_resource_at(resource_type: String, payment_position: Variant = null) -> int:
var amount: int = bank_inventory.amount(resource_type)
if fleet_can_pay_at(payment_position):
amount += fleet_inventory.amount(resource_type)
return amount
func available_ore() -> int: func available_ore() -> int:
return available_resource(RESOURCE_ORE) return available_resource(RESOURCE_ORE)
func can_afford(cost: int) -> bool: func can_afford(cost: int, payment_position: Variant = null) -> bool:
return available_ore() >= cost return available_resource_at(RESOURCE_ORE, payment_position) >= cost
func can_afford_resources(costs: Dictionary) -> bool: func can_afford_resources(costs: Dictionary, payment_position: Variant = null) -> bool:
for resource_type in costs: for resource_type in costs:
if available_resource(String(resource_type)) < int(costs[resource_type]): if available_resource_at(String(resource_type), payment_position) \
< int(costs[resource_type]):
return false return false
return true return true
func spend(cost: int) -> bool: func spend(cost: int, payment_position: Variant = null) -> bool:
return spend_resources({RESOURCE_ORE: cost}) return spend_resources({RESOURCE_ORE: cost}, payment_position)
func spend_resources(costs: Dictionary) -> bool: func spend_resources(costs: Dictionary, payment_position: Variant = null) -> bool:
# Affordability is settled in full BEFORE anything is taken. Charging for # Affordability is settled in full BEFORE anything is taken. Charging for
# part of a purchase that then fails is worse than refusing it, and with # part of a purchase that then fails is worse than refusing it, and with
# several resource types in one price that is a real possibility rather # several resource types in one price that is a real possibility rather
# than a theoretical one. # than a theoretical one.
if not can_afford_resources(costs): if not can_afford_resources(costs, payment_position):
return false return false
var fleet_can_pay: bool = fleet_can_pay_at(payment_position)
for resource_type in costs: for resource_type in costs:
var id: String = String(resource_type) var id: String = String(resource_type)
var owed: int = int(costs[resource_type]) var owed: int = int(costs[resource_type])
owed -= bank_inventory.withdraw(id, owed) owed -= bank_inventory.withdraw(id, owed)
if owed > 0: if owed > 0 and fleet_can_pay:
fleet_inventory.withdraw(id, owed) fleet_inventory.withdraw(id, owed)
return true return true

View file

@ -13,7 +13,9 @@ enum State { NEUTRAL, BLOCKED, CAPTURING, CAPTURED }
@export var point_index: int = 0 @export var point_index: int = 0
@export var capture_radius: float = 220.0 @export var capture_radius: float = 220.0
@export var capture_time: float = 8.0 # #177: holding an already-cleared point should resolve briskly. Eight seconds
# made every point feel like waiting after the fight was over.
@export var capture_time: float = 4.0
@export var enemy_clear_radius: float = 700.0 @export var enemy_clear_radius: float = 700.0
@export var drain_rate: float = 0.5 @export var drain_rate: float = 0.5

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=19 format=3] [gd_scene load_steps=20 format=3]
[ext_resource type="Script" path="res://controllers/GameController.gd" id="1"] [ext_resource type="Script" path="res://controllers/GameController.gd" id="1"]
[ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"] [ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"]
@ -11,6 +11,7 @@
[ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"] [ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"]
[ext_resource type="Script" path="res://controllers/GasBombController.gd" id="14"] [ext_resource type="Script" path="res://controllers/GasBombController.gd" id="14"]
[ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"] [ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"]
[ext_resource type="Script" path="res://controllers/DashController.gd" id="dash_ctrl"]
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"]
[ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"] [ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"]
[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"] [ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"]
@ -39,6 +40,9 @@ script = ExtResource("mouse_ctrl")
[node name="LaserController" type="Node2D" parent="."] [node name="LaserController" type="Node2D" parent="."]
script = ExtResource("16") script = ExtResource("16")
[node name="DashController" type="Node2D" parent="."]
script = ExtResource("dash_ctrl")
[node name="Camera2D" type="Camera2D" parent="."] [node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(0, 0) position = Vector2(0, 0)
zoom = Vector2(1, 1) zoom = Vector2(1, 1)
@ -205,8 +209,25 @@ anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -196 offset_left = -196
offset_right = -100 offset_right = -100
offset_top = -492 offset_top = -548
offset_bottom = -396 offset_bottom = -452
[node name="DashButton" type="Control" parent="UI"]
visible = false
mouse_filter = 2
script = ExtResource("6")
round_frame = true
icon = 45
glow_color = Color(0.54, 0.62, 0.05, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -452
offset_right = -356
offset_top = -548
offset_bottom = -452
[node name="PivotCCWButton" type="Control" parent="UI"] [node name="PivotCCWButton" type="Control" parent="UI"]
mouse_filter = 2 mouse_filter = 2
@ -219,8 +240,8 @@ anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
anchor_top = 1.0 anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -468 offset_left = -620
offset_right = -364 offset_right = -516
offset_top = -130 offset_top = -130
offset_bottom = -26 offset_bottom = -26
@ -238,8 +259,8 @@ anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -324 offset_left = -324
offset_right = -228 offset_right = -228
offset_top = -424 offset_top = -548
offset_bottom = -328 offset_bottom = -452
[node name="GasBombStatus" type="Label" parent="UI"] [node name="GasBombStatus" type="Label" parent="UI"]
mouse_filter = 2 mouse_filter = 2
@ -273,6 +294,24 @@ offset_right = -24
offset_top = -616 offset_top = -616
offset_bottom = -568 offset_bottom = -568
[node name="DashStatus" type="Label" parent="UI"]
mouse_filter = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.54, 0.62, 0.05, 1)
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
theme_override_constants/outline_size = 4
autowrap_mode = 2
horizontal_alignment = 1
text = ""
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -500
offset_right = -104
offset_top = -672
offset_bottom = -624
[node name="PivotCWButton" type="Control" parent="UI"] [node name="PivotCWButton" type="Control" parent="UI"]
mouse_filter = 2 mouse_filter = 2
script = ExtResource("6") script = ExtResource("6")
@ -284,8 +323,8 @@ anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
anchor_top = 1.0 anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -356 offset_left = -508
offset_right = -252 offset_right = -404
offset_top = -130 offset_top = -130
offset_bottom = -26 offset_bottom = -26

View file

@ -11,6 +11,8 @@ func _ready() -> void:
GameState.extractors.clear() GameState.extractors.clear()
GameState.vessel_levels["unlock_laser"] = 1 GameState.vessel_levels["unlock_laser"] = 1
GameState.vessel_levels["unlock_gas_bomber"] = 1 GameState.vessel_levels["unlock_gas_bomber"] = 1
GameState.vessel_levels["unlock_distorder"] = 1
GameState.unlocked.append("wing")
GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200) GameState.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 200)
GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200) GameState.deposit_fleet_resource(GameState.RESOURCE_GAS, 200)
var main: Node = load("res://world/Main.tscn").instantiate() var main: Node = load("res://world/Main.tscn").instantiate()
@ -18,10 +20,12 @@ func _ready() -> void:
main.get_node("EnemySpawner").set_process(false) main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false) main.get_node("AsteroidField").set_process(false)
var fleet: Node = main.get_node("Fleet") var fleet: Node = main.get_node("Fleet")
fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([ fleet.spawn_ships(4, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER, GameState.VESSEL_GAS_BOMBER])) GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER,
GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_DISTORDER]))
main.get_node("LaserController")._process(0.0) main.get_node("LaserController")._process(0.0)
main.get_node("GasBombController")._process(0.0) main.get_node("GasBombController")._process(0.0)
main.get_node("DashController")._process(0.0)
# Push the fire stick, so its knob shows deflected the way it looks in use. # Push the fire stick, so its knob shows deflected the way it looks in use.
var stick: Control = main.get_node("UI/FireStick") var stick: Control = main.get_node("UI/FireStick")
var press := InputEventScreenTouch.new() var press := InputEventScreenTouch.new()

View file

@ -2,19 +2,18 @@ extends SceneTree
# Headless gameplay test: run with # Headless gameplay test: run with
# godot --headless -s res://tests/test_building_collateral.gd # godot --headless -s res://tests/test_building_collateral.gd
# Forgejo #165: a gas bomb and a vessel flying fast enough both damage a # Forgejo #176: the player's own ships and their weapons do not damage the
# structure. The blast used to stop politely at anything the player owned, # player's buildings. Friendly fire on structures made defending them with a
# which made a bomb dropped on a besieged mothership a free way to sweep the # gas bomb or crossing them at speed punish ordinary play.
# attackers off it.
# #
# Covers: # Covers:
# A. a bomb landing on a structure damages it # A. a bomb landing on a structure leaves it unharmed
# B. a bomb landing clear of it does not - the radius is the radius, and # B. a bomb landing clear of it does not - the radius is the radius, and
# this is a weapon that has to be aimed # this is a weapon that has to be aimed
# C. a vessel resting against a structure does NOT damage it. The fleet # C. a vessel resting against a structure does NOT damage it. The fleet
# sits on its own mothership constantly to bank ore; if that hurt, the # sits on its own mothership constantly to bank ore; if that hurt, the
# game's own economy loop would grind its buildings down # game's own economy loop would grind its buildings down
# D. a vessel arriving fast does # D. a vessel arriving fast still does not damage it or itself
var game_state: Node var game_state: Node
var main: Node var main: Node
@ -23,6 +22,7 @@ var building_id: int = 0
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
var hp_before: int = 0 var hp_before: int = 0
var ships_before: int = 0
const BUILDING_AT := Vector2(1200.0, 0.0) const BUILDING_AT := Vector2(1200.0, 0.0)
@ -78,11 +78,11 @@ func _process(delta: float) -> bool:
return _fail("a bomb 900 away still damaged the structure") return _fail("a bomb 900 away still damaged the structure")
print("a bomb clear of the structure leaves it alone") print("a bomb clear of the structure leaves it alone")
# --- A. a bomb on top of it does -------------------------------- # --- A. a friendly bomb on top of it is safe ----------------------
_detonate_at(BUILDING_AT) _detonate_at(BUILDING_AT)
if _hp() >= hp_before: if _hp() != hp_before:
return _fail("a bomb landing on the structure did not damage it (hp %d)" % _hp()) return _fail("a friendly bomb damaged the structure (%d -> %d)" % [hp_before, _hp()])
print("a bomb on the structure took it from %d to %d" % [hp_before, _hp()]) print("a friendly bomb on the structure leaves it at %d hp" % _hp())
# --- C. resting against it is free ------------------------------ # --- C. resting against it is free ------------------------------
hp_before = _hp() hp_before = _hp()
@ -104,8 +104,9 @@ func _process(delta: float) -> bool:
% [hp_before, _hp()]) % [hp_before, _hp()])
print("a vessel parked on the structure for %.1fs did nothing to it" % elapsed) print("a vessel parked on the structure for %.1fs did nothing to it" % elapsed)
# --- D. arriving fast does -------------------------------------- # --- D. arriving fast is still friendly -------------------------
hp_before = _hp() hp_before = _hp()
ships_before = fleet.ships.size()
for ship in fleet.ships: for ship in fleet.ships:
# Placed clear and sent IN, because the impact is detected on # Placed clear and sent IN, because the impact is detected on
# entry - a ship spawned already overlapping never enters. # entry - a ship spawned already overlapping never enters.
@ -118,9 +119,11 @@ func _process(delta: float) -> bool:
3: 3:
if elapsed < 1.2: if elapsed < 1.2:
return false return false
if _hp() >= hp_before: if _hp() != hp_before:
return _fail("a vessel arriving at 900 did not damage the structure") return _fail("a fast friendly vessel damaged the structure (%d -> %d)" % [hp_before, _hp()])
print("a vessel arriving fast took it from %d to %d" % [hp_before, _hp()]) if fleet.ships.size() != ships_before:
return _fail("a friendly building destroyed a vessel crossing it")
print("a vessel crossing the structure at speed leaves both unharmed")
print("TEST_BUILDING_COLLATERAL: PASS") print("TEST_BUILDING_COLLATERAL: PASS")
quit(0) quit(0)

View file

@ -95,6 +95,8 @@ func _process(delta: float) -> bool:
point = load("res://surface/CapturePoint.tscn").instantiate() point = load("res://surface/CapturePoint.tscn").instantiate()
point.point_index = 0 point.point_index = 0
if point.capture_time > 4.0:
return _fail("default capture still takes %.1fs" % point.capture_time)
point.capture_time = 1.0 point.capture_time = 1.0
point.capture_radius = 200.0 point.capture_radius = 200.0
point.enemy_clear_radius = 300.0 point.enemy_clear_radius = 300.0

View file

@ -21,10 +21,10 @@ extends SceneTree
# E. it types while the game is stopped, being the one node still running # E. it types while the game is stopped, being the one node still running
# F. the sci-fi part: the panel is drawn differently while decoding, and # F. the sci-fi part: the panel is drawn differently while decoding, and
# settles when the line is complete # settles when the line is complete
# G. #168 - a KEY does exactly what a click does, both halves of it, since # G. #175 - keyboard presses do not finish or dismiss a dialog; only a
# the game is played on a desktop with WASD and a mouse and pressing # deliberate tap/click advances it
# space at a dialog reached nothing at all. Modifiers alone and held-key # H. #178 - revealed text is seated at the left edge rather than growing
# echoes are not presses, or a line would be run out by a held key. # outward from the middle
var game_state: Node var game_state: Node
var scene: Node var scene: Node
@ -90,6 +90,8 @@ func _process(delta: float) -> bool:
return _fail("the label holds a partial string - the line will re-wrap as it types") return _fail("the label holds a partial string - the line will re-wrap as it types")
if narration.label.visible_characters < 0: if narration.label.visible_characters < 0:
return _fail("the whole line is visible already") return _fail("the whole line is visible already")
if narration.label.horizontal_alignment != HORIZONTAL_ALIGNMENT_LEFT:
return _fail("dialog text is not left-aligned - partial text will grow from the middle")
seen.append(narration.label.visible_characters) seen.append(narration.label.visible_characters)
# Hand-ticked, so the reveal is measured rather than raced. The # Hand-ticked, so the reveal is measured rather than raced. The
@ -151,37 +153,34 @@ func _process(delta: float) -> bool:
return _fail("dismissing landed on \"%s\"" % narration.current) return _fail("dismissing landed on \"%s\"" % narration.current)
print("the press after that moves on to \"%s\"" % narration.current) print("the press after that moves on to \"%s\"" % narration.current)
# --- G. #168: a key is a press too ----------------------- # --- G. #175: keys never advance a dialog ----------------
narration.clear() narration.clear()
narration.say(first_line, "TEST") narration.say(first_line, "TEST")
narration.say("Second.", "TEST") narration.say("Second.", "TEST")
if not narration.is_typing(): if not narration.is_typing():
return _fail("the fresh line was not arriving") return _fail("the fresh line was not arriving")
# Not everything that reaches the banner is a press.
_key(KEY_SHIFT) _key(KEY_SHIFT)
_key(KEY_SPACE, true) _key(KEY_SPACE, true)
if not narration.is_typing():
return _fail("a modifier or a held-key echo finished the line")
# Still arriving: the key shows the whole of it, and no more.
_key(KEY_SPACE) _key(KEY_SPACE)
if narration.is_typing():
return _fail("a key did not finish a line that was still arriving")
if narration.current != first_line:
return _fail("a key on an arriving line dismissed it - the next line would be skipped unread")
# Whole: the key moves on, the same as a click.
_key(KEY_SPACE)
if narration.current != "Second.":
return _fail("a key on a finished line landed on \"%s\"" % narration.current)
# Any key, not one chosen key - the player has no reason to guess.
_key(KEY_W) _key(KEY_W)
if narration.is_typing():
return _fail("only some keys finish a line")
_key(KEY_ENTER) _key(KEY_ENTER)
if not narration.is_typing() or narration.current != first_line:
return _fail("a keyboard press changed a dialog that was still arriving")
narration.finish_typing()
_key(KEY_W)
_key(KEY_ENTER)
if narration.current != first_line or narration.is_typing():
return _fail("a keyboard press dismissed a complete dialog")
_click()
if narration.current != "Second.":
return _fail("clicking after ignored keys did not reach the second line")
narration.finish_typing()
_click()
if narration.is_speaking(): if narration.is_speaking():
return _fail("a key on the last line did not close the dialog") return _fail("clicking the last complete line did not close the dialog")
if paused: if paused:
return _fail("keying through every line left the game stopped") return _fail("clicking through every line left the game stopped")
print("space, W and enter all finish then advance a line; shift and echoes do not") print("space, W and enter leave dialogs untouched; clicks still finish and advance")
print("TEST_DIALOG_TYPING: PASS") print("TEST_DIALOG_TYPING: PASS")
quit(0) quit(0)

View file

@ -0,0 +1,137 @@
extends SceneTree
# Forgejo #179: an Energy Propulser in the fleet grants a hold-aim-release
# dash. Covers visibility, aimed movement, energy cost, formation integrity,
# and one cooldown per Propulser.
const DASH_COST: int = 15
const DASH_DISTANCE: float = 520.0
var state: Node
var main: Node
var fleet: Node
var dash: Node
var stage: int = 0
var elapsed: float = 0.0
var first_origin: Vector2
var second_origin: Vector2
var offsets_before: Array = []
var first_component: Node
func _initialize() -> void:
state = root.get_node("GameState")
state.reset_new_game()
state.intro_active = false
state.reset_quests()
state.vessel_levels["unlock_distorder"] = 1
state.fleet_inventory.total_capacity = -1
state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, DASH_COST * 3)
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
dash = main.get_node("DashController")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
if main.get_node("UI/DashButton").visible:
return _fail("dash button appeared without an Energy Propulser")
# The carrier keeps enough hold capacity for both dashes. Two
# Propulsers prove that cooldown belongs to a vessel, not the fleet.
fleet.spawn_ships(3, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER,
GameState.VESSEL_DISTORDER,
]))
dash._process(0.0)
if not main.get_node("UI/DashButton").visible:
return _fail("an Energy Propulser did not reveal the dash button")
first_component = dash._next_ready_ship().get_component("distorder")
first_origin = fleet.get_centroid()
for ship in fleet.ships:
offsets_before.append(ship.position - first_origin)
_aim_and_release(Vector2.RIGHT, 11)
if not fleet.is_dashing():
return _fail("releasing a rightward aim did not begin a dash")
if state.fleet_resource(GameState.RESOURCE_ENERGY) != DASH_COST * 2:
return _fail("first dash did not consume %d energy" % DASH_COST)
if first_component.is_ready():
return _fail("the Propulser that dashed did not enter cooldown")
dash._process(0.0)
if not main.get_node("UI/DashButton").enabled:
return _fail("one cooling Propulser disabled a second ready one")
stage = 1
elapsed = 0.0
return false
1:
if elapsed < 0.7:
return false
var moved: Vector2 = fleet.get_centroid() - first_origin
if absf(moved.x - DASH_DISTANCE) > 8.0 or absf(moved.y) > 8.0:
return _fail("rightward aim moved the fleet by %s" % moved)
for i in range(fleet.ships.size()):
var offset: Vector2 = fleet.ships[i].position - fleet.get_centroid()
if offset.distance_to(offsets_before[i]) > 1.0:
return _fail("dash distorted formation slot %d by %.2f" \
% [i, offset.distance_to(offsets_before[i])])
second_origin = fleet.get_centroid()
_aim_and_release(Vector2.UP, 12)
if state.fleet_resource(GameState.RESOURCE_ENERGY) != DASH_COST:
return _fail("second Propulser did not pay for its own dash")
dash._process(0.0)
if main.get_node("UI/DashButton").enabled:
return _fail("dash remained enabled after both Propulsers fired")
stage = 2
elapsed = 0.0
return false
2:
if elapsed < 0.7:
return false
var moved: Vector2 = fleet.get_centroid() - second_origin
if absf(moved.y + DASH_DISTANCE) > 8.0 or absf(moved.x) > 8.0:
return _fail("upward aim moved the fleet by %s" % moved)
first_component.tick(first_component.DASH_COOLDOWN)
dash._process(0.0)
if not main.get_node("UI/DashButton").enabled:
return _fail("button did not re-enable when one Propulser recharged")
var surface: Node = load("res://surface/PlanetSurface.tscn").instantiate()
if not surface.has_node("DashController") or not surface.has_node("UI/DashButton"):
return _fail("planet surface does not carry the dash ability")
surface.free()
print("two aimed dashes moved %.0f units, preserved formation, and cooled down per Propulser" \
% DASH_DISTANCE)
print("TEST_ENERGY_PROPULSER_DASH: PASS")
quit(0)
return true
return false
func _aim_and_release(direction: Vector2, touch_index: int) -> void:
var button: Control = main.get_node("UI/DashButton")
var press := InputEventScreenTouch.new()
press.index = touch_index
press.pressed = true
press.position = button.global_position + button.size * 0.5
main._handle_touch(press)
var drag := InputEventScreenDrag.new()
drag.index = touch_index
drag.position = press.position + direction * 240.0
main._handle_drag(drag)
var release := InputEventScreenTouch.new()
release.index = touch_index
release.pressed = false
release.position = drag.position
main._handle_touch(release)
func _fail(message: String) -> bool:
print("TEST_ENERGY_PROPULSER_DASH: FAIL (%s)" % message)
quit(1)
return true

View file

@ -15,6 +15,8 @@ extends SceneTree
# 5. #169 - a second press while a load is running is ignored, or an # 5. #169 - a second press while a load is running is ignored, or an
# impatient double tap builds the world twice (on the web a button fires # impatient double tap builds the world twice (on the web a button fires
# on PRESS, so that tap costs nothing to make) # on PRESS, so that tap costs nothing to make)
# 6. #174 - the opaque loading cover fills the viewport and puts its
# activity bar at the middle of the screen
var menu: Node var menu: Node
var game_state: Node var game_state: Node
@ -61,7 +63,19 @@ func _process(delta: float) -> bool:
return _fail("the press started a load with no loading screen up") return _fail("the press started a load with no loading screen up")
if current_scene != menu: if current_scene != menu:
return _fail("the scene swapped inside the press, so nothing was ever shown") return _fail("the scene swapped inside the press, so nothing was ever shown")
var viewport_size: Vector2 = menu.get_viewport().get_visible_rect().size
if menu.loading_screen.size.distance_to(viewport_size) > 1.0:
return _fail("loading cover is %s, viewport is %s" \
% [menu.loading_screen.size, viewport_size])
if menu.loading_screen.BACKDROP.a < 0.999:
return _fail("loading cover is translucent (alpha %.2f)" \
% menu.loading_screen.BACKDROP.a)
var bar: Rect2 = menu.loading_screen._bar_rect()
if absf(bar.get_center().y - viewport_size.y * 0.5) > 1.0:
return _fail("loading bar centre %.1f is not screen centre %.1f" \
% [bar.get_center().y, viewport_size.y * 0.5])
print("pressing an entry raises the loading screen and leaves the menu up") print("pressing an entry raises the loading screen and leaves the menu up")
print("loading cover fills %s and centres its bar" % viewport_size)
# --- 5. and a second press changes nothing -------------------- # --- 5. and a second press changes nothing --------------------
var screen: Node = menu.loading_screen var screen: Node = menu.loading_screen

View file

@ -2,9 +2,9 @@ extends SceneTree
# Headless gameplay test: run with # Headless gameplay test: run with
# godot --headless -s res://tests/test_payments_use_fleet_hold.gd # godot --headless -s res://tests/test_payments_use_fleet_hold.gd
# Every payment draws on the bank AND on what the ships are carrying. The # A payment draws on the bank and, only at the place being paid, what the
# player can already shuttle resources between the two by hand, so refusing a # ships are carrying. Fleet cargo is physical: a building across the map may
# purchase that a couple of taps would have made affordable was ceremony. # not reach into it (#180).
# #
# It was also a deadlock. A new game starts with an empty bank, ore is mined # It was also a deadlock. A new game starts with an empty bank, ore is mined
# into the fleet hold, and the only way to bank it is to deliver it to a # into the fleet hold, and the only way to bank it is to deliver it to a
@ -22,8 +22,10 @@ extends SceneTree
# are physically carrying # are physically carrying
# F. end to end: an empty bank and ore in the hold actually gets a # F. end to end: an empty bank and ore in the hold actually gets a
# mothership built through BuildController # mothership built through BuildController
# G. no payment position, or a distant one, cannot drain the fleet hold
const HOLD_ORE: int = 40 const HOLD_ORE: int = 40
const PAYMENT_AT := Vector2(120.0, 60.0)
var game_state: Node var game_state: Node
var scene: Node var scene: Node
@ -37,6 +39,7 @@ func _initialize() -> void:
game_state.reset_progression() game_state.reset_progression()
game_state.buildings.clear() game_state.buildings.clear()
game_state.extractors.clear() game_state.extractors.clear()
game_state.fleet_position = PAYMENT_AT
var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])
@ -50,13 +53,16 @@ func _initialize() -> void:
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) >= cost: if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) >= cost:
_fail("the bank was not actually empty, so nothing below tests the deadlock") _fail("the bank was not actually empty, so nothing below tests the deadlock")
return return
if not game_state.can_afford(cost): if game_state.can_afford(cost):
_fail("a location-free affordability check reached into the fleet hold")
return
if not game_state.can_afford(cost, PAYMENT_AT):
_fail("a fleet holding %d ore could not afford a %d purchase" % [HOLD_ORE, cost]) _fail("a fleet holding %d ore could not afford a %d purchase" % [HOLD_ORE, cost])
return return
print("bank 0 + hold %d can afford a %d purchase" % [HOLD_ORE, cost]) print("bank 0 + hold %d can afford a %d purchase" % [HOLD_ORE, cost])
# --- B. bank first, hold for the shortfall ------------------------------- # --- B. bank first, hold for the shortfall -------------------------------
if not game_state.spend(cost): if not game_state.spend(cost, PAYMENT_AT):
_fail("spend refused a purchase it had just called affordable") _fail("spend refused a purchase it had just called affordable")
return return
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \
@ -68,7 +74,7 @@ func _initialize() -> void:
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8) game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30)
if not game_state.spend(20): if not game_state.spend(20, PAYMENT_AT):
_fail("a 20 purchase was refused with 8 banked and 30 held") _fail("a 20 purchase was refused with 8 banked and 30 held")
return return
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \
@ -82,7 +88,7 @@ func _initialize() -> void:
# --- C. no partial charge ----------------------------------------------- # --- C. no partial charge -----------------------------------------------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5) game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5)
if game_state.spend(50): if game_state.spend(50, PAYMENT_AT):
_fail("a purchase costing more than everything owned was allowed") _fail("a purchase costing more than everything owned was allowed")
return return
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \ if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \
@ -93,6 +99,22 @@ func _initialize() -> void:
return return
print("a purchase nobody can afford takes nothing") print("a purchase nobody can afford takes nothing")
# --- G. cargo does not teleport to a remote purchase ------------------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost)
game_state.fleet_position = PAYMENT_AT + Vector2(9000.0, 0.0)
if game_state.spend(cost, PAYMENT_AT):
_fail("a remote purchase took resources from the fleet hold")
return
if game_state.fleet_resource(GameState.RESOURCE_ORE) != cost:
_fail("a refused remote payment still drained the hold")
return
if game_state.spend(cost):
_fail("a purchase with no location reached into the fleet hold")
return
game_state.fleet_position = PAYMENT_AT
print("distant and location-free payments leave fleet cargo untouched")
# The hold has a real capacity (one starting interceptor carries 20), and # The hold has a real capacity (one starting interceptor carries 20), and
# set_amount() is clamped by it - which silently gave the checks below # set_amount() is clamped by it - which silently gave the checks below
# less than they asked for and made an affordable price look unaffordable. # less than they asked for and made an affordable price look unaffordable.
@ -125,12 +147,12 @@ func _check_multi_resource_atomicity() -> bool:
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 10) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 10)
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 1) game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 1)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 1) game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 1)
if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}): if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}, PAYMENT_AT):
return _fail("a two-resource price was charged with only one of them affordable") return _fail("a two-resource price was charged with only one of them affordable")
if game_state.available_ore() != 20 or game_state.available_resource(GameState.RESOURCE_GAS) != 2: if game_state.available_ore() != 20 or game_state.available_resource(GameState.RESOURCE_GAS) != 2:
return _fail("a refused two-resource purchase still took some ore or gas (ore %d, gas %d)" \ return _fail("a refused two-resource purchase still took some ore or gas (ore %d, gas %d)" \
% [game_state.available_ore(), game_state.available_resource(GameState.RESOURCE_GAS)]) % [game_state.available_ore(), game_state.available_resource(GameState.RESOURCE_GAS)])
if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}): if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}, PAYMENT_AT):
return _fail("an affordable two-resource price was refused") return _fail("an affordable two-resource price was refused")
if game_state.available_ore() != 5 or game_state.available_resource(GameState.RESOURCE_GAS) != 0: if game_state.available_ore() != 5 or game_state.available_resource(GameState.RESOURCE_GAS) != 0:
return _fail("a two-resource purchase charged wrongly (ore %d left, gas %d left)" \ return _fail("a two-resource purchase charged wrongly (ore %d left, gas %d left)" \
@ -147,9 +169,9 @@ func _check_skill_tree() -> bool:
game_state.research_points = 999 game_state.research_points = 999
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost)
if not game_state.can_unlock(id): if not game_state.can_unlock(id, PAYMENT_AT):
return _fail("the skill tree refused '%s' (cost %d) with the whole price in the fleet hold" % [id, cost]) return _fail("the skill tree refused '%s' (cost %d) with the whole price in the fleet hold" % [id, cost])
if not game_state.unlock(id): if not game_state.unlock(id, PAYMENT_AT):
return _fail("unlocking '%s' failed despite can_unlock saying yes" % id) return _fail("unlocking '%s' failed despite can_unlock saying yes" % id)
if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0: if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0:
return _fail("the skill unlock did not charge the hold: %d left of %d" \ return _fail("the skill unlock did not charge the hold: %d left of %d" \
@ -177,9 +199,9 @@ func _check_ship_lab() -> bool:
game_state.research_points = 999 game_state.research_points = 999
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost)
if not game_state.can_buy_upgrade(id): if not game_state.can_buy_upgrade(id, PAYMENT_AT):
return _fail("the ship lab refused upgrade '%s' (cost %d) with the whole price in the hold" % [id, cost]) return _fail("the ship lab refused upgrade '%s' (cost %d) with the whole price in the hold" % [id, cost])
if not game_state.buy_upgrade(id): if not game_state.buy_upgrade(id, PAYMENT_AT):
return _fail("buying '%s' failed despite can_buy_upgrade saying yes" % id) return _fail("buying '%s' failed despite can_buy_upgrade saying yes" % id)
if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0: if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0:
return _fail("the upgrade did not charge the hold: %d left of %d" \ return _fail("the upgrade did not charge the hold: %d left of %d" \
@ -206,7 +228,7 @@ func _check_vessel_research() -> bool:
for resource_type in costs: for resource_type in costs:
game_state.bank_inventory.set_amount(String(resource_type), 0) game_state.bank_inventory.set_amount(String(resource_type), 0)
game_state.fleet_inventory.set_amount(String(resource_type), int(costs[resource_type])) game_state.fleet_inventory.set_amount(String(resource_type), int(costs[resource_type]))
if not game_state.unlock_vessel(vessel): if not game_state.unlock_vessel(vessel, PAYMENT_AT):
return _fail("researching '%s' was refused with its whole price (%s) in the fleet hold" % [vessel, str(costs)]) return _fail("researching '%s' was refused with its whole price (%s) in the fleet hold" % [vessel, str(costs)])
for resource_type in costs: for resource_type in costs:
if game_state.available_resource(String(resource_type)) != 0: if game_state.available_resource(String(resource_type)) != 0:

View file

@ -33,6 +33,7 @@ func _initialize() -> void:
game_state.buildings.clear() game_state.buildings.clear()
game_state.extractors.clear() game_state.extractors.clear()
game_state.reset_progression() game_state.reset_progression()
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
main = load("res://world/Main.tscn").instantiate() main = load("res://world/Main.tscn").instantiate()
root.add_child(main) root.add_child(main)
current_scene = main current_scene = main
@ -92,9 +93,17 @@ func _process(delta: float) -> bool:
return _fail("the research panel still has an UnlockButton node") return _fail("the research panel still has an UnlockButton node")
print("the separate buy button is gone from the panel") print("the separate buy button is gone from the panel")
main.get_node("UI/SkillMenu").visible = true # Open through the menu's public API so research has the mothership as its
tree_view.visible = true # payment site. Since fleet-held resources became location-aware, merely
tree_view.reset_scroll() # forcing the Control visible no longer represents a reachable game state.
var mothership_id: int = 0
for building in game_state.buildings:
if String(building.type) == GameState.BUILDING_MOTHERSHIP:
mothership_id = int(building.id)
break
if mothership_id == 0:
return _fail("the test has no mothership research site")
main.get_node("UI/SkillMenu").open_for(mothership_id)
game_state.unlocked.clear() game_state.unlocked.clear()
game_state.research_points = 99 game_state.research_points = 99
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500) game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 500)

View file

@ -61,6 +61,8 @@ enum Icon {
# inserting anywhere earlier renumbers every icon int already stored in # inserting anywhere earlier renumbers every icon int already stored in
# every scene. # every scene.
QUEST, QUEST,
# #179: a fleet-wide thrust burst, distinct from the Propulser vessel icon.
DASH,
} }
# Size the glyphs were originally drawn against. Every glyph dimension is # Size the glyphs were originally drawn against. Every glyph dimension is
@ -319,6 +321,24 @@ func _draw() -> void:
_draw_repair_drone_icon(center, pulse, s) _draw_repair_drone_icon(center, pulse, s)
Icon.QUEST: Icon.QUEST:
_draw_quest_icon(center, pulse, s) _draw_quest_icon(center, pulse, s)
Icon.DASH:
_draw_dash_icon(center, pulse, s)
func _draw_dash_icon(center: Vector2, pulse: float, s: float) -> void:
var tip: Vector2 = center + Vector2(12.0, 0.0) * s
var tail: Vector2 = center + Vector2(-5.0, 0.0) * s
NeonDraw.draw_glowing_line(self, tail, tip, _active_color(), pulse,
0.7 * s, core_color)
NeonDraw.draw_glowing_path(self, PackedVector2Array([
tip + Vector2(-7.0, -7.0) * s, tip,
tip + Vector2(-7.0, 7.0) * s,
]), _active_color(), pulse, 0.7 * s, core_color)
for y in [-7.0, 0.0, 7.0]:
NeonDraw.draw_glowing_line(self,
center + Vector2(-13.0, y) * s,
center + Vector2(-7.0, y) * s,
_active_color(), pulse * 0.7, 0.45 * s, core_color)
# #140: a checklist - a page with a tick against the top line. Reads as # #140: a checklist - a page with a tick against the top line. Reads as

View file

@ -25,7 +25,7 @@ extends Control
# with nothing to author, and building it here keeps MainMenu.tscn - which the # with nothing to author, and building it here keeps MainMenu.tscn - which the
# player's own editor touches - out of the change. # player's own editor touches - out of the change.
const BACKDROP: Color = Color(0.01, 0.02, 0.06, 0.94) const BACKDROP: Color = Color(0.01, 0.02, 0.06, 1.0)
const GLOW: Color = Color(0.42, 0.86, 0.95) const GLOW: Color = Color(0.42, 0.86, 0.95)
const TEXT: Color = Color(0.82, 0.94, 1.0) const TEXT: Color = Color(0.82, 0.94, 1.0)
const MUTED: Color = Color(0.52, 0.66, 0.76) const MUTED: Color = Color(0.52, 0.66, 0.76)
@ -33,7 +33,6 @@ const TITLE_SIZE: int = 44
const SUBTITLE_SIZE: int = 24 const SUBTITLE_SIZE: int = 24
const BAR_WIDTH: float = 520.0 const BAR_WIDTH: float = 520.0
const BAR_HEIGHT: float = 16.0 const BAR_HEIGHT: float = 16.0
const BAR_GAP: float = 46.0
# The travelling band inside the bar, and how much of the bar it covers. # The travelling band inside the bar, and how much of the bar it covers.
const SWEEP_SECONDS: float = 1.2 const SWEEP_SECONDS: float = 1.2
const SWEEP_WIDTH: float = 0.22 const SWEEP_WIDTH: float = 0.22
@ -55,12 +54,22 @@ func _ready() -> void:
# MainMenu refuses a second press while a load is running anyway. # MainMenu refuses a second press while a load is running anyway.
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_FULL_RECT) set_anchors_preset(Control.PRESET_FULL_RECT)
_fit_viewport()
get_viewport().size_changed.connect(_fit_viewport)
title_label = _make_label("LOADING", TITLE_SIZE, TEXT) title_label = _make_label("LOADING", TITLE_SIZE, TEXT)
subtitle_label = _make_label("", SUBTITLE_SIZE, MUTED) subtitle_label = _make_label("", SUBTITLE_SIZE, MUTED)
resized.connect(_layout) resized.connect(_layout)
_layout() _layout()
# A Control parent would resolve full-rect anchors for us. This node sits
# directly under a CanvasLayer, so its first layout pass can otherwise be
# 0x0 - exactly the frame that is supposed to cover the menu before loading.
func _fit_viewport() -> void:
position = Vector2.ZERO
size = get_viewport_rect().size
func _make_label(text_value: String, font_size: int, colour: Color) -> Label: func _make_label(text_value: String, font_size: int, colour: Color) -> Label:
var label := Label.new() var label := Label.new()
label.text = text_value label.text = text_value
@ -83,16 +92,17 @@ func begin(what: String) -> void:
func _bar_rect() -> Rect2: func _bar_rect() -> Rect2:
var width: float = minf(BAR_WIDTH, maxf(size.x - 120.0, 40.0)) var width: float = minf(BAR_WIDTH, maxf(size.x - 120.0, 40.0))
return Rect2(Vector2((size.x - width) * 0.5, size.y * 0.5 + BAR_GAP), return Rect2(Vector2((size.x - width) * 0.5, (size.y - BAR_HEIGHT) * 0.5),
Vector2(width, BAR_HEIGHT)) Vector2(width, BAR_HEIGHT))
func _layout() -> void: func _layout() -> void:
if title_label == null: if title_label == null:
return return
title_label.position = Vector2(0.0, size.y * 0.5 - float(TITLE_SIZE) * 1.4) var bar: Rect2 = _bar_rect()
title_label.position = Vector2(0.0, bar.position.y - float(TITLE_SIZE) * 1.8)
title_label.size = Vector2(size.x, float(TITLE_SIZE) * 1.4) title_label.size = Vector2(size.x, float(TITLE_SIZE) * 1.4)
subtitle_label.position = Vector2(0.0, size.y * 0.5) subtitle_label.position = Vector2(0.0, bar.end.y + 18.0)
subtitle_label.size = Vector2(size.x, float(SUBTITLE_SIZE) * 1.6) subtitle_label.size = Vector2(size.x, float(SUBTITLE_SIZE) * 1.6)
queue_redraw() queue_redraw()

View file

@ -36,9 +36,9 @@ extends Control
# lines never sees the second one at all, and the whole point of #127 was # lines never sees the second one at all, and the whole point of #127 was
# that lines wait for the reader. # that lines wait for the reader.
# #
# #168 says the same rule out loud - still arriving, show it all; already # A dialog is advanced deliberately with a tap or click. Keyboard presses are
# whole, move on - and extends it to the KEYBOARD, which had no way in at # ignored while it is open: movement keys are too easy to hold when a line
# all. See _is_advance_press. # arrives, and must never turn into an accidental skip (#175).
signal dismissed signal dismissed
@ -139,7 +139,9 @@ func _ensure_label() -> void:
label.add_theme_color_override("font_color", TEXT_COLOR) label.add_theme_color_override("font_color", TEXT_COLOR)
label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95)) label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95))
label.add_theme_constant_override("outline_size", 4) label.add_theme_constant_override("outline_size", 4)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # #178: decoded text grows from a stable left edge. Centring a partially
# revealed line made it appear in the middle and expand in both directions.
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(label) add_child(label)
@ -268,28 +270,18 @@ func _process(delta: float) -> void:
queue_redraw() queue_redraw()
# #168: what counts as "go on". A tap, a left click, or a key. # What counts as "go on": a deliberate tap or left click.
# #
# The rule the ticket asks for was already the rule for a click (#149) - a # A press finishes a line still arriving; a press on a finished line moves to
# press finishes a line still arriving, a press on a finished line moves to # the next (#149). #175 deliberately limits that gesture to pointer input: the
# the next - but the game is played on a desktop with WASD and a mouse, and # player may still be holding a
# a keyboard press reached nothing at all. Sitting behind a dialog pressing # movement key when a transmission arrives, and that must leave the line
# space is the most obvious thing a player can do, so the keyboard now goes # untouched rather than finishing or dismissing it.
# through the same one door every other press does (dismiss()).
#
# Modifiers alone are not a press: holding shift before typing must not eat
# a line. Neither is an auto-repeat echo - a held key would otherwise run
# the whole queue out in a second.
func _is_advance_press(event: InputEvent) -> bool: func _is_advance_press(event: InputEvent) -> bool:
if event is InputEventScreenTouch: if event is InputEventScreenTouch:
return event.pressed return event.pressed
if event is InputEventMouseButton: if event is InputEventMouseButton:
return event.pressed and event.button_index == MOUSE_BUTTON_LEFT return event.pressed and event.button_index == MOUSE_BUTTON_LEFT
if event is InputEventKey:
if not event.pressed or event.echo:
return false
return not (event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META,
KEY_CAPSLOCK, KEY_NUMLOCK, KEY_SCROLLLOCK])
return false return false

View file

@ -67,6 +67,11 @@ var _entries: Array = []
var _shown_costs: Dictionary = {} var _shown_costs: Dictionary = {}
var _shown_points: int = 0 var _shown_points: int = 0
var _built: bool = false var _built: bool = false
# Optional site whose purchase this tag describes. With one, the displayed
# "have" includes fleet cargo only while the fleet is close enough to hand it
# over. Most tags describe construction at the fleet itself and leave this
# null, retaining the ordinary combined inventory readout.
var payment_position: Variant = null
func _ready() -> void: func _ready() -> void:
@ -89,7 +94,7 @@ func show_price(costs: Dictionary, points: int = 0) -> void:
if amount <= 0: if amount <= 0:
continue continue
_add_entry(String(resource_type), "", amount, _add_entry(String(resource_type), "", amount,
GameState.available_resource(String(resource_type)), _available(String(resource_type)),
GameState.resource_color(resource_type)) GameState.resource_color(resource_type))
if points > 0: if points > 0:
_add_entry("", POINT_MARK, points, GameState.research_points, POINT_COLOR) _add_entry("", POINT_MARK, points, GameState.research_points, POINT_COLOR)
@ -102,12 +107,22 @@ func set_costs(costs: Dictionary) -> void:
show_price(costs) show_price(costs)
func set_payment_position(at: Variant) -> void:
payment_position = at
_refresh_held()
func _available(resource_type: String) -> int:
return GameState.available_resource(resource_type) if payment_position == null \
else GameState.available_resource_at(resource_type, payment_position)
# What the player holds, re-read. The price does not move; the wallet does. # What the player holds, re-read. The price does not move; the wallet does.
func _refresh_held() -> void: func _refresh_held() -> void:
var relayout: bool = false var relayout: bool = false
for entry in _entries: for entry in _entries:
var have: int = GameState.research_points if String(entry.resource) == "" \ var have: int = GameState.research_points if String(entry.resource) == "" \
else GameState.available_resource(String(entry.resource)) else _available(String(entry.resource))
if have == int(entry.have): if have == int(entry.have):
continue continue
entry.have = have entry.have = have

View file

@ -8,6 +8,8 @@ signal research_requested(id: String)
@onready var status: Label = $StatusLabel @onready var status: Label = $StatusLabel
@onready var resource_bar: Control = $Bar @onready var resource_bar: Control = $Bar
var building_id: int = 0
func _ready() -> void: func _ready() -> void:
super._ready() super._ready()
@ -15,13 +17,22 @@ func _ready() -> void:
tree.node_confirmed.connect(func(id: String): research_requested.emit(id)) tree.node_confirmed.connect(func(id: String): research_requested.emit(id))
func open_for(building_id: int) -> void: func open_for(next_building_id: int) -> void:
building_id = next_building_id
resource_bar.building_id = building_id resource_bar.building_id = building_id
var building: Dictionary = GameState.find_building(building_id)
cost.set_payment_position(building.get("position", null))
open_menu() open_menu()
tree.reset_scroll() tree.reset_scroll()
refresh() refresh()
func close_menu() -> void:
super.close_menu()
building_id = 0
cost.set_payment_position(null)
func selected_id() -> String: func selected_id() -> String:
return String(tree.selected_id) return String(tree.selected_id)
@ -31,6 +42,10 @@ func research_completed(id: String) -> void:
refresh(id) refresh(id)
func set_status(message: String) -> void:
status.text = message
func refresh(id: String = "") -> void: func refresh(id: String = "") -> void:
if id == "": if id == "":
id = String(tree.selected_id) id = String(tree.selected_id)

View file

@ -5,7 +5,7 @@
script = ExtResource("1") script = ExtResource("1")
definition_id = "distorder" definition_id = "distorder"
display_name = "Energy Propulser" display_name = "Energy Propulser"
description = "Unarmed propulsion hull that burns energy to drive the whole fleet faster. While one flies with you, a fader lets you push cruise speed past its default." description = "Unarmed propulsion hull that burns energy to drive the whole fleet faster. It also grants an aimed fleet dash; each Propulser carries its own recharge."
build_cost = 14 build_cost = 14
build_resource_costs = {"ore": 14, "energy": 8} build_resource_costs = {"ore": 14, "energy": 8}
build_time = 16.0 build_time = 16.0
@ -18,7 +18,7 @@ component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 645.0, "acceleration": 1845.0, "delivery_speed": 180.0} base_stats = {"max_speed": 645.0, "acceleration": 1845.0, "delivery_speed": 180.0}
unlock_id = "unlock_distorder" unlock_id = "unlock_distorder"
unlock_name = "Unlock Energy Propulser" unlock_name = "Unlock Energy Propulser"
unlock_description = "Research a propulsion hull that burns energy to drive the whole fleet faster - while one flies with you, a fader lets you push cruise speed past its default." unlock_description = "Research a propulsion hull that burns energy to drive the whole fleet faster and grants an aimed fleet dash."
unlock_cost = 0 unlock_cost = 0
unlock_resource_costs = {"energy": 50} unlock_resource_costs = {"energy": 50}
research_point_cost = 1 research_point_cost = 1

View file

@ -33,6 +33,11 @@ var fleet_target: Vector2 = Vector2.ZERO
# travelling along. Zero heading means the fleet is not being steered. # travelling along. Zero heading means the fleet is not being steered.
var anchor: Vector2 = Vector2.ZERO var anchor: Vector2 = Vector2.ZERO
var steer_direction: Vector2 = Vector2.ZERO var steer_direction: Vector2 = Vector2.ZERO
# An aimed Propulser burst translates the complete formation as one body.
var dash_direction: Vector2 = Vector2.ZERO
var dash_remaining: float = 0.0
const DASH_SPEED: float = 1400.0
func _ready() -> void: func _ready() -> void:
@ -227,12 +232,29 @@ func move_in_direction(direction: Vector2) -> void:
steer_direction = direction.normalized() steer_direction = direction.normalized()
func dash(direction: Vector2, distance: float) -> bool:
if direction.length() < 0.001 or distance <= 0.0 or ships.is_empty():
return false
if dash_remaining <= 0.0:
anchor = get_formation_origin()
dash_direction = direction.normalized()
dash_remaining = distance
return true
func is_dashing() -> bool:
return dash_remaining > 0.0
func _process(delta: float) -> void: func _process(delta: float) -> void:
# #158: a building may pay a local cost out of the hold when the fleet is # #158: a building may pay a local cost out of the hold when the fleet is
# actually standing at it, and the autoload has no other way to know # actually standing at it, and the autoload has no other way to know
# where that is. Written before the early return below - a parked fleet # where that is. Written before the early return below - a parked fleet
# is exactly the case that matters. # is exactly the case that matters.
GameState.fleet_position = get_centroid() GameState.fleet_position = get_centroid()
if is_dashing():
_advance_dash(delta)
return
if steer_direction == Vector2.ZERO: if steer_direction == Vector2.ZERO:
return return
# The anchor - the fleet's formation centre - travels at cruise_speed. # The anchor - the fleet's formation centre - travels at cruise_speed.
@ -244,6 +266,29 @@ func _process(delta: float) -> void:
_assign_slot_targets() _assign_slot_targets()
func _advance_dash(delta: float) -> void:
var step: float = minf(DASH_SPEED * delta, dash_remaining)
var displacement: Vector2 = dash_direction * step
dash_remaining -= step
anchor += displacement
fleet_target += displacement
for ship in ships:
if not is_instance_valid(ship):
continue
# Move the hull and its destination together. That preserves every
# formation offset and prevents normal steering from trying to "repair"
# the burst by pulling ships back toward their pre-dash targets.
ship.position += displacement
ship.move_target += displacement
ship.velocity = Vector2.ZERO
ship.fleet_velocity = Vector2.ZERO
ship.rotation = dash_direction.angle() + PI / 2.0
if dash_remaining <= 0.001:
dash_remaining = 0.0
if steer_direction != Vector2.ZERO:
_assign_slot_targets()
func _effective_cruise_speed() -> float: func _effective_cruise_speed() -> float:
return cruise_speed * GameState.fleet_speed_multiplier return cruise_speed * GameState.fleet_speed_multiplier

View file

@ -1,9 +1,25 @@
class_name VesselDistorderComponent class_name VesselDistorderComponent
extends Node extends "res://vessels/VesselCooldownFillComponent.gd"
# Pure presence marker (#59) - no per-shot behaviour, no tick(). Its only # The presence marker used by the speed fader, plus the Energy Propulser's
# job is to make Ship.has_capability("distorder") true, which is what # independently recharging dash (#179). Keeping both on one component means
# SpeedFader.gd/Fleet.gd key off to reveal and apply the speed boost. # a vessel is the source of both propulsion capabilities and carries its own
# Energy drain while boosted is a later ticket (#62). # cooldown just like gas bombers and laser vessels do.
var capability_id := "distorder" var capability_id := "distorder"
const DASH_COOLDOWN: float = 5.0
const READY_COLOR := Color(0.85, 0.95, 0.15)
const COOLING_COLOR := Color(0.28, 0.31, 0.08)
func _init() -> void:
cooldown_duration = DASH_COOLDOWN
func consume_dash() -> bool:
return _begin_cooldown(DASH_COOLDOWN)
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

@ -2,5 +2,5 @@
[ext_resource type="Script" path="res://vessels/VesselDistorderComponent.gd" id="1"] [ext_resource type="Script" path="res://vessels/VesselDistorderComponent.gd" id="1"]
[node name="DistorderComponent" type="Node"] [node name="DistorderComponent" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")

View file

@ -70,13 +70,10 @@ func _explode() -> void:
# Asteroids too - a blast that clears everything around it but leaves the # Asteroids too - a blast that clears everything around it but leaves the
# rocks untouched would need explaining, and they take damage the same way. # rocks untouched would need explaining, and they take damage the same way.
# #
# #165: and STRUCTURES. The blast used to stop politely at anything the # #176: player weapons never damage player buildings. A bomb may be thrown
# player owned, which made a bomb dropped on a besieged mothership a free # into a siege without punishing the structure it is meant to defend.
# way to clear the attackers off it. Everything inside the radius is
# inside the radius; where it lands is the decision the weapon asks for.
for target_node in get_tree().get_nodes_in_group("enemies") \ for target_node in get_tree().get_nodes_in_group("enemies") \
+ get_tree().get_nodes_in_group("asteroids") \ + get_tree().get_nodes_in_group("asteroids"):
+ get_tree().get_nodes_in_group("player_buildings"):
if is_instance_valid(target_node) and target_node is Node2D \ if is_instance_valid(target_node) and target_node is Node2D \
and target_node.global_position.distance_to(target) <= AOE_RADIUS: and target_node.global_position.distance_to(target) <= AOE_RADIUS:
target_node.take_damage(damage) target_node.take_damage(damage)

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=43 format=3] [gd_scene load_steps=44 format=3]
[ext_resource type="PackedScene" path="res://ui/menus/BuildMenu.tscn" id="menu_build"] [ext_resource type="PackedScene" path="res://ui/menus/BuildMenu.tscn" id="menu_build"]
[ext_resource type="PackedScene" path="res://ui/menus/HangarMenu.tscn" id="menu_hangar"] [ext_resource type="PackedScene" path="res://ui/menus/HangarMenu.tscn" id="menu_hangar"]
[ext_resource type="PackedScene" path="res://ui/menus/LabMenu.tscn" id="menu_lab"] [ext_resource type="PackedScene" path="res://ui/menus/LabMenu.tscn" id="menu_lab"]
@ -39,6 +39,7 @@
[ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"] [ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"]
[ext_resource type="Script" path="res://controllers/GasBombController.gd" id="29"] [ext_resource type="Script" path="res://controllers/GasBombController.gd" id="29"]
[ext_resource type="Script" path="res://controllers/LaserController.gd" id="30"] [ext_resource type="Script" path="res://controllers/LaserController.gd" id="30"]
[ext_resource type="Script" path="res://controllers/DashController.gd" id="dash_ctrl"]
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"]
[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] [ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"]
[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"] [ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"]
@ -82,6 +83,9 @@ script = ExtResource("mouse_ctrl")
[node name="LaserController" type="Node2D" parent="."] [node name="LaserController" type="Node2D" parent="."]
script = ExtResource("30") script = ExtResource("30")
[node name="DashController" type="Node2D" parent="."]
script = ExtResource("dash_ctrl")
[node name="Camera2D" type="Camera2D" parent="."] [node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(0, 0) position = Vector2(0, 0)
zoom = Vector2(1, 1) zoom = Vector2(1, 1)
@ -343,6 +347,22 @@ offset_right = -100
offset_top = -548 offset_top = -548
offset_bottom = -452 offset_bottom = -452
[node name="DashButton" type="Control" parent="UI"]
visible = false
mouse_filter = 2
script = ExtResource("10")
round_frame = true
icon = 45
glow_color = Color(0.85, 0.95, 0.15, 1)
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -452
offset_right = -356
offset_top = -548
offset_bottom = -452
[node name="PivotCCWButton" type="Control" parent="UI"] [node name="PivotCCWButton" type="Control" parent="UI"]
mouse_filter = 2 mouse_filter = 2
script = ExtResource("10") script = ExtResource("10")
@ -405,6 +425,22 @@ offset_right = -24
offset_top = -616 offset_top = -616
offset_bottom = -568 offset_bottom = -568
[node name="DashStatus" type="Label" parent="UI"]
mouse_filter = 2
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.85, 0.95, 0.15, 1)
autowrap_mode = 2
horizontal_alignment = 1
text = ""
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -500
offset_right = -104
offset_top = -672
offset_bottom = -624
[node name="PivotCWButton" type="Control" parent="UI"] [node name="PivotCWButton" type="Control" parent="UI"]
mouse_filter = 2 mouse_filter = 2
script = ExtResource("10") script = ExtResource("10")