Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df2f252970 | ||
|
|
def224667d | ||
|
|
fc2b5ca74a | ||
|
|
c4e2ee7178 |
56 changed files with 2296 additions and 52 deletions
16
ArtilleryEnemyDefinition.tres
Normal file
16
ArtilleryEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyArtilleryComponent.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
enemy_id = "artillery"
|
||||||
|
display_name = "Artillery"
|
||||||
|
max_hp = 8
|
||||||
|
speed = 60.0
|
||||||
|
acceleration = 120.0
|
||||||
|
rotation_speed = 3.0
|
||||||
|
hull_points = PackedVector2Array(0, -26, 18.4, -18.4, 26, 0, 18.4, 18.4, 0, 26, -18.4, 18.4, -26, 0, -18.4, -18.4)
|
||||||
|
glow_color = Color(1.0, 0.5, 0.2, 1)
|
||||||
|
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||||
112
ArtilleryShell.gd
Normal file
112
ArtilleryShell.gd
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
class_name ArtilleryShell
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Self-contained telegraph -> travel -> explode sequence for the artillery
|
||||||
|
# enemy's attack (see EnemyArtilleryComponent). Lives independently of the
|
||||||
|
# enemy that fired it once launched - added to the tree root, exactly like
|
||||||
|
# EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't
|
||||||
|
# cancel a shot already telegraphed to the player.
|
||||||
|
#
|
||||||
|
# Shield interaction: a shield charge absorbs this hit entirely, same
|
||||||
|
# all-or-nothing semantics as every other damage source in this game
|
||||||
|
# (VesselShieldComponent.absorb_hit) - _explode() just calls
|
||||||
|
# Ship.take_damage() like everything else and lets that logic decide.
|
||||||
|
|
||||||
|
signal finished
|
||||||
|
|
||||||
|
@export var telegraph_time: float = 1.8
|
||||||
|
@export var shell_speed: float = 180.0
|
||||||
|
@export var aoe_radius: float = 220.0
|
||||||
|
@export var damage: int = 3
|
||||||
|
@export var color: Color = Color(1.0, 0.5, 0.2)
|
||||||
|
|
||||||
|
enum Phase { TELEGRAPHING, TRAVELING, EXPLODING }
|
||||||
|
|
||||||
|
const EXPLOSION_DURATION: float = 0.8
|
||||||
|
|
||||||
|
var phase: int = Phase.TELEGRAPHING
|
||||||
|
var target: Vector2 = Vector2.ZERO
|
||||||
|
var shell_pos: Vector2 = Vector2.ZERO
|
||||||
|
var telegraph_left: float = 0.0
|
||||||
|
var explosion_time: float = 0.0
|
||||||
|
var trail: PackedVector2Array = PackedVector2Array()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("artillery_shells")
|
||||||
|
|
||||||
|
|
||||||
|
func launch(from: Vector2, at: Vector2) -> void:
|
||||||
|
target = at
|
||||||
|
shell_pos = from
|
||||||
|
telegraph_left = telegraph_time
|
||||||
|
phase = Phase.TELEGRAPHING
|
||||||
|
position = target
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
match phase:
|
||||||
|
Phase.TELEGRAPHING:
|
||||||
|
telegraph_left -= delta
|
||||||
|
if telegraph_left <= 0.0:
|
||||||
|
phase = Phase.TRAVELING
|
||||||
|
trail = PackedVector2Array([shell_pos])
|
||||||
|
queue_redraw()
|
||||||
|
Phase.TRAVELING:
|
||||||
|
var to_target: Vector2 = target - shell_pos
|
||||||
|
var step: float = shell_speed * delta
|
||||||
|
if to_target.length() <= step:
|
||||||
|
shell_pos = target
|
||||||
|
_explode()
|
||||||
|
else:
|
||||||
|
shell_pos += to_target.normalized() * step
|
||||||
|
trail.append(shell_pos)
|
||||||
|
while trail.size() > 14:
|
||||||
|
trail.remove_at(0)
|
||||||
|
position = shell_pos
|
||||||
|
queue_redraw()
|
||||||
|
Phase.EXPLODING:
|
||||||
|
explosion_time -= delta
|
||||||
|
queue_redraw()
|
||||||
|
if explosion_time <= 0.0:
|
||||||
|
finished.emit()
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _explode() -> void:
|
||||||
|
phase = Phase.EXPLODING
|
||||||
|
explosion_time = EXPLOSION_DURATION
|
||||||
|
position = target
|
||||||
|
for ship in get_tree().get_nodes_in_group("player_ships"):
|
||||||
|
if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius:
|
||||||
|
ship.take_damage(damage)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
match phase:
|
||||||
|
Phase.TELEGRAPHING:
|
||||||
|
var progress: float = clampf(1.0 - telegraph_left / maxf(telegraph_time, 0.001), 0.0, 1.0)
|
||||||
|
# Growing ring plus a filling countdown arc: the warning reads
|
||||||
|
# clearly without needing a number on screen.
|
||||||
|
var radius: float = lerpf(24.0, aoe_radius, progress)
|
||||||
|
_draw_ring(radius, 0.7)
|
||||||
|
draw_arc(Vector2.ZERO, aoe_radius + 14.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 48,
|
||||||
|
Color(color, 0.85), 8.0, true)
|
||||||
|
Phase.TRAVELING:
|
||||||
|
for i in range(1, trail.size()):
|
||||||
|
var alpha: float = float(i) / trail.size()
|
||||||
|
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true)
|
||||||
|
draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35))
|
||||||
|
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0)
|
||||||
|
Phase.EXPLODING:
|
||||||
|
var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION
|
||||||
|
var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0))
|
||||||
|
draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18))
|
||||||
|
_draw_ring(eradius, 1.0 - eprogress)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_ring(radius: float, alpha: float) -> void:
|
||||||
|
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true)
|
||||||
|
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true)
|
||||||
|
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(1.0, 0.92, 0.86, 0.9 * alpha), 2.0, true)
|
||||||
1
ArtilleryShell.gd.uid
Normal file
1
ArtilleryShell.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cvxao0mjslo5x
|
||||||
6
ArtilleryShell.tscn
Normal file
6
ArtilleryShell.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://ArtilleryShell.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ArtilleryShell" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
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
|
||||||
61
EnemyArtilleryComponent.gd
Normal file
61
EnemyArtilleryComponent.gd
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
class_name EnemyArtilleryComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Stationary siege piece: drifts slowly toward the fleet (deliberately no
|
||||||
|
# pursuit component - closing distance is incidental, not its purpose) and
|
||||||
|
# fires a slow, heavily telegraphed AOE shell on a long cooldown via a
|
||||||
|
# standalone ArtilleryShell, so an already-launched shot survives this
|
||||||
|
# enemy dying mid-flight, same as EnemyProjectile/Projectile already do.
|
||||||
|
|
||||||
|
var capability_id: String = "artillery"
|
||||||
|
|
||||||
|
@export var fire_cooldown: float = 6.0
|
||||||
|
@export var fire_range: float = 900.0
|
||||||
|
@export var telegraph_time: float = 1.8
|
||||||
|
@export var shell_speed: float = 180.0
|
||||||
|
@export var aoe_radius: float = 220.0
|
||||||
|
@export var damage: int = 3
|
||||||
|
@export var drift_speed: float = 20.0
|
||||||
|
@export var color: Color = Color(1.0, 0.5, 0.2)
|
||||||
|
|
||||||
|
var cooldown_left: float = 0.0
|
||||||
|
var shell_scene: PackedScene = preload("res://ArtilleryShell.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
_drift(enemy, delta)
|
||||||
|
|
||||||
|
cooldown_left -= delta
|
||||||
|
if cooldown_left > 0.0:
|
||||||
|
return
|
||||||
|
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return
|
||||||
|
var centroid: Vector2 = fleet.get_centroid()
|
||||||
|
if enemy.global_position.distance_to(centroid) > fire_range:
|
||||||
|
return
|
||||||
|
|
||||||
|
var shell: Node2D = shell_scene.instantiate()
|
||||||
|
shell.telegraph_time = telegraph_time
|
||||||
|
shell.shell_speed = shell_speed
|
||||||
|
shell.aoe_radius = aoe_radius
|
||||||
|
shell.damage = damage
|
||||||
|
shell.color = color
|
||||||
|
enemy.get_tree().root.add_child(shell)
|
||||||
|
shell.launch(enemy.global_position, centroid)
|
||||||
|
cooldown_left = fire_cooldown
|
||||||
|
|
||||||
|
|
||||||
|
func _drift(enemy: Node2D, delta: float) -> void:
|
||||||
|
# Local position throughout, same convention as EnemyPursuitComponent/
|
||||||
|
# EnemyStandoffComponent: valid as long as the enemy's container and
|
||||||
|
# Fleet share a common origin, which every scene in this project does.
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return
|
||||||
|
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
|
||||||
|
if to_fleet.length() < 1.0:
|
||||||
|
return
|
||||||
|
enemy.position += to_fleet.normalized() * drift_speed * delta
|
||||||
1
EnemyArtilleryComponent.gd.uid
Normal file
1
EnemyArtilleryComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b0woyjce8kl6x
|
||||||
6
EnemyArtilleryComponent.tscn
Normal file
6
EnemyArtilleryComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyArtilleryComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyArtilleryComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
@ -2,9 +2,11 @@ class_name EnemyDefinitions
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const DEFAULT_ID: String = "chaser"
|
const DEFAULT_ID: String = "chaser"
|
||||||
const ORDER: PackedStringArray = ["chaser"]
|
const ORDER: PackedStringArray = ["chaser", "sniper", "artillery"]
|
||||||
const DEFINITIONS: Dictionary = {
|
const DEFINITIONS: Dictionary = {
|
||||||
"chaser": preload("res://ChaserEnemyDefinition.tres"),
|
"chaser": preload("res://ChaserEnemyDefinition.tres"),
|
||||||
|
"sniper": preload("res://SniperEnemyDefinition.tres"),
|
||||||
|
"artillery": preload("res://ArtilleryEnemyDefinition.tres"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
54
EnemyRangedWeaponComponent.gd
Normal file
54
EnemyRangedWeaponComponent.gd
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
class_name EnemyRangedWeaponComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Same shape as VesselWeaponComponent, mirrored for an enemy: fires an
|
||||||
|
# EnemyProjectile at the nearest player ship's CURRENT position, not led,
|
||||||
|
# so a moving ship can dodge it.
|
||||||
|
|
||||||
|
var capability_id: String = "ranged_weapon"
|
||||||
|
|
||||||
|
@export var fire_range: float = 700.0
|
||||||
|
@export var fire_cooldown: float = 2.2
|
||||||
|
@export var projectile_speed: float = 260.0
|
||||||
|
@export var damage: int = 1
|
||||||
|
|
||||||
|
var fire_timer: float = 0.0
|
||||||
|
var projectile_scene: PackedScene = preload("res://EnemyProjectile.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
fire_timer -= delta
|
||||||
|
if fire_timer > 0.0:
|
||||||
|
return
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
var target: Node2D = _nearest_ship_in_range(enemy)
|
||||||
|
if target == null:
|
||||||
|
fire_timer = 0.25
|
||||||
|
return
|
||||||
|
var projectile: Area2D = projectile_scene.instantiate()
|
||||||
|
# global_position: the projectile is reparented to root, a different
|
||||||
|
# node than the enemy's own (possibly nested) parent, so local
|
||||||
|
# position would be wrong the moment an enemy isn't a direct root
|
||||||
|
# child - exactly the case #44 introduced (planet-surface hosting).
|
||||||
|
projectile.position = enemy.global_position
|
||||||
|
projectile.damage = damage
|
||||||
|
projectile.speed = projectile_speed
|
||||||
|
enemy.get_tree().root.add_child(projectile)
|
||||||
|
projectile.launch(target.global_position - enemy.global_position)
|
||||||
|
fire_timer = fire_cooldown
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null:
|
||||||
|
return null
|
||||||
|
var nearest: Node2D = null
|
||||||
|
var best_distance: float = fire_range
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if not is_instance_valid(ship):
|
||||||
|
continue
|
||||||
|
var distance: float = enemy.global_position.distance_to(ship.global_position)
|
||||||
|
if distance <= best_distance:
|
||||||
|
best_distance = distance
|
||||||
|
nearest = ship
|
||||||
|
return nearest
|
||||||
1
EnemyRangedWeaponComponent.gd.uid
Normal file
1
EnemyRangedWeaponComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dx1lmxw6ia66a
|
||||||
6
EnemyRangedWeaponComponent.tscn
Normal file
6
EnemyRangedWeaponComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyRangedWeaponComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyRangedWeaponComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
58
EnemyStandoffComponent.gd
Normal file
58
EnemyStandoffComponent.gd
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
class_name EnemyStandoffComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Holds a preferred distance from the nearest player ship instead of
|
||||||
|
# ramming: advances when too far, retreats when too close, drifts
|
||||||
|
# laterally in the comfortable band between so it still reads as alive
|
||||||
|
# rather than frozen. Never pair with a pursuit component on the same
|
||||||
|
# enemy - the two would fight over velocity every tick.
|
||||||
|
|
||||||
|
var capability_id: String = "standoff"
|
||||||
|
|
||||||
|
@export var preferred_range: float = 620.0
|
||||||
|
@export var retreat_range_factor: float = 0.75
|
||||||
|
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
var target: Node2D = _nearest_ship(enemy)
|
||||||
|
if target == null:
|
||||||
|
return
|
||||||
|
var to_target: Vector2 = target.position - enemy.position
|
||||||
|
var distance: float = to_target.length()
|
||||||
|
if distance < 1.0:
|
||||||
|
return
|
||||||
|
var direction: Vector2 = to_target / distance
|
||||||
|
var retreat_range: float = preferred_range * retreat_range_factor
|
||||||
|
|
||||||
|
var desired_velocity: Vector2
|
||||||
|
if distance > preferred_range:
|
||||||
|
desired_velocity = direction * enemy.speed
|
||||||
|
elif distance < retreat_range:
|
||||||
|
desired_velocity = -direction * enemy.speed
|
||||||
|
else:
|
||||||
|
var lateral: Vector2 = Vector2(-direction.y, direction.x)
|
||||||
|
desired_velocity = lateral * enemy.speed * 0.5
|
||||||
|
|
||||||
|
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||||
|
enemy.position += velocity * delta
|
||||||
|
|
||||||
|
if velocity.length() > 5.0:
|
||||||
|
var target_angle: float = velocity.angle() + PI / 2.0
|
||||||
|
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return null
|
||||||
|
var nearest: Node2D = fleet.ships[0]
|
||||||
|
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||||
|
for ship in fleet.ships:
|
||||||
|
var d: float = enemy.position.distance_to(ship.position)
|
||||||
|
if d < best_dist:
|
||||||
|
best_dist = d
|
||||||
|
nearest = ship
|
||||||
|
return nearest
|
||||||
1
EnemyStandoffComponent.gd.uid
Normal file
1
EnemyStandoffComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c7axi54f6p7gk
|
||||||
6
EnemyStandoffComponent.tscn
Normal file
6
EnemyStandoffComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyStandoffComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyStandoffComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ extends Node2D
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
play_button.pressed.connect(_on_play_pressed)
|
play_button.pressed.connect(_on_play_pressed)
|
||||||
editor_button.pressed.connect(_on_editor_pressed)
|
editor_button.pressed.connect(_on_editor_pressed)
|
||||||
# #48 wires the real map editor scene up to this button; until then it
|
editor_button.enabled = true
|
||||||
# stays visible but explains itself instead of silently doing nothing
|
|
||||||
# or pointing at a scene that doesn't exist yet.
|
|
||||||
editor_button.enabled = false
|
|
||||||
quit_button.pressed.connect(_on_quit_pressed)
|
quit_button.pressed.connect(_on_quit_pressed)
|
||||||
quit_button.visible = not OS.has_feature("web")
|
quit_button.visible = not OS.has_feature("web")
|
||||||
|
|
||||||
|
|
|
||||||
56
PaletteEntryButton.gd
Normal file
56
PaletteEntryButton.gd
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
class_name PaletteEntryButton
|
||||||
|
extends Control
|
||||||
|
|
||||||
|
# One selectable tile in the setup editor's palette: either the generic
|
||||||
|
# capture-point marker, or one enemy definition's own hull/colour. Drawn
|
||||||
|
# directly from the definition rather than a fixed icon set, so a new
|
||||||
|
# EnemyDefinitions entry shows up automatically with no editor code change.
|
||||||
|
|
||||||
|
signal pressed
|
||||||
|
|
||||||
|
@export var is_capture_point: bool = false
|
||||||
|
@export var enemy_definition: Resource
|
||||||
|
@export var selected: bool = false: set = set_selected
|
||||||
|
|
||||||
|
const RING_COLOR: Color = Color(0.55, 0.85, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func set_selected(value: bool) -> void:
|
||||||
|
selected = value
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _gui_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventScreenTouch and not event.pressed \
|
||||||
|
and Rect2(Vector2.ZERO, size).has_point(event.position):
|
||||||
|
pressed.emit()
|
||||||
|
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:
|
||||||
|
pressed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var center: Vector2 = size / 2.0
|
||||||
|
var margin: float = 4.0
|
||||||
|
var frame: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(margin, margin), Vector2(size.x - margin, margin),
|
||||||
|
Vector2(size.x - margin, size.y - margin), Vector2(margin, size.y - margin),
|
||||||
|
])
|
||||||
|
var frame_color: Color = Color(1.0, 0.95, 0.9) if selected else Color(0.4, 0.45, 0.5)
|
||||||
|
NeonDraw.draw_glowing_polygon(self, frame, frame_color, 0.85 if selected else 0.5)
|
||||||
|
|
||||||
|
if is_capture_point:
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for i in range(16):
|
||||||
|
var angle: float = float(i) / 16.0 * TAU
|
||||||
|
points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28))
|
||||||
|
NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8)
|
||||||
|
elif enemy_definition != null:
|
||||||
|
var scale_factor: float = (size.x * 0.5) / 28.0
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for p in enemy_definition.hull_points:
|
||||||
|
points.append(center + p * scale_factor * 0.6)
|
||||||
|
NeonDraw.draw_glowing_polygon(self, points, enemy_definition.glow_color, 0.9)
|
||||||
1
PaletteEntryButton.gd.uid
Normal file
1
PaletteEntryButton.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ge3e0dc3cekb
|
||||||
460
PlanetSetupEditor.gd
Normal file
460
PlanetSetupEditor.gd
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Authoring tool for PlanetSetup resources (#40/#48): place/move/delete
|
||||||
|
# capture points and enemy spawns for a chosen surface profile, then save
|
||||||
|
# to res://PlanetSetups/<id>.tres so the game picks it up the same way it
|
||||||
|
# picks up every other authored setup. Deliberately does not touch
|
||||||
|
# GameState - this tool only edits committed resources, never gameplay
|
||||||
|
# state.
|
||||||
|
#
|
||||||
|
# The mutation methods below (new_setup/load_setup/add_*/move_*/remove_*/
|
||||||
|
# save_setup) are the editor's whole model and are safe to call directly
|
||||||
|
# without any touch input, which is how tests/test_setup_editor.gd drives
|
||||||
|
# them; the touch handlers at the bottom are a thin input layer on top.
|
||||||
|
|
||||||
|
# Preloaded by path rather than referenced by global class name, same as
|
||||||
|
# every other cross-file lookup added this session (see #42/#44's
|
||||||
|
# load-order notes) - a bare class-name reference needs the global
|
||||||
|
# class_name cache already warm, which an explicit script path never does.
|
||||||
|
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
|
||||||
|
const PLANET_SETUP_SCRIPT = preload("res://PlanetSetup.gd")
|
||||||
|
const PLANET_SETUP_SPAWN_SCRIPT = preload("res://PlanetSetupSpawn.gd")
|
||||||
|
const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://PaletteEntryButton.gd")
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
# cache already warm (see #42/#44's load-order notes), which a plain
|
||||||
|
# local const sidesteps entirely.
|
||||||
|
const CAPTURE_MARKER_COLOR: Color = Color(0.55, 0.85, 1.0)
|
||||||
|
const PALETTE_STEP: float = 92.0
|
||||||
|
|
||||||
|
@onready var camera: Camera2D = $Camera2D
|
||||||
|
@onready var background: ColorRect = $BackgroundLayer/Background
|
||||||
|
@onready var item_host: Node2D = $ItemHost
|
||||||
|
@onready var back_button: IconButton = $UI/BackButton
|
||||||
|
@onready var profile_button: IconButton = $UI/ProfileButton
|
||||||
|
@onready var profile_value_label: Label = $UI/ProfileValueLabel
|
||||||
|
@onready var name_input: LineEdit = $UI/NameInput
|
||||||
|
@onready var new_button: IconButton = $UI/NewButton
|
||||||
|
@onready var save_button: IconButton = $UI/SaveButton
|
||||||
|
@onready var load_button: IconButton = $UI/LoadButton
|
||||||
|
@onready var delete_button: IconButton = $UI/DeleteButton
|
||||||
|
@onready var status_label: Label = $UI/StatusLabel
|
||||||
|
@onready var palette_row: Control = $UI/PaletteRow
|
||||||
|
|
||||||
|
var current_setup: Resource
|
||||||
|
var selected_palette: String = "capture_point"
|
||||||
|
var selected_item: Dictionary = {}
|
||||||
|
var _profile_ids: Array = []
|
||||||
|
var _profile_cursor: int = 0
|
||||||
|
var _load_cursor: int = -1
|
||||||
|
var _palette_buttons: Array = []
|
||||||
|
var _origin_marker: NeonPiece
|
||||||
|
|
||||||
|
var touches: Dictionary = {}
|
||||||
|
var pinch_active: bool = false
|
||||||
|
var pinch_start_distance: float = 0.0
|
||||||
|
var pinch_start_zoom: float = 1.0
|
||||||
|
var drag_touch_index: int = -1
|
||||||
|
var drag_mode: String = ""
|
||||||
|
var dragging_item: Dictionary = {}
|
||||||
|
var pan_start_screen: Vector2 = Vector2.ZERO
|
||||||
|
var pan_start_camera: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_profile_ids = PlanetSurfaceProfiles.PROFILES.keys()
|
||||||
|
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://MainMenu.tscn"))
|
||||||
|
profile_button.pressed.connect(_on_profile_pressed)
|
||||||
|
new_button.pressed.connect(_on_new_pressed)
|
||||||
|
save_button.pressed.connect(_on_save_pressed)
|
||||||
|
load_button.pressed.connect(_on_load_pressed)
|
||||||
|
delete_button.pressed.connect(_on_delete_pressed)
|
||||||
|
name_input.text_changed.connect(_on_name_changed)
|
||||||
|
|
||||||
|
_origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR)
|
||||||
|
var cross_size: float = 26.0
|
||||||
|
_origin_marker.add_line(Vector2(-cross_size, 0), Vector2(cross_size, 0), 0.6)
|
||||||
|
_origin_marker.add_line(Vector2(0, -cross_size), Vector2(0, cross_size), 0.6)
|
||||||
|
_origin_marker.queue_redraw()
|
||||||
|
|
||||||
|
_build_palette()
|
||||||
|
new_setup("untitled", "Untitled", _profile_ids[0])
|
||||||
|
|
||||||
|
|
||||||
|
# --- model: setup lifecycle -------------------------------------------------
|
||||||
|
|
||||||
|
func new_setup(setup_id: String, display_name: String, profile_id: String) -> void:
|
||||||
|
current_setup = PLANET_SETUP_SCRIPT.new()
|
||||||
|
current_setup.setup_id = setup_id
|
||||||
|
current_setup.display_name = display_name
|
||||||
|
current_setup.surface_profile_id = profile_id
|
||||||
|
# capture_points/enemy_spawns are left at PlanetSetup's own declared
|
||||||
|
# defaults (already empty) rather than reassigned here - assigning a
|
||||||
|
# plain `[]` to enemy_spawns from outside PlanetSetup.gd's own file
|
||||||
|
# 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 = {}
|
||||||
|
_profile_cursor = maxi(_profile_ids.find(profile_id), 0)
|
||||||
|
if name_input != null:
|
||||||
|
name_input.text = display_name
|
||||||
|
_refresh_background()
|
||||||
|
_rebuild_markers()
|
||||||
|
_set_status("")
|
||||||
|
|
||||||
|
|
||||||
|
func load_setup(setup_id: String) -> bool:
|
||||||
|
for setup in PlanetSetups.all():
|
||||||
|
if setup.setup_id == setup_id:
|
||||||
|
# Edit a duplicate so in-progress edits never mutate the live
|
||||||
|
# registry entry (which other code may be reading) until an
|
||||||
|
# explicit save.
|
||||||
|
current_setup = setup.duplicate(true)
|
||||||
|
selected_item = {}
|
||||||
|
_profile_cursor = maxi(_profile_ids.find(current_setup.surface_profile_id), 0)
|
||||||
|
if name_input != null:
|
||||||
|
name_input.text = current_setup.display_name
|
||||||
|
_refresh_background()
|
||||||
|
_rebuild_markers()
|
||||||
|
_set_status("Loaded '%s'." % setup_id)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func save_setup() -> Dictionary:
|
||||||
|
if current_setup == null:
|
||||||
|
return {"ok": false, "message": "No setup to save."}
|
||||||
|
if current_setup.setup_id.strip_edges() == "":
|
||||||
|
return {"ok": false, "message": "Name the setup before saving."}
|
||||||
|
if not OS.has_feature("editor"):
|
||||||
|
return {"ok": false, "message": "Setups can only be saved when running from the project."}
|
||||||
|
var path: String = "res://PlanetSetups/%s.tres" % current_setup.setup_id
|
||||||
|
var err: int = ResourceSaver.save(current_setup, path)
|
||||||
|
if err != OK:
|
||||||
|
return {"ok": false, "message": "Save failed (error %d)." % err}
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
return {"ok": true, "message": "Saved '%s'." % current_setup.setup_id}
|
||||||
|
|
||||||
|
|
||||||
|
# --- model: capture points ---------------------------------------------
|
||||||
|
|
||||||
|
func add_capture_point(at: Vector2) -> int:
|
||||||
|
if current_setup == null:
|
||||||
|
return -1
|
||||||
|
var points: PackedVector2Array = current_setup.capture_points
|
||||||
|
points.append(at)
|
||||||
|
current_setup.capture_points = points
|
||||||
|
_rebuild_markers()
|
||||||
|
return points.size() - 1
|
||||||
|
|
||||||
|
|
||||||
|
func move_capture_point(index: int, at: Vector2) -> void:
|
||||||
|
if current_setup == null or index < 0 or index >= current_setup.capture_points.size():
|
||||||
|
return
|
||||||
|
current_setup.capture_points[index] = at
|
||||||
|
_rebuild_markers()
|
||||||
|
|
||||||
|
|
||||||
|
func remove_capture_point(index: int) -> void:
|
||||||
|
if current_setup == null or index < 0 or index >= current_setup.capture_points.size():
|
||||||
|
return
|
||||||
|
var points: PackedVector2Array = current_setup.capture_points
|
||||||
|
points.remove_at(index)
|
||||||
|
current_setup.capture_points = points
|
||||||
|
_rebuild_markers()
|
||||||
|
|
||||||
|
|
||||||
|
# --- model: enemy spawns ------------------------------------------------
|
||||||
|
|
||||||
|
func add_enemy(enemy_id: String, at: Vector2) -> int:
|
||||||
|
if current_setup == null:
|
||||||
|
return -1
|
||||||
|
var spawn: Resource = PLANET_SETUP_SPAWN_SCRIPT.new()
|
||||||
|
spawn.enemy_id = enemy_id
|
||||||
|
spawn.position = at
|
||||||
|
current_setup.enemy_spawns.append(spawn)
|
||||||
|
_rebuild_markers()
|
||||||
|
return current_setup.enemy_spawns.size() - 1
|
||||||
|
|
||||||
|
|
||||||
|
func move_enemy(index: int, at: Vector2) -> void:
|
||||||
|
if current_setup == null or index < 0 or index >= current_setup.enemy_spawns.size():
|
||||||
|
return
|
||||||
|
current_setup.enemy_spawns[index].position = at
|
||||||
|
_rebuild_markers()
|
||||||
|
|
||||||
|
|
||||||
|
func remove_enemy(index: int) -> void:
|
||||||
|
if current_setup == null or index < 0 or index >= current_setup.enemy_spawns.size():
|
||||||
|
return
|
||||||
|
current_setup.enemy_spawns.remove_at(index)
|
||||||
|
_rebuild_markers()
|
||||||
|
|
||||||
|
|
||||||
|
# --- background/markers --------------------------------------------------
|
||||||
|
|
||||||
|
func _refresh_background() -> void:
|
||||||
|
if current_setup == null or background == null:
|
||||||
|
return
|
||||||
|
var profile: Resource = PlanetSurfaceProfiles.get_profile(current_setup.surface_profile_id)
|
||||||
|
var material: ShaderMaterial = background.material
|
||||||
|
material.set_shader_parameter("base_color", profile.base_color)
|
||||||
|
material.set_shader_parameter("crater_color", profile.crater_color)
|
||||||
|
material.set_shader_parameter("highlight_color", profile.highlight_color)
|
||||||
|
if profile_value_label != null:
|
||||||
|
profile_value_label.text = current_setup.surface_profile_id.to_upper()
|
||||||
|
|
||||||
|
|
||||||
|
func _rebuild_markers() -> void:
|
||||||
|
if item_host == null:
|
||||||
|
return
|
||||||
|
for child in item_host.get_children():
|
||||||
|
if child != _origin_marker:
|
||||||
|
child.queue_free()
|
||||||
|
if current_setup == null:
|
||||||
|
return
|
||||||
|
for i in range(current_setup.capture_points.size()):
|
||||||
|
_add_capture_marker(i, current_setup.capture_points[i])
|
||||||
|
for i in range(current_setup.enemy_spawns.size()):
|
||||||
|
_add_enemy_marker(i, current_setup.enemy_spawns[i])
|
||||||
|
|
||||||
|
|
||||||
|
func _add_capture_marker(index: int, at: Vector2) -> void:
|
||||||
|
var is_selected: bool = selected_item.get("kind", "") == "capture" and selected_item.get("index", -1) == index
|
||||||
|
var color: Color = Color(1.0, 0.95, 0.9) if is_selected else CAPTURE_MARKER_COLOR
|
||||||
|
var marker: NeonPiece = NeonPiece.create(item_host, at, color)
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for i in range(16):
|
||||||
|
var angle: float = float(i) / 16.0 * TAU
|
||||||
|
points.append(Vector2(cos(angle), sin(angle)) * 60.0)
|
||||||
|
marker.add_polygon(points, 0.6)
|
||||||
|
marker.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _add_enemy_marker(index: int, spawn: Resource) -> void:
|
||||||
|
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
|
||||||
|
var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index
|
||||||
|
var color: Color = Color(1.0, 0.95, 0.9) if is_selected else definition.glow_color
|
||||||
|
var marker: NeonPiece = NeonPiece.create(item_host, spawn.position, color)
|
||||||
|
marker.add_polygon(definition.hull_points, 0.9)
|
||||||
|
marker.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
# --- palette ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func _build_palette() -> void:
|
||||||
|
var entries: Array = [{"id": "capture_point", "is_point": true, "definition": null}]
|
||||||
|
for definition in ENEMY_DEFINITIONS.all():
|
||||||
|
entries.append({"id": definition.enemy_id, "is_point": false, "definition": definition})
|
||||||
|
|
||||||
|
var x: float = 0.0
|
||||||
|
for entry in entries:
|
||||||
|
var button: Control = PALETTE_ENTRY_BUTTON_SCRIPT.new()
|
||||||
|
button.position = Vector2(x, 0)
|
||||||
|
button.size = Vector2(84, 84)
|
||||||
|
button.is_capture_point = entry.is_point
|
||||||
|
button.enemy_definition = entry.definition
|
||||||
|
button.selected = entry.id == selected_palette
|
||||||
|
button.pressed.connect(_on_palette_pressed.bind(entry.id))
|
||||||
|
palette_row.add_child(button)
|
||||||
|
_palette_buttons.append(button)
|
||||||
|
x += PALETTE_STEP
|
||||||
|
|
||||||
|
|
||||||
|
func _on_palette_pressed(id: String) -> void:
|
||||||
|
selected_palette = id
|
||||||
|
_refresh_palette_selection()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_palette_selection() -> void:
|
||||||
|
for button in _palette_buttons:
|
||||||
|
var entry_id: String = "capture_point" if button.is_capture_point else button.enemy_definition.enemy_id
|
||||||
|
button.selected = entry_id == selected_palette
|
||||||
|
|
||||||
|
|
||||||
|
# --- UI handlers -------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_profile_pressed() -> void:
|
||||||
|
if _profile_ids.is_empty():
|
||||||
|
return
|
||||||
|
_profile_cursor = (_profile_cursor + 1) % _profile_ids.size()
|
||||||
|
current_setup.surface_profile_id = _profile_ids[_profile_cursor]
|
||||||
|
_refresh_background()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_new_pressed() -> void:
|
||||||
|
new_setup("untitled", "Untitled", _profile_ids[_profile_cursor])
|
||||||
|
|
||||||
|
|
||||||
|
func _on_save_pressed() -> void:
|
||||||
|
var result: Dictionary = save_setup()
|
||||||
|
_set_status(result.message)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_load_pressed() -> void:
|
||||||
|
var setups: Array = PlanetSetups.all()
|
||||||
|
if setups.is_empty():
|
||||||
|
_set_status("No saved setups yet.")
|
||||||
|
return
|
||||||
|
_load_cursor = (_load_cursor + 1) % setups.size()
|
||||||
|
load_setup(setups[_load_cursor].setup_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_delete_pressed() -> void:
|
||||||
|
if selected_item.is_empty():
|
||||||
|
_set_status("Nothing selected to delete.")
|
||||||
|
return
|
||||||
|
if selected_item.kind == "capture":
|
||||||
|
remove_capture_point(selected_item.index)
|
||||||
|
else:
|
||||||
|
remove_enemy(selected_item.index)
|
||||||
|
selected_item = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _on_name_changed(new_text: String) -> void:
|
||||||
|
if current_setup == null:
|
||||||
|
return
|
||||||
|
current_setup.display_name = new_text
|
||||||
|
current_setup.setup_id = _sanitize_id(new_text)
|
||||||
|
|
||||||
|
|
||||||
|
func _sanitize_id(text: String) -> String:
|
||||||
|
var lowered: String = text.strip_edges().to_lower()
|
||||||
|
var result: String = ""
|
||||||
|
for c in lowered:
|
||||||
|
result += c if (c.to_lower() != c.to_upper() or c == "_" or (c >= "0" and c <= "9")) else "_"
|
||||||
|
return result if result != "" else "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
func _set_status(message: String) -> void:
|
||||||
|
if status_label != null:
|
||||||
|
status_label.text = message
|
||||||
|
|
||||||
|
|
||||||
|
# --- placement/selection helpers ----------------------------------------
|
||||||
|
|
||||||
|
func _place_selected(world_pos: Vector2) -> void:
|
||||||
|
if selected_palette == "capture_point":
|
||||||
|
add_capture_point(world_pos)
|
||||||
|
else:
|
||||||
|
add_enemy(selected_palette, world_pos)
|
||||||
|
|
||||||
|
|
||||||
|
func _find_marker_near(world_pos: Vector2) -> Dictionary:
|
||||||
|
if current_setup == null:
|
||||||
|
return {}
|
||||||
|
var best: Dictionary = {}
|
||||||
|
var best_dist: float = HIT_RADIUS
|
||||||
|
for i in range(current_setup.capture_points.size()):
|
||||||
|
var d: float = world_pos.distance_to(current_setup.capture_points[i])
|
||||||
|
if d <= best_dist:
|
||||||
|
best_dist = d
|
||||||
|
best = {"kind": "capture", "index": i}
|
||||||
|
for i in range(current_setup.enemy_spawns.size()):
|
||||||
|
var d: float = world_pos.distance_to(current_setup.enemy_spawns[i].position)
|
||||||
|
if d <= best_dist:
|
||||||
|
best_dist = d
|
||||||
|
best = {"kind": "enemy", "index": i}
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
func _move_selected(world_pos: Vector2) -> void:
|
||||||
|
if dragging_item.is_empty():
|
||||||
|
return
|
||||||
|
if dragging_item.kind == "capture":
|
||||||
|
move_capture_point(dragging_item.index, world_pos)
|
||||||
|
else:
|
||||||
|
move_enemy(dragging_item.index, world_pos)
|
||||||
|
|
||||||
|
|
||||||
|
func _screen_to_world(screen_pos: Vector2) -> Vector2:
|
||||||
|
var viewport_size: Vector2 = get_viewport_rect().size
|
||||||
|
return camera.position + (screen_pos - viewport_size / 2.0) / camera.zoom.x
|
||||||
|
|
||||||
|
|
||||||
|
# --- touch/pan/zoom (mirrors GameController's pinch idiom) ---------------
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventScreenTouch:
|
||||||
|
_handle_touch(event)
|
||||||
|
elif event is InputEventScreenDrag:
|
||||||
|
_handle_drag(event)
|
||||||
|
elif event is InputEventMagnifyGesture:
|
||||||
|
_set_zoom(camera.zoom.x * event.factor)
|
||||||
|
elif event is InputEventMouseButton and event.pressed:
|
||||||
|
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||||
|
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)
|
||||||
|
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||||
|
_set_zoom(camera.zoom.x / WHEEL_ZOOM_STEP)
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_touch(event: InputEventScreenTouch) -> void:
|
||||||
|
if event.pressed:
|
||||||
|
touches[event.index] = event.position
|
||||||
|
if touches.size() == 2:
|
||||||
|
_start_pinch()
|
||||||
|
drag_touch_index = -1
|
||||||
|
drag_mode = ""
|
||||||
|
return
|
||||||
|
if touches.size() == 1:
|
||||||
|
var world_pos: Vector2 = _screen_to_world(event.position)
|
||||||
|
var hit: Dictionary = _find_marker_near(world_pos)
|
||||||
|
drag_touch_index = event.index
|
||||||
|
if not hit.is_empty():
|
||||||
|
selected_item = hit
|
||||||
|
dragging_item = hit
|
||||||
|
drag_mode = "item"
|
||||||
|
_rebuild_markers()
|
||||||
|
elif selected_palette != "":
|
||||||
|
_place_selected(world_pos)
|
||||||
|
drag_mode = ""
|
||||||
|
else:
|
||||||
|
drag_mode = "pan"
|
||||||
|
pan_start_screen = event.position
|
||||||
|
pan_start_camera = camera.position
|
||||||
|
else:
|
||||||
|
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:
|
||||||
|
if not touches.has(event.index):
|
||||||
|
return
|
||||||
|
touches[event.index] = event.position
|
||||||
|
|
||||||
|
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
|
||||||
|
var positions: Array = touches.values()
|
||||||
|
var current_distance: float = positions[0].distance_to(positions[1])
|
||||||
|
_set_zoom(pinch_start_zoom * (current_distance / maxf(pinch_start_distance, 1.0)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.index != drag_touch_index:
|
||||||
|
return
|
||||||
|
match drag_mode:
|
||||||
|
"item":
|
||||||
|
_move_selected(_screen_to_world(event.position))
|
||||||
|
"pan":
|
||||||
|
var delta_screen: Vector2 = event.position - pan_start_screen
|
||||||
|
camera.position = pan_start_camera - delta_screen / camera.zoom.x
|
||||||
|
|
||||||
|
|
||||||
|
func _start_pinch() -> void:
|
||||||
|
pinch_active = true
|
||||||
|
var positions: Array = touches.values()
|
||||||
|
pinch_start_distance = positions[0].distance_to(positions[1])
|
||||||
|
pinch_start_zoom = camera.zoom.x
|
||||||
|
|
||||||
|
|
||||||
|
func _set_zoom(raw_zoom: float) -> void:
|
||||||
|
var z: float = clampf(raw_zoom, MIN_ZOOM, MAX_ZOOM)
|
||||||
|
camera.zoom = Vector2(z, z)
|
||||||
1
PlanetSetupEditor.gd.uid
Normal file
1
PlanetSetupEditor.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bprbw56kcmdsk
|
||||||
179
PlanetSetupEditor.tscn
Normal file
179
PlanetSetupEditor.tscn
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
[gd_scene load_steps=5 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetupEditor.gd" id="1"]
|
||||||
|
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
|
||||||
|
[ext_resource type="Shader" path="res://lunar_surface.gdshader" id="3"]
|
||||||
|
[ext_resource type="Script" path="res://IconButton.gd" id="4"]
|
||||||
|
|
||||||
|
[sub_resource type="ShaderMaterial" id="1"]
|
||||||
|
shader = ExtResource("3")
|
||||||
|
shader_parameter/camera_zoom = 1.0
|
||||||
|
shader_parameter/viewport_size = Vector2(720, 1280)
|
||||||
|
shader_parameter/cell_size = 220.0
|
||||||
|
shader_parameter/base_color = Color(0.75, 0.75, 0.76, 1)
|
||||||
|
shader_parameter/crater_color = Color(0.44, 0.44, 0.45, 1)
|
||||||
|
shader_parameter/highlight_color = Color(0.89, 0.89, 0.9, 1)
|
||||||
|
|
||||||
|
[node name="PlanetSetupEditor" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
|
||||||
|
layer = -10
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="BackgroundLayer"]
|
||||||
|
material = SubResource("1")
|
||||||
|
mouse_filter = 2
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("2")
|
||||||
|
|
||||||
|
[node name="Camera2D" type="Camera2D" parent="."]
|
||||||
|
position = Vector2(0, 0)
|
||||||
|
zoom = Vector2(1, 1)
|
||||||
|
|
||||||
|
[node name="ItemHost" type="Node2D" parent="."]
|
||||||
|
|
||||||
|
[node name="UI" type="CanvasLayer" parent="."]
|
||||||
|
layer = 1
|
||||||
|
|
||||||
|
[node name="BackButton" type="Control" parent="UI"]
|
||||||
|
offset_left = 20
|
||||||
|
offset_top = 20
|
||||||
|
offset_right = 92
|
||||||
|
offset_bottom = 92
|
||||||
|
script = ExtResource("4")
|
||||||
|
icon = 1
|
||||||
|
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||||
|
|
||||||
|
[node name="BackCaption" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 20
|
||||||
|
offset_top = 96
|
||||||
|
offset_right = 92
|
||||||
|
offset_bottom = 122
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Back"
|
||||||
|
|
||||||
|
[node name="ProfileButton" type="Control" parent="UI"]
|
||||||
|
offset_left = 110
|
||||||
|
offset_top = 20
|
||||||
|
offset_right = 182
|
||||||
|
offset_bottom = 92
|
||||||
|
script = ExtResource("4")
|
||||||
|
icon = 0
|
||||||
|
glow_color = Color(0.5, 0.75, 1, 1)
|
||||||
|
|
||||||
|
[node name="ProfileValueLabel" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 190
|
||||||
|
offset_top = 36
|
||||||
|
offset_right = 340
|
||||||
|
offset_bottom = 76
|
||||||
|
theme_override_font_sizes/font_size = 26
|
||||||
|
text = "ROCKY"
|
||||||
|
|
||||||
|
[node name="NameInput" type="LineEdit" parent="UI"]
|
||||||
|
offset_left = 350
|
||||||
|
offset_top = 36
|
||||||
|
offset_right = 696
|
||||||
|
offset_bottom = 76
|
||||||
|
placeholder_text = "Setup name"
|
||||||
|
text = "Untitled"
|
||||||
|
|
||||||
|
[node name="NewButton" type="Control" parent="UI"]
|
||||||
|
offset_left = 20
|
||||||
|
offset_top = 140
|
||||||
|
offset_right = 92
|
||||||
|
offset_bottom = 212
|
||||||
|
script = ExtResource("4")
|
||||||
|
icon = 5
|
||||||
|
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||||
|
|
||||||
|
[node name="NewCaption" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 20
|
||||||
|
offset_top = 216
|
||||||
|
offset_right = 92
|
||||||
|
offset_bottom = 242
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "New"
|
||||||
|
|
||||||
|
[node name="SaveButton" type="Control" parent="UI"]
|
||||||
|
offset_left = 110
|
||||||
|
offset_top = 140
|
||||||
|
offset_right = 182
|
||||||
|
offset_bottom = 212
|
||||||
|
script = ExtResource("4")
|
||||||
|
icon = 4
|
||||||
|
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||||
|
|
||||||
|
[node name="SaveCaption" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 110
|
||||||
|
offset_top = 216
|
||||||
|
offset_right = 182
|
||||||
|
offset_bottom = 242
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Save"
|
||||||
|
|
||||||
|
[node name="LoadButton" type="Control" parent="UI"]
|
||||||
|
offset_left = 200
|
||||||
|
offset_top = 140
|
||||||
|
offset_right = 272
|
||||||
|
offset_bottom = 212
|
||||||
|
script = ExtResource("4")
|
||||||
|
icon = 3
|
||||||
|
glow_color = Color(0.5, 0.75, 1, 1)
|
||||||
|
|
||||||
|
[node name="LoadCaption" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 200
|
||||||
|
offset_top = 216
|
||||||
|
offset_right = 272
|
||||||
|
offset_bottom = 242
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Load"
|
||||||
|
|
||||||
|
[node name="DeleteButton" type="Control" parent="UI"]
|
||||||
|
offset_left = 290
|
||||||
|
offset_top = 140
|
||||||
|
offset_right = 362
|
||||||
|
offset_bottom = 212
|
||||||
|
script = ExtResource("4")
|
||||||
|
icon = 28
|
||||||
|
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||||
|
|
||||||
|
[node name="DeleteCaption" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 290
|
||||||
|
offset_top = 216
|
||||||
|
offset_right = 362
|
||||||
|
offset_bottom = 242
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Delete"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
offset_left = 380
|
||||||
|
offset_top = 140
|
||||||
|
offset_right = 696
|
||||||
|
offset_bottom = 242
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
autowrap_mode = 2
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
[node name="PaletteRow" type="Control" parent="UI"]
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 20
|
||||||
|
offset_top = -110
|
||||||
|
offset_right = 696
|
||||||
|
offset_bottom = -20
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
17
SniperEnemyDefinition.tres
Normal file
17
SniperEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyStandoffComponent.tscn" id="2"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyRangedWeaponComponent.tscn" id="3"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
display_name = "Sniper"
|
||||||
|
max_hp = 4
|
||||||
|
speed = 90.0
|
||||||
|
acceleration = 180.0
|
||||||
|
rotation_speed = 5.0
|
||||||
|
hull_points = PackedVector2Array(0, -28, 7, -6, 7, 22, -7, 22, -7, -6)
|
||||||
|
glow_color = Color(0.42, 0.72, 1.0, 1)
|
||||||
|
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
|
||||||
20
tests/_artillery_telegraph_gas_preview.gd
Normal file
20
tests/_artillery_telegraph_gas_preview.gd
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.planet_name = "Violet Tempest"
|
||||||
|
GameState.planet_id = "artillery_preview_gas"
|
||||||
|
GameState.planet_definition_id = "gas_giant"
|
||||||
|
GameState.surviving_ship_count = 3
|
||||||
|
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
surface.get_node("SurfaceController").fade_duration = 0.0
|
||||||
|
add_child(surface)
|
||||||
|
|
||||||
|
var shell: Node = load("res://ArtilleryShell.tscn").instantiate()
|
||||||
|
shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot
|
||||||
|
shell.aoe_radius = 220.0
|
||||||
|
surface.add_child(shell)
|
||||||
|
shell.launch(Vector2(-600, -600), Vector2.ZERO)
|
||||||
|
shell.telegraph_left = shell.telegraph_time * 0.45
|
||||||
|
shell.set_process(false)
|
||||||
|
shell.queue_redraw()
|
||||||
1
tests/_artillery_telegraph_gas_preview.gd.uid
Normal file
1
tests/_artillery_telegraph_gas_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://be13xddivhtr
|
||||||
6
tests/_artillery_telegraph_gas_preview.tscn
Normal file
6
tests/_artillery_telegraph_gas_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_artillery_telegraph_gas_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ArtilleryTelegraphGasPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
20
tests/_artillery_telegraph_rocky_preview.gd
Normal file
20
tests/_artillery_telegraph_rocky_preview.gd
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.planet_name = "Kepler's Rest"
|
||||||
|
GameState.planet_id = "artillery_preview_rocky"
|
||||||
|
GameState.planet_definition_id = "rocky"
|
||||||
|
GameState.surviving_ship_count = 3
|
||||||
|
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
surface.get_node("SurfaceController").fade_duration = 0.0
|
||||||
|
add_child(surface)
|
||||||
|
|
||||||
|
var shell: Node = load("res://ArtilleryShell.tscn").instantiate()
|
||||||
|
shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot
|
||||||
|
shell.aoe_radius = 220.0
|
||||||
|
surface.add_child(shell)
|
||||||
|
shell.launch(Vector2(-600, -600), Vector2.ZERO)
|
||||||
|
shell.telegraph_left = shell.telegraph_time * 0.45
|
||||||
|
shell.set_process(false)
|
||||||
|
shell.queue_redraw()
|
||||||
1
tests/_artillery_telegraph_rocky_preview.gd.uid
Normal file
1
tests/_artillery_telegraph_rocky_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dlaxr7it3582h
|
||||||
6
tests/_artillery_telegraph_rocky_preview.tscn
Normal file
6
tests/_artillery_telegraph_rocky_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_artillery_telegraph_rocky_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ArtilleryTelegraphRockyPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
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")
|
||||||
14
tests/_setup_editor_preview.gd
Normal file
14
tests/_setup_editor_preview.gd
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var editor: Node = load("res://PlanetSetupEditor.tscn").instantiate()
|
||||||
|
add_child(editor)
|
||||||
|
editor.new_setup("preview_setup", "Preview Setup", "rocky")
|
||||||
|
editor.add_capture_point(Vector2(300, 150))
|
||||||
|
editor.add_capture_point(Vector2(-250, 300))
|
||||||
|
editor.add_enemy("chaser", Vector2(200, -300))
|
||||||
|
editor.add_enemy("sniper", Vector2(-300, -150))
|
||||||
|
editor.add_enemy("artillery", Vector2(0, 350))
|
||||||
|
editor.selected_item = {"kind": "capture", "index": 0}
|
||||||
|
editor._rebuild_markers()
|
||||||
1
tests/_setup_editor_preview.gd.uid
Normal file
1
tests/_setup_editor_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dltm5ro7xcjii
|
||||||
6
tests/_setup_editor_preview.tscn
Normal file
6
tests/_setup_editor_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_setup_editor_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="SetupEditorPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
76
tests/_sniper_vs_chaser_preview.gd
Normal file
76
tests/_sniper_vs_chaser_preview.gd
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Setup is deferred to the first _process() tick rather than done in
|
||||||
|
# _ready(): PlanetAtmosphereController auto-spawns a 5-ship default fleet
|
||||||
|
# in ITS _ready() on any fresh (non-returning) Main.tscn load, and since
|
||||||
|
# this scene is itself added to the tree from inside _screenshot_tool.gd's
|
||||||
|
# own _initialize() (a nested, not-yet-settled context), there's no
|
||||||
|
# guaranteed ordering between the two _ready() calls - clearing the fleet
|
||||||
|
# here could run BEFORE that auto-spawn and then get silently overwritten
|
||||||
|
# by it. Waiting one frame guarantees it has already run first.
|
||||||
|
#
|
||||||
|
# Even after clearing, physics ticks can run several times before this
|
||||||
|
# script's own next _process() tick (softpipe rendering is slow, and
|
||||||
|
# physics is decoupled from it), so an auto-spawned ship can still get one
|
||||||
|
# shot off in flight toward these enemies before the clearing code above
|
||||||
|
# ever runs. Pausing the whole tree once everything is placed (last thing
|
||||||
|
# this script does) freezes any such shot in transit for good, rather than
|
||||||
|
# letting it arrive a few physics ticks later while the screenshot is
|
||||||
|
# still waiting out its frame count.
|
||||||
|
|
||||||
|
var main: Node
|
||||||
|
var setup_done: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.buildings.clear()
|
||||||
|
GameState.extractors.clear()
|
||||||
|
GameState.transporters.clear()
|
||||||
|
main = load("res://Main.tscn").instantiate()
|
||||||
|
add_child(main)
|
||||||
|
main.get_node("BackgroundLayer").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)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if setup_done:
|
||||||
|
return
|
||||||
|
setup_done = true
|
||||||
|
|
||||||
|
var fleet: Node = main.get_node("Fleet")
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if is_instance_valid(ship):
|
||||||
|
ship.set_physics_process(false)
|
||||||
|
fleet.spawn_ships(0, Vector2.ZERO)
|
||||||
|
# spawn_ships(0, ...) still isn't a truly empty fleet: it falls back to
|
||||||
|
# one default interceptor whenever the requested composition would
|
||||||
|
# otherwise be empty (Fleet.gd's own "the fleet is never really empty"
|
||||||
|
# rule). Clear that forced ship too, and halt it the same way.
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if is_instance_valid(ship):
|
||||||
|
ship.set_physics_process(false)
|
||||||
|
ship.queue_free()
|
||||||
|
fleet.ships.clear()
|
||||||
|
for projectile in get_tree().get_nodes_in_group("player_projectiles"):
|
||||||
|
projectile.queue_free()
|
||||||
|
|
||||||
|
var chaser: Node = load("res://Enemy.tscn").instantiate()
|
||||||
|
chaser.enemy_id = "chaser"
|
||||||
|
chaser.position = Vector2(-160, -80)
|
||||||
|
main.add_child(chaser)
|
||||||
|
chaser.speed = 0.0
|
||||||
|
|
||||||
|
var sniper: Node = load("res://Enemy.tscn").instantiate()
|
||||||
|
sniper.enemy_id = "sniper"
|
||||||
|
sniper.position = Vector2(160, -80)
|
||||||
|
main.add_child(sniper)
|
||||||
|
sniper.speed = 0.0
|
||||||
|
|
||||||
|
var camera: Camera2D = main.get_node("Camera2D")
|
||||||
|
camera.zoom = Vector2(1.4, 1.4)
|
||||||
|
|
||||||
|
get_tree().paused = true
|
||||||
1
tests/_sniper_vs_chaser_preview.gd.uid
Normal file
1
tests/_sniper_vs_chaser_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://d2fj1p61hp2pk
|
||||||
6
tests/_sniper_vs_chaser_preview.tscn
Normal file
6
tests/_sniper_vs_chaser_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_sniper_vs_chaser_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="SniperVsChaserPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
195
tests/test_artillery_enemy.gd
Normal file
195
tests/test_artillery_enemy.gd
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_artillery_enemy.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole
|
||||||
|
# window before any damage lands
|
||||||
|
# 2. a ship that leaves the blast radius before impact takes no damage;
|
||||||
|
# one that stays takes the full hit
|
||||||
|
# 3. a shielded ship inside the blast survives, losing exactly one charge
|
||||||
|
# 4. the impact point is fixed at fire time - moving the fleet after the
|
||||||
|
# shot is launched does not redirect it (tested through the real
|
||||||
|
# EnemyArtilleryComponent, since that's what could tempt "aim at the
|
||||||
|
# fleet's live position" instead of its position at fire time)
|
||||||
|
|
||||||
|
# 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. See test_capture_points.gd
|
||||||
|
# for the full explanation.
|
||||||
|
var ARTILLERY_SHELL_SCRIPT: Script
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var main: Node
|
||||||
|
var fleet: Node
|
||||||
|
var shell: Node
|
||||||
|
var ship_inside: Node
|
||||||
|
var ship_leaves: Node
|
||||||
|
var shield_ship: Node
|
||||||
|
var far_ship: Node
|
||||||
|
var artillery: Node
|
||||||
|
var art_component: Node
|
||||||
|
var live_shell: Node
|
||||||
|
var recorded_target: Vector2 = Vector2.ZERO
|
||||||
|
var impact_point: Vector2 = Vector2.ZERO
|
||||||
|
var travel_time: float = 0.0
|
||||||
|
var stage2_wait: float = 0.0
|
||||||
|
var stage5_wait: float = 0.0
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
ARTILLERY_SHELL_SCRIPT = load("res://ArtilleryShell.gd")
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.buildings.clear()
|
||||||
|
game_state.extractors.clear()
|
||||||
|
game_state.transporters.clear()
|
||||||
|
game_state.stored_vessels.clear()
|
||||||
|
game_state.build_time_scale = 0.02
|
||||||
|
game_state.reset_progression()
|
||||||
|
game_state.rocks = 2000
|
||||||
|
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()
|
||||||
|
|
||||||
|
impact_point = Vector2(5000, 0)
|
||||||
|
fleet.spawn_ships(1, impact_point)
|
||||||
|
ship_inside = fleet.ships[0]
|
||||||
|
ship_inside.position = impact_point
|
||||||
|
|
||||||
|
fleet.add_ship(GameState.VESSEL_INTERCEPTOR)
|
||||||
|
ship_leaves = fleet.ships[fleet.ships.size() - 1]
|
||||||
|
ship_leaves.position = impact_point + Vector2(100, 0)
|
||||||
|
|
||||||
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
||||||
|
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
||||||
|
shield_ship.position = impact_point + Vector2(-100, 0)
|
||||||
|
|
||||||
|
var origin: Vector2 = impact_point + Vector2(400, 400)
|
||||||
|
travel_time = origin.distance_to(impact_point) / 300.0
|
||||||
|
shell = load("res://ArtilleryShell.tscn").instantiate()
|
||||||
|
shell.telegraph_time = 1.0
|
||||||
|
shell.shell_speed = 300.0
|
||||||
|
shell.aoe_radius = 220.0
|
||||||
|
shell.damage = 3
|
||||||
|
main.add_child(shell)
|
||||||
|
shell.launch(origin, impact_point)
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# Mid-telegraph: nobody hurt yet, still visibly telegraphing.
|
||||||
|
if elapsed < shell.telegraph_time * 0.8:
|
||||||
|
return false
|
||||||
|
if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING:
|
||||||
|
return _fail("shell left the telegraph phase too early")
|
||||||
|
if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \
|
||||||
|
or not is_instance_valid(shield_ship):
|
||||||
|
return _fail("damage landed before the telegraph window finished")
|
||||||
|
|
||||||
|
# Ship B clears the blast radius now, well before impact.
|
||||||
|
ship_leaves.position = impact_point + Vector2(2000, 0)
|
||||||
|
# Captured once, as a plain float: the shell frees itself once
|
||||||
|
# it resolves, so re-reading shell.telegraph_time from inside
|
||||||
|
# the wait condition below (evaluated every frame) would touch
|
||||||
|
# a freed object on any frame after that.
|
||||||
|
stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
# Enough time for the remaining telegraph, travel, and the full
|
||||||
|
# explosion visual to resolve.
|
||||||
|
if elapsed < stage2_wait:
|
||||||
|
return false
|
||||||
|
if is_instance_valid(ship_inside):
|
||||||
|
return _fail("a ship that stayed inside the blast radius survived")
|
||||||
|
if not is_instance_valid(ship_leaves):
|
||||||
|
return _fail("a ship that left the blast radius before impact was still hit")
|
||||||
|
if not is_instance_valid(shield_ship):
|
||||||
|
return _fail("a shielded ship inside the blast was destroyed instead of absorbing the hit")
|
||||||
|
if shield_ship.shield_charges != 0:
|
||||||
|
return _fail("shield charges did not drop to 0 after absorbing the artillery hit (charges=%d)" %
|
||||||
|
shield_ship.shield_charges)
|
||||||
|
print("stayed=destroyed left=survived shielded=absorbed-one-charge")
|
||||||
|
|
||||||
|
# --- Part B: the impact point does not home onto a fleet that
|
||||||
|
# moves after the shot is fired ---------------------------------
|
||||||
|
fleet.spawn_ships(1, Vector2(9000, 0))
|
||||||
|
far_ship = fleet.ships[0]
|
||||||
|
artillery = load("res://Enemy.tscn").instantiate()
|
||||||
|
artillery.enemy_id = "artillery"
|
||||||
|
artillery.position = far_ship.position + Vector2(300, 0)
|
||||||
|
main.add_child(artillery)
|
||||||
|
art_component = artillery.get_component("artillery")
|
||||||
|
art_component.cooldown_left = 0.0
|
||||||
|
art_component.telegraph_time = 0.3
|
||||||
|
art_component.fire_cooldown = 999.0 # only the one shot for this check
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
if elapsed < 0.15:
|
||||||
|
return false
|
||||||
|
# The shell is added to the tree root (a sibling of main, not
|
||||||
|
# its child - see EnemyArtilleryComponent.tick()), so it has to
|
||||||
|
# be found via its group, not main.get_children().
|
||||||
|
var shells: Array = get_nodes_in_group("artillery_shells")
|
||||||
|
if shells.is_empty():
|
||||||
|
return _fail("artillery enemy did not fire when in range and off cooldown")
|
||||||
|
live_shell = shells[0]
|
||||||
|
recorded_target = live_shell.target
|
||||||
|
|
||||||
|
far_ship.position += Vector2(4000, 0) # flee well clear of the recorded target
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 4
|
||||||
|
return false
|
||||||
|
4:
|
||||||
|
# Check the target field WHILE the shell is still alive - it
|
||||||
|
# frees itself right after exploding, so this has to happen
|
||||||
|
# before that, not after.
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(live_shell):
|
||||||
|
return _fail("shell resolved implausibly fast - timing assumption broke")
|
||||||
|
if live_shell.target != recorded_target:
|
||||||
|
return _fail("shell target changed after the fleet moved - it homed instead of staying fixed")
|
||||||
|
# Captured once, as a plain float: far_ship's own weapon can
|
||||||
|
# destroy the artillery enemy (and with it art_component) well
|
||||||
|
# before this wait ends, so the condition below must not keep
|
||||||
|
# re-reading a property off a node that might already be freed.
|
||||||
|
stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 5
|
||||||
|
return false
|
||||||
|
5:
|
||||||
|
if elapsed < stage5_wait:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(far_ship):
|
||||||
|
return _fail("fleeing ship was hit by a shot aimed at its old position - homing occurred")
|
||||||
|
print("shell held its fire-time target and did not home onto the fleeing ship")
|
||||||
|
print("TEST_ARTILLERY_ENEMY: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_ARTILLERY_ENEMY: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_artillery_enemy.gd.uid
Normal file
1
tests/test_artillery_enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cv3lvd01j4kp
|
||||||
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
|
||||||
|
|
@ -3,8 +3,8 @@ extends SceneTree
|
||||||
# Headless gameplay test: run with
|
# Headless gameplay test: run with
|
||||||
# godot --headless -s res://tests/test_main_menu.gd
|
# godot --headless -s res://tests/test_main_menu.gd
|
||||||
# Verifies:
|
# Verifies:
|
||||||
# 1. the menu loads with Play/Map Editor/Quit, the editor button starts
|
# 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and
|
||||||
# disabled and explains itself instead of silently doing nothing
|
# reaches PlanetSetupEditor.tscn (#48)
|
||||||
# 2. Quit's visibility follows OS.has_feature("web") (this platform is
|
# 2. Quit's visibility follows OS.has_feature("web") (this platform is
|
||||||
# not a web export, so this run exercises the "visible" branch; the
|
# not a web export, so this run exercises the "visible" branch; the
|
||||||
# condition itself is what's under test, not the concrete boolean)
|
# condition itself is what's under test, not the concrete boolean)
|
||||||
|
|
@ -33,8 +33,8 @@ func _process(delta: float) -> bool:
|
||||||
# frame has actually processed.
|
# frame has actually processed.
|
||||||
if elapsed < 0.1:
|
if elapsed < 0.1:
|
||||||
return false
|
return false
|
||||||
if menu.editor_button.enabled:
|
if not menu.editor_button.enabled:
|
||||||
return _fail("map editor button is not disabled ahead of #48")
|
return _fail("map editor button is not enabled (#48 shipped)")
|
||||||
if menu.quit_button.visible != (not OS.has_feature("web")):
|
if menu.quit_button.visible != (not OS.has_feature("web")):
|
||||||
return _fail("quit button visibility did not follow OS.has_feature('web')")
|
return _fail("quit button visibility did not follow OS.has_feature('web')")
|
||||||
|
|
||||||
|
|
@ -43,13 +43,22 @@ func _process(delta: float) -> bool:
|
||||||
stage = 1
|
stage = 1
|
||||||
return false
|
return false
|
||||||
1:
|
1:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor"
|
||||||
|
if not on_editor:
|
||||||
|
return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn")
|
||||||
|
|
||||||
|
# Return to the menu the same way the player would, then test Play.
|
||||||
|
menu = load("res://MainMenu.tscn").instantiate()
|
||||||
|
root.add_child(menu)
|
||||||
|
current_scene = menu
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
if elapsed < 0.1:
|
if elapsed < 0.1:
|
||||||
return false
|
return false
|
||||||
if "coming soon" not in menu.status_label.text.to_lower():
|
|
||||||
return _fail("disabled editor button press did not explain itself")
|
|
||||||
if current_scene != menu:
|
|
||||||
return _fail("pressing the disabled editor button changed scenes")
|
|
||||||
|
|
||||||
# Dirty the game state exactly like an in-progress run would,
|
# Dirty the game state exactly like an in-progress run would,
|
||||||
# so a genuine reset is distinguishable from a coincidental match.
|
# so a genuine reset is distinguishable from a coincidental match.
|
||||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999)
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999)
|
||||||
|
|
@ -59,9 +68,9 @@ func _process(delta: float) -> bool:
|
||||||
|
|
||||||
_press(menu.play_button)
|
_press(menu.play_button)
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
stage = 2
|
stage = 3
|
||||||
return false
|
return false
|
||||||
2:
|
3:
|
||||||
if elapsed < 0.3:
|
if elapsed < 0.3:
|
||||||
return false
|
return false
|
||||||
var on_main: bool = current_scene != null and current_scene.name == "Main"
|
var on_main: bool = current_scene != null and current_scene.name == "Main"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
136
tests/test_setup_editor.gd
Normal file
136
tests/test_setup_editor.gd
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_setup_editor.gd
|
||||||
|
# Drives PlanetSetupEditor's model directly (place/move/delete/save/reload)
|
||||||
|
# rather than through touch input - see the ticket's own note that the
|
||||||
|
# touch layer can stay out of the headless test. Verifies:
|
||||||
|
# 1. placing capture points and enemies updates current_setup
|
||||||
|
# 2. moving and deleting update it correctly, including index shifting
|
||||||
|
# 3. saving writes a real res://PlanetSetups/<id>.tres and the registry
|
||||||
|
# picks it up
|
||||||
|
# 4. reopening that file in a fresh editor instance round-trips the
|
||||||
|
# exact points/enemies, and they can be moved/deleted again
|
||||||
|
# 5. the palette has one entry per EnemyDefinitions entry plus the
|
||||||
|
# capture point, generated from the registry rather than hardcoded
|
||||||
|
|
||||||
|
const TEST_SETUP_ID: String = "test_editor_setup"
|
||||||
|
const TEST_SETUP_PATH: String = "res://PlanetSetups/test_editor_setup.tres"
|
||||||
|
|
||||||
|
var editor: Node
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_cleanup_scratch_file()
|
||||||
|
editor = load("res://PlanetSetupEditor.tscn").instantiate()
|
||||||
|
root.add_child(editor)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
# _ready() (palette build, initial new_setup) needs a frame to
|
||||||
|
# flush - same hazard as test_planet_setups.gd.
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
|
||||||
|
var expected_palette_size: int = 1 + EnemyDefinitions.all().size()
|
||||||
|
if editor._palette_buttons.size() != expected_palette_size:
|
||||||
|
return _fail("palette has %d entries, expected 1 + %d enemy definitions" % [
|
||||||
|
editor._palette_buttons.size(), EnemyDefinitions.all().size()
|
||||||
|
])
|
||||||
|
|
||||||
|
editor.new_setup(TEST_SETUP_ID, "Test Editor Setup", "rocky")
|
||||||
|
editor.add_capture_point(Vector2(100, 200))
|
||||||
|
editor.add_capture_point(Vector2(-300, 50))
|
||||||
|
editor.add_capture_point(Vector2(0, -400))
|
||||||
|
editor.add_enemy("chaser", Vector2(500, 500))
|
||||||
|
editor.add_enemy("sniper", Vector2(-500, -500))
|
||||||
|
|
||||||
|
if editor.current_setup.capture_points.size() != 3:
|
||||||
|
return _fail("placing 3 capture points did not land 3")
|
||||||
|
if editor.current_setup.enemy_spawns.size() != 2:
|
||||||
|
return _fail("placing 2 enemies did not land 2")
|
||||||
|
|
||||||
|
editor.move_capture_point(0, Vector2(150, 250))
|
||||||
|
if editor.current_setup.capture_points[0] != Vector2(150, 250):
|
||||||
|
return _fail("move_capture_point did not update the position")
|
||||||
|
|
||||||
|
editor.remove_enemy(0) # removes the chaser spawn
|
||||||
|
if editor.current_setup.enemy_spawns.size() != 1:
|
||||||
|
return _fail("remove_enemy did not shrink enemy_spawns")
|
||||||
|
if editor.current_setup.enemy_spawns[0].enemy_id != "sniper":
|
||||||
|
return _fail("remove_enemy removed the wrong entry (index did not shift)")
|
||||||
|
|
||||||
|
var result: Dictionary = editor.save_setup()
|
||||||
|
if not result.ok:
|
||||||
|
return _fail("save_setup reported failure: %s" % result.message)
|
||||||
|
if not FileAccess.file_exists(TEST_SETUP_PATH):
|
||||||
|
return _fail("save_setup reported success but wrote no file at %s" % TEST_SETUP_PATH)
|
||||||
|
|
||||||
|
var found_in_registry: bool = false
|
||||||
|
for setup in PlanetSetups.all():
|
||||||
|
if setup.setup_id == TEST_SETUP_ID:
|
||||||
|
found_in_registry = true
|
||||||
|
if not found_in_registry:
|
||||||
|
return _fail("saved setup was not picked up by PlanetSetups after save")
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
# Reopen in a FRESH editor instance, as a re-landing/re-editing
|
||||||
|
# session would.
|
||||||
|
var reopened: Node = load("res://PlanetSetupEditor.tscn").instantiate()
|
||||||
|
root.add_child(reopened)
|
||||||
|
editor = reopened
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
if not editor.load_setup(TEST_SETUP_ID):
|
||||||
|
return _fail("reopening the saved setup failed")
|
||||||
|
var points: PackedVector2Array = editor.current_setup.capture_points
|
||||||
|
var spawns: Array = editor.current_setup.enemy_spawns
|
||||||
|
if points.size() != 3 or points[0] != Vector2(150, 250) \
|
||||||
|
or points[1] != Vector2(-300, 50) or points[2] != Vector2(0, -400):
|
||||||
|
return _fail("reopened capture points did not match what was saved: %s" % [points])
|
||||||
|
if spawns.size() != 1 or spawns[0].enemy_id != "sniper" or spawns[0].position != Vector2(-500, -500):
|
||||||
|
return _fail("reopened enemy spawns did not match what was saved")
|
||||||
|
|
||||||
|
# The reopened items can still be moved and deleted.
|
||||||
|
editor.move_enemy(0, Vector2(-600, -600))
|
||||||
|
if editor.current_setup.enemy_spawns[0].position != Vector2(-600, -600):
|
||||||
|
return _fail("could not move an item after reopening")
|
||||||
|
editor.remove_capture_point(1)
|
||||||
|
if editor.current_setup.capture_points.size() != 2:
|
||||||
|
return _fail("could not delete an item after reopening")
|
||||||
|
|
||||||
|
print("TEST_SETUP_EDITOR: PASS")
|
||||||
|
_cleanup_scratch_file()
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _cleanup_scratch_file() -> void:
|
||||||
|
if FileAccess.file_exists(TEST_SETUP_PATH):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_SETUP_PATH))
|
||||||
|
var import_file: String = TEST_SETUP_PATH + ".import"
|
||||||
|
if FileAccess.file_exists(import_file):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(import_file))
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_SETUP_EDITOR: FAIL (%s)" % message)
|
||||||
|
_cleanup_scratch_file()
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_setup_editor.gd.uid
Normal file
1
tests/test_setup_editor.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cegx70j0hkj4m
|
||||||
226
tests/test_sniper_enemy.gd
Normal file
226
tests/test_sniper_enemy.gd
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_sniper_enemy.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. against a stationary fleet, a sniper closes to roughly its standoff
|
||||||
|
# component's preferred_range and never reaches contact range
|
||||||
|
# 2. its ranged weapon only fires within fire_range, and respects cooldown
|
||||||
|
# 3. the projectile is slow enough that a ship moving away right after the
|
||||||
|
# shot clears the impact point unharmed
|
||||||
|
# 4. a hit (ship held in place) destroys an unshielded ship and is
|
||||||
|
# absorbed by a shielded one, losing exactly one charge
|
||||||
|
#
|
||||||
|
# Movement (1) and combat (2-4) use SEPARATE sniper instances: a stationary
|
||||||
|
# ship left in weapon range for many seconds would otherwise take stray
|
||||||
|
# hits during the movement check and die before the later stages run, which
|
||||||
|
# is a test-harness artifact, not anything about the sniper's own design.
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var main: Node
|
||||||
|
var fleet: Node
|
||||||
|
var mover: Node # movement-only: its ranged_weapon is removed
|
||||||
|
var sniper: Node # combat-only: pinned in place via speed = 0
|
||||||
|
var target_ship: Node
|
||||||
|
var interceptor: Node
|
||||||
|
var shield_ship: Node
|
||||||
|
var min_distance: float = INF
|
||||||
|
var cooldown_watermark: float = 0.0
|
||||||
|
var projectiles_before: int = 0
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var settle_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.stored_vessels.clear()
|
||||||
|
game_state.build_time_scale = 0.02
|
||||||
|
game_state.reset_progression()
|
||||||
|
game_state.rocks = 2000
|
||||||
|
main = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
fleet = main.get_node("Fleet")
|
||||||
|
|
||||||
|
|
||||||
|
func _projectile_count() -> int:
|
||||||
|
return get_nodes_in_group("enemy_projectiles").size()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
fleet.spawn_ships(1, Vector2.ZERO)
|
||||||
|
target_ship = fleet.ships[0]
|
||||||
|
|
||||||
|
mover = load("res://Enemy.tscn").instantiate()
|
||||||
|
mover.enemy_id = "sniper"
|
||||||
|
mover.position = target_ship.position + Vector2(1000, 0)
|
||||||
|
main.add_child(mover)
|
||||||
|
mover.get_component("ranged_weapon").queue_free()
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
settle_elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# --- standoff: close in, never reach contact, settle near
|
||||||
|
# preferred_range -----------------------------------------
|
||||||
|
settle_elapsed += delta
|
||||||
|
min_distance = minf(min_distance, mover.position.distance_to(target_ship.position))
|
||||||
|
if min_distance < 100.0:
|
||||||
|
return _fail("sniper reached near-contact range (%.1f) against a stationary fleet" % min_distance)
|
||||||
|
if settle_elapsed < 8.0:
|
||||||
|
return false
|
||||||
|
var standoff: Node = mover.get_component("standoff")
|
||||||
|
var final_distance: float = mover.position.distance_to(target_ship.position)
|
||||||
|
var preferred: float = standoff.preferred_range
|
||||||
|
print("min_distance=%.1f final_distance=%.1f preferred=%.1f" % [min_distance, final_distance, preferred])
|
||||||
|
if final_distance < preferred * 0.6 or final_distance > preferred * 1.4:
|
||||||
|
return _fail("sniper did not settle near its preferred_range (final=%.1f preferred=%.1f)" % [
|
||||||
|
final_distance, preferred
|
||||||
|
])
|
||||||
|
mover.queue_free()
|
||||||
|
|
||||||
|
# --- fire range gating: a fresh, pinned sniper out of range ---
|
||||||
|
fleet.spawn_ships(1, target_ship.position + Vector2(-2000, 0))
|
||||||
|
interceptor = fleet.ships[0]
|
||||||
|
sniper = load("res://Enemy.tscn").instantiate()
|
||||||
|
sniper.enemy_id = "sniper"
|
||||||
|
main.add_child(sniper)
|
||||||
|
sniper.speed = 0.0
|
||||||
|
sniper.position = interceptor.position + Vector2(2000, 0) # beyond fire_range
|
||||||
|
projectiles_before = _projectile_count()
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
var ranged: Node = sniper.get_component("ranged_weapon")
|
||||||
|
if elapsed < ranged.fire_cooldown + 0.5:
|
||||||
|
return false
|
||||||
|
if _projectile_count() != projectiles_before:
|
||||||
|
return _fail("sniper fired while beyond fire_range")
|
||||||
|
|
||||||
|
# --- move into range: it should fire ---
|
||||||
|
sniper.position = interceptor.position + Vector2(400, 0)
|
||||||
|
projectiles_before = _projectile_count()
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
if elapsed < 0.4:
|
||||||
|
return false
|
||||||
|
if _projectile_count() <= projectiles_before:
|
||||||
|
return _fail("sniper did not fire once in range")
|
||||||
|
|
||||||
|
# The already-fired shot is still travelling toward interceptor's
|
||||||
|
# stationary position and would otherwise connect and vanish -
|
||||||
|
# sidestep it (staying within fire_range, so the next stage's
|
||||||
|
# wait for a fresh shot still has a target). Also re-pin
|
||||||
|
# move_target to match - otherwise Ship's own steering
|
||||||
|
# immediately drives it back toward the stale target.
|
||||||
|
interceptor.position += Vector2(0, 150)
|
||||||
|
interceptor.move_target = interceptor.position
|
||||||
|
|
||||||
|
# --- cooldown: fire_timer must not jump back up before it's
|
||||||
|
# counted all the way down, which is what a second shot would
|
||||||
|
# do. Read from the component's own state rather than inferring
|
||||||
|
# it from projectile counts against a wall-clock wait: physics
|
||||||
|
# ticks can run in catch-up bursts relative to this SceneTree's
|
||||||
|
# own _process() under device load, so an EnemyProjectile's age
|
||||||
|
# and this script's `elapsed` are not guaranteed to stay in
|
||||||
|
# lockstep even with --fixed-fps.
|
||||||
|
cooldown_watermark = sniper.get_component("ranged_weapon").fire_timer
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 4
|
||||||
|
return false
|
||||||
|
4:
|
||||||
|
# fire_timer can never be caught externally at exactly <= 0: the
|
||||||
|
# component decrements-checks-refires atomically in one tick, so
|
||||||
|
# by the time this poll runs it has either not reached zero yet
|
||||||
|
# or has already fired and jumped back up to fire_cooldown. A
|
||||||
|
# RISE from the last observed value is therefore the reliable
|
||||||
|
# externally-visible signal for "it just fired again" - within
|
||||||
|
# the first 0.6s that is a bug (still cooling down from the
|
||||||
|
# first shot), afterward it is the legitimate next shot to dodge.
|
||||||
|
var current: float = sniper.get_component("ranged_weapon").fire_timer
|
||||||
|
if current > cooldown_watermark:
|
||||||
|
if elapsed < 0.6:
|
||||||
|
return _fail("sniper's cooldown timer jumped back up (a second shot fired) before it finished counting down")
|
||||||
|
interceptor.position += Vector2(0, 900) # perpendicular, well clear
|
||||||
|
interceptor.move_target = interceptor.position
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 6
|
||||||
|
return false
|
||||||
|
cooldown_watermark = current
|
||||||
|
if elapsed > 6.0:
|
||||||
|
return _fail("sniper never fired its next shot")
|
||||||
|
return false
|
||||||
|
6:
|
||||||
|
# Give the projectile its full lifetime to have resolved either way.
|
||||||
|
if elapsed < 3.5:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(interceptor):
|
||||||
|
return _fail("a slow sniper shot still hit a ship that moved away immediately")
|
||||||
|
print("dodge held: interceptor survived after moving off the impact point")
|
||||||
|
|
||||||
|
# --- direct hit: ship held in place, unshielded then shielded ---
|
||||||
|
interceptor.position = sniper.position + Vector2(300, 0)
|
||||||
|
interceptor.move_target = interceptor.position
|
||||||
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
||||||
|
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
||||||
|
shield_ship.position = Vector2(20000, 0) # far from the line of fire
|
||||||
|
shield_ship.move_target = shield_ship.position
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 7
|
||||||
|
return false
|
||||||
|
7:
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
if not shield_ship.has_shield():
|
||||||
|
return _fail("a fresh shield vessel has no shield")
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 8
|
||||||
|
return false
|
||||||
|
8:
|
||||||
|
if is_instance_valid(interceptor):
|
||||||
|
if elapsed > 4.0:
|
||||||
|
return _fail("held interceptor was never hit by the sniper")
|
||||||
|
return false
|
||||||
|
print("stationary unshielded interceptor destroyed by sniper fire")
|
||||||
|
|
||||||
|
shield_ship.position = sniper.position + Vector2(300, 0)
|
||||||
|
shield_ship.move_target = shield_ship.position
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 9
|
||||||
|
return false
|
||||||
|
9:
|
||||||
|
if elapsed < 4.0:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(shield_ship):
|
||||||
|
return _fail("a shielded ship was destroyed by sniper fire")
|
||||||
|
if shield_ship.shield_charges != 0:
|
||||||
|
return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" %
|
||||||
|
shield_ship.shield_charges)
|
||||||
|
print("shielded ship absorbed a sniper hit and lost exactly one charge")
|
||||||
|
print("TEST_SNIPER_ENEMY: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_SNIPER_ENEMY: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_sniper_enemy.gd.uid
Normal file
1
tests/test_sniper_enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://eaxineak2fte
|
||||||
Loading…
Reference in a new issue