Add capture points and the planet conquest loop (#43)
CapturePoint tracks neutral/blocked/capturing/captured state by polling proximity each frame (a ship in capture_radius, no enemy within enemy_clear_radius), draining gradually rather than resetting when interrupted, and calling GameState.mark_point_captured() on completion. PlanetSetupHost now instantiates the real CapturePoint scene per authored point (restoring already-captured state immediately on re-landing) and spawns each setup's authored enemies, replacing the placeholder markers from #42. CaptureLabel is a small self-polling HUD (same convention as ResourceCounter) showing "CAPTURED n/m" or "CONQUERED" with a one-shot fade on completion, themed per surface profile alongside the existing bank/fleet captions. test_planet_atmosphere_transition.gd's "no enemies on a landed surface" assumption no longer holds now that setups can place guard enemies by design - narrowed the check to what it actually needs to prove: no space-side EnemySpawner/AsteroidField node leaks across the scene swap. Part of the #40 conquest-mechanic breakdown.
This commit is contained in:
parent
65fe19aeb6
commit
c4e2ee7178
16 changed files with 511 additions and 36 deletions
32
CaptureLabel.gd
Normal file
32
CaptureLabel.gd
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
extends Label
|
||||||
|
|
||||||
|
# Polls GameState directly, same convention as ResourceCounter/GasCounter,
|
||||||
|
# so it stays correct without anyone having to push updates into it. Hidden
|
||||||
|
# entirely when the current planet has no capture points (e.g. no setup
|
||||||
|
# resolved for it) rather than showing "0/0".
|
||||||
|
|
||||||
|
var _was_conquered: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
var planet_id: String = GameState.planet_id
|
||||||
|
var total: int = GameState.total_capture_points(planet_id)
|
||||||
|
if total <= 0:
|
||||||
|
visible = false
|
||||||
|
return
|
||||||
|
visible = true
|
||||||
|
|
||||||
|
var conquered: bool = GameState.is_planet_conquered(planet_id)
|
||||||
|
if conquered:
|
||||||
|
text = "CONQUERED"
|
||||||
|
if not _was_conquered:
|
||||||
|
_flash()
|
||||||
|
else:
|
||||||
|
text = "CAPTURED %d/%d" % [GameState.captured_point_count(planet_id), total]
|
||||||
|
_was_conquered = conquered
|
||||||
|
|
||||||
|
|
||||||
|
func _flash() -> void:
|
||||||
|
modulate.a = 0.0
|
||||||
|
var tween: Tween = create_tween()
|
||||||
|
tween.tween_property(self, "modulate:a", 1.0, 1.2)
|
||||||
1
CaptureLabel.gd.uid
Normal file
1
CaptureLabel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dxuwfa5j7oh80
|
||||||
113
CapturePoint.gd
Normal file
113
CapturePoint.gd
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
class_name CapturePoint
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
# One point on a landed planet's surface that must be held (fleet ship
|
||||||
|
# nearby, no enemy nearby) for capture_time seconds. Progress is polled by
|
||||||
|
# distance each frame rather than through physics signals - same idiom as
|
||||||
|
# RockField/GasVentField - since no collision response is needed, only
|
||||||
|
# proximity.
|
||||||
|
|
||||||
|
signal captured
|
||||||
|
|
||||||
|
enum State { NEUTRAL, BLOCKED, CAPTURING, CAPTURED }
|
||||||
|
|
||||||
|
@export var point_index: int = 0
|
||||||
|
@export var capture_radius: float = 220.0
|
||||||
|
@export var capture_time: float = 8.0
|
||||||
|
@export var enemy_clear_radius: float = 700.0
|
||||||
|
@export var drain_rate: float = 0.5
|
||||||
|
|
||||||
|
const NEUTRAL_COLOR: Color = Color(0.55, 0.85, 1.0)
|
||||||
|
const BLOCKED_COLOR: Color = Color(1.0, 0.3, 0.3)
|
||||||
|
const CAPTURING_COLOR: Color = Color(0.4, 1.0, 0.55)
|
||||||
|
const CAPTURED_COLOR: Color = Color(1.0, 0.85, 0.3)
|
||||||
|
const CORE_RADIUS: float = 60.0
|
||||||
|
|
||||||
|
var progress: float = 0.0
|
||||||
|
var state: int = State.NEUTRAL
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
# Called by PlanetSetupHost when this point was already captured on a
|
||||||
|
# previous visit, so re-landing restores it instead of starting the fill
|
||||||
|
# over. Permanent for the run - _process() below returns early once
|
||||||
|
# CAPTURED and never re-evaluates it.
|
||||||
|
func mark_captured() -> void:
|
||||||
|
progress = 1.0
|
||||||
|
state = State.CAPTURED
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if state == State.CAPTURED:
|
||||||
|
return
|
||||||
|
|
||||||
|
var enemy_nearby: bool = _enemy_nearby()
|
||||||
|
var ship_in_range: bool = _ship_in_range()
|
||||||
|
|
||||||
|
if enemy_nearby:
|
||||||
|
state = State.BLOCKED
|
||||||
|
progress = maxf(progress - drain_rate * delta / capture_time, 0.0)
|
||||||
|
elif ship_in_range:
|
||||||
|
state = State.CAPTURING
|
||||||
|
progress = minf(progress + delta / capture_time, 1.0)
|
||||||
|
if progress >= 1.0:
|
||||||
|
state = State.CAPTURED
|
||||||
|
GameState.mark_point_captured(GameState.planet_id, point_index)
|
||||||
|
captured.emit()
|
||||||
|
else:
|
||||||
|
state = State.NEUTRAL
|
||||||
|
progress = maxf(progress - drain_rate * delta / capture_time, 0.0)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _ship_in_range() -> bool:
|
||||||
|
for ship in get_tree().get_nodes_in_group("player_ships"):
|
||||||
|
if is_instance_valid(ship) and global_position.distance_to(ship.global_position) <= capture_radius:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _enemy_nearby() -> bool:
|
||||||
|
for enemy in get_tree().get_nodes_in_group("enemies"):
|
||||||
|
if is_instance_valid(enemy) and global_position.distance_to(enemy.global_position) <= enemy_clear_radius:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _state_color() -> Color:
|
||||||
|
match state:
|
||||||
|
State.BLOCKED:
|
||||||
|
return BLOCKED_COLOR
|
||||||
|
State.CAPTURING:
|
||||||
|
return CAPTURING_COLOR
|
||||||
|
State.CAPTURED:
|
||||||
|
return CAPTURED_COLOR
|
||||||
|
_:
|
||||||
|
return NEUTRAL_COLOR
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var color: Color = _state_color()
|
||||||
|
# Outer ring: the actual capture radius, faint - tells the player how
|
||||||
|
# close they need to stay without dominating the screen.
|
||||||
|
_draw_ring(capture_radius, 0.0, TAU, color, 0.35)
|
||||||
|
# Inner beacon: fixed size regardless of capture_radius, so it reads
|
||||||
|
# clearly even when zoomed out. A filling arc sweeps clockwise from the
|
||||||
|
# top as progress advances, same "fill toward completion" idea as
|
||||||
|
# VesselGasBomberComponent's hull fill, adapted to a ring since a
|
||||||
|
# capture point has no hull.
|
||||||
|
_draw_ring(CORE_RADIUS, 0.0, TAU, color, 1.0)
|
||||||
|
if progress > 0.001:
|
||||||
|
var sweep: float = TAU * progress
|
||||||
|
_draw_ring(CORE_RADIUS, -PI / 2.0, -PI / 2.0 + sweep, color, 1.0, 10.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_ring(radius: float, from_angle: float, to_angle: float, color: Color, alpha: float,
|
||||||
|
core_width: float = 6.0) -> void:
|
||||||
|
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(color, 0.16 * alpha), core_width * 2.6, true)
|
||||||
|
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(color, 0.5 * alpha), core_width, true)
|
||||||
|
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(1.0, 0.96, 0.9, 0.9 * alpha), core_width * 0.3, true)
|
||||||
1
CapturePoint.gd.uid
Normal file
1
CapturePoint.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dwt256xl1txjo
|
||||||
8
CapturePoint.tscn
Normal file
8
CapturePoint.tscn
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://CapturePoint.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="CapturePoint" type="Area2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
monitoring = false
|
||||||
|
monitorable = false
|
||||||
|
|
@ -1107,6 +1107,10 @@ func captured_point_count(planet_key: String) -> int:
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
func total_capture_points(planet_key: String) -> int:
|
||||||
|
return capture_state(planet_key).get("captured", []).size()
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
# Runs in the autoload, so mining and transport continue while the player
|
# Runs in the autoload, so mining and transport continue while the player
|
||||||
# is on a planet's surface or nowhere near either.
|
# is on a planet's surface or nowhere near either.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
class_name PlanetSetupHost
|
class_name PlanetSetupHost
|
||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
# Instantiates the contents of a PlanetSetup onto a landed surface. Capture
|
# Instantiates the contents of a PlanetSetup onto a landed surface: one
|
||||||
# points are placeholder markers here; #43 replaces them with the real
|
# CapturePoint per authored point - registering/restoring conquest progress
|
||||||
# CapturePoint scene through the same apply_setup() entry point, and enemy
|
# through GameState so re-landing on a partly-taken planet keeps its
|
||||||
# spawning is filled in by the enemy-foundation ticket (#44). Keeping this
|
# progress - and one enemy per authored spawn.
|
||||||
# seam stable is the point of this ticket.
|
|
||||||
|
|
||||||
const MARKER_COLOR: Color = Color(0.55, 0.85, 1.0)
|
const CAPTURE_POINT_SCENE: PackedScene = preload("res://CapturePoint.tscn")
|
||||||
const MARKER_RADIUS: float = 36.0
|
const ENEMY_SCENE: PackedScene = preload("res://Enemy.tscn")
|
||||||
|
|
||||||
var setup: Resource
|
var setup: Resource
|
||||||
|
|
||||||
|
|
@ -19,15 +18,25 @@ func apply_setup(new_setup: Resource) -> void:
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
if setup == null:
|
if setup == null:
|
||||||
return
|
return
|
||||||
for point in setup.capture_points:
|
|
||||||
_add_capture_point_marker(point)
|
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size())
|
||||||
|
for i in range(setup.capture_points.size()):
|
||||||
|
_add_capture_point(i, setup.capture_points[i])
|
||||||
|
for spawn in setup.enemy_spawns:
|
||||||
|
_add_enemy(spawn)
|
||||||
|
|
||||||
|
|
||||||
func _add_capture_point_marker(at: Vector2) -> void:
|
func _add_capture_point(index: int, at: Vector2) -> void:
|
||||||
var marker: NeonPiece = NeonPiece.create(self, at, MARKER_COLOR)
|
var point: Node = CAPTURE_POINT_SCENE.instantiate()
|
||||||
var points: PackedVector2Array = PackedVector2Array()
|
point.point_index = index
|
||||||
for i in range(16):
|
point.position = at
|
||||||
var angle: float = float(i) / 16.0 * TAU
|
add_child(point)
|
||||||
points.append(Vector2(cos(angle), sin(angle)) * MARKER_RADIUS)
|
if GameState.is_point_captured(GameState.planet_id, index):
|
||||||
marker.add_polygon(points, 0.6)
|
point.mark_captured()
|
||||||
marker.queue_redraw()
|
|
||||||
|
|
||||||
|
func _add_enemy(spawn: Resource) -> void:
|
||||||
|
var enemy: Node = ENEMY_SCENE.instantiate()
|
||||||
|
enemy.enemy_id = spawn.enemy_id
|
||||||
|
enemy.position = spawn.position
|
||||||
|
add_child(enemy)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
[gd_scene load_steps=16 format=3]
|
[gd_scene load_steps=17 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://GameController.gd" id="1"]
|
[ext_resource type="Script" path="res://GameController.gd" id="1"]
|
||||||
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
|
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
[ext_resource type="Script" path="res://CargoCounter.gd" id="12"]
|
[ext_resource type="Script" path="res://CargoCounter.gd" id="12"]
|
||||||
[ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"]
|
[ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"]
|
||||||
[ext_resource type="Script" path="res://GasBombController.gd" id="14"]
|
[ext_resource type="Script" path="res://GasBombController.gd" id="14"]
|
||||||
|
[ext_resource type="Script" path="res://CaptureLabel.gd" id="15"]
|
||||||
|
|
||||||
[sub_resource type="ShaderMaterial" id="1"]
|
[sub_resource type="ShaderMaterial" id="1"]
|
||||||
shader = ExtResource("3")
|
shader = ExtResource("3")
|
||||||
|
|
@ -132,6 +133,20 @@ theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
|
||||||
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
|
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
|
||||||
theme_override_constants/outline_size = 5
|
theme_override_constants/outline_size = 5
|
||||||
|
|
||||||
|
[node name="CaptureLabel" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 24
|
||||||
|
offset_top = 286
|
||||||
|
offset_right = 696
|
||||||
|
offset_bottom = 326
|
||||||
|
theme_override_font_sizes/font_size = 28
|
||||||
|
theme_override_colors/font_color = Color(0.08, 0.16, 0.2, 1)
|
||||||
|
theme_override_colors/font_outline_color = Color(0.9, 0.95, 0.96, 0.8)
|
||||||
|
theme_override_constants/outline_size = 4
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = ""
|
||||||
|
script = ExtResource("15")
|
||||||
|
|
||||||
[node name="ExitButton" type="Control" parent="UI"]
|
[node name="ExitButton" type="Control" parent="UI"]
|
||||||
anchor_top = 1.0
|
anchor_top = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ func _compose_surface(profile: Resource) -> void:
|
||||||
atmosphere.set_shader_parameter("base_color", profile.base_color)
|
atmosphere.set_shader_parameter("base_color", profile.base_color)
|
||||||
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
|
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
|
||||||
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
|
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
|
||||||
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]:
|
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption", "../UI/CaptureLabel"]:
|
||||||
var caption: Label = get_node(caption_path)
|
var caption: Label = get_node(caption_path)
|
||||||
caption.add_theme_color_override("font_color", profile.caption_color)
|
caption.add_theme_color_override("font_color", profile.caption_color)
|
||||||
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
||||||
|
|
|
||||||
54
tests/_capture_points_preview.gd
Normal file
54
tests/_capture_points_preview.gd
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Preloaded by path rather than an `is CapturePoint` check - this preview
|
||||||
|
# runs through screenshot.sh, which (unlike run_tests.sh) does not run an
|
||||||
|
# import pass first, so the global class_name cache may not be warm yet.
|
||||||
|
const CAPTURE_POINT_SCRIPT = preload("res://CapturePoint.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.planet_name = "Kepler's Rest"
|
||||||
|
GameState.planet_id = "chunk:3:7"
|
||||||
|
GameState.planet_definition_id = "rocky"
|
||||||
|
GameState.surviving_ship_count = 3
|
||||||
|
GameState.rocks = 128
|
||||||
|
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
surface.get_node("SurfaceController").fade_duration = 0.0
|
||||||
|
add_child(surface)
|
||||||
|
|
||||||
|
# Zoomed out so all three ~220-radius capture rings fit the frame with
|
||||||
|
# margin, regardless of wherever the real setup's points happen to sit.
|
||||||
|
surface.get_node("Camera2D").zoom = Vector2(0.55, 0.55)
|
||||||
|
|
||||||
|
var host: Node = surface.get_node("SurfaceFeatures/PlanetSetupHost")
|
||||||
|
var points: Array = []
|
||||||
|
for child in host.get_children():
|
||||||
|
if child.get_script() == CAPTURE_POINT_SCRIPT:
|
||||||
|
points.append(child)
|
||||||
|
|
||||||
|
# Lay the three points out in a compact, clearly-separated triangle for
|
||||||
|
# the screenshot, rather than the real (far-flung) authored positions.
|
||||||
|
var layout: Array = [Vector2(300, 150), Vector2(-300, 150), Vector2(0, -280)]
|
||||||
|
for i in range(mini(points.size(), layout.size())):
|
||||||
|
points[i].position = layout[i]
|
||||||
|
|
||||||
|
# Show all three states at once: captured, mid-fill, and blocked. Each
|
||||||
|
# point's own _process() would otherwise immediately recompute state
|
||||||
|
# from the real (empty, this far from any ship) fleet/enemy proximity
|
||||||
|
# and overwrite these on the very next frame, so freeze it after setting
|
||||||
|
# the display state - same technique as freezing enemy.speed for the
|
||||||
|
# gas bomber screenshot.
|
||||||
|
if points.size() >= 1:
|
||||||
|
points[0].mark_captured()
|
||||||
|
points[0].set_process(false)
|
||||||
|
GameState.mark_point_captured(GameState.planet_id, points[0].point_index)
|
||||||
|
if points.size() >= 2:
|
||||||
|
points[1].state = CAPTURE_POINT_SCRIPT.State.CAPTURING
|
||||||
|
points[1].progress = 0.55
|
||||||
|
points[1].queue_redraw()
|
||||||
|
points[1].set_process(false)
|
||||||
|
if points.size() >= 3:
|
||||||
|
points[2].state = CAPTURE_POINT_SCRIPT.State.BLOCKED
|
||||||
|
points[2].progress = 0.3
|
||||||
|
points[2].queue_redraw()
|
||||||
|
points[2].set_process(false)
|
||||||
1
tests/_capture_points_preview.gd.uid
Normal file
1
tests/_capture_points_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://njieetp3l0xc
|
||||||
6
tests/_capture_points_preview.tscn
Normal file
6
tests/_capture_points_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_capture_points_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="CapturePointsPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
210
tests/test_capture_points.gd
Normal file
210
tests/test_capture_points.gd
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_capture_points.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. PlanetSetupHost restores an already-captured point immediately on
|
||||||
|
# (re-)composition, instead of restarting its fill
|
||||||
|
# 2. a ship parked in range with no enemies nearby fills a point to
|
||||||
|
# CAPTURED in about capture_time seconds, and GameState reflects it
|
||||||
|
# (captured_point_count, is_planet_conquered - which is what
|
||||||
|
# BuildController's extractor gate reads, see test_planet_conquest_gate)
|
||||||
|
# 3. an enemy within enemy_clear_radius blocks further progress
|
||||||
|
# (state == BLOCKED) without erasing what was already filled
|
||||||
|
# 4. moving out of range drains progress gradually, not instantly, and
|
||||||
|
# never below what a completed point already holds
|
||||||
|
|
||||||
|
# Resolved via load() inside _initialize(), NOT a top-level `const ...
|
||||||
|
# preload(...)`. A top-level preload in a script-mode ENTRY point (-s
|
||||||
|
# this_file.gd) evaluates during this file's own early compilation, which
|
||||||
|
# can happen before autoloads are registered - and CapturePoint.gd
|
||||||
|
# references GameState in a function body, which GDScript resolves at
|
||||||
|
# compile time. preload()ing it from inside _initialize() defers that
|
||||||
|
# until after the engine has finished booting, same as every plain load()
|
||||||
|
# call elsewhere in this file.
|
||||||
|
var CAPTURE_POINT_SCRIPT: Script
|
||||||
|
var PLANET_SETUP_SCRIPT: Script
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var main: Node
|
||||||
|
var fleet: Node
|
||||||
|
var point: Node
|
||||||
|
var enemy: Node
|
||||||
|
var planet_id: String = "test_capture_planet"
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var progress_before_block: float = 0.0
|
||||||
|
var progress_before_drain: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
CAPTURE_POINT_SCRIPT = load("res://CapturePoint.gd")
|
||||||
|
PLANET_SETUP_SCRIPT = load("res://PlanetSetup.gd")
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.planet_capture.clear()
|
||||||
|
|
||||||
|
# --- 1. restoration: an already-captured point stays captured -----
|
||||||
|
var restore_planet_id: String = "test_capture_restore_planet"
|
||||||
|
var fake_setup: Resource = PLANET_SETUP_SCRIPT.new()
|
||||||
|
fake_setup.setup_id = "test_restore"
|
||||||
|
fake_setup.surface_profile_id = "rocky"
|
||||||
|
fake_setup.capture_points = PackedVector2Array([Vector2(0, 0), Vector2(500, 0)])
|
||||||
|
|
||||||
|
game_state.planet_id = restore_planet_id
|
||||||
|
var host1: Node = load("res://PlanetSetupHost.tscn").instantiate()
|
||||||
|
host1.apply_setup(fake_setup)
|
||||||
|
game_state.mark_point_captured(restore_planet_id, 0) # simulates a completed prior visit
|
||||||
|
|
||||||
|
var host2: Node = load("res://PlanetSetupHost.tscn").instantiate()
|
||||||
|
host2.apply_setup(fake_setup)
|
||||||
|
var restored: Node = null
|
||||||
|
var fresh: Node = null
|
||||||
|
for child in host2.get_children():
|
||||||
|
if child.get_script() == CAPTURE_POINT_SCRIPT and child.point_index == 0:
|
||||||
|
restored = child
|
||||||
|
elif child.get_script() == CAPTURE_POINT_SCRIPT and child.point_index == 1:
|
||||||
|
fresh = child
|
||||||
|
if restored == null or restored.state != CAPTURE_POINT_SCRIPT.State.CAPTURED or restored.progress < 0.999:
|
||||||
|
return _fail("re-landing did not restore an already-captured point")
|
||||||
|
if fresh == null or fresh.state == CAPTURE_POINT_SCRIPT.State.CAPTURED:
|
||||||
|
return _fail("an uncaptured point came back already CAPTURED")
|
||||||
|
|
||||||
|
# --- 2-4. capture mechanics, in a live scene with a real fleet -----
|
||||||
|
game_state.planet_capture.clear()
|
||||||
|
game_state.planet_id = planet_id
|
||||||
|
game_state.register_capture_points(planet_id, 1)
|
||||||
|
game_state.buildings.clear()
|
||||||
|
game_state.extractors.clear()
|
||||||
|
game_state.rocks = 200
|
||||||
|
|
||||||
|
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.1:
|
||||||
|
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()
|
||||||
|
|
||||||
|
point = load("res://CapturePoint.tscn").instantiate()
|
||||||
|
point.point_index = 0
|
||||||
|
point.capture_time = 1.0
|
||||||
|
point.capture_radius = 200.0
|
||||||
|
point.enemy_clear_radius = 300.0
|
||||||
|
point.drain_rate = 0.5
|
||||||
|
point.position = Vector2(4000, 0) # well clear of spawn-time activity
|
||||||
|
main.add_child(point)
|
||||||
|
|
||||||
|
var far_away: Vector2 = point.position + Vector2(2500, 0)
|
||||||
|
for ship in fleet.ships:
|
||||||
|
ship.position = far_away
|
||||||
|
fleet.move_to(far_away)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# Out of range: no progress at all.
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
if point.progress > 0.001 or point.state != CAPTURE_POINT_SCRIPT.State.NEUTRAL:
|
||||||
|
return _fail("progress rose while no ship was in capture_radius")
|
||||||
|
|
||||||
|
for ship in fleet.ships:
|
||||||
|
ship.position = point.position
|
||||||
|
fleet.move_to(point.position)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
# Partway through capture_time: rising, not yet complete.
|
||||||
|
if elapsed < point.capture_time * 0.4:
|
||||||
|
return false
|
||||||
|
if point.state != CAPTURE_POINT_SCRIPT.State.CAPTURING or point.progress <= 0.05:
|
||||||
|
return _fail("point did not start filling with a ship in range (state=%d progress=%.2f)" % [
|
||||||
|
point.state, point.progress
|
||||||
|
])
|
||||||
|
|
||||||
|
# An enemy shows up within enemy_clear_radius: must block further
|
||||||
|
# fill without erasing what's already there. A bare "enemies"
|
||||||
|
# group member (not a real Enemy.tscn) is deliberate: CapturePoint
|
||||||
|
# only cares that something is in the group and in range, and
|
||||||
|
# this avoids a real chaser/weapon exchange destroying the fleet
|
||||||
|
# ships this test also needs alive in later stages.
|
||||||
|
enemy = Node2D.new()
|
||||||
|
enemy.add_to_group("enemies")
|
||||||
|
enemy.position = point.position + Vector2(50, 0)
|
||||||
|
main.add_child(enemy)
|
||||||
|
progress_before_block = point.progress
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
if elapsed < 0.5:
|
||||||
|
return false
|
||||||
|
if point.state != CAPTURE_POINT_SCRIPT.State.BLOCKED:
|
||||||
|
return _fail("point did not report BLOCKED with an enemy nearby (state=%d)" % point.state)
|
||||||
|
if point.progress > progress_before_block + 0.01:
|
||||||
|
return _fail("progress rose while an enemy was inside enemy_clear_radius")
|
||||||
|
|
||||||
|
# Enemy leaves and the ship backs off out of range: progress
|
||||||
|
# should drain, but gradually - not snap to zero.
|
||||||
|
enemy.queue_free()
|
||||||
|
var far_away: Vector2 = point.position + Vector2(2500, 0)
|
||||||
|
for ship in fleet.ships:
|
||||||
|
ship.position = far_away
|
||||||
|
fleet.move_to(far_away)
|
||||||
|
progress_before_drain = point.progress
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 4
|
||||||
|
return false
|
||||||
|
4:
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
if point.progress >= progress_before_drain - 0.001:
|
||||||
|
return _fail("progress did not drain while out of range")
|
||||||
|
if point.progress <= 0.001:
|
||||||
|
return _fail("progress dropped to zero instantly instead of draining gradually")
|
||||||
|
if point.state != CAPTURE_POINT_SCRIPT.State.NEUTRAL:
|
||||||
|
return _fail("point state did not return to NEUTRAL while draining out of range")
|
||||||
|
|
||||||
|
# Move back in and let it finish this time.
|
||||||
|
for ship in fleet.ships:
|
||||||
|
ship.position = point.position
|
||||||
|
fleet.move_to(point.position)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 5
|
||||||
|
return false
|
||||||
|
5:
|
||||||
|
# Draining first means completion now takes longer than a bare
|
||||||
|
# capture_time - give it a generous margin.
|
||||||
|
if elapsed < point.capture_time * 2.0 + 0.5:
|
||||||
|
return false
|
||||||
|
if point.state != CAPTURE_POINT_SCRIPT.State.CAPTURED:
|
||||||
|
return _fail("point never reached CAPTURED (state=%d progress=%.2f after %.2fs)" % [
|
||||||
|
point.state, point.progress, elapsed
|
||||||
|
])
|
||||||
|
if game_state.captured_point_count(planet_id) != 1:
|
||||||
|
return _fail("GameState.captured_point_count did not record the capture")
|
||||||
|
# is_planet_conquered is exactly what BuildController's extractor
|
||||||
|
# gate reads (see test_planet_conquest_gate.gd) - not re-tested
|
||||||
|
# through the build UI here to avoid duplicating that coverage.
|
||||||
|
if not game_state.is_planet_conquered(planet_id):
|
||||||
|
return _fail("single-point planet was not marked conquered")
|
||||||
|
|
||||||
|
print("TEST_CAPTURE_POINTS: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_CAPTURE_POINTS: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_capture_points.gd.uid
Normal file
1
tests/test_capture_points.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cmordwxwbr5ca
|
||||||
|
|
@ -6,10 +6,14 @@ extends SceneTree
|
||||||
# scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place
|
# scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place
|
||||||
# overlay: standing on a planet shows the prompt, pressing it fades out
|
# overlay: standing on a planet shows the prompt, pressing it fades out
|
||||||
# and swaps to PlanetSurface.tscn with the SAME surviving ship count,
|
# and swaps to PlanetSurface.tscn with the SAME surviving ship count,
|
||||||
# where no asteroid/enemy ever exists (not hidden - structurally absent,
|
# where no asteroid ever exists and no space-side EnemySpawner/AsteroidField
|
||||||
# since PlanetSurface.tscn has no such nodes at all) and the fleet is
|
# node carries over (structurally absent - PlanetSurface.tscn has no such
|
||||||
# still fully flyable. Exiting fades back to Main.tscn with the fleet
|
# nodes at all), and the fleet is still fully flyable. Landed enemies MAY
|
||||||
# restored at the world position it left from.
|
# still exist post-#43 (PlanetSetupHost places authored ones to guard
|
||||||
|
# capture points), which is a different thing from the space-side spawner
|
||||||
|
# leaking across the swap - see the structural check below. Exiting fades
|
||||||
|
# back to Main.tscn with the fleet restored at the world position it left
|
||||||
|
# from.
|
||||||
|
|
||||||
var main_scene: Node
|
var main_scene: Node
|
||||||
var fleet: Node
|
var fleet: Node
|
||||||
|
|
@ -163,11 +167,16 @@ func _process(delta: float) -> bool:
|
||||||
var ship_count_ok: bool = surface_fleet.ships.size() == 5
|
var ship_count_ok: bool = surface_fleet.ships.size() == 5
|
||||||
var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types
|
var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types
|
||||||
var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty()
|
var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty()
|
||||||
var no_enemies: bool = get_nodes_in_group("enemies").is_empty()
|
# Landed enemies from an authored PlanetSetup are expected
|
||||||
print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [
|
# post-#43 (they guard capture points) - what this test actually
|
||||||
ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies
|
# owes is that the SPACE-SIDE spawner nodes never carry across,
|
||||||
|
# checked structurally rather than by assuming zero enemies.
|
||||||
|
var no_space_spawners: bool = current_scene.get_node_or_null("EnemySpawner") == null \
|
||||||
|
and current_scene.get_node_or_null("AsteroidField") == null
|
||||||
|
print("ship_count_ok=%s (count=%d) no_asteroids=%s no_space_spawners=%s" % [
|
||||||
|
ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_space_spawners
|
||||||
])
|
])
|
||||||
if not (ship_count_ok and composition_ok and no_asteroids and no_enemies):
|
if not (ship_count_ok and composition_ok and no_asteroids and no_space_spawners):
|
||||||
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)")
|
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ extends SceneTree
|
||||||
|
|
||||||
const SCRATCH_SETUP_PATH: String = "res://PlanetSetups/_test_scratch_setup.tres"
|
const SCRATCH_SETUP_PATH: String = "res://PlanetSetups/_test_scratch_setup.tres"
|
||||||
|
|
||||||
|
# Resolved via load() inside _initialize(), NOT a top-level `const ...
|
||||||
|
# preload(...)` - a top-level preload in a script-mode ENTRY point (-s
|
||||||
|
# this_file.gd) evaluates during this file's own early compilation, which
|
||||||
|
# can happen before autoloads are registered, and CapturePoint.gd
|
||||||
|
# references GameState in a function body (resolved at compile time). See
|
||||||
|
# test_capture_points.gd for the full explanation.
|
||||||
|
var PLANET_SETUP_SCRIPT: Script
|
||||||
|
var CAPTURE_POINT_SCRIPT: Script
|
||||||
|
|
||||||
var game_state: Node
|
var game_state: Node
|
||||||
var rocky_setup: Resource
|
var rocky_setup: Resource
|
||||||
var stage: int = 0
|
var stage: int = 0
|
||||||
|
|
@ -20,6 +29,8 @@ var pending_surface: Node
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
|
PLANET_SETUP_SCRIPT = load("res://PlanetSetup.gd")
|
||||||
|
CAPTURE_POINT_SCRIPT = load("res://CapturePoint.gd")
|
||||||
# --- 1. determinism and profile filtering ---
|
# --- 1. determinism and profile filtering ---
|
||||||
rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky")
|
rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky")
|
||||||
if rocky_setup == null:
|
if rocky_setup == null:
|
||||||
|
|
@ -42,7 +53,7 @@ func _initialize() -> void:
|
||||||
|
|
||||||
# --- 2. dropping a new .tres is picked up by rescan() ---
|
# --- 2. dropping a new .tres is picked up by rescan() ---
|
||||||
var before_count: int = PlanetSetups.setups_for_profile("rocky").size()
|
var before_count: int = PlanetSetups.setups_for_profile("rocky").size()
|
||||||
var scratch: Resource = PlanetSetup.new()
|
var scratch: Resource = PLANET_SETUP_SCRIPT.new()
|
||||||
scratch.setup_id = "_test_scratch_setup"
|
scratch.setup_id = "_test_scratch_setup"
|
||||||
scratch.display_name = "Scratch"
|
scratch.display_name = "Scratch"
|
||||||
scratch.surface_profile_id = "rocky"
|
scratch.surface_profile_id = "rocky"
|
||||||
|
|
@ -89,14 +100,14 @@ func _process(delta: float) -> bool:
|
||||||
if host.setup != rocky_setup:
|
if host.setup != rocky_setup:
|
||||||
pending_surface.free()
|
pending_surface.free()
|
||||||
return _fail("host did not receive the deterministically resolved setup")
|
return _fail("host did not receive the deterministically resolved setup")
|
||||||
var marker_count: int = 0
|
var point_count: int = 0
|
||||||
for child in host.get_children():
|
for child in host.get_children():
|
||||||
if child is NeonPiece:
|
if child.get_script() == CAPTURE_POINT_SCRIPT:
|
||||||
marker_count += 1
|
point_count += 1
|
||||||
if marker_count != rocky_setup.capture_points.size():
|
if point_count != rocky_setup.capture_points.size():
|
||||||
pending_surface.free()
|
pending_surface.free()
|
||||||
return _fail("host placed %d markers for %d capture points" % [
|
return _fail("host placed %d capture points for %d authored ones" % [
|
||||||
marker_count, rocky_setup.capture_points.size()
|
point_count, rocky_setup.capture_points.size()
|
||||||
])
|
])
|
||||||
pending_surface.free()
|
pending_surface.free()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue