diff --git a/ArtilleryEnemyDefinition.tres b/ArtilleryEnemyDefinition.tres new file mode 100644 index 0000000..f47c597 --- /dev/null +++ b/ArtilleryEnemyDefinition.tres @@ -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")]) diff --git a/ArtilleryShell.gd b/ArtilleryShell.gd new file mode 100644 index 0000000..2c8b1f9 --- /dev/null +++ b/ArtilleryShell.gd @@ -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) diff --git a/ArtilleryShell.gd.uid b/ArtilleryShell.gd.uid new file mode 100644 index 0000000..cfd76b3 --- /dev/null +++ b/ArtilleryShell.gd.uid @@ -0,0 +1 @@ +uid://cvxao0mjslo5x diff --git a/ArtilleryShell.tscn b/ArtilleryShell.tscn new file mode 100644 index 0000000..17ff161 --- /dev/null +++ b/ArtilleryShell.tscn @@ -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") diff --git a/CaptureLabel.gd b/CaptureLabel.gd new file mode 100644 index 0000000..1c6bd07 --- /dev/null +++ b/CaptureLabel.gd @@ -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) diff --git a/CaptureLabel.gd.uid b/CaptureLabel.gd.uid new file mode 100644 index 0000000..9f9afa6 --- /dev/null +++ b/CaptureLabel.gd.uid @@ -0,0 +1 @@ +uid://dxuwfa5j7oh80 diff --git a/CapturePoint.gd b/CapturePoint.gd new file mode 100644 index 0000000..eb1f447 --- /dev/null +++ b/CapturePoint.gd @@ -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) diff --git a/CapturePoint.gd.uid b/CapturePoint.gd.uid new file mode 100644 index 0000000..54f1d5f --- /dev/null +++ b/CapturePoint.gd.uid @@ -0,0 +1 @@ +uid://dwt256xl1txjo diff --git a/CapturePoint.tscn b/CapturePoint.tscn new file mode 100644 index 0000000..39dbe89 --- /dev/null +++ b/CapturePoint.tscn @@ -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 diff --git a/EnemyArtilleryComponent.gd b/EnemyArtilleryComponent.gd new file mode 100644 index 0000000..43250c3 --- /dev/null +++ b/EnemyArtilleryComponent.gd @@ -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 diff --git a/EnemyArtilleryComponent.gd.uid b/EnemyArtilleryComponent.gd.uid new file mode 100644 index 0000000..5ea4f97 --- /dev/null +++ b/EnemyArtilleryComponent.gd.uid @@ -0,0 +1 @@ +uid://b0woyjce8kl6x diff --git a/EnemyArtilleryComponent.tscn b/EnemyArtilleryComponent.tscn new file mode 100644 index 0000000..dcc574b --- /dev/null +++ b/EnemyArtilleryComponent.tscn @@ -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") diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index fa910b5..ca44537 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,9 +2,11 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser"] +const ORDER: PackedStringArray = ["chaser", "sniper", "artillery"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), + "sniper": preload("res://SniperEnemyDefinition.tres"), + "artillery": preload("res://ArtilleryEnemyDefinition.tres"), } diff --git a/EnemyRangedWeaponComponent.gd b/EnemyRangedWeaponComponent.gd new file mode 100644 index 0000000..c8600ce --- /dev/null +++ b/EnemyRangedWeaponComponent.gd @@ -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 diff --git a/EnemyRangedWeaponComponent.gd.uid b/EnemyRangedWeaponComponent.gd.uid new file mode 100644 index 0000000..b0cb4c9 --- /dev/null +++ b/EnemyRangedWeaponComponent.gd.uid @@ -0,0 +1 @@ +uid://dx1lmxw6ia66a diff --git a/EnemyRangedWeaponComponent.tscn b/EnemyRangedWeaponComponent.tscn new file mode 100644 index 0000000..7c8b203 --- /dev/null +++ b/EnemyRangedWeaponComponent.tscn @@ -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") diff --git a/EnemyStandoffComponent.gd b/EnemyStandoffComponent.gd new file mode 100644 index 0000000..0111738 --- /dev/null +++ b/EnemyStandoffComponent.gd @@ -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 diff --git a/EnemyStandoffComponent.gd.uid b/EnemyStandoffComponent.gd.uid new file mode 100644 index 0000000..b725855 --- /dev/null +++ b/EnemyStandoffComponent.gd.uid @@ -0,0 +1 @@ +uid://c7axi54f6p7gk diff --git a/EnemyStandoffComponent.tscn b/EnemyStandoffComponent.tscn new file mode 100644 index 0000000..7858cc2 --- /dev/null +++ b/EnemyStandoffComponent.tscn @@ -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") diff --git a/GameState.gd b/GameState.gd index 5487299..0516b14 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1107,6 +1107,10 @@ func captured_point_count(planet_key: String) -> int: return count +func total_capture_points(planet_key: String) -> int: + return capture_state(planet_key).get("captured", []).size() + + func _process(delta: float) -> void: # Runs in the autoload, so mining and transport continue while the player # is on a planet's surface or nowhere near either. diff --git a/MainMenu.gd b/MainMenu.gd index e5afbcb..7ea9255 100644 --- a/MainMenu.gd +++ b/MainMenu.gd @@ -9,10 +9,7 @@ extends Node2D func _ready() -> void: play_button.pressed.connect(_on_play_pressed) editor_button.pressed.connect(_on_editor_pressed) - # #48 wires the real map editor scene up to this button; until then it - # stays visible but explains itself instead of silently doing nothing - # or pointing at a scene that doesn't exist yet. - editor_button.enabled = false + editor_button.enabled = true quit_button.pressed.connect(_on_quit_pressed) quit_button.visible = not OS.has_feature("web") diff --git a/PaletteEntryButton.gd b/PaletteEntryButton.gd new file mode 100644 index 0000000..ea479a2 --- /dev/null +++ b/PaletteEntryButton.gd @@ -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) diff --git a/PaletteEntryButton.gd.uid b/PaletteEntryButton.gd.uid new file mode 100644 index 0000000..8dda8dc --- /dev/null +++ b/PaletteEntryButton.gd.uid @@ -0,0 +1 @@ +uid://ge3e0dc3cekb diff --git a/PlanetSetupEditor.gd b/PlanetSetupEditor.gd new file mode 100644 index 0000000..0f34b17 --- /dev/null +++ b/PlanetSetupEditor.gd @@ -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/.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) diff --git a/PlanetSetupEditor.gd.uid b/PlanetSetupEditor.gd.uid new file mode 100644 index 0000000..ce90c4d --- /dev/null +++ b/PlanetSetupEditor.gd.uid @@ -0,0 +1 @@ +uid://bprbw56kcmdsk diff --git a/PlanetSetupEditor.tscn b/PlanetSetupEditor.tscn new file mode 100644 index 0000000..99e033d --- /dev/null +++ b/PlanetSetupEditor.tscn @@ -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 diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd index f2df9c5..1b79167 100644 --- a/PlanetSetupHost.gd +++ b/PlanetSetupHost.gd @@ -1,14 +1,13 @@ class_name PlanetSetupHost extends Node2D -# Instantiates the contents of a PlanetSetup onto a landed surface. Capture -# points are placeholder markers here; #43 replaces them with the real -# CapturePoint scene through the same apply_setup() entry point, and enemy -# spawning is filled in by the enemy-foundation ticket (#44). Keeping this -# seam stable is the point of this ticket. +# Instantiates the contents of a PlanetSetup onto a landed surface: one +# CapturePoint per authored point - registering/restoring conquest progress +# through GameState so re-landing on a partly-taken planet keeps its +# progress - and one enemy per authored spawn. -const MARKER_COLOR: Color = Color(0.55, 0.85, 1.0) -const MARKER_RADIUS: float = 36.0 +const CAPTURE_POINT_SCENE: PackedScene = preload("res://CapturePoint.tscn") +const ENEMY_SCENE: PackedScene = preload("res://Enemy.tscn") var setup: Resource @@ -19,15 +18,25 @@ func apply_setup(new_setup: Resource) -> void: child.queue_free() if setup == null: 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: - var marker: NeonPiece = NeonPiece.create(self, at, MARKER_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)) * MARKER_RADIUS) - marker.add_polygon(points, 0.6) - marker.queue_redraw() +func _add_capture_point(index: int, at: Vector2) -> void: + var point: Node = CAPTURE_POINT_SCENE.instantiate() + point.point_index = index + point.position = at + add_child(point) + if GameState.is_point_captured(GameState.planet_id, index): + point.mark_captured() + + +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) diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index c9d7a26..82626a5 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -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://LunarBackground.gd" id="2"] @@ -12,6 +12,7 @@ [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://GasBombController.gd" id="14"] +[ext_resource type="Script" path="res://CaptureLabel.gd" id="15"] [sub_resource type="ShaderMaterial" id="1"] 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_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"] anchor_top = 1.0 anchor_bottom = 1.0 diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 7569796..c984c65 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -44,7 +44,7 @@ func _compose_surface(profile: Resource) -> void: atmosphere.set_shader_parameter("base_color", profile.base_color) atmosphere.set_shader_parameter("crater_color", profile.crater_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) caption.add_theme_color_override("font_color", profile.caption_color) caption.add_theme_color_override("font_outline_color", profile.caption_outline_color) diff --git a/SniperEnemyDefinition.tres b/SniperEnemyDefinition.tres new file mode 100644 index 0000000..102455f --- /dev/null +++ b/SniperEnemyDefinition.tres @@ -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")]) diff --git a/tests/_artillery_telegraph_gas_preview.gd b/tests/_artillery_telegraph_gas_preview.gd new file mode 100644 index 0000000..c255b9a --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.gd @@ -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() diff --git a/tests/_artillery_telegraph_gas_preview.gd.uid b/tests/_artillery_telegraph_gas_preview.gd.uid new file mode 100644 index 0000000..e5f7dff --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.gd.uid @@ -0,0 +1 @@ +uid://be13xddivhtr diff --git a/tests/_artillery_telegraph_gas_preview.tscn b/tests/_artillery_telegraph_gas_preview.tscn new file mode 100644 index 0000000..cbcb749 --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.tscn @@ -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") diff --git a/tests/_artillery_telegraph_rocky_preview.gd b/tests/_artillery_telegraph_rocky_preview.gd new file mode 100644 index 0000000..eaa8372 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.gd @@ -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() diff --git a/tests/_artillery_telegraph_rocky_preview.gd.uid b/tests/_artillery_telegraph_rocky_preview.gd.uid new file mode 100644 index 0000000..6571802 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.gd.uid @@ -0,0 +1 @@ +uid://dlaxr7it3582h diff --git a/tests/_artillery_telegraph_rocky_preview.tscn b/tests/_artillery_telegraph_rocky_preview.tscn new file mode 100644 index 0000000..c38c8e8 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.tscn @@ -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") diff --git a/tests/_capture_points_preview.gd b/tests/_capture_points_preview.gd new file mode 100644 index 0000000..29a3ddf --- /dev/null +++ b/tests/_capture_points_preview.gd @@ -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) diff --git a/tests/_capture_points_preview.gd.uid b/tests/_capture_points_preview.gd.uid new file mode 100644 index 0000000..3c81cd3 --- /dev/null +++ b/tests/_capture_points_preview.gd.uid @@ -0,0 +1 @@ +uid://njieetp3l0xc diff --git a/tests/_capture_points_preview.tscn b/tests/_capture_points_preview.tscn new file mode 100644 index 0000000..49c82d1 --- /dev/null +++ b/tests/_capture_points_preview.tscn @@ -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") diff --git a/tests/_setup_editor_preview.gd b/tests/_setup_editor_preview.gd new file mode 100644 index 0000000..2cc48dd --- /dev/null +++ b/tests/_setup_editor_preview.gd @@ -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() diff --git a/tests/_setup_editor_preview.gd.uid b/tests/_setup_editor_preview.gd.uid new file mode 100644 index 0000000..5a63532 --- /dev/null +++ b/tests/_setup_editor_preview.gd.uid @@ -0,0 +1 @@ +uid://dltm5ro7xcjii diff --git a/tests/_setup_editor_preview.tscn b/tests/_setup_editor_preview.tscn new file mode 100644 index 0000000..bb4e3e8 --- /dev/null +++ b/tests/_setup_editor_preview.tscn @@ -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") diff --git a/tests/_sniper_vs_chaser_preview.gd b/tests/_sniper_vs_chaser_preview.gd new file mode 100644 index 0000000..9f87fe5 --- /dev/null +++ b/tests/_sniper_vs_chaser_preview.gd @@ -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 diff --git a/tests/_sniper_vs_chaser_preview.gd.uid b/tests/_sniper_vs_chaser_preview.gd.uid new file mode 100644 index 0000000..688d960 --- /dev/null +++ b/tests/_sniper_vs_chaser_preview.gd.uid @@ -0,0 +1 @@ +uid://d2fj1p61hp2pk diff --git a/tests/_sniper_vs_chaser_preview.tscn b/tests/_sniper_vs_chaser_preview.tscn new file mode 100644 index 0000000..a9680ba --- /dev/null +++ b/tests/_sniper_vs_chaser_preview.tscn @@ -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") diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd new file mode 100644 index 0000000..3fe97c0 --- /dev/null +++ b/tests/test_artillery_enemy.gd @@ -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 diff --git a/tests/test_artillery_enemy.gd.uid b/tests/test_artillery_enemy.gd.uid new file mode 100644 index 0000000..33da4a1 --- /dev/null +++ b/tests/test_artillery_enemy.gd.uid @@ -0,0 +1 @@ +uid://cv3lvd01j4kp diff --git a/tests/test_capture_points.gd b/tests/test_capture_points.gd new file mode 100644 index 0000000..1f281b7 --- /dev/null +++ b/tests/test_capture_points.gd @@ -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 diff --git a/tests/test_capture_points.gd.uid b/tests/test_capture_points.gd.uid new file mode 100644 index 0000000..e0c46e3 --- /dev/null +++ b/tests/test_capture_points.gd.uid @@ -0,0 +1 @@ +uid://cmordwxwbr5ca diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index 5193f33..8d2982f 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -3,8 +3,8 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_main_menu.gd # Verifies: -# 1. the menu loads with Play/Map Editor/Quit, the editor button starts -# disabled and explains itself instead of silently doing nothing +# 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and +# reaches PlanetSetupEditor.tscn (#48) # 2. Quit's visibility follows OS.has_feature("web") (this platform is # not a web export, so this run exercises the "visible" branch; the # condition itself is what's under test, not the concrete boolean) @@ -33,8 +33,8 @@ func _process(delta: float) -> bool: # frame has actually processed. if elapsed < 0.1: return false - if menu.editor_button.enabled: - return _fail("map editor button is not disabled ahead of #48") + if not menu.editor_button.enabled: + return _fail("map editor button is not enabled (#48 shipped)") if menu.quit_button.visible != (not 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 return false 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: 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, # so a genuine reset is distinguishable from a coincidental match. game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999) @@ -59,9 +68,9 @@ func _process(delta: float) -> bool: _press(menu.play_button) elapsed = 0.0 - stage = 2 + stage = 3 return false - 2: + 3: if elapsed < 0.3: return false var on_main: bool = current_scene != null and current_scene.name == "Main" diff --git a/tests/test_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd index 84da9cb..109bfa2 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -6,10 +6,14 @@ extends SceneTree # scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place # overlay: standing on a planet shows the prompt, pressing it fades out # and swaps to PlanetSurface.tscn with the SAME surviving ship count, -# where no asteroid/enemy ever exists (not hidden - structurally absent, -# since PlanetSurface.tscn has no such nodes at all) and the fleet is -# still fully flyable. Exiting fades back to Main.tscn with the fleet -# restored at the world position it left from. +# where no asteroid ever exists and no space-side EnemySpawner/AsteroidField +# node carries over (structurally absent - PlanetSurface.tscn has no such +# nodes at all), and the fleet is still fully flyable. Landed enemies MAY +# 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 fleet: Node @@ -163,11 +167,16 @@ func _process(delta: float) -> bool: var ship_count_ok: bool = surface_fleet.ships.size() == 5 var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty() - var no_enemies: bool = get_nodes_in_group("enemies").is_empty() - print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [ - ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies + # Landed enemies from an authored PlanetSetup are expected + # post-#43 (they guard capture points) - what this test actually + # 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)") quit(1) return true diff --git a/tests/test_planet_setups.gd b/tests/test_planet_setups.gd index 9a735e7..723be8b 100644 --- a/tests/test_planet_setups.gd +++ b/tests/test_planet_setups.gd @@ -12,6 +12,15 @@ extends SceneTree 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 rocky_setup: Resource var stage: int = 0 @@ -20,6 +29,8 @@ var pending_surface: Node func _initialize() -> void: + PLANET_SETUP_SCRIPT = load("res://PlanetSetup.gd") + CAPTURE_POINT_SCRIPT = load("res://CapturePoint.gd") # --- 1. determinism and profile filtering --- rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky") if rocky_setup == null: @@ -42,7 +53,7 @@ func _initialize() -> void: # --- 2. dropping a new .tres is picked up by rescan() --- 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.display_name = "Scratch" scratch.surface_profile_id = "rocky" @@ -89,14 +100,14 @@ func _process(delta: float) -> bool: if host.setup != rocky_setup: pending_surface.free() 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(): - if child is NeonPiece: - marker_count += 1 - if marker_count != rocky_setup.capture_points.size(): + if child.get_script() == CAPTURE_POINT_SCRIPT: + point_count += 1 + if point_count != rocky_setup.capture_points.size(): pending_surface.free() - return _fail("host placed %d markers for %d capture points" % [ - marker_count, rocky_setup.capture_points.size() + return _fail("host placed %d capture points for %d authored ones" % [ + point_count, rocky_setup.capture_points.size() ]) pending_surface.free() diff --git a/tests/test_setup_editor.gd b/tests/test_setup_editor.gd new file mode 100644 index 0000000..fe8c572 --- /dev/null +++ b/tests/test_setup_editor.gd @@ -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/.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 diff --git a/tests/test_setup_editor.gd.uid b/tests/test_setup_editor.gd.uid new file mode 100644 index 0000000..513f38c --- /dev/null +++ b/tests/test_setup_editor.gd.uid @@ -0,0 +1 @@ +uid://cegx70j0hkj4m diff --git a/tests/test_sniper_enemy.gd b/tests/test_sniper_enemy.gd new file mode 100644 index 0000000..3a4d369 --- /dev/null +++ b/tests/test_sniper_enemy.gd @@ -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 diff --git a/tests/test_sniper_enemy.gd.uid b/tests/test_sniper_enemy.gd.uid new file mode 100644 index 0000000..410c027 --- /dev/null +++ b/tests/test_sniper_enemy.gd.uid @@ -0,0 +1 @@ +uid://eaxineak2fte