From c4e2ee717806d3de761c27d2c5dd02ba1aaddde2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:22:10 +0200 Subject: [PATCH 1/4] Add capture points and the planet conquest loop (#43) CapturePoint tracks neutral/blocked/capturing/captured state by polling proximity each frame (a ship in capture_radius, no enemy within enemy_clear_radius), draining gradually rather than resetting when interrupted, and calling GameState.mark_point_captured() on completion. PlanetSetupHost now instantiates the real CapturePoint scene per authored point (restoring already-captured state immediately on re-landing) and spawns each setup's authored enemies, replacing the placeholder markers from #42. CaptureLabel is a small self-polling HUD (same convention as ResourceCounter) showing "CAPTURED n/m" or "CONQUERED" with a one-shot fade on completion, themed per surface profile alongside the existing bank/fleet captions. test_planet_atmosphere_transition.gd's "no enemies on a landed surface" assumption no longer holds now that setups can place guard enemies by design - narrowed the check to what it actually needs to prove: no space-side EnemySpawner/AsteroidField node leaks across the scene swap. Part of the #40 conquest-mechanic breakdown. --- CaptureLabel.gd | 32 ++++ CaptureLabel.gd.uid | 1 + CapturePoint.gd | 113 +++++++++++ CapturePoint.gd.uid | 1 + CapturePoint.tscn | 8 + GameState.gd | 4 + PlanetSetupHost.gd | 43 +++-- PlanetSurface.tscn | 17 +- PlanetSurfaceController.gd | 2 +- tests/_capture_points_preview.gd | 54 ++++++ tests/_capture_points_preview.gd.uid | 1 + tests/_capture_points_preview.tscn | 6 + tests/test_capture_points.gd | 210 +++++++++++++++++++++ tests/test_capture_points.gd.uid | 1 + tests/test_planet_atmosphere_transition.gd | 25 ++- tests/test_planet_setups.gd | 25 ++- 16 files changed, 509 insertions(+), 34 deletions(-) create mode 100644 CaptureLabel.gd create mode 100644 CaptureLabel.gd.uid create mode 100644 CapturePoint.gd create mode 100644 CapturePoint.gd.uid create mode 100644 CapturePoint.tscn create mode 100644 tests/_capture_points_preview.gd create mode 100644 tests/_capture_points_preview.gd.uid create mode 100644 tests/_capture_points_preview.tscn create mode 100644 tests/test_capture_points.gd create mode 100644 tests/test_capture_points.gd.uid 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/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/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/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/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_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() From fc2b5ca74a9f353c26df778032fd6f8bcc1aeaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:23:01 +0200 Subject: [PATCH 2/4] Add standoff sniper enemy (#45) EnemyStandoffComponent holds a preferred distance from the nearest player ship instead of ramming (advance beyond it, retreat inside retreat_range_factor of it, drift laterally in the comfortable band between) - never paired with a pursuit component. EnemyRangedWeaponComponent mirrors VesselWeaponComponent for an enemy: fires an EnemyProjectile aimed at the target's CURRENT position rather than led, so a moving ship can dodge it (projectile_speed 260 vs the interceptor's 460 max_speed). Registered in EnemyDefinitions so it's placeable from a planet setup by enemy_id. test_sniper_enemy.gd went through a few iterations to get its own timing right: physics ticks can run ahead of a SceneTree test's own _process() clock under this device's load, so the cooldown-violation check reads the component's own fire_timer directly (a rising value means it refired) instead of comparing projectile counts against a wall-clock wait, and manually repositioned ships need their move_target re-pinned or Ship's own steering silently drives them back to the stale target. Part of the #40 conquest-mechanic breakdown. --- EnemyDefinitions.gd | 3 +- EnemyRangedWeaponComponent.gd | 54 ++++++ EnemyRangedWeaponComponent.gd.uid | 1 + EnemyRangedWeaponComponent.tscn | 6 + EnemyStandoffComponent.gd | 58 +++++++ EnemyStandoffComponent.gd.uid | 1 + EnemyStandoffComponent.tscn | 6 + SniperEnemyDefinition.tres | 17 ++ tests/_sniper_vs_chaser_preview.gd | 76 +++++++++ tests/_sniper_vs_chaser_preview.gd.uid | 1 + tests/_sniper_vs_chaser_preview.tscn | 6 + tests/test_sniper_enemy.gd | 226 +++++++++++++++++++++++++ tests/test_sniper_enemy.gd.uid | 1 + 13 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 EnemyRangedWeaponComponent.gd create mode 100644 EnemyRangedWeaponComponent.gd.uid create mode 100644 EnemyRangedWeaponComponent.tscn create mode 100644 EnemyStandoffComponent.gd create mode 100644 EnemyStandoffComponent.gd.uid create mode 100644 EnemyStandoffComponent.tscn create mode 100644 SniperEnemyDefinition.tres create mode 100644 tests/_sniper_vs_chaser_preview.gd create mode 100644 tests/_sniper_vs_chaser_preview.gd.uid create mode 100644 tests/_sniper_vs_chaser_preview.tscn create mode 100644 tests/test_sniper_enemy.gd create mode 100644 tests/test_sniper_enemy.gd.uid diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index fa910b5..b641b60 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,9 +2,10 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser"] +const ORDER: PackedStringArray = ["chaser", "sniper"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), + "sniper": preload("res://SniperEnemyDefinition.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/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/_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_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 From def224667d42e0f573ec6742eb9a928ed412b874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:23:43 +0200 Subject: [PATCH 3/4] Add artillery enemy with telegraphed AOE (#46) EnemyArtilleryComponent is a stationary siege piece (slow drift toward the fleet, deliberately no pursuit component) that fires at the fleet centroid on a long cooldown via a standalone ArtilleryShell - telegraph ring (~1.8s, growing radius plus a filling countdown arc) -> slow visible travel -> AOE explosion via Ship.take_damage() on every player_ships node inside, same all-or-nothing shield absorption as every other damage source. The shell is independent of the enemy that fired it (added to the tree root, like EnemyProjectile/Projectile) so the shooter dying mid-flight doesn't cancel an already-telegraphed shot, and the impact point is fixed at fire time - never re-aimed at where the fleet moves to afterward. Part of the #40 conquest-mechanic breakdown. --- ArtilleryEnemyDefinition.tres | 16 ++ ArtilleryShell.gd | 112 ++++++++++ ArtilleryShell.gd.uid | 1 + ArtilleryShell.tscn | 6 + EnemyArtilleryComponent.gd | 61 ++++++ EnemyArtilleryComponent.gd.uid | 1 + EnemyArtilleryComponent.tscn | 6 + EnemyDefinitions.gd | 3 +- tests/_artillery_telegraph_gas_preview.gd | 20 ++ tests/_artillery_telegraph_gas_preview.gd.uid | 1 + tests/_artillery_telegraph_gas_preview.tscn | 6 + tests/_artillery_telegraph_rocky_preview.gd | 20 ++ .../_artillery_telegraph_rocky_preview.gd.uid | 1 + tests/_artillery_telegraph_rocky_preview.tscn | 6 + tests/test_artillery_enemy.gd | 195 ++++++++++++++++++ tests/test_artillery_enemy.gd.uid | 1 + 16 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 ArtilleryEnemyDefinition.tres create mode 100644 ArtilleryShell.gd create mode 100644 ArtilleryShell.gd.uid create mode 100644 ArtilleryShell.tscn create mode 100644 EnemyArtilleryComponent.gd create mode 100644 EnemyArtilleryComponent.gd.uid create mode 100644 EnemyArtilleryComponent.tscn create mode 100644 tests/_artillery_telegraph_gas_preview.gd create mode 100644 tests/_artillery_telegraph_gas_preview.gd.uid create mode 100644 tests/_artillery_telegraph_gas_preview.tscn create mode 100644 tests/_artillery_telegraph_rocky_preview.gd create mode 100644 tests/_artillery_telegraph_rocky_preview.gd.uid create mode 100644 tests/_artillery_telegraph_rocky_preview.tscn create mode 100644 tests/test_artillery_enemy.gd create mode 100644 tests/test_artillery_enemy.gd.uid 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/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 b641b60..ca44537 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,10 +2,11 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser", "sniper"] +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/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/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 From df2f25297000d18b3c366bd6028c371257cf8e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:24:28 +0200 Subject: [PATCH 4/4] Add the planet setup map editor (#48) PlanetSetupEditor authors PlanetSetup resources: pick a surface profile (drives the real lunar_surface background, not a blank sheet), place/move/delete capture points and enemies from a palette generated straight off EnemyDefinitions.all() (a new enemy definition shows up with no editor change), pan/pinch-zoom the canvas with the same touch idiom as GameController, then save to res://PlanetSetups/.tres via ResourceSaver - reachable now from the main menu's Map Editor button. The mutation methods (new_setup/load_setup/add_*/move_*/remove_*/save_setup) are the whole model and work standalone, which is what lets tests/test_setup_editor.gd drive placement/move/delete/save/reload directly without simulating touch input. Every cross-file reference is a preloaded script const rather than a bare global class name, matching #42/#44's load-order notes - this file is instantiated from other scripts' function bodies, never as a script-mode test entry point itself, so it wasn't at risk the same way the test files were, but the pattern is kept consistent throughout. Part of the #40 conquest-mechanic breakdown. This closes out the breakdown: #41-#48 are now all implemented, tested, and merged. --- MainMenu.gd | 5 +- PaletteEntryButton.gd | 56 ++++ PaletteEntryButton.gd.uid | 1 + PlanetSetupEditor.gd | 460 +++++++++++++++++++++++++++++ PlanetSetupEditor.gd.uid | 1 + PlanetSetupEditor.tscn | 179 +++++++++++ tests/_setup_editor_preview.gd | 14 + tests/_setup_editor_preview.gd.uid | 1 + tests/_setup_editor_preview.tscn | 6 + tests/test_main_menu.gd | 31 +- tests/test_setup_editor.gd | 136 +++++++++ tests/test_setup_editor.gd.uid | 1 + 12 files changed, 876 insertions(+), 15 deletions(-) create mode 100644 PaletteEntryButton.gd create mode 100644 PaletteEntryButton.gd.uid create mode 100644 PlanetSetupEditor.gd create mode 100644 PlanetSetupEditor.gd.uid create mode 100644 PlanetSetupEditor.tscn create mode 100644 tests/_setup_editor_preview.gd create mode 100644 tests/_setup_editor_preview.gd.uid create mode 100644 tests/_setup_editor_preview.tscn create mode 100644 tests/test_setup_editor.gd create mode 100644 tests/test_setup_editor.gd.uid 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/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/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_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