Compare commits

..
29 changed files with 137 additions and 606 deletions

View file

@ -5,12 +5,6 @@ extends Label
# entirely when the current planet has no capture points (e.g. no setup
# resolved for it) rather than showing "0/0".
# A direct script preload, not the bare "PlanetDefinitions" global class_name
# - the same load-order hazard PlanetSurfaceController's setup_host comment
# already documents: the global identifier is only guaranteed registered
# after an editor/import pass, a direct preload never depends on that.
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
var _was_conquered: bool = false
@ -24,14 +18,7 @@ func _process(_delta: float) -> void:
var conquered: bool = GameState.is_planet_conquered(planet_id)
if conquered:
# #76: a research_lab's whole reward is invisible otherwise - the
# research point balance lives in a HUD number the player has no
# reason to be watching at the exact moment it changes. Naming the
# reward right on the same flash that already announces conquest
# is the same "explain it where it happens" idiom BuildController
# uses for a refused build.
text = "CONQUERED - +1 RESEARCH POINT" \
if GameState.planet_definition_id == PLANET_DEFINITIONS.RESEARCH_LAB_ID else "CONQUERED"
text = "CONQUERED"
if not _was_conquered:
_flash()
else:

View file

@ -56,11 +56,6 @@ func _drift(enemy: Node2D, delta: float) -> void:
if fleet == null or fleet.ships.is_empty():
return
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
# #69: gated by the same fire_range it already shells within, rather
# than a second tunable that could drift out of sync - outside its own
# firing envelope it has nothing to be drifting toward yet.
if to_fleet.length() > fire_range:
return
if to_fleet.length() < 1.0:
return
enemy.position += to_fleet.normalized() * drift_speed * delta

View file

@ -7,20 +7,11 @@ extends Node
var capability_id: String = "pursuit"
var velocity: Vector2 = Vector2.ZERO
# #69: used to beeline from anywhere on the map the instant it spawned,
# which read as omniscient rather than alert. Idle until the fleet actually
# comes within this radius. A persistent stalking threat wants the opposite
# of this - see EnemyPursuitComponentMothership.tscn, which overrides it to
# an effectively unlimited value for the mothership's own instance.
@export var detection_range: float = 850.0
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target: Node2D = _nearest_ship(enemy)
if target == null or enemy.position.distance_to(target.position) > detection_range:
return
var to_target: Vector2 = target.position - enemy.position
var target_pos: Vector2 = _nearest_ship_position(enemy)
var to_target: Vector2 = target_pos - enemy.position
if to_target.length() > 1.0:
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
@ -32,10 +23,10 @@ func tick(delta: float) -> void:
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
func _nearest_ship(enemy: Node2D) -> Node2D:
func _nearest_ship_position(enemy: Node2D) -> Vector2:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return null
return enemy.position
var nearest: Node2D = fleet.ships[0]
var best_dist: float = enemy.position.distance_to(nearest.position)
@ -44,4 +35,4 @@ func _nearest_ship(enemy: Node2D) -> Node2D:
if d < best_dist:
best_dist = d
nearest = ship
return nearest
return nearest.position

View file

@ -1,7 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"]
[node name="EnemyPursuitComponentMothership" type="Node"]
script = ExtResource("1")
detection_range = 1000000.0

View file

@ -11,10 +11,6 @@ var capability_id: String = "standoff"
@export var preferred_range: float = 620.0
@export var retreat_range_factor: float = 0.75
# #69: kept comfortably beyond preferred_range so it still has room to close
# in before settling at stand-off distance, but the fleet has to actually be
# somewhere on the board first - see EnemyPursuitComponent's identical fix.
@export var detection_range: float = 1200.0
var velocity: Vector2 = Vector2.ZERO
@ -26,7 +22,7 @@ func tick(delta: float) -> void:
return
var to_target: Vector2 = target.position - enemy.position
var distance: float = to_target.length()
if distance < 1.0 or distance > detection_range:
if distance < 1.0:
return
var direction: Vector2 = to_target / distance
var retreat_range: float = preferred_range * retreat_range_factor

View file

@ -42,28 +42,8 @@ var pinch_start_zoom: float = 1.0
# release, so tapping still produces a clear, visible nudge.
const PIVOT_SPEED: float = 1.6
const PIVOT_MIN_STEP: float = PI / 10.0
# #72: the tap top-up below used to fire in full on EVERY release, with no
# regard for how recently the last one landed - so tapping fast enough
# granted rotation far above PIVOT_SPEED's smooth rate, well beyond what
# holding the button can ever produce. Each of those artificially-frequent
# re-seats (Fleet.apply_formation()) forces every ship into a fresh
# max-speed catch-up dash toward its new slot with no time to settle
# between them, and since outer-wedge ships swing further per pivot than
# inner ones, spamming made them visibly diverge in speed ("desync") while
# each individual ship's own bounded max_speed made the fleet as a whole
# read as constantly at its speed ceiling ("build up speed"). This cooldown
# is exactly the time a genuine hold needs to reach PIVOT_MIN_STEP on its
# own, so tap-spam can't out-pace holding the button down.
const PIVOT_TAP_COOLDOWN: float = PIVOT_MIN_STEP / PIVOT_SPEED
var pivot_hold_amount: float = 0.0
# Tracked PER DIRECTION, not shared - a single quick correction (tap the
# other way right after an overshoot) is normal play, not the spam this
# cooldown targets, and Q/E each independently producing their own minimum
# step back-to-back is an existing, tested guarantee (test_keyboard_input.gd).
# Only repeatedly re-tapping the SAME direction needs to be capped.
var pivot_cw_tap_cooldown_left: float = 0.0
var pivot_ccw_tap_cooldown_left: float = 0.0
# Touch index currently holding each pivot button, or -1. These buttons are
# driven straight from raw touch events rather than through _gui_input,
# because Godot's Control GUI treats touch as a single emulated pointer and
@ -206,16 +186,9 @@ func _update_pivot(delta: float) -> void:
func _finish_pivot_tap(direction: float) -> void:
# Fired on release. A press held long enough has already turned far
# enough on its own; a brief tap gets topped up to the minimum step -
# but only once THAT DIRECTION's own cooldown has cleared, so spamming
# one side can't grant rotation faster than holding the button would.
var cooldown_left: float = pivot_cw_tap_cooldown_left if direction > 0.0 else pivot_ccw_tap_cooldown_left
if pivot_hold_amount < PIVOT_MIN_STEP and cooldown_left <= 0.0:
# enough on its own; a brief tap gets topped up to the minimum step.
if pivot_hold_amount < PIVOT_MIN_STEP:
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
if direction > 0.0:
pivot_cw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
else:
pivot_ccw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
pivot_hold_amount = 0.0
@ -238,8 +211,6 @@ func _on_joystick_direction(direction: Vector2) -> void:
func _process(delta: float) -> void:
pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0)
pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0)
_update_pivot(delta)
# Keep the heading fresh while a control is held. Fleet advances its own

View file

@ -329,19 +329,13 @@ const SKILL_TREE: Array = [
"desc": "Permanent: +1 vessel in the fleet.",
},
{
# #70: was x=0.50 - the same lane freight's branch already occupies at
# this same depth (power_systems), so the two nodes were drawn
# directly on top of each other. Kept in line with wing (its own
# prerequisite, also x=0.82) instead, so the whole fleet-size
# doctrine branch now runs straight rather than zigzagging into
# someone else's lane.
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
"x": 0.50, "fleet_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.82, "fleet_bonus": 1, "research_points": 3,
"x": 0.50, "fleet_bonus": 1, "research_points": 3,
"desc": "Permanent: +1 vessel in the fleet.",
},
]

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -1,9 +1,10 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=8 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://InterceptorDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://InterceptorRateUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://InterceptorRangeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://InterceptorSpeedUpgrade.tres" id="6"]
[ext_resource type="PackedScene" path="res://VesselCargoComponent.tscn" id="7"]
[resource]
script = ExtResource("1")
@ -18,4 +19,4 @@ fleet_eligible = true
capabilities = PackedStringArray("weapon", "cargo")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "delivery_speed": 180.0, "cargo_capacity": 20.0}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5"), ExtResource("6")])

107
Main.tscn
View file

@ -227,12 +227,10 @@ visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -94
offset_right = -24
offset_top = -720
offset_bottom = -560
offset_top = 740
offset_bottom = 920
script = ExtResource("31")
[node name="Label" type="Label" parent="UI/SpeedFader"]
@ -1297,12 +1295,11 @@ text = ""
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -310
offset_right = -230
offset_left = -330
offset_right = -210
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Interceptor"
@ -1310,12 +1307,11 @@ text = "Interceptor"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -220
offset_right = -140
offset_left = -240
offset_right = -120
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Shield"
@ -1323,12 +1319,11 @@ text = "Shield"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -130
offset_right = -50
offset_left = -150
offset_right = -30
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Gas Bomber"
@ -1336,12 +1331,11 @@ text = "Gas Bomber"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -40
offset_right = 40
offset_left = -60
offset_right = 60
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Transporter"
@ -1349,12 +1343,11 @@ text = "Transporter"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 50
offset_right = 130
offset_left = 30
offset_right = 150
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Carrier"
@ -1362,12 +1355,11 @@ text = "Carrier"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 140
offset_right = 220
offset_left = 120
offset_right = 240
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Laser Ship"
@ -1375,12 +1367,11 @@ text = "Laser Ship"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 230
offset_right = 310
offset_left = 210
offset_right = 330
offset_top = 400
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Distorder"
@ -1388,9 +1379,9 @@ text = "Distorder"
mouse_filter = 2
anchor_right = 1.0
offset_left = 48
offset_top = 480
offset_top = 462
offset_right = -48
offset_bottom = 484
offset_bottom = 466
color = Color(0.25, 0.85, 1, 0.6)
[node name="UpgradeList" type="Label" parent="UI/LabMenu"]
@ -1399,7 +1390,7 @@ mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32
offset_top = 488
offset_top = 470
offset_right = -32
offset_bottom = -470
theme_override_font_sizes/font_size = 30
@ -1481,8 +1472,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -240
offset_right = -120
offset_top = 488
offset_bottom = 598
offset_top = 470
offset_bottom = 580
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1493,8 +1484,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -310
offset_right = -50
offset_top = 603
offset_bottom = 673
offset_top = 585
offset_bottom = 655
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1
@ -1505,8 +1496,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 120
offset_right = 240
offset_top = 488
offset_bottom = 598
offset_top = 470
offset_bottom = 580
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1517,8 +1508,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 50
offset_right = 310
offset_top = 603
offset_bottom = 673
offset_top = 585
offset_bottom = 655
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1
@ -1529,8 +1520,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -240
offset_right = -120
offset_top = 678
offset_bottom = 783
offset_top = 660
offset_bottom = 765
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1541,8 +1532,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -310
offset_right = -50
offset_top = 788
offset_bottom = 833
offset_top = 770
offset_bottom = 815
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1
@ -1553,8 +1544,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 120
offset_right = 240
offset_top = 678
offset_bottom = 783
offset_top = 660
offset_bottom = 765
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1565,8 +1556,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 50
offset_right = 310
offset_top = 788
offset_bottom = 833
offset_top = 770
offset_bottom = 815
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1

View file

@ -1,7 +1,7 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://EnemyPursuitComponentMothership.tscn" id="2"]
[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://EnemySpawnerComponentChaser.tscn" id="3"]
[ext_resource type="PackedScene" path="res://EnemySpawnerComponentArtillery.tscn" id="4"]
[ext_resource type="PackedScene" path="res://EnemyBigLaserComponent.tscn" id="5"]

View file

@ -25,10 +25,6 @@ const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 4.0
const WHEEL_ZOOM_STEP: float = 1.1
const HIT_RADIUS: float = 50.0
# #65: how far a single-finger press has to move before it counts as a pan
# rather than a tap-to-place, once it's confirmed not to be starting on an
# existing marker.
const PAN_DEADZONE: float = 24.0
const ORIGIN_COLOR: Color = Color(1.0, 0.9, 0.4)
# Duplicated from PaletteEntryButton.RING_COLOR rather than referenced
# cross-class - a global class_name lookup at parse time needs the class
@ -49,22 +45,14 @@ const PALETTE_STEP: float = 92.0
@onready var load_button: IconButton = $UI/LoadButton
@onready var delete_button: IconButton = $UI/DeleteButton
@onready var status_label: Label = $UI/StatusLabel
@onready var selected_label: Label = $UI/SelectedLabel
@onready var palette_row: Control = $UI/PaletteRow
@onready var load_panel: Control = $UI/LoadPanel
@onready var load_list: ItemList = $UI/LoadPanel/List
@onready var load_close_button: IconButton = $UI/LoadPanel/CloseButton
var current_setup: Resource
var selected_palette: String = "capture_point"
var selected_item: Dictionary = {}
var _profile_ids: Array = []
var _profile_cursor: int = 0
# #64: replaced the old "Load cycles to the next saved setup" behaviour,
# which gave no way to see what existed or jump straight to one by name -
# kept only as the id list _load_list_setup_ids indexes into when the
# panel's own selection fires.
var _load_list_setup_ids: PackedStringArray = PackedStringArray()
var _load_cursor: int = -1
var _palette_buttons: Array = []
var _origin_marker: NeonPiece
@ -88,8 +76,6 @@ func _ready() -> void:
load_button.pressed.connect(_on_load_pressed)
delete_button.pressed.connect(_on_delete_pressed)
name_input.text_changed.connect(_on_name_changed)
load_close_button.pressed.connect(func(): load_panel.visible = false)
load_list.item_selected.connect(_on_load_list_item_selected)
_origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR)
var cross_size: float = 26.0
@ -114,7 +100,6 @@ func new_setup(setup_id: String, display_name: String, profile_id: String) -> vo
# fails at runtime, since the compiler here only sees current_setup as
# a plain Resource and can't confirm the target is Array[Resource].
selected_item = {}
_refresh_selected_label()
_profile_cursor = maxi(_profile_ids.find(profile_id), 0)
if name_input != null:
name_input.text = display_name
@ -131,7 +116,6 @@ func load_setup(setup_id: String) -> bool:
# explicit save.
current_setup = setup.duplicate(true)
selected_item = {}
_refresh_selected_label()
_profile_cursor = maxi(_profile_ids.find(current_setup.surface_profile_id), 0)
if name_input != null:
name_input.text = current_setup.display_name
@ -317,19 +301,8 @@ func _on_load_pressed() -> void:
if setups.is_empty():
_set_status("No saved setups yet.")
return
load_list.clear()
_load_list_setup_ids = PackedStringArray()
for setup in setups:
load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id])
_load_list_setup_ids.append(setup.setup_id)
load_panel.visible = true
func _on_load_list_item_selected(index: int) -> void:
if index < 0 or index >= _load_list_setup_ids.size():
return
load_setup(_load_list_setup_ids[index])
load_panel.visible = false
_load_cursor = (_load_cursor + 1) % setups.size()
load_setup(setups[_load_cursor].setup_id)
func _on_delete_pressed() -> void:
@ -341,7 +314,6 @@ func _on_delete_pressed() -> void:
else:
remove_enemy(selected_item.index)
selected_item = {}
_refresh_selected_label()
func _on_name_changed(new_text: String) -> void:
@ -364,24 +336,6 @@ func _set_status(message: String) -> void:
status_label.text = message
# #66: names the currently-selected marker so a tap actually confirms what
# got picked, instead of the player having to infer it from which dot on
# screen turned white.
func _refresh_selected_label() -> void:
if selected_label == null:
return
if selected_item.is_empty() or current_setup == null:
selected_label.text = ""
return
var index: int = int(selected_item.index)
if selected_item.kind == "capture":
selected_label.text = "Selected: Capture Point #%d" % (index + 1)
else:
var spawn: Resource = current_setup.enemy_spawns[index]
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
selected_label.text = "Selected: %s #%d" % [definition.display_name, index + 1]
# --- placement/selection helpers ----------------------------------------
func _place_selected(world_pos: Vector2) -> void:
@ -451,33 +405,26 @@ func _handle_touch(event: InputEventScreenTouch) -> void:
var world_pos: Vector2 = _screen_to_world(event.position)
var hit: Dictionary = _find_marker_near(world_pos)
drag_touch_index = event.index
pan_start_screen = event.position
pan_start_camera = camera.position
if not hit.is_empty():
selected_item = hit
dragging_item = hit
drag_mode = "item"
_rebuild_markers()
_refresh_selected_label()
elif selected_palette != "":
_place_selected(world_pos)
drag_mode = ""
else:
# #65: a press over empty canvas no longer commits to placing
# an item immediately - it might be the start of a pan. Which
# one it turns out to be is only known on release (still
# under PAN_DEADZONE = a tap = place) or once a drag actually
# crosses that deadzone (= a pan), same tap-vs-drag idiom
# GasBombController uses for its own AIM_DEADZONE.
dragging_item = {}
drag_mode = "pending"
drag_mode = "pan"
pan_start_screen = event.position
pan_start_camera = camera.position
else:
if event.index == drag_touch_index:
if drag_mode == "pending":
_place_selected(_screen_to_world(event.position))
drag_touch_index = -1
drag_mode = ""
dragging_item = {}
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
if event.index == drag_touch_index:
drag_touch_index = -1
drag_mode = ""
dragging_item = {}
func _handle_drag(event: InputEventScreenDrag) -> void:
@ -493,8 +440,6 @@ func _handle_drag(event: InputEventScreenDrag) -> void:
if event.index != drag_touch_index:
return
if drag_mode == "pending" and event.position.distance_to(pan_start_screen) >= PAN_DEADZONE:
drag_mode = "pan"
match drag_mode:
"item":
_move_selected(_screen_to_world(event.position))

View file

@ -170,17 +170,6 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 2
text = ""
[node name="SelectedLabel" type="Label" parent="UI"]
mouse_filter = 2
offset_left = 20
offset_top = 250
offset_right = 696
offset_bottom = 286
theme_override_font_sizes/font_size = 22
theme_override_colors/font_color = Color(1, 0.95, 0.9, 1)
autowrap_mode = 2
text = ""
[node name="PaletteRow" type="Control" parent="UI"]
anchor_top = 1.0
anchor_bottom = 1.0
@ -188,42 +177,3 @@ offset_left = 20
offset_top = -110
offset_right = 696
offset_bottom = -20
[node name="LoadPanel" type="Control" parent="UI"]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/LoadPanel"]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.94)
[node name="TitleLabel" type="Label" parent="UI/LoadPanel"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 36
offset_right = -24
offset_bottom = 90
theme_override_font_sizes/font_size = 34
horizontal_alignment = 1
text = "Load a setup"
[node name="List" type="ItemList" parent="UI/LoadPanel"]
offset_left = 24
offset_top = 110
offset_right = 696
offset_bottom = 1180
theme_override_font_sizes/font_size = 24
[node name="CloseButton" type="Control" parent="UI/LoadPanel"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -92
offset_top = 20
offset_right = -20
offset_bottom = 92
script = ExtResource("4")
icon = 1
glow_color = Color(0.9, 0.35, 0.35, 1)

View file

@ -163,12 +163,10 @@ visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -94
offset_right = -24
offset_top = -720
offset_bottom = -560
offset_top = 740
offset_bottom = 920
script = ExtResource("17")
glow_color = Color(0.5, 0.18, 0.6, 1)
core_color = Color(0.02, 0.09, 0.15, 1)

12
ShieldSpeedUpgrade.tres Normal file
View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -1,8 +1,9 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=5 format=3]
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselShieldComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://ShieldHitsUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://ShieldRechargeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://ShieldSpeedUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "shield"
@ -18,7 +19,7 @@ component_scenes = Array[PackedScene]([ExtResource("3")])
hull_points = PackedVector2Array(0, -24, 20.7846, -12, 20.7846, 12, 0, 24, -20.7846, 12, -20.7846, -12)
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0}
minimum_stats = {"shield_recharge": 2.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5"), ExtResource("6")])
unlock_id = "unlock_shield"
unlock_name = "Unlock Shield Vessel"
unlock_description = "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up."

View file

@ -22,12 +22,10 @@ const GAS_GIANT_THRESHOLD: float = 0.55
# chance independent of biome - not a second smooth region to tune against
# the first.
const BLACK_HOLE_CHANCE: float = 0.05
# #63/#74: findable without excessive exploration - research points gate
# mid/late skill-tree nodes and vessel research, so a player needs to run
# into one of these reasonably often, not just as a rare curiosity. Raised
# from 0.08 after playtesting found that rate too scarce for how central
# research points had become to progression.
const RESEARCH_LAB_CHANCE: float = 0.16
# #63: findable without excessive exploration - research points gate mid/
# late skill-tree nodes and vessel research, so a player needs to run into
# one of these reasonably often, not just as a rare curiosity.
const RESEARCH_LAB_CHANCE: float = 0.08
# A predictable early gas source for play-testing. The origin chunk is
# deterministically normal space. Its explicit in-chunk position puts it

View file

@ -1,29 +1,37 @@
extends Node2D
# Preview, not a test - #71's ship lab layout with all 7 vessels present,
# checking the vessel-name row no longer overlaps itself.
# Preview, not a test. The ship lab's upgrade screen (#5), so its layout can
# be checked on a real viewport - headless draws no pixels.
#
# bash tests/screenshot.sh res://tests/_lab_menu_preview.tscn /tmp/lab.png 20 720x1280
var configured: bool = false
func _process(_delta: float) -> void:
if configured:
return
configured = true
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 500
GameState.research_points = 5
GameState.reset_progression()
GameState.rocks = 240
GameState.gas = 36
GameState.unlocked = ["shipyards", "vessel_science"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BackgroundLayer").visible = false
main.get_node("Fleet").visible = false
main.get_node("AsteroidField").visible = false
main.get_node("PlanetField").visible = false
main.get_node("BuildingField").visible = false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_LAB, Vector2(400.0, 0.0))
var lab_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(lab_id, 18)
GameState.fleet_cargo = 12
GameState.fleet_gas_cargo = 8
var bc: Node = main.get_node("BuildController")
bc.lab_building_id = lab_id
# The shared header (#24) hides its HERE column when it has no building,
# so bind it the way opening the panel from the building does.
for bar in bc.resource_bars:
bar.building_id = lab_id
main.get_node("UI/LabMenu").visible = true
bc.lab_vessel = GameState.VESSEL_GAS_BOMBER
bc._refresh_lab()

View file

@ -1,12 +0,0 @@
extends Node2D
# Preview, not a test - #64's Load panel: a real selectable list of saved
# setups instead of blind cycling.
#
# bash tests/screenshot.sh res://tests/_setup_editor_load_panel_preview.tscn /tmp/load.png 20 720x1280
func _ready() -> void:
var editor: Node = load("res://PlanetSetupEditor.tscn").instantiate()
add_child(editor)
editor._on_load_pressed()

View file

@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_setup_editor_load_panel_preview.gd" id="1"]
[node name="SetupEditorLoadPanelPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -12,4 +12,3 @@ func _ready() -> void:
editor.add_enemy("artillery", Vector2(0, 350))
editor.selected_item = {"kind": "capture", "index": 0}
editor._rebuild_markers()
editor._refresh_selected_label()

View file

@ -1,129 +0,0 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_detection_range.gd
# Forgejo #69: enemies used to beeline toward the fleet from anywhere on the
# map the instant they spawned. Chasers (EnemyPursuitComponent) and snipers
# (EnemyStandoffComponent) now sit idle until the fleet is within their own
# detection_range; the enemy mothership deliberately keeps an (effectively)
# unlimited range instead, since it is meant to read as a persistent,
# unavoidable stalking threat rather than a local sentry.
#
# Covers:
# A. a chaser beyond detection_range does not move at all
# B. the same chaser, once the fleet is within range, closes in
# C. a sniper beyond detection_range does not move either
# D. the enemy mothership closes in even from far beyond a chaser's range
var game_state: Node
var main: Node
var fleet: Node
var chaser: Node
var sniper: Node
var mothership: Node
var chaser_start: Vector2
var sniper_start: Vector2
var mothership_start: Vector2
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
for a in get_nodes_in_group("asteroids"):
a.queue_free()
fleet.spawn_ships(1, Vector2.ZERO)
# --- A. beyond detection_range: no movement at all ---------------
chaser = load("res://Enemy.tscn").instantiate()
chaser.enemy_id = "chaser"
var pursuit: Node = null
main.add_child(chaser)
pursuit = chaser.get_component("pursuit")
chaser.position = fleet.get_centroid() + Vector2(pursuit.detection_range + 300.0, 0.0)
chaser_start = chaser.position
sniper = load("res://Enemy.tscn").instantiate()
sniper.enemy_id = "sniper"
main.add_child(sniper)
var standoff: Node = sniper.get_component("standoff")
sniper.position = fleet.get_centroid() + Vector2(0.0, standoff.detection_range + 300.0)
sniper_start = sniper.position
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.0:
return false
var chaser_moved: float = chaser.position.distance_to(chaser_start)
var sniper_moved: float = sniper.position.distance_to(sniper_start)
print("beyond range for 1.0s: chaser moved %.1f, sniper moved %.1f" % [chaser_moved, sniper_moved])
if chaser_moved > 1.0:
return _fail("a chaser beyond detection_range moved %.1f - it should sit idle" % chaser_moved)
if sniper_moved > 1.0:
return _fail("a sniper beyond detection_range moved %.1f - it should sit idle" % sniper_moved)
# --- B. bring the chaser within range, it should now close in ----
var pursuit: Node = chaser.get_component("pursuit")
chaser.position = fleet.get_centroid() + Vector2(pursuit.detection_range * 0.5, 0.0)
chaser_start = chaser.position
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 1.0:
return false
var chaser_moved: float = chaser.position.distance_to(chaser_start)
print("within range for 1.0s: chaser moved %.1f" % chaser_moved)
if chaser_moved < 20.0:
return _fail("a chaser within detection_range did not close in (moved %.1f)" % chaser_moved)
print("chaser correctly idle beyond range, closes in once inside it")
# --- D. the mothership closes in from far beyond a chaser's range -
mothership = load("res://Enemy.tscn").instantiate()
mothership.enemy_id = "enemy_mothership"
main.add_child(mothership)
var mother_pursuit: Node = mothership.get_component("pursuit")
if mother_pursuit.detection_range < 50000.0:
return _fail("the mothership's pursuit range is not effectively unlimited (%f)" \
% mother_pursuit.detection_range)
mothership.position = fleet.get_centroid() + Vector2(5000.0, 0.0)
mothership_start = mothership.position
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 1.0:
return false
var mothership_moved: float = mothership.position.distance_to(mothership_start)
print("mothership 5000 units out, 1.0s: moved %.1f" % mothership_moved)
if mothership_moved < 5.0:
return _fail("the mothership did not close in from far away (moved %.1f)" % mothership_moved)
print("TEST_ENEMY_DETECTION_RANGE: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_ENEMY_DETECTION_RANGE: FAIL (%s)" % message)
quit(1)
return true

View file

@ -27,12 +27,7 @@ func _initialize() -> void:
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
# #75: this test is about the planet-proximity/duplicate rules,
# not resource type - skip a research_lab specifically, since it
# yields nothing and is correctly refused (see
# test_research_lab_extractor_gate.gd for that behaviour).
if WorldGen.has_planet_at_chunk(chunk) \
and WorldGen.planet_data_at_chunk(chunk).definition_id != "research_lab":
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break

View file

@ -5,10 +5,7 @@ extends SceneTree
# Covers the fleet-pivot buttons:
# 1. HOLDING one turns the formation continuously, not once per press
# 2. a brief tap still produces a clear minimum step
# 3. immediately repeating a tap grants nothing extra until the cooldown
# clears (#72) - spamming taps must never rotate faster than holding
# the button would
# 4. pivoting works while the fleet is under way, i.e. the new layout
# 3. pivoting works while the fleet is under way, i.e. the new layout
# survives the joystick's per-frame steering updates instead of being
# overwritten by the old offsets
@ -79,17 +76,6 @@ func _process(delta: float) -> bool:
quit(1)
return true
# --- #72: spamming taps grants nothing extra during the cooldown ---
var before_spam: float = _angle()
for i in range(5):
controller._finish_pivot_tap(1.0)
var spammed: float = absf(angle_difference(before_spam, _angle()))
print("5 taps back-to-back -> turned %.4f rad (expected ~0, cooldown not yet cleared)" % spammed)
if spammed > 0.001:
print("TEST_PIVOT_CONTROLS: FAIL (tap-spam granted rotation during the cooldown)")
quit(1)
return true
# --- pivot WHILE the joystick is held -------------------------
# Two fingers at once. This is routed through the same raw-touch
# path the real device uses, because Godot's Control GUI treats

View file

@ -29,12 +29,7 @@ func _initialize() -> void:
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
# #75: this test is about the conquest gate, not resource type -
# skip a research_lab specifically, since it yields nothing and
# is correctly refused regardless of conquest (see
# test_research_lab_extractor_gate.gd for that behaviour).
if WorldGen.has_planet_at_chunk(chunk) \
and WorldGen.planet_data_at_chunk(chunk).definition_id != "research_lab":
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break

View file

@ -1,114 +0,0 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_research_lab_extractor_gate.gd
# Forgejo #75: a research_lab planet yields no resources at all (#63) - an
# extractor placed there would sit on an empty resource id forever. The
# build menu must refuse the placement and explain why, the same "explain
# the refusal" idiom used for every other build gate. Mirrors
# test_extractor_building.gd's own chunk-discovery/settle/confirm shape.
var main_scene: Node
var fleet: Node
var game_state: Node
var controller: Node
var target_chunk: Vector2i
var target_planet_id: String
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
var found: bool = false
for x in range(-40, 40):
for y in range(-40, 40):
var chunk: Vector2i = Vector2i(x, y)
if not WorldGen.has_planet_at_chunk(chunk):
continue
if WorldGen.planet_data_at_chunk(chunk).definition_id == "research_lab":
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: FAIL (no research_lab chunk found to test with)")
quit(1)
return
game_state = root.get_node("GameState")
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
game_state.planet_capture.clear()
target_planet_id = "chunk:%d:%d" % [target_chunk.x, target_chunk.y]
# This test is about the resource-id gate, not conquest itself (see
# test_planet_conquest_gate) - pre-conquer the target directly.
game_state.register_capture_points(target_planet_id, 1, "research_lab")
game_state.mark_point_captured(target_planet_id, 0)
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
controller = main_scene.get_node("BuildController")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
var near: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = near
fleet.move_to(near)
elapsed = 0.0
stage = 1
return false
1:
var planet_field: Node = main_scene.get_node("PlanetField")
if elapsed < planet_field.check_interval + 0.2:
return false
if not planet_field.populated_chunks.has(target_chunk):
return _fail("the research_lab planet never spawned")
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
if String(planet.resource_type) != "":
return _fail("test setup error: chosen planet's resource_type is '%s', expected empty" \
% planet.resource_type)
for ship in fleet.ships:
ship.position = planet.position
fleet.move_to(planet.position)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
controller._on_build_pressed()
if not controller.build_menu.visible:
return _fail("the build menu did not open over the planet")
var before: int = game_state.extractors.size()
controller._show_building_info(GameState.BUILDING_EXTRACTOR)
controller._confirm_build()
if game_state.extractors.size() != before:
return _fail("an extractor was built on a research_lab planet")
if not ("nothing to extract" in controller.build_status.text):
return _fail("refusal did not explain itself, status was '%s'" % controller.build_status.text)
print("extractor refused on a research_lab planet, explained: '%s'" % controller.build_status.text)
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: FAIL (%s)" % message)
quit(1)
return true

View file

@ -113,10 +113,7 @@ func _process(delta: float) -> bool:
quit(1)
return true
controller.call("_on_lab_vessel", 0)
# #73: the interceptor's Thrusters upgrade was removed (global
# fleet speed now belongs to the distorder fader, #59/#62)
# leaving 3 upgrade cards, not 4 - card 3 is correctly hidden now.
if not lab_card_0.visible or not lab_card_2.visible or lab_card_3.visible:
if not lab_card_0.visible or not lab_card_3.visible:
print("TEST_SHIP_LAB: FAIL (interceptor upgrade cards were not shown)")
quit(1)
return true
@ -159,7 +156,7 @@ func _process(delta: float) -> bool:
# --- 7. per vessel type --------------------------------------
var trn_speed_before: float = game_state.transporter_speed()
game_state.buy_upgrade("int_range")
game_state.buy_upgrade("int_speed")
if game_state.transporter_speed() != trn_speed_before:
print("TEST_SHIP_LAB: FAIL (an interceptor upgrade changed the transporter)")
quit(1)

View file

@ -29,24 +29,6 @@ var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
# --- #70: no two nodes drawn on top of each other -------------------
# SkillTreeView.node_screen_pos() places a node purely from its own
# (depth, x) - two nodes sharing both values land on the exact same
# pixel with no visual way to tell them apart. depth is derived from
# `requires` chains, not authored directly, so this has to check the
# same way the view does rather than eyeballing the data.
var seen: Dictionary = {}
for node in GameState.SKILL_TREE:
var key: String = "%d:%s" % [game_state.skill_depth(node.id), node.x]
if seen.has(key):
print("TEST_SKILL_TREE: FAIL (%s and %s are both at depth/x %s - drawn on top of each other)" \
% [seen[key], node.id, key])
quit(1)
return
seen[key] = node.id
print("no two skill-tree nodes share the same (depth, x)")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()

View file

@ -151,18 +151,13 @@ func _process(delta: float) -> bool:
# "trailing", it just hasn't finished the same (time-invariant)
# catch-up burst yet. Keep steering a while longer so that burst
# is over before judging whether formation holds.
if elapsed < 2.5:
if elapsed < 1.5:
return false
# Allow the lag to scale with the boost (it inherently does, even
# with both the anchor and ship speeds multiplied by the exact
# same factor - see the note by unboosted_lag above) but not
# beyond that, with generous slack for measurement noise - this
# ran under a full-suite JOBS=1 sequential load once and strayed
# 77.7 against a 30.0 allowance (2.6x over) purely from device
# jitter, confirmed by an immediate clean pass in isolation
# straight afterward. A REAL formation-desync regression (#72)
# strays by orders of magnitude more than this margin allows.
var allowed_lag: float = maxf(unboosted_lag, 20.0) * game_state.fleet_speed_multiplier * 3.0
# beyond that, with slack for measurement noise.
var allowed_lag: float = maxf(unboosted_lag, 20.0) * game_state.fleet_speed_multiplier * 1.5
for i in range(fleet.ships.size()):
var ship: Node2D = fleet.ships[i]
if not is_instance_valid(ship):