From 85814ce88cbc6f489a5f949829ccbfef1c6ed2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Mon, 10 Aug 2026 18:41:58 +0200 Subject: [PATCH] Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1 A fleet of neon-outlined ships explores a procedurally generated, seeded infinite world: tap/joystick steering, pinch zoom, formation pivoting, planet landings, an ore economy with extractors and haulers, and player built structures (mothership, ship crafter, command centre). All art is hand-drawn with draw_* calls rather than textures, and the backgrounds are camera-relative shaders so they stay precise at large world coordinates. Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot tooling that renders real frames through Xvfb and Mesa softpipe, since --headless has no GPU. Excluded from version control: .godot/ (import cache, and it holds export credentials), builds/, android/build/ and packaged APKs - all regenerable, and ~325 MB combined. Co-Authored-By: Claude Opus 5 --- .gitignore | 15 + Asteroid.gd | 40 + Asteroid.gd.uid | 1 + Asteroid.tscn | 12 + AsteroidExplosionVFX.gd | 29 + AsteroidExplosionVFX.gd.uid | 1 + AsteroidField.gd | 86 +++ AsteroidField.gd.uid | 1 + BuildController.gd | 351 +++++++++ BuildController.gd.uid | 1 + Building.gd | 364 +++++++++ Building.gd.uid | 1 + Building.tscn | 6 + BuildingField.gd | 50 ++ BuildingField.gd.uid | 1 + Enemy.gd | 64 ++ Enemy.gd.uid | 1 + Enemy.tscn | 12 + EnemyExplosionVFX.gd | 12 + EnemyExplosionVFX.gd.uid | 1 + EnemySpawner.gd | 42 ++ EnemySpawner.gd.uid | 1 + ExplorationTracker.gd | 23 + ExplorationTracker.gd.uid | 1 + ExplosionVFXBase.gd | 58 ++ ExplosionVFXBase.gd.uid | 1 + Fleet.gd | 195 +++++ Fleet.gd.uid | 1 + FormationEditor.gd | 138 ++++ FormationEditor.gd.uid | 1 + GameController.gd | 313 ++++++++ GameController.gd.uid | 1 + GameState.gd | 194 +++++ GameState.gd.uid | 1 + Hauler.gd | 39 + Hauler.gd.uid | 1 + Hauler.tscn | 6 + HaulerField.gd | 57 ++ HaulerField.gd.uid | 1 + IconButton.gd | 277 +++++++ IconButton.gd.uid | 1 + LightBurstVFX.gd | 15 + LightBurstVFX.gd.uid | 1 + LunarBackground.gd | 19 + LunarBackground.gd.uid | 1 + Main.tscn | 698 ++++++++++++++++++ NeonDraw.gd | 42 ++ NeonDraw.gd.uid | 1 + NeonNumber.gd | 84 +++ NeonNumber.gd.uid | 1 + NeonPanel.gd | 47 ++ NeonPanel.gd.uid | 1 + Planet.gd | 39 + Planet.gd.uid | 1 + Planet.tscn | 6 + PlanetAtmosphereController.gd | 124 ++++ PlanetAtmosphereController.gd.uid | 1 + PlanetField.gd | 93 +++ PlanetField.gd.uid | 1 + PlanetSurface.tscn | 172 +++++ PlanetSurfaceController.gd | 45 ++ PlanetSurfaceController.gd.uid | 1 + Projectile.gd | 47 ++ Projectile.gd.uid | 1 + Projectile.tscn | 12 + ResourceCounter.gd | 10 + ResourceCounter.gd.uid | 1 + Rock.gd | 102 +++ Rock.gd.uid | 1 + Rock.tscn | 12 + RockField.gd | 71 ++ RockField.gd.uid | 1 + Ship.gd | 139 ++++ Ship.gd.uid | 1 + Ship.tscn | 12 + ShipExplosionVFX.gd | 12 + ShipExplosionVFX.gd.uid | 1 + Starfield.gd | 28 + Starfield.gd.uid | 1 + TouchJoystick.gd | 110 +++ TouchJoystick.gd.uid | 1 + ViewUtils.gd | 31 + ViewUtils.gd.uid | 1 + WorldGen.gd | 90 +++ WorldGen.gd.uid | 1 + WorldMap.gd | 93 +++ WorldMap.gd.uid | 1 + WorldMapContent.gd | 42 ++ WorldMapContent.gd.uid | 1 + android/.build_version | 1 + android/.gdignore | 0 export_presets.cfg | 271 +++++++ icon.svg | 4 + icon.svg.import | 43 ++ lunar_surface.gdshader | 212 ++++++ lunar_surface.gdshader.uid | 1 + project.godot | 42 ++ starfield.gdshader | 125 ++++ starfield.gdshader.uid | 1 + tests/_build_popup_preview.gd | 31 + tests/_build_popup_preview.gd.uid | 1 + tests/_build_popup_preview.tscn | 6 + tests/_crafter_preview.gd | 41 + tests/_crafter_preview.gd.uid | 1 + tests/_crafter_preview.tscn | 6 + tests/_font_probe.gd | 39 + tests/_font_probe.gd.uid | 1 + tests/_icon_preview.gd | 35 + tests/_icon_preview.gd.uid | 1 + tests/_icon_preview.tscn | 8 + tests/_ingame_buildings_preview.gd | 27 + tests/_ingame_buildings_preview.gd.uid | 1 + tests/_ingame_buildings_preview.tscn | 6 + tests/_lunar_check.png.import | 40 + tests/_screenshot_tool.gd | 53 ++ tests/_screenshot_tool.gd.uid | 1 + tests/run_tests.sh | 20 + tests/scratch_scene_a.gd.uid | 1 + tests/scratch_scene_b.gd.uid | 1 + tests/scratch_swap_check.gd.uid | 1 + tests/screenshot.sh | 38 + tests/test_animation_continuity.gd | 120 +++ tests/test_animation_continuity.gd.uid | 1 + tests/test_asteroid_ship_collision.gd | 72 ++ tests/test_asteroid_ship_collision.gd.uid | 1 + tests/test_asteroid_spawning.gd | 115 +++ tests/test_asteroid_spawning.gd.uid | 1 + tests/test_biome_generation.gd | 38 + tests/test_biome_generation.gd.uid | 1 + tests/test_build_info_popup.gd | 123 +++ tests/test_build_info_popup.gd.uid | 1 + tests/test_build_menu_icons.gd | 64 ++ tests/test_build_menu_icons.gd.uid | 1 + tests/test_camera_settles.gd | 98 +++ tests/test_camera_settles.gd.uid | 1 + tests/test_crafter_animation.gd | 118 +++ tests/test_crafter_animation.gd.uid | 1 + tests/test_enemy_collision.gd | 65 ++ tests/test_enemy_collision.gd.uid | 1 + tests/test_enemy_combat.gd | 74 ++ tests/test_enemy_combat.gd.uid | 1 + tests/test_exploration_tracker.gd | 59 ++ tests/test_exploration_tracker.gd.uid | 1 + tests/test_extractor_building.gd | 161 ++++ tests/test_extractor_building.gd.uid | 1 + tests/test_extractor_income.gd | 106 +++ tests/test_extractor_income.gd.uid | 1 + tests/test_fleet_formation.gd | 77 ++ tests/test_fleet_formation.gd.uid | 1 + tests/test_icon_button.gd | 56 ++ tests/test_icon_button.gd.uid | 1 + tests/test_icon_button_drag_cancel.gd | 56 ++ tests/test_icon_button_drag_cancel.gd.uid | 1 + tests/test_joystick_control.gd | 134 ++++ tests/test_joystick_control.gd.uid | 1 + tests/test_keyboard_input.gd | 145 ++++ tests/test_keyboard_input.gd.uid | 1 + tests/test_lopsided_formation_movement.gd | 118 +++ tests/test_lopsided_formation_movement.gd.uid | 1 + tests/test_mothership_replacement.gd | 150 ++++ tests/test_mothership_replacement.gd.uid | 1 + tests/test_no_popin_on_load.gd | 70 ++ tests/test_no_popin_on_load.gd.uid | 1 + tests/test_pinch_zoom.gd | 94 +++ tests/test_pinch_zoom.gd.uid | 1 + tests/test_pivot_controls.gd | 153 ++++ tests/test_pivot_controls.gd.uid | 1 + tests/test_planet_atmosphere_transition.gd | 260 +++++++ .../test_planet_atmosphere_transition.gd.uid | 1 + tests/test_planet_identity_persists.gd | 122 +++ tests/test_planet_identity_persists.gd.uid | 1 + tests/test_planet_landing_proximity.gd | 122 +++ tests/test_planet_landing_proximity.gd.uid | 1 + tests/test_planet_map_marker_matches_spawn.gd | 69 ++ ...est_planet_map_marker_matches_spawn.gd.uid | 1 + tests/test_planet_prompt_map_interaction.gd | 123 +++ .../test_planet_prompt_map_interaction.gd.uid | 1 + tests/test_planet_spawning.gd | 105 +++ tests/test_planet_spawning.gd.uid | 1 + tests/test_prompt_debounce.gd | 107 +++ tests/test_prompt_debounce.gd.uid | 1 + tests/test_resources_and_buildings.gd | 222 ++++++ tests/test_resources_and_buildings.gd.uid | 1 + tests/test_return_position_matches_planet.gd | 174 +++++ ...test_return_position_matches_planet.gd.uid | 1 + tests/test_ship_facing.gd | 104 +++ tests/test_ship_facing.gd.uid | 1 + tests/test_star_coverage.gd | 66 ++ tests/test_star_coverage.gd.uid | 1 + tests/test_world_map_ui.gd | 136 ++++ tests/test_world_map_ui.gd.uid | 1 + ui_theme.tres | 15 + 192 files changed, 9602 insertions(+) create mode 100644 .gitignore create mode 100644 Asteroid.gd create mode 100644 Asteroid.gd.uid create mode 100644 Asteroid.tscn create mode 100644 AsteroidExplosionVFX.gd create mode 100644 AsteroidExplosionVFX.gd.uid create mode 100644 AsteroidField.gd create mode 100644 AsteroidField.gd.uid create mode 100644 BuildController.gd create mode 100644 BuildController.gd.uid create mode 100644 Building.gd create mode 100644 Building.gd.uid create mode 100644 Building.tscn create mode 100644 BuildingField.gd create mode 100644 BuildingField.gd.uid create mode 100644 Enemy.gd create mode 100644 Enemy.gd.uid create mode 100644 Enemy.tscn create mode 100644 EnemyExplosionVFX.gd create mode 100644 EnemyExplosionVFX.gd.uid create mode 100644 EnemySpawner.gd create mode 100644 EnemySpawner.gd.uid create mode 100644 ExplorationTracker.gd create mode 100644 ExplorationTracker.gd.uid create mode 100644 ExplosionVFXBase.gd create mode 100644 ExplosionVFXBase.gd.uid create mode 100644 Fleet.gd create mode 100644 Fleet.gd.uid create mode 100644 FormationEditor.gd create mode 100644 FormationEditor.gd.uid create mode 100644 GameController.gd create mode 100644 GameController.gd.uid create mode 100644 GameState.gd create mode 100644 GameState.gd.uid create mode 100644 Hauler.gd create mode 100644 Hauler.gd.uid create mode 100644 Hauler.tscn create mode 100644 HaulerField.gd create mode 100644 HaulerField.gd.uid create mode 100644 IconButton.gd create mode 100644 IconButton.gd.uid create mode 100644 LightBurstVFX.gd create mode 100644 LightBurstVFX.gd.uid create mode 100644 LunarBackground.gd create mode 100644 LunarBackground.gd.uid create mode 100644 Main.tscn create mode 100644 NeonDraw.gd create mode 100644 NeonDraw.gd.uid create mode 100644 NeonNumber.gd create mode 100644 NeonNumber.gd.uid create mode 100644 NeonPanel.gd create mode 100644 NeonPanel.gd.uid create mode 100644 Planet.gd create mode 100644 Planet.gd.uid create mode 100644 Planet.tscn create mode 100644 PlanetAtmosphereController.gd create mode 100644 PlanetAtmosphereController.gd.uid create mode 100644 PlanetField.gd create mode 100644 PlanetField.gd.uid create mode 100644 PlanetSurface.tscn create mode 100644 PlanetSurfaceController.gd create mode 100644 PlanetSurfaceController.gd.uid create mode 100644 Projectile.gd create mode 100644 Projectile.gd.uid create mode 100644 Projectile.tscn create mode 100644 ResourceCounter.gd create mode 100644 ResourceCounter.gd.uid create mode 100644 Rock.gd create mode 100644 Rock.gd.uid create mode 100644 Rock.tscn create mode 100644 RockField.gd create mode 100644 RockField.gd.uid create mode 100644 Ship.gd create mode 100644 Ship.gd.uid create mode 100644 Ship.tscn create mode 100644 ShipExplosionVFX.gd create mode 100644 ShipExplosionVFX.gd.uid create mode 100644 Starfield.gd create mode 100644 Starfield.gd.uid create mode 100644 TouchJoystick.gd create mode 100644 TouchJoystick.gd.uid create mode 100644 ViewUtils.gd create mode 100644 ViewUtils.gd.uid create mode 100644 WorldGen.gd create mode 100644 WorldGen.gd.uid create mode 100644 WorldMap.gd create mode 100644 WorldMap.gd.uid create mode 100644 WorldMapContent.gd create mode 100644 WorldMapContent.gd.uid create mode 100644 android/.build_version create mode 100644 android/.gdignore create mode 100644 export_presets.cfg create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 lunar_surface.gdshader create mode 100644 lunar_surface.gdshader.uid create mode 100644 project.godot create mode 100644 starfield.gdshader create mode 100644 starfield.gdshader.uid create mode 100644 tests/_build_popup_preview.gd create mode 100644 tests/_build_popup_preview.gd.uid create mode 100644 tests/_build_popup_preview.tscn create mode 100644 tests/_crafter_preview.gd create mode 100644 tests/_crafter_preview.gd.uid create mode 100644 tests/_crafter_preview.tscn create mode 100644 tests/_font_probe.gd create mode 100644 tests/_font_probe.gd.uid create mode 100644 tests/_icon_preview.gd create mode 100644 tests/_icon_preview.gd.uid create mode 100644 tests/_icon_preview.tscn create mode 100644 tests/_ingame_buildings_preview.gd create mode 100644 tests/_ingame_buildings_preview.gd.uid create mode 100644 tests/_ingame_buildings_preview.tscn create mode 100644 tests/_lunar_check.png.import create mode 100644 tests/_screenshot_tool.gd create mode 100644 tests/_screenshot_tool.gd.uid create mode 100644 tests/run_tests.sh create mode 100644 tests/scratch_scene_a.gd.uid create mode 100644 tests/scratch_scene_b.gd.uid create mode 100644 tests/scratch_swap_check.gd.uid create mode 100644 tests/screenshot.sh create mode 100644 tests/test_animation_continuity.gd create mode 100644 tests/test_animation_continuity.gd.uid create mode 100644 tests/test_asteroid_ship_collision.gd create mode 100644 tests/test_asteroid_ship_collision.gd.uid create mode 100644 tests/test_asteroid_spawning.gd create mode 100644 tests/test_asteroid_spawning.gd.uid create mode 100644 tests/test_biome_generation.gd create mode 100644 tests/test_biome_generation.gd.uid create mode 100644 tests/test_build_info_popup.gd create mode 100644 tests/test_build_info_popup.gd.uid create mode 100644 tests/test_build_menu_icons.gd create mode 100644 tests/test_build_menu_icons.gd.uid create mode 100644 tests/test_camera_settles.gd create mode 100644 tests/test_camera_settles.gd.uid create mode 100644 tests/test_crafter_animation.gd create mode 100644 tests/test_crafter_animation.gd.uid create mode 100644 tests/test_enemy_collision.gd create mode 100644 tests/test_enemy_collision.gd.uid create mode 100644 tests/test_enemy_combat.gd create mode 100644 tests/test_enemy_combat.gd.uid create mode 100644 tests/test_exploration_tracker.gd create mode 100644 tests/test_exploration_tracker.gd.uid create mode 100644 tests/test_extractor_building.gd create mode 100644 tests/test_extractor_building.gd.uid create mode 100644 tests/test_extractor_income.gd create mode 100644 tests/test_extractor_income.gd.uid create mode 100644 tests/test_fleet_formation.gd create mode 100644 tests/test_fleet_formation.gd.uid create mode 100644 tests/test_icon_button.gd create mode 100644 tests/test_icon_button.gd.uid create mode 100644 tests/test_icon_button_drag_cancel.gd create mode 100644 tests/test_icon_button_drag_cancel.gd.uid create mode 100644 tests/test_joystick_control.gd create mode 100644 tests/test_joystick_control.gd.uid create mode 100644 tests/test_keyboard_input.gd create mode 100644 tests/test_keyboard_input.gd.uid create mode 100644 tests/test_lopsided_formation_movement.gd create mode 100644 tests/test_lopsided_formation_movement.gd.uid create mode 100644 tests/test_mothership_replacement.gd create mode 100644 tests/test_mothership_replacement.gd.uid create mode 100644 tests/test_no_popin_on_load.gd create mode 100644 tests/test_no_popin_on_load.gd.uid create mode 100644 tests/test_pinch_zoom.gd create mode 100644 tests/test_pinch_zoom.gd.uid create mode 100644 tests/test_pivot_controls.gd create mode 100644 tests/test_pivot_controls.gd.uid create mode 100644 tests/test_planet_atmosphere_transition.gd create mode 100644 tests/test_planet_atmosphere_transition.gd.uid create mode 100644 tests/test_planet_identity_persists.gd create mode 100644 tests/test_planet_identity_persists.gd.uid create mode 100644 tests/test_planet_landing_proximity.gd create mode 100644 tests/test_planet_landing_proximity.gd.uid create mode 100644 tests/test_planet_map_marker_matches_spawn.gd create mode 100644 tests/test_planet_map_marker_matches_spawn.gd.uid create mode 100644 tests/test_planet_prompt_map_interaction.gd create mode 100644 tests/test_planet_prompt_map_interaction.gd.uid create mode 100644 tests/test_planet_spawning.gd create mode 100644 tests/test_planet_spawning.gd.uid create mode 100644 tests/test_prompt_debounce.gd create mode 100644 tests/test_prompt_debounce.gd.uid create mode 100644 tests/test_resources_and_buildings.gd create mode 100644 tests/test_resources_and_buildings.gd.uid create mode 100644 tests/test_return_position_matches_planet.gd create mode 100644 tests/test_return_position_matches_planet.gd.uid create mode 100644 tests/test_ship_facing.gd create mode 100644 tests/test_ship_facing.gd.uid create mode 100644 tests/test_star_coverage.gd create mode 100644 tests/test_star_coverage.gd.uid create mode 100644 tests/test_world_map_ui.gd create mode 100644 tests/test_world_map_ui.gd.uid create mode 100644 ui_theme.tres diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d479896 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Godot's import/cache folder. Regenerated on open, and it also holds +# .godot/export_credentials.cfg, which must never be published. +.godot/ + +# Build outputs and the generated Android build template (~325 MB combined). +# All reproducible: the template comes from Godot's "Install Android Build +# Template", the rest from an export. +builds/ +android/build/ +*.apk +*.aab +*.idsig + +# Android's "don't index this as media" marker, not project content. +.nomedia diff --git a/Asteroid.gd b/Asteroid.gd new file mode 100644 index 0000000..eaac5c2 --- /dev/null +++ b/Asteroid.gd @@ -0,0 +1,40 @@ +extends Area2D + +var velocity: Vector2 = Vector2.ZERO +var rotation_speed: float = 0.0 + +var shape_points: PackedVector2Array = PackedVector2Array() + + +func _ready() -> void: + randomize() + add_to_group("asteroids") + area_entered.connect(_on_area_entered) + + var point_count: int = 7 + for i in range(point_count): + var angle: float = (float(i) / point_count) * TAU + var radius: float = randf_range(14.0, 22.0) + shape_points.append(Vector2(cos(angle), sin(angle)) * radius) + queue_redraw() + + +func _process(delta: float) -> void: + # Deliberately no queue_redraw() here. position and rotation are node + # TRANSFORM properties - Godot applies them when rendering, and the + # polygon built in _ready() is in local space, so it never changes. + # Redrawing each frame re-tessellated four antialiased polylines per + # asteroid for an identical result; with ~150 asteroids on screen that + # was the single largest source of stutter. + position += velocity * delta + rotation += rotation_speed * delta + + +func _on_area_entered(area: Area2D) -> void: + if area.is_in_group("player_ships"): + AsteroidExplosionVFX.spawn(get_tree().root, position) + queue_free() + + +func _draw() -> void: + NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.65, 0.5, 0.35), 1.0) diff --git a/Asteroid.gd.uid b/Asteroid.gd.uid new file mode 100644 index 0000000..1bc36d6 --- /dev/null +++ b/Asteroid.gd.uid @@ -0,0 +1 @@ +uid://cfakkuki24iks diff --git a/Asteroid.tscn b/Asteroid.tscn new file mode 100644 index 0000000..c26e2f2 --- /dev/null +++ b/Asteroid.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://Asteroid.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 18.0 + +[node name="Asteroid" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/AsteroidExplosionVFX.gd b/AsteroidExplosionVFX.gd new file mode 100644 index 0000000..1396edb --- /dev/null +++ b/AsteroidExplosionVFX.gd @@ -0,0 +1,29 @@ +class_name AsteroidExplosionVFX +extends ExplosionVFXBase + +# Tumbling rock-chunk debris, distinct from the energy-ship light-burst +# style - asteroids are matter, not light. + +const COLOR: Color = Color(0.65, 0.5, 0.35) + + +static func spawn(parent: Node, world_position: Vector2) -> void: + var vfx: AsteroidExplosionVFX = AsteroidExplosionVFX.new() + vfx.position = world_position + parent.add_child(vfx) + + +func _random_particle_size() -> float: + return randf_range(3.0, 7.0) + + +func _draw_particle(p: Dictionary, alpha: float) -> void: + var s: float = p["size"] + var local_pts: PackedVector2Array = PackedVector2Array([ + Vector2(-s, -s * 0.6), Vector2(s * 0.7, -s * 0.5), + Vector2(s, s * 0.5), Vector2(-s * 0.6, s), + ]) + var world_pts: PackedVector2Array = PackedVector2Array() + for pt in local_pts: + world_pts.append(p["pos"] + pt.rotated(p["rot"])) + draw_colored_polygon(world_pts, Color(COLOR.r, COLOR.g, COLOR.b, alpha)) diff --git a/AsteroidExplosionVFX.gd.uid b/AsteroidExplosionVFX.gd.uid new file mode 100644 index 0000000..607d892 --- /dev/null +++ b/AsteroidExplosionVFX.gd.uid @@ -0,0 +1 @@ +uid://dfy47yln6oyhx diff --git a/AsteroidField.gd b/AsteroidField.gd new file mode 100644 index 0000000..efb7b39 --- /dev/null +++ b/AsteroidField.gd @@ -0,0 +1,86 @@ +extends Node2D + +# Spawns slow-drifting asteroids inside ASTEROID_FIELD biome chunks near +# the fleet, and despawns them once the fleet has moved far enough away - +# keeps the scene from accumulating unbounded nodes over a long session +# while still populating newly-entered asteroid regions on the fly. + +@export var check_interval: float = 1.0 +@export var spawn_radius_chunks: int = 2 +@export var despawn_radius_chunks: int = 4 +@export var asteroids_per_chunk: int = 6 +@export var min_speed: float = 12.0 +@export var max_speed: float = 35.0 + +@onready var fleet: Node2D = get_node("../Fleet") + +var asteroid_scene: PackedScene = preload("res://Asteroid.tscn") +var populated_chunks: Dictionary = {} +var timer: float = 0.0 + + +func _ready() -> void: + randomize() + + +func _process(delta: float) -> void: + timer += delta + if timer < check_interval: + return + timer = 0.0 + update_chunks() + + +func update_chunks() -> void: + # Public (not just called from _process's own timer): also invoked + # once synchronously right after the fleet is positioned on a fresh + # Main.tscn load, so nearby asteroids exist immediately instead of + # only appearing ~check_interval seconds later - most noticeable as a + # jarring pop-in right after returning from a planet's surface. + var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid()) + + for dx in range(-spawn_radius_chunks, spawn_radius_chunks + 1): + for dy in range(-spawn_radius_chunks, spawn_radius_chunks + 1): + var chunk: Vector2i = center_chunk + Vector2i(dx, dy) + if populated_chunks.has(chunk): + continue + if WorldGen.biome_at_chunk(chunk) != WorldGen.Biome.ASTEROID_FIELD: + continue + _spawn_chunk_asteroids(chunk) + + var to_remove: Array = [] + for chunk in populated_chunks.keys(): + if _chebyshev_chunk_distance(chunk, center_chunk) > despawn_radius_chunks: + for asteroid in populated_chunks[chunk]: + if is_instance_valid(asteroid): + asteroid.queue_free() + to_remove.append(chunk) + for chunk in to_remove: + populated_chunks.erase(chunk) + + +func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int: + return max(abs(a.x - b.x), abs(a.y - b.y)) + + +func _spawn_chunk_asteroids(chunk: Vector2i) -> void: + var origin: Vector2 = WorldGen.chunk_origin(chunk) + var spawned: Array = [] + + for i in range(asteroids_per_chunk): + var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE) + var asteroid: Node2D = asteroid_scene.instantiate() + asteroid.position = origin + local_pos + var angle: float = randf() * TAU + var speed: float = randf_range(min_speed, max_speed) + asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed + asteroid.rotation_speed = randf_range(-0.4, 0.4) + # Deferred: update_chunks() can run synchronously from inside a + # parent's own _ready() chain (forced once right after the fleet + # is positioned, to avoid a pop-in on scene load) - Godot refuses + # a direct add_child() while the tree is still "busy setting up + # children" in that situation, so this must be deferred either way. + get_parent().add_child.call_deferred(asteroid) + spawned.append(asteroid) + + populated_chunks[chunk] = spawned diff --git a/AsteroidField.gd.uid b/AsteroidField.gd.uid new file mode 100644 index 0000000..c53713f --- /dev/null +++ b/AsteroidField.gd.uid @@ -0,0 +1 @@ +uid://bmwogtrwsr7kh diff --git a/BuildController.gd b/BuildController.gd new file mode 100644 index 0000000..1931cae --- /dev/null +++ b/BuildController.gd @@ -0,0 +1,351 @@ +extends Node2D + +# Space-side building UI. Owns: +# - the build menu (place a mothership, ship crafter or command centre) +# - the shared ACTION button, which adapts to whatever the fleet is +# standing on (craft ships at a crafter, configure formation at a +# command centre). The enter-atmosphere prompt uses the same slot, and +# PlanetAtmosphereController suppresses itself while a building prompt +# is showing so the two can never fight over it. +# - rebuilding a wiped-out fleet at the mothership +# +# Buildings themselves live in GameState (they must outlive the trip to a +# planet's surface); this only drives the UI and spends resources. + +@export var prompt_debounce_time: float = 0.15 + +@onready var fleet: Node2D = get_node("../Fleet") +@onready var building_field: Node2D = get_node("../BuildingField") +@onready var world_map: Control = get_node("../UI/WorldMap") +@onready var build_button: IconButton = get_node("../UI/BuildButton") +@onready var action_button: IconButton = get_node("../UI/BuildActionButton") +@onready var action_label: Label = get_node("../UI/BuildActionLabel") + +@onready var build_menu: Control = get_node("../UI/BuildMenu") +@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton") +@onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton") +@onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton") +@onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") +@onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") +@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") +@onready var planet_field: Node2D = get_node("../PlanetField") +@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") +@onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") +@onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel") +@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") +@onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton") +@onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton") + +@onready var craft_menu: Control = get_node("../UI/CraftMenu") +@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") +@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") +@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") + +@onready var formation_menu: Control = get_node("../UI/FormationMenu") +@onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor") +@onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") +@onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton") + +# What each option is and does, shown before anything is spent. Picking a +# structure is a real commitment - it costs ore, and a mothership placement +# can destroy the one you already have - so no option builds straight from +# the menu; each one explains itself first and waits for a confirmation. +# +# The bodies are kept short on purpose: at the UI's font size the popup holds +# roughly eight lines, and a placement condition or a price warning is +# appended below them and needs some of those lines. +const BUILDING_INFO: Dictionary = { + GameState.BUILDING_MOTHERSHIP: { + "title": "Mothership", + "body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.", + }, + GameState.BUILDING_SHIP_CRAFTER: { + "title": "Ship Crafter", + "body": "Fly the fleet onto it, then use the action button to build extra vessels.", + }, + GameState.BUILDING_COMMAND_CENTER: { + "title": "Command Center", + "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", + }, + GameState.BUILDING_EXTRACTOR: { + "title": "Extractor", + "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", + }, +} + +# Which structure the popup is currently offering to build, or "" when it is +# only explaining something that cannot be built right now. +var pending_build_type: String = "" + +var current_building: Node2D = null +var pending_building: Node2D = null +var pending_time: float = 0.0 + + +func _ready() -> void: + build_button.pressed.connect(_on_build_pressed) + build_close_button.pressed.connect(func(): build_menu.visible = false) + build_mothership_button.pressed.connect(_on_place_mothership) + build_crafter_button.pressed.connect(_on_place_crafter) + build_command_button.pressed.connect(_on_place_command_center) + build_extractor_button.pressed.connect(_on_place_extractor) + info_close_button.pressed.connect(_close_info) + info_build_button.pressed.connect(_confirm_build) + + action_button.pressed.connect(_on_action_pressed) + + craft_interceptor_button.pressed.connect(_on_craft_interceptor) + craft_close_button.pressed.connect(func(): craft_menu.visible = false) + + formation_close_button.pressed.connect(func(): formation_menu.visible = false) + formation_reset_button.pressed.connect(_on_reset_formation) + formation_editor.formation_changed.connect(_on_formation_changed) + + build_menu.visible = false + info_popup.visible = false + craft_menu.visible = false + formation_menu.visible = false + action_button.visible = false + action_label.visible = false + + +# True while a building prompt or panel owns the shared action slot, so the +# enter-atmosphere prompt knows to stay out of the way. +func is_showing_building_prompt() -> bool: + return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible + + +func _unhandled_input(event: InputEvent) -> void: + # The formation editor is driven from here rather than via _gui_input, + # for the same reason as the joystick: its Control must not swallow + # touches, or dragging a slot would never reach it. + if not formation_menu.visible: + return + if event is InputEventScreenTouch and formation_editor.handle_touch(event): + get_viewport().set_input_as_handled() + elif event is InputEventScreenDrag and formation_editor.handle_drag(event): + get_viewport().set_input_as_handled() + + +func _process(delta: float) -> void: + _check_fleet_wipe() + + if build_menu.visible: + # Keep the greyed state live while the menu is open, so flying onto + # a planet lights the option up without reopening it. + build_extractor_button.enabled = _planet_under_fleet() != null + + if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible: + current_building = null + pending_building = null + action_button.visible = false + action_label.visible = false + return + + # Debounced exactly like the enter-atmosphere prompt, so the action + # button can't flicker while the fleet drifts on the interact boundary. + var detected: Node2D = building_field.get_building_fleet_is_on() + if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP: + detected = null # nothing to do at a mothership beyond respawning + + if detected != pending_building: + pending_building = detected + pending_time = 0.0 + else: + pending_time += delta + + if pending_time < prompt_debounce_time or pending_building == current_building: + return + + current_building = pending_building + action_button.visible = current_building != null + action_label.visible = current_building != null + if current_building != null: + action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP + action_label.text = current_building.get_display_name() + action_button.queue_redraw() + + +func _check_fleet_wipe() -> void: + if not fleet.ships.is_empty(): + return + # Whole fleet destroyed: rebuild a single vessel at the mothership, or + # at the world origin when none has been built yet. + var spawn_pos: Variant = GameState.get_mothership_position() + fleet.spawn_ships(1, spawn_pos if spawn_pos != null else Vector2.ZERO) + + +func _on_action_pressed() -> void: + if current_building == null: + return + if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: + craft_menu.visible = true + _refresh_craft_status() + elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: + formation_menu.visible = true + formation_editor.queue_redraw() + + +func _on_build_pressed() -> void: + build_menu.visible = true + info_popup.visible = false + pending_build_type = "" + _refresh_build_status() + + +func _planet_under_fleet() -> Node2D: + # An extractor mines a specific planet, so one has to be within reach. + return planet_field.get_planet_fleet_is_on() + + +func _refresh_build_status() -> void: + # The extractor option is always listed, but greyed unless the fleet is + # actually over a planet - showing it only sometimes would leave the + # player wondering whether the game has it at all. + build_extractor_button.enabled = _planet_under_fleet() != null + + # Prices are shown under each option now, so this only needs to say + # whether the fleet is somewhere an extractor could go. + if _planet_under_fleet() != null: + build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name + else: + build_status.text = "" + + +func _on_place_mothership() -> void: + _show_building_info(GameState.BUILDING_MOTHERSHIP) + + +func _on_place_crafter() -> void: + _show_building_info(GameState.BUILDING_SHIP_CRAFTER) + + +func _on_place_command_center() -> void: + _show_building_info(GameState.BUILDING_COMMAND_CENTER) + + +func _on_place_extractor() -> void: + # The option stays pressable while greyed, so this is also how a + # not-over-a-planet extractor explains itself. + _show_building_info(GameState.BUILDING_EXTRACTOR) + + +func cost_of(type: String) -> int: + match type: + GameState.BUILDING_MOTHERSHIP: + return GameState.MOTHERSHIP_COST + GameState.BUILDING_SHIP_CRAFTER: + return GameState.SHIP_CRAFTER_COST + GameState.BUILDING_COMMAND_CENTER: + return GameState.COMMAND_CENTER_COST + _: + return GameState.EXTRACTOR_COST + + +func _show_building_info(type: String) -> void: + var info: Dictionary = BUILDING_INFO[type] + var cost: int = cost_of(type) + var lines: Array = [info.body] + var can_build: bool = true + + # Anything that would stop this particular placement is spelled out in + # the same popup, rather than silently failing after the press. + if type == GameState.BUILDING_EXTRACTOR: + var planet: Node2D = _planet_under_fleet() + if planet == null: + lines.append("No planet in reach - fly over one.") + can_build = false + elif GameState.has_extractor_at(planet.planet_name): + lines.append("%s already has an extractor." % planet.planet_name) + can_build = false + else: + lines.append("Will mine %s." % planet.planet_name) + elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + lines.append("WARNING: only one may exist - this destroys your current one.") + + if can_build and not GameState.can_afford(cost): + lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks]) + can_build = false + + info_title.text = info.title + info_message.text = "\n\n".join(lines) + info_cost.amount = cost + info_build_button.visible = can_build + pending_build_type = type if can_build else "" + info_popup.visible = true + + +func _close_info() -> void: + info_popup.visible = false + pending_build_type = "" + + +func _confirm_build() -> void: + var type: String = pending_build_type + if type == "": + return + pending_build_type = "" + info_popup.visible = false + + # Everything is re-checked here rather than trusted from when the popup + # opened: the fleet keeps flying while it is up, so it can drift off the + # planet an extractor was going to mine. + if type == GameState.BUILDING_EXTRACTOR: + var planet: Node2D = _planet_under_fleet() + if planet == null or GameState.has_extractor_at(planet.planet_name): + build_status.text = "No planet in reach any more" + return + if not GameState.spend(GameState.EXTRACTOR_COST): + build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] + return + GameState.add_extractor(planet.planet_name, planet.position) + build_menu.visible = false + return + + if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + if not GameState.can_afford(GameState.MOTHERSHIP_COST): + build_status.text = "Not enough rocks (need %d, have %d)" % [ + GameState.MOTHERSHIP_COST, GameState.rocks + ] + return + # Only now is the old one destroyed - never before the new one is + # known to be affordable, or the player would be left with none. + GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP) + building_field.rebuild_all() + + _try_place(type, cost_of(type)) + + +func _try_place(type: String, cost: int) -> void: + if not GameState.spend(cost): + build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks] + return + building_field.place(type, fleet.get_centroid()) + build_menu.visible = false + + +func _refresh_craft_status() -> void: + craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ + GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size() + ] + + +func _on_craft_interceptor() -> void: + if fleet.ships.size() >= GameState.formation.size(): + craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size() + return + if not GameState.spend(GameState.SHIP_COST): + craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] + return + fleet.add_ship() + _refresh_craft_status() + + +func _on_formation_changed() -> void: + fleet.apply_formation() + + +func _on_reset_formation() -> void: + GameState.reset_formation() + fleet.apply_formation() + formation_editor.queue_redraw() diff --git a/BuildController.gd.uid b/BuildController.gd.uid new file mode 100644 index 0000000..2272b2c --- /dev/null +++ b/BuildController.gd.uid @@ -0,0 +1 @@ +uid://csfvnrer8sb4a diff --git a/Building.gd b/Building.gd new file mode 100644 index 0000000..e9fe17a --- /dev/null +++ b/Building.gd @@ -0,0 +1,364 @@ +extends Node2D + +# A player-placed structure in space. Three kinds for now: +# mothership - where a wiped-out fleet is rebuilt +# ship_crafter - fly the fleet onto it to buy additional ships +# command_center - reconfigure the fleet formation +# +# Buildings are stationary and harmless; whether the fleet is "on" one is +# decided by BuildingField comparing the fleet CENTROID against +# interact_radius, the same stable single-number test Planet uses (see +# Planet.gd for why per-ship overlap was a poor fit). +# +# Nothing here rests on ground or vents into an atmosphere - these are +# orbital structures, so they are built out of trusses, radiator fins, +# reactor cores and open cradles rather than walls and roofs. +# +# The crafter and the mothership animate, so they run _process. The command +# centre is drawn once and never redrawn - per-frame redraws of art that +# never changes were a real framerate cost earlier on. + +@export var building_type: String = "mothership" +@export var interact_radius: float = 150.0 + +const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) +const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) +const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0) +# The vessel on the assembly line is drawn in the fleet's own colour, so it +# reads as "one of yours being built" rather than as shipyard machinery. +const HULL_COLOR: Color = Color(0.25, 0.85, 1.0) + +# One assembly cycle: parts are welded on, the finished hull flashes, then +# it clears the cradle and the line starts over. +const CYCLE: float = 3.6 +const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding +const FLASH_END: float = 0.76 # ... then the completion flash + # ... then launch, to 1.0 + +const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0) +const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0) +const RIB_RADIUS: float = 36.0 +const RAIL_Y: float = -32.0 + +# The hull in four pieces, attached in this order. Each carries the point +# the crane welds it on at, which is where the sparks fly from. +const SHIP_PARTS: Array = [ + {"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]}, + {"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]}, + {"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]}, + {"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]}, +] + +# The assembly clock, wrapped at CYCLE because the whole build sequence is +# expressed as a fraction of it. +var anim_time: float = 0.0 + +# Free-running seconds for everything that is merely cyclic - reactor pulses, +# orbiting satellites, blinking beacons. These must NOT share anim_time: +# CYCLE is 3.6s, which is not a whole number of periods for any of them, so +# wrapping made every one of them leap at the wrap. The satellites were the +# obvious casualty, snapping back 66 degrees every 3.6 seconds. +# +# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours +# of play the argument to sin() keeps far more precision than a frame needs, +# and any wrap point would reintroduce exactly the discontinuity above. +var phase_time: float = 0.0 + +var _hull_parts: Array[PackedVector2Array] = [] + + +func _ready() -> void: + add_to_group("buildings") + z_index = -1 + # The crafter runs an assembly line and the mothership holds a live + # reactor with satellites in orbit, so both redraw. The command centre is + # static art and is drawn exactly once - per-frame redraws of art that + # never changes were a real framerate cost earlier on. + var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER + set_process(animated) + if building_type == GameState.BUILDING_SHIP_CRAFTER: + for part in SHIP_PARTS: + _hull_parts.append(PackedVector2Array(part.points)) + queue_redraw() + + +func _process(delta: float) -> void: + anim_time = fmod(anim_time + delta, CYCLE) + phase_time += delta + queue_redraw() + + +# Where the satellites are, and how open the reactor core is. Both are read +# by _draw and by the test that checks they stay continuous across the +# assembly clock's wrap. +func orbit_angle() -> float: + return phase_time * ORBIT_SPEED + + +func core_pulse() -> float: + return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED) + + +func get_display_name() -> String: + match building_type: + GameState.BUILDING_MOTHERSHIP: + return "Mothership" + GameState.BUILDING_SHIP_CRAFTER: + return "Ship Crafter" + _: + return "Command Center" + + +func _draw() -> void: + match building_type: + GameState.BUILDING_COMMAND_CENTER: + _draw_command_center() + GameState.BUILDING_MOTHERSHIP: + _draw_mothership() + _: + _draw_crafter() + + +# --- shared helpers ------------------------------------------------------- + +func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(sides): + var a: float = (float(i) / float(sides)) * TAU + phase + pts.append(centre + Vector2(cos(a), sin(a)) * radius) + return pts + + +func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(steps + 1): + var a: float = lerpf(from_a, to_a, float(i) / float(steps)) + pts.append(centre + Vector2(cos(a), sin(a)) * radius) + return pts + + + + +func _draw_command_center() -> void: + # Diamond with three formation pips inside - reads as "arrangement". + var body: PackedVector2Array = PackedVector2Array([ + Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0), + ]) + NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0) + for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]: + var pip: PackedVector2Array = PackedVector2Array([ + p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8), + ]) + NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0) + + +# --- mothership: a radial flagship ---------------------------------------- +# Not a big fighter. A mothership is the thing everything else orbits, so it +# is built radially around a reactor core rather than pointed in a direction: +# six swept talons, crystal panels set between them, and satellites circling +# the whole assembly. A forward-facing hull, however large, just reads as +# another ship. + +const ARM_COUNT: int = 6 +const ARM_INNER: float = 42.0 +const ARM_OUTER: float = 106.0 +const ARM_SWEEP: float = 0.72 +const SATELLITE_COUNT: int = 4 +const SATELLITE_ORBIT: float = 126.0 +const ORBIT_SPEED: float = 0.32 +const PULSE_SPEED: float = 2.4 + + +func _talon(base_angle: float) -> PackedVector2Array: + # One swept claw: the outer edge runs from the core to the tip, and the + # inner edge comes back a little behind it, closing to a point so the arm + # tapers instead of ending in a blunt stub. + var steps: int = 7 + var pts: PackedVector2Array = PackedVector2Array() + for i in range(steps + 1): + var f: float = float(i) / float(steps) + var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) + pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) + for i in range(steps, -1, -1): + var f: float = float(i) / float(steps) + var trail: float = 0.34 * (1.0 - f) + 0.04 + var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail + pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) + return pts + + +func _draw_mothership() -> void: + var c: Color = MOTHERSHIP_COLOR + var core_color: Color = Color(0.75, 1.0, 0.85) + + for i in range(ARM_COUNT): + var base: float = (float(i) / float(ARM_COUNT)) * TAU + NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95) + # Crystal panel set into the gap behind each arm. + var pa: float = base - 0.30 + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0, + Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0, + Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0, + Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0, + ]), c, 0.7) + + # Reactor core: nested rings around a cell that breathes. + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) + var breath: float = core_pulse() + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0), + core_color, lerpf(0.55, 1.0, breath)) + + # Satellites holding station around it, drifting slowly round. + var orbit: float = orbit_angle() + for i in range(SATELLITE_COUNT): + var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU + var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT + NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85) + NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8) + + +# --- ship crafter: an orbital yard with a hull in the cradle -------------- + +func _draw_crafter() -> void: + var c: Color = CRAFTER_COLOR + var t: float = anim_time / CYCLE + + # Fabricator core: a reactor housing whose inner cell pulses as it feeds + # the yard. This replaces the chimney - nothing vents smoke in vacuum. + NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) + var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) + NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow) + + # Radiator fins above and below the core, slatted like real ones. + for dir_y in [-1.0, 1.0]: + var fin: PackedVector2Array = PackedVector2Array([ + CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), + CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), + ]) + NeonDraw.draw_glowing_polygon(self, fin, c, 0.75) + for f in [0.35, 0.68]: + NeonDraw.draw_glowing_line(self, + fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28) + + # Truss spine linking the core to the cradle: two rails with X-bracing, + # the standard "this was assembled in orbit" read. + var spine_top: float = 4.0 + var spine_bottom: float = 22.0 + NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) + NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) + for i in range(3): + var x0: float = -32.0 + float(i) * 15.0 + var x1: float = x0 + 15.0 + NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) + NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) + + # Construction cradle: two open ribs holding the hull, with the crane + # rail overhead. Open at the top so the finished hull can clear it. + NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) + for side in [-1.0, 1.0]: + NeonDraw.draw_glowing_line(self, + BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3) + + # Docking beacons, blinking out of step with each other. + for i in range(2): + var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0) + var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) + NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink) + + if t < ASSEMBLE_END: + _draw_assembly(t / ASSEMBLE_END, c) + elif t < FLASH_END: + _draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c) + else: + _draw_launch((t - FLASH_END) / (1.0 - FLASH_END)) + + +# How many pieces are on the hull at this point through the assembly phase. +# Parts attach in order, so this is just how many thresholds have passed. +func attached_part_count(p: float) -> int: + var n: int = 0 + for part in SHIP_PARTS: + if p >= float(part.at): + n += 1 + return n + + +func _draw_clamps(c: Color, pulse: float) -> void: + # Arms reaching in from each rib to hold the hull steady. They let go + # once it launches, so this is not drawn during the launch phase. + for side in [-1.0, 1.0]: + var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) + var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) + NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35) + NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3) + + +func _draw_assembly(p: float, c: Color) -> void: + _draw_clamps(c, 0.8) + + # Everything welded on so far, newest part still glowing from the seam. + var next_part: int = attached_part_count(p) + for i in range(next_part): + # Freshly attached parts flare, then settle to full brightness. + var age: float = p - float(SHIP_PARTS[i].at) + var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) + _draw_hull_part(i, BAY_CENTRE, pulse) + + # The crane rides the rail over whichever seam is being worked, and + # retracts once there is nothing left to attach. + var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) + if next_part >= _hull_parts.size(): + weld.y = RAIL_Y + 16.0 + var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0) + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + head + Vector2(-10, -7), head + Vector2(10, -7), + head + Vector2(6, 6), head + Vector2(-6, 6), + ]), c, 1.0) + NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35) + NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3) + + if next_part < _hull_parts.size(): + # Weld sparks: short spokes whose angles jitter frame to frame. + var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0)) + for i in range(4): + var a: float = phase_time * 7.0 + float(i) * TAU / 4.0 + var length: float = 6.0 + 5.0 * flicker + NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length, + Color(1.0, 0.95, 0.7), flicker, 0.3) + + +func _draw_completion_flash(p: float, c: Color) -> void: + # Whole hull, clamps springing open, and a ring pushing outwards to mark + # it as finished. + _draw_clamps(c, 1.0 - p) + for i in range(_hull_parts.size()): + _draw_hull_part(i, BAY_CENTRE, 1.0) + NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p) + + +func _draw_launch(p: float) -> void: + # Clears the cradle and fades out; the next cycle starts from bare ribs. + var eased: float = p * p + var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased) + var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) + for i in range(_hull_parts.size()): + _draw_hull_part(i, centre, fade) + # Engine wash, strongest just after it breaks free of the clamps. + var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI))) + for dx in [-4.0, 4.0]: + NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14), + centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust), + Color(1.0, 0.85, 0.45), thrust, 0.45) + + +func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void: + if pulse <= 0.01: + return + var moved: PackedVector2Array = PackedVector2Array() + for point in _hull_parts[index]: + moved.append(centre + point) + NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse) diff --git a/Building.gd.uid b/Building.gd.uid new file mode 100644 index 0000000..d33bd7f --- /dev/null +++ b/Building.gd.uid @@ -0,0 +1 @@ +uid://c8udgpiqd4rul diff --git a/Building.tscn b/Building.tscn new file mode 100644 index 0000000..174c302 --- /dev/null +++ b/Building.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Building.gd" id="1"] + +[node name="Building" type="Node2D"] +script = ExtResource("1") diff --git a/BuildingField.gd b/BuildingField.gd new file mode 100644 index 0000000..cd27afe --- /dev/null +++ b/BuildingField.gd @@ -0,0 +1,50 @@ +extends Node2D + +# Rebuilds every player-placed structure from GameState when the space +# scene loads, and answers "is the fleet standing on one?". +# +# Buildings live in GameState rather than in the scene because Main.tscn is +# torn down and recreated on every trip to a planet's surface - anything +# kept only as a node would be lost on the first landing. + +@onready var fleet: Node2D = get_node("../Fleet") + +var building_scene: PackedScene = preload("res://Building.tscn") +var spawned: Array = [] + + +func _ready() -> void: + rebuild_all() + + +func rebuild_all() -> void: + for b in spawned: + if is_instance_valid(b): + b.queue_free() + spawned.clear() + for record in GameState.buildings: + _spawn(record) + + +func place(type: String, at_position: Vector2) -> void: + GameState.add_building(type, at_position) + _spawn(GameState.buildings[-1]) + + +func get_building_fleet_is_on() -> Node2D: + var centre: Vector2 = fleet.get_centroid() + for b in spawned: + if is_instance_valid(b) and b.position.distance_to(centre) <= b.interact_radius: + return b + return null + + +func _spawn(record: Dictionary) -> void: + var b: Node2D = building_scene.instantiate() + b.position = record.position + b.building_type = record.type + # Deferred for the same reason as the other field spawners: this can run + # from inside a parent's _ready() chain, where a direct add_child() is + # rejected while the tree is still setting up children. + get_parent().add_child.call_deferred(b) + spawned.append(b) diff --git a/BuildingField.gd.uid b/BuildingField.gd.uid new file mode 100644 index 0000000..3ec27c9 --- /dev/null +++ b/BuildingField.gd.uid @@ -0,0 +1 @@ +uid://bomt0q8eour38 diff --git a/Enemy.gd b/Enemy.gd new file mode 100644 index 0000000..5f1ee45 --- /dev/null +++ b/Enemy.gd @@ -0,0 +1,64 @@ +extends Area2D + +signal destroyed + +@export var speed: float = 140.0 +@export var acceleration: float = 260.0 +@export var rotation_speed: float = 6.0 +@export var max_hp: int = 3 + +var velocity: Vector2 = Vector2.ZERO +var hp: int + +@onready var fleet: Node2D = get_node("../Fleet") + + + +func _ready() -> void: + hp = max_hp + add_to_group("enemies") + queue_redraw() + + +func _physics_process(delta: float) -> void: + var target_pos: Vector2 = _nearest_ship_position() + var to_target: Vector2 = target_pos - position + + if to_target.length() > 1.0: + var desired_velocity: Vector2 = to_target.normalized() * speed + velocity = velocity.move_toward(desired_velocity, acceleration * delta) + position += velocity * delta + + if velocity.length() > 5.0: + var target_angle: float = velocity.angle() + PI / 2.0 + rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) + + +func _nearest_ship_position() -> Vector2: + if fleet.ships.is_empty(): + return position + + var nearest: Node2D = fleet.ships[0] + var best_dist: float = position.distance_to(nearest.position) + for ship in fleet.ships: + var d: float = position.distance_to(ship.position) + if d < best_dist: + best_dist = d + nearest = ship + return nearest.position + + +func take_damage(amount: int) -> void: + hp -= amount + if hp <= 0: + EnemyExplosionVFX.spawn(get_tree().root, position) + destroyed.emit() + queue_free() + + +func _draw() -> void: + var points: PackedVector2Array = PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0) + ]) + # Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw. + NeonDraw.draw_glowing_polygon(self, points, Color(1.0, 0.15, 0.2), 1.0) diff --git a/Enemy.gd.uid b/Enemy.gd.uid new file mode 100644 index 0000000..667c14f --- /dev/null +++ b/Enemy.gd.uid @@ -0,0 +1 @@ +uid://b7b4c2nvmevyl diff --git a/Enemy.tscn b/Enemy.tscn new file mode 100644 index 0000000..7b3ac89 --- /dev/null +++ b/Enemy.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://Enemy.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 14.0 + +[node name="Enemy" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/EnemyExplosionVFX.gd b/EnemyExplosionVFX.gd new file mode 100644 index 0000000..ce6c437 --- /dev/null +++ b/EnemyExplosionVFX.gd @@ -0,0 +1,12 @@ +class_name EnemyExplosionVFX +extends LightBurstVFX + + +func _init() -> void: + color = Color(1.0, 0.15, 0.2) + + +static func spawn(parent: Node, world_position: Vector2) -> void: + var vfx: EnemyExplosionVFX = EnemyExplosionVFX.new() + vfx.position = world_position + parent.add_child(vfx) diff --git a/EnemyExplosionVFX.gd.uid b/EnemyExplosionVFX.gd.uid new file mode 100644 index 0000000..f4c272b --- /dev/null +++ b/EnemyExplosionVFX.gd.uid @@ -0,0 +1 @@ +uid://cblt8bqc6mc3q diff --git a/EnemySpawner.gd b/EnemySpawner.gd new file mode 100644 index 0000000..1ddb7e9 --- /dev/null +++ b/EnemySpawner.gd @@ -0,0 +1,42 @@ +extends Node2D + +@export var min_spawn_interval: float = 3.0 +@export var max_spawn_interval: float = 6.0 +@export var spawn_margin: float = 150.0 + +@onready var fleet: Node2D = get_node("../Fleet") + +var enemy_scene: PackedScene = preload("res://Enemy.tscn") +var timer: float = 0.0 +var next_spawn_time: float = 0.0 + + +func _ready() -> void: + randomize() + _reset_timer() + + +func _process(delta: float) -> void: + timer += delta + if timer >= next_spawn_time: + timer = 0.0 + _reset_timer() + _spawn_enemy() + + +func _reset_timer() -> void: + next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval) + + +func _spawn_enemy() -> void: + if fleet.ships.is_empty(): + return + + var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self) + var radius: float = visible_rect.size.length() / 2.0 + spawn_margin + var angle: float = randf() * TAU + var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius + + var enemy: Node2D = enemy_scene.instantiate() + enemy.position = spawn_pos + get_parent().add_child(enemy) diff --git a/EnemySpawner.gd.uid b/EnemySpawner.gd.uid new file mode 100644 index 0000000..be4e84d --- /dev/null +++ b/EnemySpawner.gd.uid @@ -0,0 +1 @@ +uid://g3c73d6givuo diff --git a/ExplorationTracker.gd b/ExplorationTracker.gd new file mode 100644 index 0000000..a57fcbd --- /dev/null +++ b/ExplorationTracker.gd @@ -0,0 +1,23 @@ +extends Node2D + +# Tracks which world chunks have been explored (fleet has come within +# sensor range), so the world map can reveal only chunks actually visited +# instead of the whole procedurally generated world at once. Writes +# directly into GameState.explored_chunks (not a local dict) so this +# history survives the Main.tscn <-> PlanetSurface.tscn scene swap, which +# tears down and recreates this node from scratch each time. + +@export var sensor_radius_chunks: int = 1 + +@onready var fleet: Node2D = get_node("../Fleet") + + +func _process(_delta: float) -> void: + var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid()) + for dx in range(-sensor_radius_chunks, sensor_radius_chunks + 1): + for dy in range(-sensor_radius_chunks, sensor_radius_chunks + 1): + GameState.explored_chunks[center_chunk + Vector2i(dx, dy)] = true + + +func get_explored_chunks() -> Array: + return GameState.explored_chunks.keys() diff --git a/ExplorationTracker.gd.uid b/ExplorationTracker.gd.uid new file mode 100644 index 0000000..039fe2f --- /dev/null +++ b/ExplorationTracker.gd.uid @@ -0,0 +1 @@ +uid://gsuymyal5kyd diff --git a/ExplosionVFXBase.gd b/ExplosionVFXBase.gd new file mode 100644 index 0000000..1196585 --- /dev/null +++ b/ExplosionVFXBase.gd @@ -0,0 +1,58 @@ +class_name ExplosionVFXBase +extends Node2D + +# Shared particle motion/fade/lifetime logic for all explosion VFX types. +# Subclasses fix their own visual style and color as facts about +# themselves (see LightBurstVFX/AsteroidExplosionVFX), not as parameters +# passed in by whatever destroyed the object - so "ships always explode +# blue" is guaranteed by the class hierarchy, not by remembering to pass +# the right Color() at each call site. + +@export var particle_count: int = 12 +@export var lifetime: float = 0.5 +@export var min_speed: float = 60.0 +@export var max_speed: float = 220.0 + +var age: float = 0.0 +var particles: Array = [] + + +func _ready() -> void: + randomize() + for i in range(particle_count): + var angle: float = randf() * TAU + var speed: float = randf_range(min_speed, max_speed) + particles.append({ + "pos": Vector2.ZERO, + "vel": Vector2(cos(angle), sin(angle)) * speed, + "rot": randf() * TAU, + "rot_speed": randf_range(-6.0, 6.0), + "size": _random_particle_size(), + }) + queue_redraw() + + +func _process(delta: float) -> void: + age += delta + if age >= lifetime: + queue_free() + return + for p in particles: + p["pos"] += p["vel"] * delta + p["vel"] *= 0.9 + p["rot"] += p["rot_speed"] * delta + queue_redraw() + + +func _draw() -> void: + var alpha: float = clamp(1.0 - (age / lifetime), 0.0, 1.0) + for p in particles: + _draw_particle(p, alpha) + + +func _random_particle_size() -> float: + return randf_range(10.0, 20.0) + + +func _draw_particle(_p: Dictionary, _alpha: float) -> void: + pass diff --git a/ExplosionVFXBase.gd.uid b/ExplosionVFXBase.gd.uid new file mode 100644 index 0000000..8aad100 --- /dev/null +++ b/ExplosionVFXBase.gd.uid @@ -0,0 +1 @@ +uid://cpb6vf3j6og6r diff --git a/Fleet.gd b/Fleet.gd new file mode 100644 index 0000000..e19a06d --- /dev/null +++ b/Fleet.gd @@ -0,0 +1,195 @@ +extends Node2D + +# Formation slots come from GameState.formation (editable at a command +# centre) rather than being fixed here, so a layout the player arranged +# survives scene swaps and fleet rebuilds. Ships are always +# spawned programmatically (spawn_ships), using however many of these +# offsets are needed - both for a fresh start (5 ships) and for restoring +# a possibly-smaller surviving fleet after returning from a planet's +# surface, so both cases share one code path instead of two that could +# drift out of sync. +# The fleet's cruise speed. This is the speed of the FLEET as a whole: the +# anchor below advances at exactly this rate, and individual ships are +# allowed to fly faster than it (Ship.max_speed) so they can close on their +# formation slot - which is what makes a pivot while under way look sharp +# instead of sluggish. +@export var cruise_speed: float = 260.0 + +# Per-scene neon palette for the fleet. PlanetSurface.tscn overrides these +# with darker values, since the lunar surface is bright grey and the default +# near-white core would wash out against it. See NeonDraw. +@export var ship_glow_color: Color = Color(0.25, 0.85, 1.0) +@export var ship_core_color: Color = Color(1.0, 1.0, 1.0) + +var ship_scene: PackedScene = preload("res://Ship.tscn") + +var ships: Array = [] +var formation_offsets: Array = [] +var fleet_target: Vector2 = Vector2.ZERO +# Formation centre while under joystick steering, and the heading it is +# travelling along. Zero heading means the fleet is not being steered. +var anchor: Vector2 = Vector2.ZERO +var steer_direction: Vector2 = Vector2.ZERO + + +func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: + for ship in ships: + if is_instance_valid(ship): + ship.queue_free() + ships.clear() + formation_offsets.clear() + + var n: int = clamp(count, 1, GameState.formation.size()) + for i in range(n): + var offset: Vector2 = GameState.formation[i] + var ship: Area2D = ship_scene.instantiate() + ship.position = center + offset + ship.glow_color = ship_glow_color + ship.core_color = ship_core_color + add_child(ship) + ships.append(ship) + formation_offsets.append(offset) + if ship.has_signal("destroyed"): + ship.destroyed.connect(_on_ship_destroyed.bind(ship)) + + fleet_target = center + + +func add_ship() -> bool: + # Adds one vessel in the next free formation slot (bought from a ship + # crafter). Returns false when the wedge is already full. + if ships.size() >= GameState.formation.size(): + return false + var offset: Vector2 = GameState.formation[ships.size()] + var ship: Area2D = ship_scene.instantiate() + # From the formation origin, not the centroid: with a lopsided layout the + # centroid is already displaced by the average offset, so adding the new + # ship's own offset to it would drop it well outside the wedge. + ship.position = get_formation_origin() + offset + ship.glow_color = ship_glow_color + ship.core_color = ship_core_color + add_child(ship) + ships.append(ship) + formation_offsets.append(offset) + if ship.has_signal("destroyed"): + ship.destroyed.connect(_on_ship_destroyed.bind(ship)) + ship.set_move_target(fleet_target + offset) + return true + + +func apply_formation() -> void: + # Re-seats the EXISTING ships into the current GameState.formation. + # Without this, editing the layout at a command centre (or pivoting it) + # only affected ships built later, which read as the edit doing nothing. + for i in range(ships.size()): + if i >= GameState.formation.size(): + break + formation_offsets[i] = GameState.formation[i] + _assign_slot_targets() + + +func move_to(world_pos: Vector2) -> void: + steer_direction = Vector2.ZERO + fleet_target = world_pos + anchor = world_pos + _assign_slot_targets() + + +func move_in_direction(direction: Vector2) -> void: + # Joystick steering. Sets the heading; _process advances the anchor. + # + # This used to park a target 2000 units ahead and let the ships chase + # it. That made formation changes barely register while flying: with + # the aim point that far away, a 60-unit formation offset moved a + # ship's heading by under two degrees, so pivoting mid-flight looked + # like it did almost nothing. Anchoring the formation and having each + # ship hold its own slot relative to it fixes that. + if direction == Vector2.ZERO: + stop() + return + if steer_direction == Vector2.ZERO: + anchor = get_formation_origin() + steer_direction = direction.normalized() + + +func _process(delta: float) -> void: + if steer_direction == Vector2.ZERO: + return + # The anchor - the fleet's formation centre - travels at cruise_speed. + # Ships may exceed it individually to reach their slots. + anchor += steer_direction * cruise_speed * delta + fleet_target = anchor + _assign_slot_targets() + + +func _assign_slot_targets() -> void: + for i in range(ships.size()): + ships[i].set_move_target(fleet_target + formation_offsets[i]) + # Context, not a command: the ship adds this to its own velocity to + # work out how it is really moving. See Ship.fleet_velocity. + ships[i].fleet_velocity = steer_direction * cruise_speed + + +func stop() -> void: + steer_direction = Vector2.ZERO + # Aim each ship at the point where its own braking curve brings it to + # rest, so releasing the stick coasts to a halt instead of snapping to + # a dead stop (which is what targeting the current position would do, + # since that lands inside arrive_radius immediately). + fleet_target = get_formation_origin() + for ship in ships: + var speed: float = ship.velocity.length() + if speed <= 0.001: + ship.set_move_target(ship.position) + continue + var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration) + ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance) + + +func get_move_targets() -> Array: + var result: Array = [] + for ship in ships: + result.append(ship.move_target) + return result + + +func restore(previous_fleet_target: Vector2, previous_move_targets: Array) -> void: + fleet_target = previous_fleet_target + for i in range(ships.size()): + ships[i].move_target = previous_move_targets[i] + + +func _on_ship_destroyed(ship: Node) -> void: + var idx: int = ships.find(ship) + if idx != -1: + ships.remove_at(idx) + formation_offsets.remove_at(idx) + + +func get_centroid() -> Vector2: + if ships.is_empty(): + return fleet_target + var sum: Vector2 = Vector2.ZERO + for ship in ships: + sum += ship.position + return sum / ships.size() + + +func get_formation_origin() -> Vector2: + # Where the anchor must be for the ships to be standing in their slots + # right now. This is NOT the centroid: ships sit at anchor + offset, so + # the centroid is anchor + the AVERAGE offset, and the two only coincide + # when the layout happens to be balanced around its origin. + # + # The default wedge is balanced, which is why using the centroid as the + # anchor looked fine - until a layout dragged off to one corner at a + # command centre made the average offset large. Seeding the anchor from + # the centroid then displaced every slot by that average the instant + # steering began, so the fleet lurched towards the formation's own + # lopsided side before the anchor caught up and it flew the right way. + if ships.is_empty(): + return fleet_target + var sum: Vector2 = Vector2.ZERO + for offset in formation_offsets: + sum += offset + return get_centroid() - sum / formation_offsets.size() diff --git a/Fleet.gd.uid b/Fleet.gd.uid new file mode 100644 index 0000000..b3f9c64 --- /dev/null +++ b/Fleet.gd.uid @@ -0,0 +1 @@ +uid://ckf3enlke7dol diff --git a/FormationEditor.gd b/FormationEditor.gd new file mode 100644 index 0000000..a4da8bc --- /dev/null +++ b/FormationEditor.gd @@ -0,0 +1,138 @@ +extends Control + +# Drag-and-drop editor for the fleet's formation, opened at a command +# centre. Each slot is a draggable marker; dropping one writes the new +# offset straight back into GameState.formation, so the change survives +# scene swaps and applies to any fleet rebuilt later. +# +# Works in "formation space": the panel centre represents the fleet centre, +# and one formation unit maps to `scale_factor` screen pixels. + +signal closed +signal formation_changed + +@export var scale_factor: float = 1.4 +@export var min_scale: float = 0.5 +@export var max_scale: float = 4.0 +@export var marker_radius: float = 26.0 +@export var glow_color: Color = Color(0.25, 0.85, 1.0) + +var dragging_index: int = -1 +var drag_touch_index: int = -1 + +# Second finger = pinch to zoom the layout view, so a spread-out formation +# can be inspected close up or seen whole. Zooming only changes how the +# formation is DISPLAYED - the stored offsets are untouched. +var touches: Dictionary = {} +var pinch_active: bool = false +var pinch_start_distance: float = 0.0 +var pinch_start_scale: float = 1.0 + + +func _ready() -> void: + queue_redraw() + + +func _centre() -> Vector2: + return size / 2.0 + + +func _slot_screen_pos(i: int) -> Vector2: + return _centre() + GameState.formation[i] * scale_factor + + +func _screen_to_formation(p: Vector2) -> Vector2: + return (p - _centre()) / scale_factor + + +# Returns true when the event was consumed. Called from BuildController, +# which forwards input while the editor is open. +func handle_touch(event: InputEventScreenTouch) -> bool: + if not visible: + return false + var local: Vector2 = event.position - global_position + + if event.pressed: + touches[event.index] = event.position + if touches.size() == 2: + # A second finger turns the gesture into a pinch: drop any slot + # being dragged so it isn't yanked around while zooming. + dragging_index = -1 + drag_touch_index = -1 + var positions: Array = touches.values() + pinch_start_distance = positions[0].distance_to(positions[1]) + pinch_start_scale = scale_factor + pinch_active = true + queue_redraw() + return true + if dragging_index != -1: + return false + for i in range(GameState.formation.size()): + if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4: + dragging_index = i + drag_touch_index = event.index + queue_redraw() + return true + return false + + touches.erase(event.index) + if touches.size() < 2: + pinch_active = false + if event.index != drag_touch_index: + return false + dragging_index = -1 + drag_touch_index = -1 + queue_redraw() + return true + + +func handle_drag(event: InputEventScreenDrag) -> bool: + if not visible: + return false + + if touches.has(event.index): + 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: float = positions[0].distance_to(positions[1]) + # Spreading apart zooms in, matching the world pinch. + scale_factor = clamp( + pinch_start_scale * (current / max(pinch_start_distance, 1.0)), + min_scale, max_scale + ) + queue_redraw() + return true + + if event.index != drag_touch_index or dragging_index == -1: + return false + var local: Vector2 = event.position - global_position + # Clamped so a slot can't be dragged off the panel and become + # unreachable (and so the fleet can't be spread absurdly far apart). + var f: Vector2 = _screen_to_formation(local) + var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor + GameState.formation[dragging_index] = Vector2( + clamp(f.x, -limit, limit), clamp(f.y, -limit, limit) + ) + queue_redraw() + formation_changed.emit() + return true + + +func _draw() -> void: + var centre: Vector2 = _centre() + + # Reference cross for the fleet centre. + var tick: float = 14.0 + NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3) + NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3) + + for i in range(GameState.formation.size()): + var p: Vector2 = _slot_screen_pos(i) + var held: bool = i == dragging_index + # Ship glyph, so the layout reads the way it will look in flight. + var points: PackedVector2Array = PackedVector2Array([ + p + Vector2(0, -marker_radius), + p + Vector2(-marker_radius * 0.66, marker_radius * 0.72), + p + Vector2(marker_radius * 0.66, marker_radius * 0.72), + ]) + NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0 if held else 0.7) diff --git a/FormationEditor.gd.uid b/FormationEditor.gd.uid new file mode 100644 index 0000000..d61232e --- /dev/null +++ b/FormationEditor.gd.uid @@ -0,0 +1 @@ +uid://dv12yrraa6oer diff --git a/GameController.gd b/GameController.gd new file mode 100644 index 0000000..c692546 --- /dev/null +++ b/GameController.gd @@ -0,0 +1,313 @@ +extends Node2D + +@export var min_zoom: float = 0.4 +@export var max_zoom: float = 6.0 +@export var camera_follow_speed: float = 5.0 +# Distance (world units) below which the camera snaps exactly onto the fleet +# instead of continuing to lerp. Without this the exponential lerp only ever +# approaches the target asymptotically and NEVER arrives, so the camera keeps +# drifting by a fraction of a pixel every single frame - forever, even with +# the fleet completely stationary. That kept the background shaders' camera_pos +# uniform changing every frame, which made screen tearing permanently visible +# as horizontal bands of the background shifted sideways against each other. +# Same class of bug (and same fix) as the ship arrival oscillation. +@export var camera_snap_distance: float = 0.5 +@export var debug_input_logging: bool = false + +@onready var camera: Camera2D = $Camera2D +@onready var fleet: Node2D = $Fleet +# Optional: scenes without a joystick (none currently) still work. +@onready var joystick: TouchJoystick = get_node_or_null("UI/Joystick") +# Navigation section. Optional so a scene without it still works. +@onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton") +@onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton") + +var touches: Dictionary = {} +var pinch_active: bool = false +var pinch_start_distance: float = 0.0 +var pinch_start_zoom: float = 1.0 + + + +# Held pivot buttons turn the formation continuously at this rate; a quick +# tap that accumulates less than PIVOT_MIN_STEP is topped up to it on +# release, so tapping still produces a clear, visible nudge. +const PIVOT_SPEED: float = 1.6 +const PIVOT_MIN_STEP: float = PI / 10.0 + +var pivot_hold_amount: float = 0.0 +# Touch index currently holding each pivot button, or -1. These buttons are +# driven straight from raw touch events rather than through _gui_input, +# because Godot's Control GUI treats touch as a single emulated pointer and +# will not reliably deliver a SECOND simultaneous finger to another +# Control - which meant pivoting while steering with the joystick silently +# did nothing. The joystick already bypasses the GUI for the same reason. +var pivot_cw_touch: int = -1 +var pivot_ccw_touch: int = -1 + +# --- keyboard and mouse ---------------------------------------------------- +# The touch UI is the whole story on a phone. On a machine with a keyboard - +# which in practice means the web build - WASD or the arrow keys steer, Q/E +# pivot the formation, and the wheel zooms. Clicking the icon buttons already +# works without any code here, because the project emulates touch from the +# mouse (see project.godot), which is the same path the buttons already read. +# +# Keys are tracked from events rather than polled off the Input singleton so +# the whole thing is drivable from a headless test. +const KEY_DIRECTIONS: Dictionary = { + KEY_W: Vector2.UP, KEY_UP: Vector2.UP, + KEY_S: Vector2.DOWN, KEY_DOWN: Vector2.DOWN, + KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT, + KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT, +} +const KEY_PIVOT_CW: int = KEY_E +const KEY_PIVOT_CCW: int = KEY_Q +const WHEEL_ZOOM_STEP: float = 1.12 + +@onready var keyboard_hint: Label = get_node_or_null("UI/KeyboardHintLabel") + +var keys_down: Dictionary = {} +var keyboard_seen: bool = false +var keyboard_steering: bool = false + + +func _ready() -> void: + if joystick != null: + joystick.direction_changed.connect(_on_joystick_direction) + if keyboard_hint != null: + keyboard_hint.visible = false + + +func _held_pivot_direction() -> float: + var dir: float = 0.0 + if pivot_cw_button != null and pivot_cw_button.pressed_down: + dir += 1.0 + if pivot_ccw_button != null and pivot_ccw_button.pressed_down: + dir -= 1.0 + if keys_down.has(KEY_PIVOT_CW): + dir += 1.0 + if keys_down.has(KEY_PIVOT_CCW): + dir -= 1.0 + return clampf(dir, -1.0, 1.0) + + +func keyboard_direction() -> Vector2: + var dir: Vector2 = Vector2.ZERO + for key in KEY_DIRECTIONS: + if keys_down.has(key): + dir += KEY_DIRECTIONS[key] + return Vector2.ZERO if dir == Vector2.ZERO else dir.normalized() + + +func _handle_key(event: InputEventKey) -> void: + if event.echo: + return + if event.pressed: + keys_down[event.keycode] = true + _note_keyboard() + else: + keys_down.erase(event.keycode) + # A quick tap of a pivot key gets the same minimum step a tapped + # button does, rather than turning by a barely-visible sliver. + if event.keycode == KEY_PIVOT_CW: + _finish_pivot_tap(1.0) + elif event.keycode == KEY_PIVOT_CCW: + _finish_pivot_tap(-1.0) + + +func _note_keyboard() -> void: + if keyboard_seen: + return + keyboard_seen = true + if keyboard_hint != null: + keyboard_hint.visible = true + # The stick is only taken away where there is no touchscreen to work it + # with. A touch device that also has a keyboard keeps both, and a device + # wrongly reported as non-touch still shows the stick until the player + # has demonstrably used a keyboard - so nobody is ever left with no way + # to steer. + if joystick != null and not DisplayServer.is_touchscreen_available(): + joystick.visible = false + + +func _button_holds(button: IconButton, at_position: Vector2) -> bool: + return button != null and Rect2(button.global_position, button.size).has_point(at_position) + + +func _set_button_held(button: IconButton, held: bool) -> void: + if button == null or button.pressed_down == held: + return + button.pressed_down = held + button.queue_redraw() + + +# Returns true when a pivot button consumed the touch. +func _handle_pivot_touch(event: InputEventScreenTouch) -> bool: + if event.pressed: + if _button_holds(pivot_cw_button, event.position) and pivot_cw_touch == -1: + pivot_cw_touch = event.index + _set_button_held(pivot_cw_button, true) + return true + if _button_holds(pivot_ccw_button, event.position) and pivot_ccw_touch == -1: + pivot_ccw_touch = event.index + _set_button_held(pivot_ccw_button, true) + return true + return false + + if event.index == pivot_cw_touch: + pivot_cw_touch = -1 + _set_button_held(pivot_cw_button, false) + _finish_pivot_tap(1.0) + return true + if event.index == pivot_ccw_touch: + pivot_ccw_touch = -1 + _set_button_held(pivot_ccw_button, false) + _finish_pivot_tap(-1.0) + return true + return false + + +func _update_pivot(delta: float) -> void: + var dir: float = _held_pivot_direction() + if dir == 0.0: + return + var amount: float = PIVOT_SPEED * delta * dir + pivot_hold_amount += absf(amount) + _pivot(amount) + + +func _finish_pivot_tap(direction: float) -> void: + # Fired on release. A press held long enough has already turned far + # enough on its own; a brief tap gets topped up to the minimum step. + if pivot_hold_amount < PIVOT_MIN_STEP: + _pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction) + pivot_hold_amount = 0.0 + + +func _pivot(radians: float) -> void: + # Turns the formation and re-seats the ships already flying it, so the + # change is visible immediately rather than only on the next rebuild. + # apply_formation() writes the new offsets into Fleet.formation_offsets, + # which move_to()/move_in_direction() also read - so a pivot applied + # while the joystick is steering survives the next steering update + # instead of being overwritten by the old layout. + GameState.rotate_formation(radians) + fleet.apply_formation() + + +func _on_joystick_direction(direction: Vector2) -> void: + # Steering is re-issued every frame in _process while the stick is held, + # so this only needs to handle the release (stick back to centre). + if direction == Vector2.ZERO: + fleet.stop() + + +func _process(delta: float) -> void: + _update_pivot(delta) + + # Keep the heading fresh while a control is held. Fleet advances its own + # anchor in _process, so this only needs to report the direction. The + # keyboard takes precedence, and releasing the keys has to stop the fleet + # itself - unlike the stick, there is no centred-again signal to hook. + var keyed: Vector2 = keyboard_direction() + if keyed != Vector2.ZERO: + keyboard_steering = true + fleet.move_in_direction(keyed) + elif keyboard_steering: + keyboard_steering = false + fleet.stop() + elif joystick != null and joystick.direction != Vector2.ZERO: + fleet.move_in_direction(joystick.direction) + + var target: Vector2 = fleet.get_centroid() + if camera.global_position.distance_to(target) <= camera_snap_distance: + # Snap and stop dead - see camera_snap_distance. Assigning the exact + # target means a settled fleet produces a bit-identical camera position + # frame after frame, so the background shaders stop re-rendering a + # shifted image and tearing has nothing to reveal. + camera.global_position = target + return + var t: float = clamp(camera_follow_speed * delta, 0.0, 1.0) + camera.global_position = camera.global_position.lerp(target, t) + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + _handle_touch(event) + elif event is InputEventScreenDrag: + _handle_drag(event) + elif event is InputEventMagnifyGesture: + _handle_magnify(event) + elif event is InputEventKey: + _handle_key(event) + elif event is InputEventMouseButton and event.pressed: + # Wheel zoom, the desktop equivalent of the pinch. + 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: + # The joystick gets first refusal on every touch, and only claims ones + # that begin inside its own rect - so pinch-to-zoom still works anywhere + # else on screen. + if _handle_pivot_touch(event): + return + if joystick != null and joystick.visible and joystick.handle_touch(event): + return + + if event.pressed: + touches[event.index] = event.position + if touches.size() == 2: + _start_pinch() + if debug_input_logging: + print("DEBUG touch pressed idx=%d pos=%s active_touches=%d" % [event.index, event.position, touches.size()]) + else: + touches.erase(event.index) + if touches.size() < 2: + pinch_active = false + if debug_input_logging: + print("DEBUG touch released idx=%d active_touches=%d" % [event.index, touches.size()]) + + +func _handle_drag(event: InputEventScreenDrag) -> void: + if joystick != null and joystick.visible and joystick.handle_drag(event): + return + 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]) + # Spreading the fingers apart zooms IN. Camera2D.zoom is a + # magnification (larger = closer), so the ratio goes + # current/start, not start/current. + var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0)) + _set_zoom(new_zoom) + + if debug_input_logging: + print("DEBUG pinch drag distance=%.1f zoom=%.3f" % [current_distance, camera.zoom.x]) + + +func _handle_magnify(event: InputEventMagnifyGesture) -> void: + # Fallback path: some platforms (and desktop trackpads) report pinch as a + # single semantic gesture instead of two raw touch streams. factor > 1 + # means the gesture is spreading outward, which zooms in - and since + # Camera2D.zoom is a magnification, that multiplies. + _set_zoom(camera.zoom.x * event.factor) + if debug_input_logging: + print("DEBUG magnify gesture factor=%.3f zoom=%.3f" % [event.factor, 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 = clamp(raw_zoom, min_zoom, max_zoom) + camera.zoom = Vector2(z, z) diff --git a/GameController.gd.uid b/GameController.gd.uid new file mode 100644 index 0000000..cb17afe --- /dev/null +++ b/GameController.gd.uid @@ -0,0 +1 @@ +uid://5tvbknhs0afu diff --git a/GameState.gd b/GameState.gd new file mode 100644 index 0000000..4bc80dd --- /dev/null +++ b/GameState.gd @@ -0,0 +1,194 @@ +extends Node + +# Autoload singleton - the one thing that survives change_scene_to_file() +# scene swaps between space (Main.tscn) and a planet's surface +# (PlanetSurface.tscn). Everything else in either scene gets torn down +# and rebuilt from scratch on each swap, so anything that needs to +# survive the trip has to live here instead. + +var has_return_position: bool = false +var surviving_ship_count: int = 5 +var return_world_position: Vector2 = Vector2.ZERO +var return_camera_zoom: float = 1.0 + +var planet_name: String = "" +var planet_type: String = "" + +# Explored-chunk history for the world map's fog-of-war reveal. Lives +# here (not on ExplorationTracker, which gets destroyed and recreated on +# every scene swap) so exploration progress isn't lost every time the +# player lands on and leaves a planet. +var explored_chunks: Dictionary = {} + +# --- Resources ------------------------------------------------------------- + +# Rocks mined from asteroid-planet surfaces, spent on buildings and ships. +# Starts stocked for play-testing so buildings can be tried without mining +# first - drop this back to 0 for a real playthrough. +var rocks: int = 500 + +const MOTHERSHIP_COST: int = 20 +const SHIP_CRAFTER_COST: int = 12 +const SHIP_COST: int = 6 + +# --- Buildings ------------------------------------------------------------- + +# Player-placed structures, as [{"type": String, "position": Vector2}]. +# Buildings exist only in space (Main.tscn) but are stored here so they +# survive the scene swap to a planet's surface and back. +const BUILDING_MOTHERSHIP: String = "mothership" +const BUILDING_SHIP_CRAFTER: String = "ship_crafter" +const BUILDING_COMMAND_CENTER: String = "command_center" +# Extractors are not placed in space like the others - they are built ON a +# planet's surface and tracked separately (see `extractors`). +const BUILDING_EXTRACTOR: String = "extractor" + +const COMMAND_CENTER_COST: int = 15 +const EXTRACTOR_COST: int = 25 + +var buildings: Array = [] + + +func add_building(type: String, at_position: Vector2) -> void: + buildings.append({"type": type, "position": at_position}) + + +func has_mothership() -> bool: + return get_mothership_position() != null + + +func remove_buildings_of_type(type: String) -> int: + var kept: Array = [] + var removed: int = 0 + for b in buildings: + if b.type == type: + removed += 1 + else: + kept.append(b) + buildings = kept + return removed + + +func get_mothership_position() -> Variant: + # Where a wiped-out fleet gets rebuilt. Returns null when no mothership + # has been built yet, so the caller can fall back to the world origin. + for b in buildings: + if b.type == BUILDING_MOTHERSHIP: + return b.position + return null + + +# --- Fleet formation ------------------------------------------------------- + +# Where each vessel sits relative to the fleet centre, editable at a command +# centre. Lives here (not as a const on Fleet) so a layout the player has +# arranged survives the trip to a planet's surface and back, and so a +# rebuilt fleet keeps their arrangement. +const DEFAULT_FORMATION: Array = [ + Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32), +] + +var formation: Array = DEFAULT_FORMATION.duplicate() + + +func reset_formation() -> void: + formation = DEFAULT_FORMATION.duplicate() + + +func rotate_formation(radians: float) -> void: + # Pivots every slot about the fleet centre, keeping the shape intact + # and only changing which way it faces. + for i in range(formation.size()): + formation[i] = formation[i].rotated(radians) + + +# --- Extractors and their haulers ----------------------------------------- + +# An extractor sits on a planet and runs a single automated hauler back and +# forth to the mothership, delivering ore on each arrival. +# +# The round trip is SIMULATED HERE, in the autoload, rather than by any +# node: it advances whether the player is in space, on a planet's surface, +# or far enough away that nothing about the route is loaded. `progress` is +# a fraction of one full there-and-back cycle, so a hauler's drawn position +# is derived from the same number that decides when ore is delivered - the +# visual arrival and the payout cannot drift apart, because they are the +# same event. +# +# Entries are {"position": Vector2 (the planet's position in SPACE), +# "planet_name": String, "progress": float}. +const HAULER_SPEED: float = 260.0 +const HAULER_YIELD: int = 4 +# Progress value at which the hauler reaches the mothership: exactly half +# way through the cycle, the outbound leg being the first half. +const HAULER_ARRIVAL: float = 0.5 + +var extractors: Array = [] + + +func has_extractor_at(planet_name: String) -> bool: + for e in extractors: + if e.planet_name == planet_name: + return true + return false + + +func add_extractor(planet_name: String, at_position: Vector2) -> void: + extractors.append({ + "position": at_position, + "planet_name": planet_name, + "progress": 0.0, + }) + + +func get_hauler_route_time(extractor: Dictionary) -> float: + # Full cycle: out to the mothership and back again. + var mothership: Variant = get_mothership_position() + if mothership == null: + return 0.0 + var distance: float = extractor.position.distance_to(mothership) + if distance < 1.0: + return 0.0 + return (distance * 2.0) / HAULER_SPEED + + +func get_hauler_position(extractor: Dictionary) -> Variant: + # Where the hauler is right now, derived purely from `progress`. Returns + # null when there is no route (no mothership built yet). + var mothership: Variant = get_mothership_position() + if mothership == null: + return null + var t: float = extractor.progress + if t <= HAULER_ARRIVAL: + return extractor.position.lerp(mothership, t / HAULER_ARRIVAL) + return mothership.lerp(extractor.position, (t - HAULER_ARRIVAL) / (1.0 - HAULER_ARRIVAL)) + + +func _process(delta: float) -> void: + # Runs in the autoload, so extraction keeps earning while the player is + # on a planet's surface or nowhere near the route. + if extractors.is_empty(): + return + for e in extractors: + var cycle: float = get_hauler_route_time(e) + if cycle <= 0.0: + continue # nothing to deliver to yet + var before: float = e.progress + e.progress += delta / cycle + # Ore lands the instant the hauler reaches the mothership - the same + # instant get_hauler_position() puts it there. + if before < HAULER_ARRIVAL and e.progress >= HAULER_ARRIVAL: + rocks += HAULER_YIELD + while e.progress >= 1.0: + e.progress -= 1.0 + + +func can_afford(cost: int) -> bool: + return rocks >= cost + + +func spend(cost: int) -> bool: + if not can_afford(cost): + return false + rocks -= cost + return true diff --git a/GameState.gd.uid b/GameState.gd.uid new file mode 100644 index 0000000..5fd795f --- /dev/null +++ b/GameState.gd.uid @@ -0,0 +1 @@ +uid://xyukiutybufd diff --git a/Hauler.gd b/Hauler.gd new file mode 100644 index 0000000..581fc7c --- /dev/null +++ b/Hauler.gd @@ -0,0 +1,39 @@ +extends Node2D + +# Purely visual: an automated ore hauler running an extractor's route. It +# carries no state of its own - GameState owns the simulation and this node +# is created, positioned and freed to match it. See HaulerField. + +const HULL_COLOR: Color = Color(0.55, 0.85, 0.4) + +var laden: bool = false + + +func _ready() -> void: + add_to_group("haulers") + z_index = -1 + queue_redraw() + + +func set_laden(value: bool) -> void: + if laden == value: + return + laden = value + queue_redraw() + + +func _draw() -> void: + # Blunt freighter silhouette - deliberately unlike the player's sharp + # triangles, so it is obvious at a glance this is not a fleet vessel. + var hull: PackedVector2Array = PackedVector2Array([ + Vector2(0, -20), Vector2(11, -6), Vector2(11, 16), Vector2(-11, 16), Vector2(-11, -6), + ]) + NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0) + if laden: + # Ore in the hold on the outbound leg, so a glance tells you which + # way it is going. + var cargo: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU - PI / 2.0 + cargo.append(Vector2(cos(a), sin(a)) * 6.0 + Vector2(0, 4)) + NeonDraw.draw_glowing_polygon(self, cargo, Color(0.0, 0.62, 0.7), 1.0) diff --git a/Hauler.gd.uid b/Hauler.gd.uid new file mode 100644 index 0000000..f65c822 --- /dev/null +++ b/Hauler.gd.uid @@ -0,0 +1 @@ +uid://dpmpi0bq7ncjb diff --git a/Hauler.tscn b/Hauler.tscn new file mode 100644 index 0000000..e293d5a --- /dev/null +++ b/Hauler.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Hauler.gd" id="1"] + +[node name="Hauler" type="Node2D"] +script = ExtResource("1") diff --git a/HaulerField.gd b/HaulerField.gd new file mode 100644 index 0000000..ba29108 --- /dev/null +++ b/HaulerField.gd @@ -0,0 +1,57 @@ +extends Node2D + +# Draws each extractor's hauler ONLY while it is somewhere the player could +# see it, and rebuilds it from GameState every frame. +# +# The point is that the visuals are optional. GameState simulates the round +# trip and pays out on arrival regardless of what exists in the scene, so a +# route far from the player costs nothing but still earns - while flying +# between a planet and the mothership shows the hauler making the run, +# positioned from the very same `progress` value that triggers the payout. + +@export var visible_radius: float = 2600.0 + +@onready var fleet: Node2D = get_node("../Fleet") + +var hauler_scene: PackedScene = preload("res://Hauler.tscn") +# extractor index -> live Hauler node +var active: Dictionary = {} + + +func _process(_delta: float) -> void: + var centre: Vector2 = fleet.get_centroid() + + for i in range(GameState.extractors.size()): + var extractor: Dictionary = GameState.extractors[i] + var pos: Variant = GameState.get_hauler_position(extractor) + + # No mothership means no route, so nothing to show. + if pos == null or pos.distance_to(centre) > visible_radius: + _despawn(i) + continue + + var hauler: Node2D = active.get(i) + if hauler == null or not is_instance_valid(hauler): + hauler = hauler_scene.instantiate() + get_parent().add_child(hauler) + active[i] = hauler + + var previous: Vector2 = hauler.position + hauler.position = pos + # Laden on the outbound leg, empty on the way back. + hauler.set_laden(extractor.progress <= GameState.HAULER_ARRIVAL) + var travel: Vector2 = pos - previous + if travel.length() > 0.5: + hauler.rotation = travel.angle() + PI / 2.0 + + # Drop visuals for extractors that no longer exist. + for key in active.keys(): + if key >= GameState.extractors.size(): + _despawn(key) + + +func _despawn(index: int) -> void: + var hauler: Node2D = active.get(index) + if hauler != null and is_instance_valid(hauler): + hauler.queue_free() + active.erase(index) diff --git a/HaulerField.gd.uid b/HaulerField.gd.uid new file mode 100644 index 0000000..dca3474 --- /dev/null +++ b/HaulerField.gd.uid @@ -0,0 +1 @@ +uid://cpjtke7w4pv0a diff --git a/IconButton.gd b/IconButton.gd new file mode 100644 index 0000000..dc91c31 --- /dev/null +++ b/IconButton.gd @@ -0,0 +1,277 @@ +class_name IconButton +extends Control + +# Square neon-outlined button, hand-drawn to match the game's existing +# no-texture glowing look (same NeonDraw technique as ships/enemies) - +# no icon image assets, just a glyph drawn inside the frame. + +signal pressed + +# Scenes store `icon` as a plain int, so NEW ENTRIES MUST BE APPENDED - +# inserting one would silently repoint every button after it. +enum Icon { + MAP, CLOSE, SHIP, DOWN_ARROW, UP_ARROW, BUILD, ROTATE_CW, ROTATE_CCW, + # One per building, each a miniature of what actually gets placed. The + # build menu used to pick from the generic set, which left the mothership + # and the command centre sharing a folded-map glyph and neither of them + # looking anything like the structure they build. + MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, +} + +# Size the glyphs were originally drawn against. Every glyph dimension is +# scaled by (actual size / this), so the button can be resized in the scene +# without the icon inside it staying small. +const REFERENCE_SIZE: float = 56.0 + +@export var icon: int = Icon.MAP +@export var glow_color: Color = Color(0.25, 0.85, 1.0) +# Colour of the bright core stroke. Defaults to near-white, which reads well +# on the dark space background; scenes drawing over a bright backdrop (the +# lunar surface) override this with a dark colour so the button doesn't wash +# out. See NeonDraw. +@export var core_color: Color = Color(1.0, 1.0, 1.0) +# When false the button is drawn greyed out, but STILL emits `pressed` - +# the caller decides what to do, which is what lets an unavailable option +# explain itself instead of silently doing nothing. +@export var enabled: bool = true: set = set_enabled +@export var disabled_color: Color = Color(0.42, 0.45, 0.5) + +var pressed_down: bool = false + + +func set_enabled(value: bool) -> void: + if enabled == value: + return + enabled = value + queue_redraw() + + +func _ready() -> void: + queue_redraw() + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + _handle_press(event.pressed, event.position) + accept_event() + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _handle_press(event.pressed, event.position) + accept_event() + + +func _handle_press(is_pressed: bool, at_position: Vector2) -> void: + if is_pressed: + pressed_down = true + queue_redraw() + elif pressed_down: + pressed_down = false + queue_redraw() + # Only counts as a real press if the finger/cursor is still over + # the button when it lifts - dragging off before releasing (a + # common way to "cancel" a touch) must not still trigger it. + if Rect2(Vector2.ZERO, size).has_point(at_position): + pressed.emit() + + +func _active_color() -> Color: + return glow_color if enabled else disabled_color + + +func _draw() -> void: + var rect_size: Vector2 = size + var s: float = min(rect_size.x, rect_size.y) / REFERENCE_SIZE + # Constant glow, brighter while held. Only the press state changes it, + # so the button redraws on press/release instead of every frame. + var pulse: float = 1.0 if pressed_down else 0.85 + + var margin: float = 4.0 * s + var square: PackedVector2Array = PackedVector2Array([ + Vector2(margin, margin), + Vector2(rect_size.x - margin, margin), + Vector2(rect_size.x - margin, rect_size.y - margin), + Vector2(margin, rect_size.y - margin), + ]) + NeonDraw.draw_glowing_polygon(self, square, _active_color(), pulse, core_color) + + var center: Vector2 = rect_size / 2.0 + match icon: + Icon.CLOSE: + _draw_close(center, pulse, s) + Icon.SHIP: + _draw_ship(center, pulse, s) + Icon.MAP: + _draw_map(center, pulse, s) + Icon.DOWN_ARROW: + _draw_arrow(center, pulse, 1.0, s) + Icon.UP_ARROW: + _draw_arrow(center, pulse, -1.0, s) + Icon.BUILD: + _draw_build(center, pulse, s) + Icon.ROTATE_CW: + _draw_rotate(center, pulse, s, 1.0) + Icon.ROTATE_CCW: + _draw_rotate(center, pulse, s, -1.0) + Icon.MOTHERSHIP: + _draw_mothership_icon(center, pulse, s) + Icon.CRAFTER: + _draw_crafter_icon(center, pulse, s) + Icon.COMMAND_CENTER: + _draw_command_icon(center, pulse, s) + Icon.EXTRACTOR: + _draw_extractor_icon(center, pulse, s) + + +func _draw_close(center: Vector2, pulse: float, s: float) -> void: + var r: float = 10.0 * s + NeonDraw.draw_glowing_line(self, center + Vector2(-r, -r), center + Vector2(r, r), _active_color(), pulse, 0.5 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(-r, r), center + Vector2(r, -r), _active_color(), pulse, 0.5 * s, core_color) + + +func _draw_ship(center: Vector2, pulse: float, s: float) -> void: + var nose: Vector2 = center + Vector2(0, -11) * s + var left: Vector2 = center + Vector2(-7, 8) * s + var right: Vector2 = center + Vector2(7, 8) * s + var points: PackedVector2Array = PackedVector2Array([nose, left, right]) + NeonDraw.draw_glowing_polygon(self, points, _active_color(), pulse, core_color) + + +func _draw_arrow(center: Vector2, pulse: float, direction: float, s: float) -> void: + var r: float = 9.0 * s + var tip: Vector2 = center + Vector2(0, r * direction) + var shaft_top: Vector2 = center + Vector2(0, -r * direction) + NeonDraw.draw_glowing_line(self, shaft_top, tip, _active_color(), pulse, 0.5 * s, core_color) + NeonDraw.draw_glowing_line(self, tip, tip + Vector2(-r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color) + NeonDraw.draw_glowing_line(self, tip, tip + Vector2(r * 0.7, -r * 0.8 * direction), _active_color(), pulse, 0.5 * s, core_color) + + +func _draw_build(center: Vector2, pulse: float, s: float) -> void: + # A plus inside a frame - "add a structure". + var r: float = 11.0 * s + var frame: PackedVector2Array = PackedVector2Array([ + center + Vector2(-r, -r), center + Vector2(r, -r), + center + Vector2(r, r), center + Vector2(-r, r), + ]) + NeonDraw.draw_glowing_polygon(self, frame, _active_color(), pulse, core_color) + var arm: float = r * 0.55 + NeonDraw.draw_glowing_line(self, center + Vector2(-arm, 0), center + Vector2(arm, 0), _active_color(), pulse, 0.4 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(0, -arm), center + Vector2(0, arm), _active_color(), pulse, 0.4 * s, core_color) + + +func _draw_rotate(center: Vector2, pulse: float, s: float, direction: float) -> void: + # Three-quarter arc with an arrowhead on the leading end, mirrored for + # the counter-clockwise variant. + var r: float = 11.0 * s + var segments: int = 14 + var sweep: float = TAU * 0.75 + var start: float = -PI * 0.5 + var arc: PackedVector2Array = PackedVector2Array() + for i in range(segments + 1): + var t: float = float(i) / segments + var a: float = start + sweep * t * direction + arc.append(center + Vector2(cos(a), sin(a)) * r) + for i in range(segments): + NeonDraw.draw_glowing_line(self, arc[i], arc[i + 1], _active_color(), pulse, 0.34 * s, core_color) + + var tip: Vector2 = arc[arc.size() - 1] + var tangent: Vector2 = (tip - arc[arc.size() - 2]).normalized() + var normal: Vector2 = Vector2(-tangent.y, tangent.x) + var head: float = 5.0 * s + NeonDraw.draw_glowing_line(self, tip, tip - tangent * head + normal * head, _active_color(), pulse, 0.34 * s, core_color) + NeonDraw.draw_glowing_line(self, tip, tip - tangent * head - normal * head, _active_color(), pulse, 0.34 * s, core_color) + + +# --- building glyphs, each echoing the placed structure's silhouette ------ + +func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void: + # Radial flagship: a reactor core with six swept talons around it, the + # same silhouette as the placed structure. + for i in range(6): + var base: float = (float(i) / 6.0) * TAU + var talon: PackedVector2Array = PackedVector2Array() + for step in [0.0, 0.5, 1.0]: + var a: float = base + 0.72 * pow(step, 1.3) + talon.append(center + Vector2(cos(a), sin(a)) * lerpf(7.0, 16.0, step) * s) + for step in [1.0, 0.5, 0.0]: + var a: float = base + 0.72 * pow(step, 1.3) - (0.34 * (1.0 - step) + 0.05) + talon.append(center + Vector2(cos(a), sin(a)) * lerpf(7.0, 16.0, step) * s) + NeonDraw.draw_glowing_polygon(self, talon, _active_color(), pulse, core_color) + + for radius in [6.5, 3.0]: + var ring: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + ring.append(center + Vector2(cos(a), sin(a)) * radius * s) + NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) + + +func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: + # Reactor core, truss spine, and an open cradle holding a hull. + var core_centre: Vector2 = center + Vector2(-11, 0) * s + var core: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + core.append(core_centre + Vector2(cos(a), sin(a)) * 5.0 * s) + NeonDraw.draw_glowing_polygon(self, core, _active_color(), pulse, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(-6, 0) * s, + center + Vector2(-2, 0) * s, _active_color(), pulse, 0.35 * s, core_color) + + var bay: Vector2 = center + Vector2(6, 0) * s + var cradle: PackedVector2Array = PackedVector2Array() + for i in range(13): + var a: float = lerpf(-0.25 * PI, 1.25 * PI, float(i) / 12.0) + cradle.append(bay + Vector2(cos(a), sin(a)) * 10.0 * s) + NeonDraw.draw_glowing_path(self, cradle, _active_color(), pulse, 0.4 * s, core_color) + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + bay + Vector2(0, -6) * s, bay + Vector2(-5, 4) * s, bay + Vector2(5, 4) * s, + ]), _active_color(), pulse, core_color) + + +func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void: + # Diamond with three formation pips - "arrangement". + var d: float = 14.0 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + Vector2(0, -d), center + Vector2(d, 0), + center + Vector2(0, d), center + Vector2(-d, 0), + ]), _active_color(), pulse, core_color) + for p in [Vector2(0, -5), Vector2(-5, 4), Vector2(5, 4)]: + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + (p + Vector2(0, -3)) * s, + center + (p + Vector2(-2.6, 2.4)) * s, + center + (p + Vector2(2.6, 2.4)) * s, + ]), _active_color(), pulse, core_color) + + +func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void: + # A derrick standing on a planet's limb, with ore lifting off it - the + # one building that only works over a planet, so the planet is the point. + var limb: PackedVector2Array = PackedVector2Array() + for i in range(11): + var a: float = lerpf(-0.82 * PI, -0.18 * PI, float(i) / 10.0) + limb.append(center + (Vector2(0, 22) + Vector2(cos(a), sin(a)) * 17.0) * s) + NeonDraw.draw_glowing_path(self, limb, _active_color(), pulse, 0.45 * s, core_color) + + NeonDraw.draw_glowing_line(self, center + Vector2(-6, 5) * s, center + Vector2(0, -9) * s, _active_color(), pulse, 0.4 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(6, 5) * s, center + Vector2(0, -9) * s, _active_color(), pulse, 0.4 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(-3.5, -1) * s, center + Vector2(3.5, -1) * s, _active_color(), pulse, 0.35 * s, core_color) + # Shaft driven down through the limb - without it the derrick reads as a + # tower standing on a hill rather than as something mining the planet. + NeonDraw.draw_glowing_line(self, center + Vector2(0, -1) * s, center + Vector2(0, 14) * s, _active_color(), pulse, 0.3 * s, core_color) + + var ore_centre: Vector2 = center + Vector2(0, -15) * s + var ore: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + ore.append(ore_centre + Vector2(cos(a), sin(a)) * 4.0 * s) + NeonDraw.draw_glowing_polygon(self, ore, _active_color(), pulse, core_color) + + +func _draw_map(center: Vector2, pulse: float, s: float) -> void: + var w: float = 11.0 * s + var h: float = 9.0 * s + var outline: PackedVector2Array = PackedVector2Array([ + center + Vector2(-w, -h), center + Vector2(w, -h * 0.7), + center + Vector2(w, h), center + Vector2(-w, h * 0.7), + ]) + NeonDraw.draw_glowing_polygon(self, outline, _active_color(), pulse, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(-w * 0.34, -h * 0.9), center + Vector2(-w * 0.34, h * 0.77), _active_color(), pulse, 0.4 * s, core_color) + NeonDraw.draw_glowing_line(self, center + Vector2(w * 0.34, -h * 0.77), center + Vector2(w * 0.34, h * 0.9), _active_color(), pulse, 0.4 * s, core_color) diff --git a/IconButton.gd.uid b/IconButton.gd.uid new file mode 100644 index 0000000..26925e6 --- /dev/null +++ b/IconButton.gd.uid @@ -0,0 +1 @@ +uid://dcvdmc68b3nve diff --git a/LightBurstVFX.gd b/LightBurstVFX.gd new file mode 100644 index 0000000..63b4f5e --- /dev/null +++ b/LightBurstVFX.gd @@ -0,0 +1,15 @@ +class_name LightBurstVFX +extends ExplosionVFXBase + +# Shared radiating-neon-streak rendering for energy-based objects (player +# ships, enemies) - concrete subclasses only need to fix their own color. + +var color: Color = Color(1, 1, 1) + + +func _draw_particle(p: Dictionary, alpha: float) -> void: + var vel: Vector2 = p["vel"] + var dir: Vector2 = vel.normalized() if vel.length() > 0.01 else Vector2.RIGHT + var half: Vector2 = dir * p["size"] * 0.5 + var pos: Vector2 = p["pos"] + NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6) diff --git a/LightBurstVFX.gd.uid b/LightBurstVFX.gd.uid new file mode 100644 index 0000000..d22ffdb --- /dev/null +++ b/LightBurstVFX.gd.uid @@ -0,0 +1 @@ +uid://b4awmav5ftmua diff --git a/LunarBackground.gd b/LunarBackground.gd new file mode 100644 index 0000000..af4f9d8 --- /dev/null +++ b/LunarBackground.gd @@ -0,0 +1,19 @@ +extends ColorRect + +# Feeds the lunar shader the camera's position split into a CELL INDEX plus +# a fraction within that cell, rather than a raw world position. See the +# long note in Starfield.gd for why - identical reasoning, identical split. + +@onready var camera: Camera2D = get_node("../../Camera2D") + + +func _process(_delta: float) -> void: + var viewport_size: Vector2 = get_viewport_rect().size + var cell_size: float = material.get_shader_parameter("cell_size") + var p: Vector2 = camera.global_position / cell_size + var cell: Vector2 = Vector2(floor(p.x), floor(p.y)) + + material.set_shader_parameter("camera_cell", cell) + material.set_shader_parameter("camera_frac", p - cell) + material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x) + material.set_shader_parameter("viewport_size", viewport_size) diff --git a/LunarBackground.gd.uid b/LunarBackground.gd.uid new file mode 100644 index 0000000..ddddddc --- /dev/null +++ b/LunarBackground.gd.uid @@ -0,0 +1 @@ +uid://jovl7hn41sup diff --git a/Main.tscn b/Main.tscn new file mode 100644 index 0000000..d7e5378 --- /dev/null +++ b/Main.tscn @@ -0,0 +1,698 @@ +[gd_scene load_steps=20 format=3] + +[ext_resource type="Script" path="res://Starfield.gd" id="1"] +[ext_resource type="Script" path="res://Fleet.gd" id="2"] +[ext_resource type="Script" path="res://GameController.gd" id="3"] +[ext_resource type="Script" path="res://EnemySpawner.gd" id="4"] +[ext_resource type="Shader" path="res://starfield.gdshader" id="5"] +[ext_resource type="Script" path="res://ExplorationTracker.gd" id="6"] +[ext_resource type="Script" path="res://AsteroidField.gd" id="7"] +[ext_resource type="Script" path="res://WorldMap.gd" id="8"] +[ext_resource type="Script" path="res://WorldMapContent.gd" id="9"] +[ext_resource type="Script" path="res://IconButton.gd" id="10"] +[ext_resource type="Script" path="res://PlanetField.gd" id="11"] +[ext_resource type="Script" path="res://PlanetAtmosphereController.gd" id="12"] +[ext_resource type="Script" path="res://TouchJoystick.gd" id="13"] +[ext_resource type="Script" path="res://BuildingField.gd" id="14"] +[ext_resource type="Script" path="res://BuildController.gd" id="15"] +[ext_resource type="Script" path="res://ResourceCounter.gd" id="16"] +[ext_resource type="Script" path="res://FormationEditor.gd" id="17"] +[ext_resource type="Script" path="res://HaulerField.gd" id="18"] +[ext_resource type="Script" path="res://NeonNumber.gd" id="19"] +[ext_resource type="Script" path="res://NeonPanel.gd" id="20"] + +[sub_resource type="ShaderMaterial" id="1"] +shader = ExtResource("5") +shader_parameter/camera_pos = Vector2(0, 0) +shader_parameter/camera_zoom = 1.0 +shader_parameter/viewport_size = Vector2(720, 1280) +shader_parameter/cell_size = 60.0 +shader_parameter/bg_color = Color(0.02, 0.02, 0.08, 1) + +[node name="Main" type="Node2D"] +script = ExtResource("3") + +[node name="BackgroundLayer" type="CanvasLayer" parent="."] +layer = -10 + +[node name="Starfield" 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("1") + +[node name="Fleet" type="Node2D" parent="."] +script = ExtResource("2") + +[node name="Camera2D" type="Camera2D" parent="."] +position = Vector2(0, 0) +zoom = Vector2(1, 1) + +[node name="EnemySpawner" type="Node2D" parent="."] +script = ExtResource("4") + +[node name="ExplorationTracker" type="Node2D" parent="."] +script = ExtResource("6") + +[node name="AsteroidField" type="Node2D" parent="."] +script = ExtResource("7") + +[node name="PlanetField" type="Node2D" parent="."] +script = ExtResource("11") + +[node name="BuildingField" type="Node2D" parent="."] +script = ExtResource("14") + +[node name="HaulerField" type="Node2D" parent="."] +script = ExtResource("18") + +[node name="BuildController" type="Node2D" parent="."] +script = ExtResource("15") + +[node name="LandingController" type="Node2D" parent="."] +script = ExtResource("12") + +[node name="UI" type="CanvasLayer" parent="."] +layer = 1 + +[node name="ResourceCounter" type="Control" parent="UI"] +mouse_filter = 2 +offset_left = 28 +offset_top = 24 +offset_right = 520 +offset_bottom = 140 +script = ExtResource("16") +digit_height = 100.0 + +[node name="PlanetNameLabel" type="Label" parent="UI"] +visible = false +offset_left = 24 +offset_top = 156 +offset_right = 696 +offset_bottom = 282 +text = "" +horizontal_alignment = 1 + +[node name="EnterAtmosphereButton" type="Control" parent="UI"] +visible = false +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -720 +offset_bottom = -572 +script = ExtResource("10") +icon = 3 + +[node name="BuildButton" type="Control" parent="UI"] +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -890 +offset_bottom = -742 +script = ExtResource("10") +icon = 5 + +[node name="BuildActionButton" type="Control" parent="UI"] +visible = false +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -720 +offset_bottom = -572 +script = ExtResource("10") +icon = 2 + +[node name="BuildActionLabel" type="Label" parent="UI"] +visible = false +mouse_filter = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +anchor_right = 1.0 +offset_left = 24 +offset_right = -24 +offset_top = -566 +offset_bottom = -440 +text = "" +horizontal_alignment = 2 + +[node name="MapButton" type="Control" parent="UI"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = 24 +offset_bottom = 172 +script = ExtResource("10") +icon = 0 + +[node name="KeyboardHintLabel" type="Label" parent="UI"] +visible = false +mouse_filter = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 28 +offset_right = 360 +offset_top = -300 +offset_bottom = -120 +theme_override_font_sizes/font_size = 36 +autowrap_mode = 2 +text = "WASD / arrows move Q / E pivot wheel zoom" + +[node name="Joystick" type="Control" parent="UI"] +mouse_filter = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 424 +offset_top = -424 +offset_bottom = -24 +script = ExtResource("13") + +[node name="PivotCCWButton" type="Control" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -196 +offset_top = -172 +offset_bottom = -24 +script = ExtResource("10") +icon = 7 + +[node name="PivotCWButton" type="Control" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = -172 +offset_bottom = -24 +script = ExtResource("10") +icon = 6 + +[node name="NavLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -322 +offset_bottom = -196 +text = "Pivot" +horizontal_alignment = 1 + +[node name="WorldMap" type="Control" parent="UI"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("8") + +[node name="Backdrop" type="ColorRect" parent="UI/WorldMap"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.96) + +[node name="MapContent" type="Node2D" parent="UI/WorldMap"] +script = ExtResource("9") + +[node name="RecenterButton" type="Control" parent="UI/WorldMap"] +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -620 +offset_bottom = -472 +script = ExtResource("10") +icon = 2 + +[node name="CloseButton" type="Control" parent="UI/WorldMap"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = 24 +offset_bottom = 172 +script = ExtResource("10") +icon = 1 + +[node name="BuildMenu" type="Control" parent="UI"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/BuildMenu"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 70 +offset_right = -24 +offset_bottom = 196 +text = "Build" +horizontal_alignment = 1 + +[node name="StatusLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 206 +offset_right = -24 +offset_bottom = 330 +theme_override_font_sizes/font_size = 40 +autowrap_mode = 2 +text = "" +horizontal_alignment = 1 + +[node name="MothershipButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -214 +offset_right = -66 +offset_top = -224 +offset_bottom = -76 +script = ExtResource("10") +icon = 8 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="MothershipLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -290 +offset_right = 10 +offset_top = -70 +offset_bottom = 60 +theme_override_font_sizes/font_size = 44 +autowrap_mode = 2 +text = "Mothership" +horizontal_alignment = 1 + +[node name="MothershipCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -220 +offset_right = -60 +offset_top = 70 +offset_bottom = 150 +script = ExtResource("19") +amount = 20 +digit_height = 66.0 + +[node name="CrafterButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 66 +offset_right = 214 +offset_top = -224 +offset_bottom = -76 +script = ExtResource("10") +icon = 9 +glow_color = Color(1, 0.72, 0.25, 1) + +[node name="CrafterLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -10 +offset_right = 290 +offset_top = -70 +offset_bottom = 60 +theme_override_font_sizes/font_size = 44 +autowrap_mode = 2 +text = "Ship Crafter" +horizontal_alignment = 1 + +[node name="CrafterCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 60 +offset_right = 220 +offset_top = 70 +offset_bottom = 150 +script = ExtResource("19") +amount = 12 +digit_height = 66.0 + +[node name="CommandButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -214 +offset_right = -66 +offset_top = 175 +offset_bottom = 323 +script = ExtResource("10") +icon = 10 +glow_color = Color(0.55, 0.6, 1, 1) + +[node name="CommandLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -290 +offset_right = 10 +offset_top = 333 +offset_bottom = 463 +theme_override_font_sizes/font_size = 44 +autowrap_mode = 2 +text = "Command Center" +horizontal_alignment = 1 + +[node name="CommandCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -220 +offset_right = -60 +offset_top = 473 +offset_bottom = 553 +script = ExtResource("19") +amount = 15 +digit_height = 66.0 + +[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 66 +offset_right = 214 +offset_top = 175 +offset_bottom = 323 +script = ExtResource("10") +icon = 11 +glow_color = Color(0, 0.62, 0.7, 1) + +[node name="ExtractorLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -10 +offset_right = 290 +offset_top = 333 +offset_bottom = 463 +theme_override_font_sizes/font_size = 44 +autowrap_mode = 2 +text = "Extractor" +horizontal_alignment = 1 + +[node name="ExtractorCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 60 +offset_right = 220 +offset_top = 473 +offset_bottom = 553 +script = ExtResource("19") +amount = 25 +digit_height = 66.0 + +[node name="CloseButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = 24 +offset_bottom = 172 +script = ExtResource("10") +icon = 1 + +[node name="InfoPopup" type="Control" parent="UI/BuildMenu"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Dim" type="ColorRect" parent="UI/BuildMenu/InfoPopup"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.01, 0.01, 0.03, 0.55) + +[node name="Panel" type="Control" parent="UI/BuildMenu/InfoPopup"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -352 +offset_right = 352 +offset_top = -600 +offset_bottom = 600 +script = ExtResource("20") +glow_color = Color(0, 0.62, 0.7, 1) + +[node name="TitleLabel" type="Label" parent="UI/BuildMenu/InfoPopup/Panel"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 44 +offset_right = -44 +offset_top = 30 +offset_bottom = 150 +text = "" +horizontal_alignment = 1 + +[node name="MessageLabel" type="Label" parent="UI/BuildMenu/InfoPopup/Panel"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 44 +offset_right = -44 +offset_top = 160 +offset_bottom = -440 +theme_override_font_sizes/font_size = 48 +text = "" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="CostNumber" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -70 +offset_right = 90 +offset_top = -424 +offset_bottom = -334 +script = ExtResource("19") +amount = 0 +digit_height = 78.0 + +[node name="BuildButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -200 +offset_right = -40 +offset_top = -300 +offset_bottom = -140 +script = ExtResource("10") +icon = 5 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="CloseButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 40 +offset_right = 200 +offset_top = -300 +offset_bottom = -140 +script = ExtResource("10") +icon = 1 +glow_color = Color(0, 0.62, 0.7, 1) + +[node name="CraftMenu" type="Control" parent="UI"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/CraftMenu"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 70 +offset_right = -24 +offset_bottom = 196 +text = "Build a ship" +horizontal_alignment = 1 + +[node name="StatusLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 206 +offset_right = -24 +offset_bottom = 360 +theme_override_font_sizes/font_size = 40 +autowrap_mode = 2 +text = "" +horizontal_alignment = 1 + +[node name="InterceptorButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -74 +offset_right = 74 +offset_top = -74 +offset_bottom = 74 +script = ExtResource("10") +icon = 2 + +[node name="InterceptorLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -260 +offset_right = 260 +offset_top = 100 +offset_bottom = 200 +theme_override_font_sizes/font_size = 60 +text = "Interceptor" +horizontal_alignment = 1 + +[node name="CloseButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = 24 +offset_bottom = 172 +script = ExtResource("10") +icon = 1 + +[node name="FormationMenu" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/FormationMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/FormationMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 70 +offset_right = -24 +offset_bottom = 220 +theme_override_font_sizes/font_size = 44 +autowrap_mode = 2 +text = "Fleet formation - drag to arrange" +horizontal_alignment = 1 + +[node name="Editor" type="Control" parent="UI/FormationMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -300 +offset_right = 300 +offset_top = -300 +offset_bottom = 300 +script = ExtResource("17") + +[node name="ResetButton" type="Control" parent="UI/FormationMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -74 +offset_right = 74 +offset_top = -270 +offset_bottom = -122 +script = ExtResource("10") +icon = 3 + +[node name="ResetLabel" type="Label" parent="UI/FormationMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -180 +offset_right = 180 +offset_top = -114 +offset_bottom = -8 +theme_override_font_sizes/font_size = 60 +text = "Reset" +horizontal_alignment = 1 + +[node name="CloseButton" type="Control" parent="UI/FormationMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = 24 +offset_bottom = 172 +script = ExtResource("10") +icon = 1 + +[node name="FadeOverlay" type="CanvasLayer" parent="."] +layer = 10 + +[node name="FadeRect" type="ColorRect" parent="FadeOverlay"] +mouse_filter = 2 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 0) diff --git a/NeonDraw.gd b/NeonDraw.gd new file mode 100644 index 0000000..b18f19e --- /dev/null +++ b/NeonDraw.gd @@ -0,0 +1,42 @@ +class_name NeonDraw +extends RefCounted + +# Cheap "poor man's glow": layered translucent strokes of increasing width +# and decreasing opacity, topped with a bright core line. No solid fill - +# gives ships/enemies/shots a light-made, neon/laser-sabre look without +# needing an HDR bloom pipeline. +# +# The core colour is a parameter rather than always white: the effect is +# built for a dark background, and a near-white core all but disappears +# against a bright one (the lunar surface, whose colours are sampled from a +# real NASA photograph, is a light grey). Callers drawing onto a bright +# backdrop pass a dark core instead, which reads as an inked outline while +# keeping the same layered-glow construction. + +const DEFAULT_CORE: Color = Color(1.0, 1.0, 1.0) + + +static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: + var closed: PackedVector2Array = points.duplicate() + closed.append(points[0]) + _draw_layers(item, closed, glow_color, pulse, 1.0, core_color) + + +static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: + _draw_layers(item, PackedVector2Array([from, to]), glow_color, pulse, width_scale, core_color) + + +# An open run of points - unlike draw_glowing_polygon, the ends are NOT +# joined. For arcs, cradle ribs and other shapes that are deliberately open. +static func draw_glowing_path(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: + if points.size() < 2: + return + _draw_layers(item, points, glow_color, pulse, width_scale, core_color) + + +static func _draw_layers(item: CanvasItem, poly: PackedVector2Array, glow_color: Color, pulse: float, width_scale: float, core_color: Color) -> void: + var c: Color = glow_color + item.draw_polyline(poly, Color(c.r, c.g, c.b, 0.12 * pulse), 18.0 * width_scale, true) + item.draw_polyline(poly, Color(c.r, c.g, c.b, 0.28 * pulse), 10.0 * width_scale, true) + item.draw_polyline(poly, Color(c.r, c.g, c.b, 0.60 * pulse), 5.0 * width_scale, true) + item.draw_polyline(poly, Color(core_color.r, core_color.g, core_color.b, 0.95 * pulse), 1.8 * width_scale, true) diff --git a/NeonDraw.gd.uid b/NeonDraw.gd.uid new file mode 100644 index 0000000..ea52545 --- /dev/null +++ b/NeonDraw.gd.uid @@ -0,0 +1 @@ +uid://d1rylxo301xin diff --git a/NeonNumber.gd b/NeonNumber.gd new file mode 100644 index 0000000..7333b36 --- /dev/null +++ b/NeonNumber.gd @@ -0,0 +1,84 @@ +class_name NeonNumber +extends Control + +# An ore amount drawn in the game's own hand-stroked style: the resource +# hexagon followed by segment-drawn digits. Godot's default Label font has +# no place in this look, which is why the digits are strokes rather than +# text. +# +# Used both for the live resource readout (see ResourceCounter, which feeds +# it from GameState) and for the fixed prices under each build option. + +@export var amount: int = 0: set = set_amount +@export var show_icon: bool = true +@export var glow_color: Color = Color(0.0, 0.62, 0.7) +@export var core_color: Color = Color(0.0, 0.12, 0.15) +@export var digit_height: float = 30.0 +@export var digit_gap: float = 9.0 + +# Which of the seven segments each digit lights, in the usual order: +# top, top-right, bottom-right, bottom, bottom-left, top-left, middle. +const DIGIT_SEGMENTS: Array = [ + [true, true, true, true, true, true, false], # 0 + [false, true, true, false, false, false, false], # 1 + [true, true, false, true, true, false, true], # 2 + [true, true, true, true, false, false, true], # 3 + [false, true, true, false, false, true, true], # 4 + [true, false, true, true, false, true, true], # 5 + [true, false, true, true, true, true, true], # 6 + [true, true, true, false, false, false, false], # 7 + [true, true, true, true, true, true, true], # 8 + [true, true, true, true, false, true, true], # 9 +] + + +func set_amount(value: int) -> void: + if amount == value: + return + amount = value + queue_redraw() + + +func _draw() -> void: + var h: float = digit_height + var w: float = h * 0.56 + var x: float = 0.0 + + if show_icon: + _draw_hexagon(Vector2(h * 0.42, h * 0.5), h * 0.62) + x = h * 1.15 + + for ch in str(maxi(amount, 0)): + _draw_digit(int(ch), Vector2(x, 0.0), w, h) + x += w + digit_gap + + +func _draw_hexagon(centre: Vector2, radius: float) -> void: + # Same silhouette as the ore pickup itself, so a price and the thing it + # is priced in are unmistakably the same resource. + var pts: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU - PI / 2.0 + pts.append(centre + Vector2(cos(a), sin(a)) * radius) + NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color) + NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, 0.26, core_color) + + +func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void: + if value < 0 or value > 9: + return + var on: Array = DIGIT_SEGMENTS[value] + var mid: float = h * 0.5 + var seg: Array = [ + [Vector2(0.0, 0.0), Vector2(w, 0.0)], # top + [Vector2(w, 0.0), Vector2(w, mid)], # top-right + [Vector2(w, mid), Vector2(w, h)], # bottom-right + [Vector2(0.0, h), Vector2(w, h)], # bottom + [Vector2(0.0, mid), Vector2(0.0, h)], # bottom-left + [Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left + [Vector2(0.0, mid), Vector2(w, mid)], # middle + ] + for i in range(seg.size()): + if not on[i]: + continue + NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, 0.3, core_color) diff --git a/NeonNumber.gd.uid b/NeonNumber.gd.uid new file mode 100644 index 0000000..4a12939 --- /dev/null +++ b/NeonNumber.gd.uid @@ -0,0 +1 @@ +uid://on4hmiwsv7yu diff --git a/NeonPanel.gd b/NeonPanel.gd new file mode 100644 index 0000000..5e212bf --- /dev/null +++ b/NeonPanel.gd @@ -0,0 +1,47 @@ +class_name NeonPanel +extends Control + +# A bounded dialog card drawn in the game's own style: a dark chamfered +# body with a glowing border and bright corner brackets. Chamfered rather +# than square because cut corners read as "console panel" and match the +# hard-edged neon of the buttons and HUD. +# +# Draws itself from its own rect, so it can be sized freely in the scene. + +@export var glow_color: Color = Color(0.25, 0.85, 1.0) +@export var core_color: Color = Color(1.0, 1.0, 1.0) +# Fully opaque: at even 97% the bright neon of whatever sits behind the +# panel ghosts through and makes the text harder to read. +@export var fill_color: Color = Color(0.02, 0.03, 0.07, 1.0) +@export var corner_cut: float = 26.0 +@export var bracket_length: float = 46.0 + + +func _draw() -> void: + var w: float = size.x + var h: float = size.y + var c: float = corner_cut + + var body: PackedVector2Array = PackedVector2Array([ + Vector2(c, 0.0), Vector2(w - c, 0.0), + Vector2(w, c), Vector2(w, h - c), + Vector2(w - c, h), Vector2(c, h), + Vector2(0.0, h - c), Vector2(0.0, c), + ]) + draw_colored_polygon(body, fill_color) + NeonDraw.draw_glowing_polygon(self, body, glow_color, 1.0, core_color) + + # Corner brackets: short doubled strokes just inside each cut corner, + # which is what gives it the heads-up-display feel rather than looking + # like a plain outlined box. + var b: float = bracket_length + var inset: float = 10.0 + var corners: Array = [ + [Vector2(inset, c + inset), Vector2(inset, c + inset + b), Vector2(c + inset, inset), Vector2(c + inset + b, inset)], + [Vector2(w - inset, c + inset), Vector2(w - inset, c + inset + b), Vector2(w - c - inset, inset), Vector2(w - c - inset - b, inset)], + [Vector2(inset, h - c - inset), Vector2(inset, h - c - inset - b), Vector2(c + inset, h - inset), Vector2(c + inset + b, h - inset)], + [Vector2(w - inset, h - c - inset), Vector2(w - inset, h - c - inset - b), Vector2(w - c - inset, h - inset), Vector2(w - c - inset - b, h - inset)], + ] + for corner in corners: + NeonDraw.draw_glowing_line(self, corner[0], corner[1], glow_color, 0.9, 0.3, core_color) + NeonDraw.draw_glowing_line(self, corner[2], corner[3], glow_color, 0.9, 0.3, core_color) diff --git a/NeonPanel.gd.uid b/NeonPanel.gd.uid new file mode 100644 index 0000000..c6ae002 --- /dev/null +++ b/NeonPanel.gd.uid @@ -0,0 +1 @@ +uid://c8abuj0i78t06 diff --git a/Planet.gd b/Planet.gd new file mode 100644 index 0000000..4882311 --- /dev/null +++ b/Planet.gd @@ -0,0 +1,39 @@ +extends Node2D + +# A landable world-object - unlike regular Asteroid.gd, planets are +# stationary ("Big Stable Asteroid" - stable means it doesn't drift or +# destroy ships on touch). Whether the fleet counts as "landed" is decided +# by PlanetField comparing the fleet's CENTROID distance to landing_radius, +# not per-ship Area2D collision - a single stable number, immune to the +# constant minor jostling of individual ships holding formation, which +# used to make the "enter atmosphere" prompt flicker on/off as ships +# drifted in and out of the boundary independently of each other even +# while the fleet as a whole was clearly sitting on the planet. + +@export var planet_name: String = "" +@export var planet_type: String = "big_stable_asteroid" +@export var landing_radius: float = 140.0 +# Set by whoever spawns this planet (PlanetField, seeded from the chunk's +# own coordinates) so the outline shape is stable across respawns of the +# same chunk, instead of reading as a "different" planet every time. +@export var shape_seed: int = 0 + +var shape_points: PackedVector2Array = PackedVector2Array() + + +func _ready() -> void: + add_to_group("planets") + z_index = -1 + + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = shape_seed + var point_count: int = 10 + for i in range(point_count): + var angle: float = (float(i) / point_count) * TAU + var radius: float = rng.randf_range(70.0, 100.0) + shape_points.append(Vector2(cos(angle), sin(angle)) * radius) + queue_redraw() + + +func _draw() -> void: + NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0) diff --git a/Planet.gd.uid b/Planet.gd.uid new file mode 100644 index 0000000..316a602 --- /dev/null +++ b/Planet.gd.uid @@ -0,0 +1 @@ +uid://berbdl8nqskqa diff --git a/Planet.tscn b/Planet.tscn new file mode 100644 index 0000000..844672b --- /dev/null +++ b/Planet.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Planet.gd" id="1"] + +[node name="Planet" type="Node2D"] +script = ExtResource("1") diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd new file mode 100644 index 0000000..cc497d4 --- /dev/null +++ b/PlanetAtmosphereController.gd @@ -0,0 +1,124 @@ +extends Node2D + +# Space-side: bootstraps the fleet on Main.tscn startup (a fresh 5-ship +# fleet at the origin on first launch, or GameState's surviving ship +# count/position if we just returned from a planet's surface), shows an +# "enter atmosphere" prompt (name + button) when the fleet is standing on +# a landable planet, and - on press - fades out and swaps to a completely +# separate PlanetSurface.tscn scene (not an overlay toggled in place), so +# asteroids/enemies genuinely cannot exist there rather than merely being +# hidden. Fleet size and the world position/zoom to return to are handed +# off via the GameState autoload, since change_scene_to_file() tears down +# this whole scene tree. + +@export var fade_duration: float = 0.4 +@export var prompt_debounce_time: float = 0.15 + +@onready var planet_field: Node2D = get_node("../PlanetField") +@onready var asteroid_field: Node2D = get_node("../AsteroidField") +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") +@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel") +@onready var enter_button: IconButton = get_node("../UI/EnterAtmosphereButton") +@onready var world_map: Control = get_node("../UI/WorldMap") +@onready var build_controller: Node2D = get_node("../BuildController") +@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect") + +var current_planet: Node2D = null +var transitioning: bool = false +var pending_planet: Node2D = null +var pending_time: float = 0.0 + + +func _ready() -> void: + enter_button.pressed.connect(_on_enter_pressed) + + if GameState.has_return_position: + fleet.spawn_ships(GameState.surviving_ship_count, GameState.return_world_position) + camera.position = GameState.return_world_position + camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) + GameState.has_return_position = false + else: + fleet.spawn_ships(5, Vector2.ZERO) + + # AsteroidField/PlanetField normally only populate nearby chunks once + # their own check_interval timer elapses (~1s) - fine during normal + # continuous play (things appear far off-screen, gradually), but + # right after a fresh scene load it means an empty-looking world for + # a second before asteroids/the planet visibly pop in. Force an + # immediate synchronous populate now that the fleet is correctly + # positioned, so nothing is missing on the very first visible frame. + asteroid_field.update_chunks() + planet_field.update_chunks() + + +func _process(delta: float) -> void: + if transitioning: + return + + if world_map.visible or build_controller.is_showing_building_prompt(): + # EnterAtmosphereButton shares one "action" slot with the map's + # RecenterButton and with BuildController's building prompt - it doesn't make sense to enter + # a planet's atmosphere while looking at the star map anyway, so + # suppress the prompt entirely rather than let it sit hidden + # behind the map's backdrop only to pop back in the instant the + # map closes. + current_planet = null + pending_planet = null + planet_name_label.visible = false + enter_button.visible = false + return + + # Debounced: only commit a visible/hidden change once the raw + # landing-detection result has stayed the same for prompt_debounce_time + # in a row, instead of reacting to every single frame's reading. Belt + # and suspenders on top of get_planet_fleet_is_on() already using the + # fleet's centroid (a single stable number - see Planet.gd) rather + # than any individual ship's position, so this only has to absorb + # genuine sub-debounce-window boundary crossings, not constant noise. + var detected_planet: Node2D = planet_field.get_planet_fleet_is_on() + if detected_planet != pending_planet: + pending_planet = detected_planet + pending_time = 0.0 + else: + pending_time += delta + + if pending_time < prompt_debounce_time or pending_planet == current_planet: + return + + current_planet = pending_planet + if current_planet != null: + planet_name_label.text = current_planet.planet_name + planet_name_label.visible = true + enter_button.visible = true + else: + planet_name_label.visible = false + enter_button.visible = false + + +func _on_enter_pressed() -> void: + if transitioning or current_planet == null: + return + transitioning = true + planet_name_label.visible = false + enter_button.visible = false + + GameState.has_return_position = true + GameState.surviving_ship_count = fleet.ships.size() + # The planet's own position, not fleet.get_centroid() - the fleet only + # needs to be somewhere within the planet's landing radius (not + # centered exactly on it) for the prompt to show, so the centroid can + # be tens of units off from the planet. Returning should always place + # the fleet back at the planet itself. + GameState.return_world_position = current_planet.position + GameState.return_camera_zoom = camera.zoom.x + GameState.planet_name = current_planet.planet_name + GameState.planet_type = current_planet.planet_type + + var tween: Tween = create_tween() + tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) + tween.tween_callback(_enter_surface) + + +func _enter_surface() -> void: + get_tree().change_scene_to_file("res://PlanetSurface.tscn") diff --git a/PlanetAtmosphereController.gd.uid b/PlanetAtmosphereController.gd.uid new file mode 100644 index 0000000..cf64c3d --- /dev/null +++ b/PlanetAtmosphereController.gd.uid @@ -0,0 +1 @@ +uid://dfof82skt2f4d diff --git a/PlanetField.gd b/PlanetField.gd new file mode 100644 index 0000000..af7a747 --- /dev/null +++ b/PlanetField.gd @@ -0,0 +1,93 @@ +extends Node2D + +# Spawns "Big Stable Asteroid" planets in ASTEROID_FIELD biome chunks near +# the fleet where WorldGen says one belongs, and despawns them once the +# fleet has moved far enough away - mirrors AsteroidField's chunk-lifecycle +# pattern, but at a sparser, larger-radius scale since planets are rare +# landmarks rather than common decoration. + +@export var check_interval: float = 1.0 +@export var spawn_radius_chunks: int = 3 +@export var despawn_radius_chunks: int = 6 + +@onready var fleet: Node2D = get_node("../Fleet") + +var planet_scene: PackedScene = preload("res://Planet.tscn") +var populated_chunks: Dictionary = {} +var timer: float = 0.0 + + +func _process(delta: float) -> void: + timer += delta + if timer < check_interval: + return + timer = 0.0 + update_chunks() + + +func get_planet_fleet_is_on() -> Node2D: + # Fleet centroid distance, not per-ship Area2D overlap - a single + # stable number instead of many independently-noisy ship positions. + # See Planet.gd for why this matters. + var fleet_centroid: Vector2 = fleet.get_centroid() + for chunk in populated_chunks.keys(): + for planet in populated_chunks[chunk]: + if is_instance_valid(planet) and planet.position.distance_to(fleet_centroid) <= planet.landing_radius: + return planet + return null + + +func update_chunks() -> void: + # Public (not just called from _process's own timer): also invoked + # once synchronously right after the fleet is positioned on a fresh + # Main.tscn load, so a nearby planet exists immediately instead of + # only appearing ~check_interval seconds later - most noticeable as a + # jarring pop-in right after returning from a planet's surface. + var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid()) + + for dx in range(-spawn_radius_chunks, spawn_radius_chunks + 1): + for dy in range(-spawn_radius_chunks, spawn_radius_chunks + 1): + var chunk: Vector2i = center_chunk + Vector2i(dx, dy) + if populated_chunks.has(chunk): + continue + if not WorldGen.has_planet_at_chunk(chunk): + continue + _spawn_chunk_planet(chunk) + + var to_remove: Array = [] + for chunk in populated_chunks.keys(): + if _chebyshev_chunk_distance(chunk, center_chunk) > despawn_radius_chunks: + for planet in populated_chunks[chunk]: + if is_instance_valid(planet): + planet.queue_free() + to_remove.append(chunk) + for chunk in to_remove: + populated_chunks.erase(chunk) + + +func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int: + return max(abs(a.x - b.x), abs(a.y - b.y)) + + +func _spawn_chunk_planet(chunk: Vector2i) -> void: + # WorldGen.planet_data_at_chunk() is seeded purely from the chunk's own + # coordinates, so a given chunk always produces the exact same planet - + # name, position within the chunk, and shape - no matter how many times + # it's despawned and respawned across a scene reload. It's also the + # same function the world map uses to place a marker for a + # not-currently-loaded explored chunk, so the two never disagree. + var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) + var origin: Vector2 = WorldGen.chunk_origin(chunk) + + var planet: Node2D = planet_scene.instantiate() + planet.position = origin + data.local_pos + planet.planet_name = data.name + planet.shape_seed = data.shape_seed + # Deferred: update_chunks() can run synchronously from inside a + # parent's own _ready() chain (forced once right after the fleet is + # positioned, to avoid a pop-in on scene load) - Godot refuses a + # direct add_child() while the tree is still "busy setting up + # children" in that situation, so this must be deferred either way. + get_parent().add_child.call_deferred(planet) + + populated_chunks[chunk] = [planet] diff --git a/PlanetField.gd.uid b/PlanetField.gd.uid new file mode 100644 index 0000000..f4057fe --- /dev/null +++ b/PlanetField.gd.uid @@ -0,0 +1 @@ +uid://cxgnqiqa11o0f diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn new file mode 100644 index 0000000..b2a7a16 --- /dev/null +++ b/PlanetSurface.tscn @@ -0,0 +1,172 @@ +[gd_scene load_steps=11 format=3] + +[ext_resource type="Script" path="res://GameController.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://Fleet.gd" id="4"] +[ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"] +[ext_resource type="Script" path="res://IconButton.gd" id="6"] +[ext_resource type="Script" path="res://TouchJoystick.gd" id="7"] +[ext_resource type="Script" path="res://RockField.gd" id="8"] +[ext_resource type="Script" path="res://ResourceCounter.gd" id="9"] + +[sub_resource type="ShaderMaterial" id="1"] +shader = ExtResource("3") +shader_parameter/camera_pos = Vector2(0, 0) +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="PlanetSurface" type="Node2D"] +script = ExtResource("1") + +[node name="BackgroundLayer" type="CanvasLayer" parent="."] +layer = -10 + +[node name="LunarBackground" 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="Fleet" type="Node2D" parent="."] +script = ExtResource("4") +ship_glow_color = Color(0.06, 0.32, 0.5, 1) +ship_core_color = Color(0.02, 0.09, 0.15, 1) + +[node name="Camera2D" type="Camera2D" parent="."] +position = Vector2(0, 0) +zoom = Vector2(1, 1) + +[node name="RockField" type="Node2D" parent="."] +script = ExtResource("8") + +[node name="SurfaceController" type="Node2D" parent="."] +script = ExtResource("5") + +[node name="UI" type="CanvasLayer" parent="."] +layer = 1 + +[node name="ResourceCounter" type="Control" parent="UI"] +mouse_filter = 2 +offset_left = 28 +offset_top = 24 +offset_right = 520 +offset_bottom = 140 +script = ExtResource("9") +digit_height = 100.0 +glow_color = Color(0, 0.42, 0.48, 1) +core_color = Color(0, 0.08, 0.1, 1) + +[node name="PlanetNameLabel" type="Label" parent="UI"] +offset_left = 24 +offset_top = 156 +offset_right = 696 +offset_bottom = 282 +text = "" +horizontal_alignment = 1 +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="ExitButton" type="Control" parent="UI"] +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -720 +offset_bottom = -572 +script = ExtResource("6") +icon = 4 +glow_color = Color(0.06, 0.32, 0.5, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + +[node name="KeyboardHintLabel" type="Label" parent="UI"] +visible = false +mouse_filter = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 28 +offset_right = 360 +offset_top = -300 +offset_bottom = -120 +theme_override_font_sizes/font_size = 36 +autowrap_mode = 2 +text = "WASD / arrows move Q / E pivot wheel zoom" + +[node name="Joystick" type="Control" parent="UI"] +mouse_filter = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 424 +offset_top = -424 +offset_bottom = -24 +script = ExtResource("7") +glow_color = Color(0.06, 0.32, 0.5, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + +[node name="PivotCCWButton" type="Control" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -196 +offset_top = -172 +offset_bottom = -24 +script = ExtResource("6") +icon = 7 +glow_color = Color(0.06, 0.32, 0.5, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + +[node name="PivotCWButton" type="Control" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -172 +offset_right = -24 +offset_top = -172 +offset_bottom = -24 +script = ExtResource("6") +icon = 6 +glow_color = Color(0.06, 0.32, 0.5, 1) +core_color = Color(0.02, 0.09, 0.15, 1) + +[node name="NavLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = -344 +offset_right = -24 +offset_top = -322 +offset_bottom = -196 +text = "Pivot" +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 +horizontal_alignment = 1 + +[node name="FadeOverlay" type="CanvasLayer" parent="."] +layer = 10 + +[node name="FadeRect" type="ColorRect" parent="FadeOverlay"] +mouse_filter = 2 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 1) diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd new file mode 100644 index 0000000..63279f4 --- /dev/null +++ b/PlanetSurfaceController.gd @@ -0,0 +1,45 @@ +extends Node2D + +# Bootstraps the surviving fleet on this scene's own startup (from +# GameState, since this is a fresh scene tree - nothing here carries +# over automatically) and handles the always-available exit prompt that +# fades back out to Main.tscn, restoring the fleet at wherever it left +# space from. + +@export var fade_duration: float = 0.4 + +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") +@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel") +@onready var exit_button: IconButton = get_node("../UI/ExitButton") +@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect") + +var transitioning: bool = false + + +func _ready() -> void: + fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO) + camera.position = Vector2.ZERO + camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) + planet_name_label.text = GameState.planet_name + exit_button.pressed.connect(_on_exit_pressed) + + fade_rect.color.a = 1.0 + var tween: Tween = create_tween() + tween.tween_property(fade_rect, "color:a", 0.0, fade_duration) + + +func _on_exit_pressed() -> void: + if transitioning: + return + transitioning = true + + GameState.surviving_ship_count = fleet.ships.size() + + var tween: Tween = create_tween() + tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) + tween.tween_callback(_return_to_space) + + +func _return_to_space() -> void: + get_tree().change_scene_to_file("res://Main.tscn") diff --git a/PlanetSurfaceController.gd.uid b/PlanetSurfaceController.gd.uid new file mode 100644 index 0000000..4b067f6 --- /dev/null +++ b/PlanetSurfaceController.gd.uid @@ -0,0 +1 @@ +uid://17thrioymdls diff --git a/Projectile.gd b/Projectile.gd new file mode 100644 index 0000000..ed05f23 --- /dev/null +++ b/Projectile.gd @@ -0,0 +1,47 @@ +extends Area2D + +@export var speed: float = 500.0 +@export var lifetime: float = 1.5 +@export var damage: int = 1 +@export var bolt_length: float = 20.0 + +var velocity: Vector2 = Vector2.ZERO +var age: float = 0.0 + + +func _ready() -> void: + add_to_group("player_projectiles") + area_entered.connect(_on_area_entered) + + +func launch(direction: Vector2) -> void: + velocity = direction.normalized() * speed + rotation = direction.angle() + PI / 2.0 + + +func _physics_process(delta: float) -> void: + position += velocity * delta + age += delta + # Redrawn every frame. In principle a fixed shape only needs drawing + # once (see Asteroid.gd, where dropping this was a large win), but + # doing that here left bolts invisible on screen - verified by + # rendering a frame mid-combat and finding zero pixels of the bolt + # colour. Rather than ship invisible shots a second time, this keeps + # the redraw: projectiles are few and live under two seconds, so the + # cost is negligible. + queue_redraw() + if age >= lifetime: + queue_free() + + +func _on_area_entered(area: Area2D) -> void: + if area.is_in_group("enemies"): + area.take_damage(damage) + queue_free() + + +func _draw() -> void: + # Fixed-length, constant-brightness bolt. Drawn once and left alone. + var tip: Vector2 = Vector2(0, -bolt_length * 0.6) + var tail: Vector2 = Vector2(0, bolt_length * 0.4) + NeonDraw.draw_glowing_line(self, tail, tip, Color(1.0, 0.85, 0.3), 1.0, 0.5) diff --git a/Projectile.gd.uid b/Projectile.gd.uid new file mode 100644 index 0000000..36a9c15 --- /dev/null +++ b/Projectile.gd.uid @@ -0,0 +1 @@ +uid://bv5epiw5tu65j diff --git a/Projectile.tscn b/Projectile.tscn new file mode 100644 index 0000000..bd199c3 --- /dev/null +++ b/Projectile.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://Projectile.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 4.0 + +[node name="Projectile" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/ResourceCounter.gd b/ResourceCounter.gd new file mode 100644 index 0000000..23a8d54 --- /dev/null +++ b/ResourceCounter.gd @@ -0,0 +1,10 @@ +extends NeonNumber + +# Top-of-screen ore readout. All the drawing lives in NeonNumber; this only +# keeps the displayed amount in step with GameState, which it reads directly +# so it stays correct across the space <-> planet-surface scene swap without +# anyone having to push updates into it. + + +func _process(_delta: float) -> void: + set_amount(GameState.rocks) diff --git a/ResourceCounter.gd.uid b/ResourceCounter.gd.uid new file mode 100644 index 0000000..ed6edb5 --- /dev/null +++ b/ResourceCounter.gd.uid @@ -0,0 +1 @@ +uid://5u8a3kx3gsh1 diff --git a/Rock.gd b/Rock.gd new file mode 100644 index 0000000..bc8ffac --- /dev/null +++ b/Rock.gd @@ -0,0 +1,102 @@ +extends Area2D + +# A gatherable crystal of asteroid ore lying on a planet's surface. Picked +# up by flying any ship over it; the fleet doesn't need to stop. +# +# Deliberately drawn as a PERFECT HEXAGON rather than the irregular lumpy +# polygon Asteroid.gd uses. The two are never on screen +# together (asteroids are space-only, rocks surface-only), so the player +# can only tell "collect this" from "this hurts" by learned shape language: +# irregular and jagged reads as a natural hazard, regular and faceted reads +# as something valuable and meant to be picked up. +# +# It also drifts toward a nearby ship (see _physics_process). Nothing +# harmful moves helpfully toward you, so the pull doubles as a readability +# cue on top of making gathering feel less fiddly. + +signal collected + +@export var attract_radius: float = 220.0 +@export var attract_speed: float = 260.0 +@export var attract_accel: float = 420.0 + +var shape_points: PackedVector2Array = PackedVector2Array() +var facet_points: PackedVector2Array = PackedVector2Array() +var size_scale: float = 1.0 +var velocity: Vector2 = Vector2.ZERO + + +func _ready() -> void: + add_to_group("rocks") + z_index = -1 + area_entered.connect(_on_area_entered) + queue_redraw() + + +func build_shape(shape_seed: int) -> void: + # Only the SIZE varies, never the silhouette: every ore node is the same + # regular hexagon, which is the whole point of the cue. + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = shape_seed + size_scale = rng.randf_range(0.85, 1.25) + + var r: float = 13.0 * size_scale + shape_points = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU - PI / 2.0 + shape_points.append(Vector2(cos(a), sin(a)) * r) + # One flat-to-flat line across the middle, so it reads as a cut face + # rather than a plain outline at small sizes. + facet_points = PackedVector2Array([ + shape_points[5], shape_points[2], + ]) + queue_redraw() + + +func _physics_process(delta: float) -> void: + # Drift toward the closest ship inside attract_radius. No redraw here: + # position is a transform property and the crystal's shape is fixed. + var target: Node2D = _nearest_ship() + if target == null: + velocity = velocity.move_toward(Vector2.ZERO, attract_accel * delta) + else: + var desired: Vector2 = (target.position - position).normalized() * attract_speed + velocity = velocity.move_toward(desired, attract_accel * delta) + position += velocity * delta + + +func _nearest_ship() -> Node2D: + var nearest: Node2D = null + var best: float = attract_radius + for ship in get_tree().get_nodes_in_group("player_ships"): + if not is_instance_valid(ship): + continue + var d: float = position.distance_to(ship.position) + if d <= best: + best = d + nearest = ship + return nearest + + +func _on_area_entered(area: Area2D) -> void: + if area.is_in_group("player_ships"): + GameState.rocks += 1 + collected.emit() + queue_free() + + +func _draw() -> void: + if shape_points.is_empty(): + return + # Deep teal with a near-black core: a dark, saturated colour reads + # clearly against the bright lunar regolith (sampled at ~0.75 grey), + # where the original warm yellow disappeared into it. + var glow: Color = Color(0.0, 0.62, 0.7) + var core: Color = Color(0.0, 0.12, 0.15) + NeonDraw.draw_glowing_polygon(self, shape_points, glow, 1.0, core) + # One line only - the hexagon has a single flat-to-flat facet. This used + # to draw two lines from a 4-point array; the hexagon rewrite left only + # 2 points, so the second line indexed out of bounds and every rock + # threw on every draw. + if facet_points.size() >= 2: + NeonDraw.draw_glowing_line(self, facet_points[0], facet_points[1], glow, 0.7, 0.3, core) diff --git a/Rock.gd.uid b/Rock.gd.uid new file mode 100644 index 0000000..b50580d --- /dev/null +++ b/Rock.gd.uid @@ -0,0 +1 @@ +uid://db2ifh5f1pjwo diff --git a/Rock.tscn b/Rock.tscn new file mode 100644 index 0000000..1062c43 --- /dev/null +++ b/Rock.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://Rock.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 26.0 + +[node name="Rock" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/RockField.gd b/RockField.gd new file mode 100644 index 0000000..6210722 --- /dev/null +++ b/RockField.gd @@ -0,0 +1,71 @@ +extends Node2D + +# Scatters gatherable rocks across a planet's surface around the fleet, and +# tops them back up as they are collected, so there is always something to +# mine wherever the player flies. +# +# Unlike AsteroidField/PlanetField this is deliberately NOT deterministic +# per chunk: rocks are consumable, and a chunk-seeded layout would respawn +# the very same rock in the very same spot the moment the player flew back, +# making them infinite and free. Tracking every collected rock forever would +# grow without bound, so they are simply scattered at random within a ring +# around the fleet and despawned once left far behind. + +@export var target_count: int = 14 +@export var spawn_radius: float = 1400.0 +@export var min_spawn_distance: float = 320.0 +@export var despawn_radius: float = 2600.0 +@export var check_interval: float = 0.5 + +@onready var fleet: Node2D = get_node("../Fleet") + +var rock_scene: PackedScene = preload("res://Rock.tscn") +var rocks: Array = [] +var timer: float = 0.0 +var rng: RandomNumberGenerator = RandomNumberGenerator.new() + + +func _ready() -> void: + rng.randomize() + # Populate immediately rather than waiting out the first interval, so + # the surface isn't visibly empty for half a second on arrival. + update_rocks() + + +func _process(delta: float) -> void: + timer += delta + if timer < check_interval: + return + timer = 0.0 + update_rocks() + + +func update_rocks() -> void: + var centre: Vector2 = fleet.get_centroid() + + var surviving: Array = [] + for rock in rocks: + if not is_instance_valid(rock): + continue + if rock.position.distance_to(centre) > despawn_radius: + rock.queue_free() + continue + surviving.append(rock) + rocks = surviving + + while rocks.size() < target_count: + _spawn_rock(centre) + + +func _spawn_rock(centre: Vector2) -> void: + # Ring, not disc: spawning right on top of the fleet would hand the + # player free rocks without flying anywhere. + var angle: float = rng.randf() * TAU + var distance: float = rng.randf_range(min_spawn_distance, spawn_radius) + var rock: Area2D = rock_scene.instantiate() + rock.position = centre + Vector2(cos(angle), sin(angle)) * distance + rock.build_shape(rng.randi()) + # Deferred: update_rocks() runs from _ready(), and Godot refuses a + # direct add_child() while the tree is still setting up children. + get_parent().add_child.call_deferred(rock) + rocks.append(rock) diff --git a/RockField.gd.uid b/RockField.gd.uid new file mode 100644 index 0000000..502a436 --- /dev/null +++ b/RockField.gd.uid @@ -0,0 +1 @@ +uid://vb0rgd6a5w52 diff --git a/Ship.gd b/Ship.gd new file mode 100644 index 0000000..8a9ed40 --- /dev/null +++ b/Ship.gd @@ -0,0 +1,139 @@ +extends Area2D + +signal destroyed + +# Deliberately faster than Fleet.cruise_speed: the fleet as a whole travels +# at the cruise rate, and this headroom is what lets an individual ship +# close on its formation slot - so a pivot mid-flight resolves quickly +# instead of the ship having no speed budget left over for it. +@export var max_speed: float = 460.0 +@export var acceleration: float = 450.0 +@export var rotation_speed: float = 8.0 +@export var arrive_radius: float = 6.0 + +@export var fire_range: float = 320.0 +@export var fire_cooldown: float = 0.5 +# How long to wait before looking for targets again after finding none. +@export var idle_rescan_interval: float = 0.25 +@export var projectile_damage: int = 1 + +# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet. +# The lunar surface is a bright grey, where the default near-white core all +# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw. +var glow_color: Color = Color(0.25, 0.85, 1.0) +var core_color: Color = Color(1.0, 1.0, 1.0) + +var velocity: Vector2 = Vector2.ZERO +# The fleet's own velocity, supplied by Fleet as CONTEXT rather than as a +# facing command - the ship works out where to point from it. +# +# It is needed because `velocity` below is not the ship's true motion while +# it is holding formation: the slot it chases sits inside arrive_radius, so +# the ship snaps onto it and reports zero velocity even though it is being +# carried along at cruise speed. Facing derived from `velocity` alone +# therefore froze at whatever heading the ship last happened to travel. +# Adding the two gives the ship's ACTUAL motion through the world, which is +# the honest thing to point along in every case: it equals the fleet's +# heading when station-keeping, the ship's own heading when manoeuvring +# alone, and the true blend of both while strafing into a new slot. +var fleet_velocity: Vector2 = Vector2.ZERO +var move_target: Vector2 +var fire_timer: float = 0.0 +var projectile_scene: PackedScene = preload("res://Projectile.tscn") + + +func _ready() -> void: + move_target = position + add_to_group("player_ships") + area_entered.connect(_on_area_entered) + queue_redraw() + + +func set_move_target(world_pos: Vector2) -> void: + move_target = world_pos + + +func _physics_process(delta: float) -> void: + var to_target: Vector2 = move_target - position + var distance: float = to_target.length() + + if distance <= arrive_radius: + # Snap and stop dead - prevents residual float-level jitter that + # would otherwise keep nudging back and forth around the target. + position = move_target + velocity = Vector2.ZERO + else: + # Braking-distance formula: the fastest speed we can be going right + # now and still be able to decelerate to 0 exactly at the target, + # given our acceleration. Without this the ship approaches at full + # speed, overshoots (stopping distance = max_speed^2 / (2*accel)), + # and oscillates back and forth forever instead of settling. + var braking_speed: float = sqrt(2.0 * acceleration * distance) + var desired_speed: float = min(max_speed, braking_speed) + var desired_velocity: Vector2 = to_target.normalized() * desired_speed + velocity = velocity.move_toward(desired_velocity, acceleration * delta) + position += velocity * delta + + # One owner, one rule: face the way this ship is actually travelling. + var motion: Vector2 = velocity + fleet_velocity + if motion.length() > 8.0: + var target_angle: float = motion.angle() + PI / 2.0 + rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) + + _update_weapon(delta) + + +func _update_weapon(delta: float) -> void: + fire_timer -= delta + if fire_timer > 0.0: + return + + var target: Node2D = _find_nearest_enemy() + if target == null: + # Back off before scanning again. Without this, fire_timer stays at + # or below zero and every ship queries the whole "enemies" group and + # loops it on EVERY physics frame whenever nothing is in range - + # which is most of the time. + fire_timer = idle_rescan_interval + return + + fire_timer = fire_cooldown + var proj: Node2D = projectile_scene.instantiate() + proj.position = position + proj.damage = projectile_damage + get_tree().root.add_child(proj) + proj.launch(target.position - position) + + +func _find_nearest_enemy() -> Node2D: + var enemies: Array = get_tree().get_nodes_in_group("enemies") + var nearest: Node2D = null + var best_dist: float = fire_range + + for e in enemies: + if not is_instance_valid(e): + continue + var d: float = position.distance_to(e.position) + if d <= best_dist: + best_dist = d + nearest = e + + return nearest + + +func _on_area_entered(area: Area2D) -> void: + if area.is_in_group("enemies") or area.is_in_group("asteroids"): + ShipExplosionVFX.spawn(get_tree().root, position) + destroyed.emit() + queue_free() + + +func _draw() -> void: + var nose: Vector2 = Vector2(0, -22) + var left: Vector2 = Vector2(-14, 16) + var right: Vector2 = Vector2(14, 16) + var points: PackedVector2Array = PackedVector2Array([nose, left, right]) + # Constant glow, drawn once. No pulse means nothing changes frame to + # frame, so the ship never needs redrawing after _ready() - which also + # removes four antialiased polyline rebuilds per ship per frame. + NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color) diff --git a/Ship.gd.uid b/Ship.gd.uid new file mode 100644 index 0000000..05eaf16 --- /dev/null +++ b/Ship.gd.uid @@ -0,0 +1 @@ +uid://dd78hwrpks1d5 diff --git a/Ship.tscn b/Ship.tscn new file mode 100644 index 0000000..85e1601 --- /dev/null +++ b/Ship.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://Ship.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 16.0 + +[node name="Ship" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/ShipExplosionVFX.gd b/ShipExplosionVFX.gd new file mode 100644 index 0000000..789f956 --- /dev/null +++ b/ShipExplosionVFX.gd @@ -0,0 +1,12 @@ +class_name ShipExplosionVFX +extends LightBurstVFX + + +func _init() -> void: + color = Color(0.25, 0.85, 1.0) + + +static func spawn(parent: Node, world_position: Vector2) -> void: + var vfx: ShipExplosionVFX = ShipExplosionVFX.new() + vfx.position = world_position + parent.add_child(vfx) diff --git a/ShipExplosionVFX.gd.uid b/ShipExplosionVFX.gd.uid new file mode 100644 index 0000000..b2eecc8 --- /dev/null +++ b/ShipExplosionVFX.gd.uid @@ -0,0 +1 @@ +uid://c20i5a56ib4pw diff --git a/Starfield.gd b/Starfield.gd new file mode 100644 index 0000000..78ad99d --- /dev/null +++ b/Starfield.gd @@ -0,0 +1,28 @@ +extends ColorRect + +# Feeds the starfield shader the camera's position split into a CELL INDEX +# plus a fraction within that cell, rather than a raw world position. +# +# Why: the fleet routinely reaches world coordinates in the tens of +# thousands. Handing those straight to the shader forces every downstream +# calculation (the division by cell_size, floor/fract, and especially the +# hash's internal multiplies) to work at large magnitudes, where a mobile +# GPU's float precision starts to break down - and no precision qualifier +# can fix that, since `highp` widens the type but the magnitudes stay huge. +# GDScript computes in double precision, so doing the split here and passing +# only small numbers (cell indices in the hundreds, fractions in [0,1]) +# keeps the shader's arithmetic in a range it can represent exactly. + +@onready var camera: Camera2D = get_node("../../Camera2D") + + +func _process(_delta: float) -> void: + var viewport_size: Vector2 = get_viewport_rect().size + var cell_size: float = material.get_shader_parameter("cell_size") + var p: Vector2 = camera.global_position / cell_size + var cell: Vector2 = Vector2(floor(p.x), floor(p.y)) + + material.set_shader_parameter("camera_cell", cell) + material.set_shader_parameter("camera_frac", p - cell) + material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x) + material.set_shader_parameter("viewport_size", viewport_size) diff --git a/Starfield.gd.uid b/Starfield.gd.uid new file mode 100644 index 0000000..9e8e004 --- /dev/null +++ b/Starfield.gd.uid @@ -0,0 +1 @@ +uid://buhc1b6iqy30n diff --git a/TouchJoystick.gd b/TouchJoystick.gd new file mode 100644 index 0000000..4c1cf2a --- /dev/null +++ b/TouchJoystick.gd @@ -0,0 +1,110 @@ +class_name TouchJoystick +extends Control + +# Touch joystick for steering the fleet. Lives in the bottom-left corner. +# +# Behaves like a "floating" stick within its own rect: the base re-centres +# on wherever the finger first lands, so the player doesn't have to hit an +# exact spot on a phone screen they can't feel. The knob then follows the +# finger out to max_radius, and `direction` reports the offset normalised +# against that radius (so magnitude is 0..1, usable for throttle later). +# +# Only claims touches that start inside its own rect - anything else falls +# through untouched, which is what keeps pinch-to-zoom working everywhere +# else on screen. + +signal direction_changed(direction: Vector2) +signal released + +# How far the knob travels from centre, as a fraction of the control's +# shorter side. Derived rather than a fixed number of pixels so that +# enlarging the stick's rect enlarges the ring the player actually sees - +# otherwise the touch area grows and the visible control stays small. +const RADIUS_RATIO: float = 0.28 + +@export var max_radius: float = 90.0 +@export var deadzone: float = 0.12 +@export var glow_color: Color = Color(0.25, 0.85, 1.0) +@export var core_color: Color = Color(1.0, 1.0, 1.0) + +var direction: Vector2 = Vector2.ZERO +var active_touch_index: int = -1 +var base_pos: Vector2 = Vector2.ZERO +var knob_pos: Vector2 = Vector2.ZERO + + +func _ready() -> void: + resized.connect(_relayout) + _relayout() + + +func _relayout() -> void: + base_pos = size / 2.0 + knob_pos = base_pos + max_radius = minf(size.x, size.y) * RADIUS_RATIO + queue_redraw() + + +func is_active() -> bool: + return active_touch_index != -1 + + +# Returns true if the event was consumed, so the caller knows not to treat +# it as a world gesture (a fleet move, or one finger of a pinch). +func handle_touch(event: InputEventScreenTouch) -> bool: + var local: Vector2 = event.position - global_position + if event.pressed: + if active_touch_index != -1: + return false + if not Rect2(Vector2.ZERO, size).has_point(local): + return false + active_touch_index = event.index + base_pos = local + knob_pos = local + _update_direction() + return true + + if event.index != active_touch_index: + return false + active_touch_index = -1 + knob_pos = base_pos + direction = Vector2.ZERO + queue_redraw() + direction_changed.emit(direction) + released.emit() + return true + + +func handle_drag(event: InputEventScreenDrag) -> bool: + if event.index != active_touch_index: + return false + knob_pos = event.position - global_position + _update_direction() + return true + + +func _update_direction() -> void: + var offset: Vector2 = knob_pos - base_pos + if offset.length() > max_radius: + offset = offset.normalized() * max_radius + knob_pos = base_pos + offset + var raw: Vector2 = offset / max_radius + direction = Vector2.ZERO if raw.length() < deadzone else raw + queue_redraw() + direction_changed.emit(direction) + + +func _draw() -> void: + var ring: PackedVector2Array = PackedVector2Array() + var segments: int = 28 + for i in range(segments): + var a: float = (float(i) / segments) * TAU + ring.append(base_pos + Vector2(cos(a), sin(a)) * max_radius) + NeonDraw.draw_glowing_polygon(self, ring, glow_color, 0.55, core_color) + + var knob: PackedVector2Array = PackedVector2Array() + var knob_radius: float = max_radius * 0.34 + for i in range(segments): + var a: float = (float(i) / segments) * TAU + knob.append(knob_pos + Vector2(cos(a), sin(a)) * knob_radius) + NeonDraw.draw_glowing_polygon(self, knob, glow_color, 1.0 if is_active() else 0.75, core_color) diff --git a/TouchJoystick.gd.uid b/TouchJoystick.gd.uid new file mode 100644 index 0000000..1f9e5f2 --- /dev/null +++ b/TouchJoystick.gd.uid @@ -0,0 +1 @@ +uid://bfd1d4343vg1d diff --git a/ViewUtils.gd b/ViewUtils.gd new file mode 100644 index 0000000..7e84e89 --- /dev/null +++ b/ViewUtils.gd @@ -0,0 +1,31 @@ +class_name ViewUtils +extends RefCounted + +# Computes the exact world-space rect currently visible on screen by +# transforming the viewport's real screen corners through the actual canvas +# transform, instead of approximating with viewport_size * camera.zoom +# (which assumes a simple centered mapping - stretch/aspect="expand" doesn't +# necessarily expand symmetrically around center, which was making that +# approximation under/over-shoot on one side and cull content that was +# actually on screen). +static func get_visible_world_rect(node: Node) -> Rect2: + var canvas_transform: Transform2D = node.get_viewport().get_canvas_transform() + var inverse: Transform2D = canvas_transform.affine_inverse() + var size: Vector2 = node.get_viewport_rect().size + + var corners: Array = [ + inverse * Vector2(0, 0), + inverse * Vector2(size.x, 0), + inverse * Vector2(0, size.y), + inverse * Vector2(size.x, size.y), + ] + + var min_pt: Vector2 = corners[0] + var max_pt: Vector2 = corners[0] + for c in corners: + min_pt.x = min(min_pt.x, c.x) + min_pt.y = min(min_pt.y, c.y) + max_pt.x = max(max_pt.x, c.x) + max_pt.y = max(max_pt.y, c.y) + + return Rect2(min_pt, max_pt - min_pt) diff --git a/ViewUtils.gd.uid b/ViewUtils.gd.uid new file mode 100644 index 0000000..0c93de8 --- /dev/null +++ b/ViewUtils.gd.uid @@ -0,0 +1 @@ +uid://bw2emlbm86eje diff --git a/WorldGen.gd b/WorldGen.gd new file mode 100644 index 0000000..75b4521 --- /dev/null +++ b/WorldGen.gd @@ -0,0 +1,90 @@ +class_name WorldGen +extends RefCounted + +# Deterministic procedural world generation: the world is divided into +# square chunks, and each chunk's biome is derived from a seeded +# low-frequency noise field sampled at the chunk's own coordinates - so +# revisiting the same chunk always yields the same biome, and biomes form +# smooth multi-chunk regions instead of a per-chunk random checkerboard. + +enum Biome { SPACE, ASTEROID_FIELD } + +const CHUNK_SIZE: float = 1200.0 +const NOISE_SEED: int = 1337 +const ASTEROID_FIELD_THRESHOLD: float = 0.15 +const PLANET_THRESHOLD: float = 0.33 + +const PLANET_NAMES: Array = [ + "Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold", + "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", + "Baros Rock", "Umbra Point", +] + +static var _noise: FastNoiseLite +static var _planet_noise: FastNoiseLite + + +static func _get_noise() -> FastNoiseLite: + if _noise == null: + _noise = FastNoiseLite.new() + _noise.seed = NOISE_SEED + _noise.frequency = 0.06 + return _noise + + +static func _get_planet_noise() -> FastNoiseLite: + if _planet_noise == null: + _planet_noise = FastNoiseLite.new() + _planet_noise.seed = NOISE_SEED + 777 + # Higher frequency than the biome noise so it spikes on individual + # chunks rather than forming its own smooth multi-chunk regions - + # "once or twice per asteroid biome [region]" needs a sparse, mostly + # independent chance per chunk, not another smooth blob. + _planet_noise.frequency = 0.35 + return _planet_noise + + +static func chunk_coords(world_pos: Vector2) -> Vector2i: + return Vector2i(int(floor(world_pos.x / CHUNK_SIZE)), int(floor(world_pos.y / CHUNK_SIZE))) + + +static func chunk_origin(chunk: Vector2i) -> Vector2: + return Vector2(chunk.x * CHUNK_SIZE, chunk.y * CHUNK_SIZE) + + +static func biome_at_chunk(chunk: Vector2i) -> int: + var n: float = _get_noise().get_noise_2d(float(chunk.x), float(chunk.y)) + if n > ASTEROID_FIELD_THRESHOLD: + return Biome.ASTEROID_FIELD + return Biome.SPACE + + +static func has_planet_at_chunk(chunk: Vector2i) -> bool: + if biome_at_chunk(chunk) != Biome.ASTEROID_FIELD: + return false + var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y)) + return n > PLANET_THRESHOLD + + +static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: + # Single source of truth for a planet's identity within its chunk - + # seeded purely from the chunk's own coordinates (same pattern as the + # biome/planet noise fields above) so it's reproducible from anywhere, + # without needing an actual spawned Planet node. Both PlanetField (to + # actually spawn the planet) and the world map (to draw a marker for + # an explored-but-not-currently-loaded chunk) call this, so the two + # can never drift out of sync with each other. + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = hash(chunk) + var local_pos: Vector2 = Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE) + var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()] + var shape_seed: int = rng.randi() + return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed} + + +static func biome_color(biome: int) -> Color: + match biome: + Biome.ASTEROID_FIELD: + return Color(0.55, 0.36, 0.18, 1.0) + _: + return Color(0.10, 0.13, 0.28, 1.0) diff --git a/WorldGen.gd.uid b/WorldGen.gd.uid new file mode 100644 index 0000000..2a2b434 --- /dev/null +++ b/WorldGen.gd.uid @@ -0,0 +1 @@ +uid://d1xpshlxsv0ap diff --git a/WorldMap.gd b/WorldMap.gd new file mode 100644 index 0000000..a36e26c --- /dev/null +++ b/WorldMap.gd @@ -0,0 +1,93 @@ +extends Control + +@export var min_zoom: float = 0.3 +@export var max_zoom: float = 3.0 + +@onready var content: Node2D = $MapContent +@onready var open_button: IconButton = get_node("../MapButton") +@onready var recenter_button: IconButton = $RecenterButton +@onready var close_button: IconButton = $CloseButton +@onready var fleet: Node2D = get_node("../../Fleet") + +var touches: Dictionary = {} +var pinch_active: bool = false +var pinch_start_distance: float = 0.0 +var pinch_start_zoom: float = 1.0 + + +func _ready() -> void: + open_button.pressed.connect(open_map) + recenter_button.pressed.connect(recenter_view) + close_button.pressed.connect(close_map) + + +func open_map() -> void: + visible = true + open_button.visible = false + recenter_view() + + +func close_map() -> void: + visible = false + open_button.visible = true + touches.clear() + pinch_active = false + + +func recenter_view() -> void: + var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL + var viewport_center: Vector2 = get_viewport_rect().size / 2.0 + content.position = viewport_center - fleet_local * content.scale.x + + +func _input(event: InputEvent) -> void: + if not visible: + return + + if event is InputEventScreenTouch: + _handle_touch(event) + get_viewport().set_input_as_handled() + elif event is InputEventScreenDrag: + _handle_drag(event) + get_viewport().set_input_as_handled() + elif event is InputEventMagnifyGesture: + _handle_magnify(event) + get_viewport().set_input_as_handled() + + +func _handle_touch(event: InputEventScreenTouch) -> void: + if event.pressed: + touches[event.index] = event.position + if touches.size() == 2: + pinch_active = true + var positions: Array = touches.values() + pinch_start_distance = positions[0].distance_to(positions[1]) + pinch_start_zoom = content.scale.x + else: + touches.erase(event.index) + if touches.size() < 2: + pinch_active = false + + +func _handle_drag(event: InputEventScreenDrag) -> void: + if not touches.has(event.index): + return + var previous_pos: Vector2 = touches[event.index] + 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]) + var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0)) + _set_zoom(new_zoom) + elif touches.size() == 1: + content.position += event.position - previous_pos + + +func _handle_magnify(event: InputEventMagnifyGesture) -> void: + _set_zoom(content.scale.x * event.factor) + + +func _set_zoom(z: float) -> void: + var clamped: float = clamp(z, min_zoom, max_zoom) + content.scale = Vector2(clamped, clamped) diff --git a/WorldMap.gd.uid b/WorldMap.gd.uid new file mode 100644 index 0000000..eef3e9f --- /dev/null +++ b/WorldMap.gd.uid @@ -0,0 +1 @@ +uid://1ava7qm2fian diff --git a/WorldMapContent.gd b/WorldMapContent.gd new file mode 100644 index 0000000..b0fe79d --- /dev/null +++ b/WorldMapContent.gd @@ -0,0 +1,42 @@ +extends Node2D + +const WORLD_TO_LOCAL: float = 0.04 + +@onready var tracker: Node2D = get_node("../../../ExplorationTracker") +@onready var fleet: Node2D = get_node("../../../Fleet") + + +func _process(_delta: float) -> void: + if is_visible_in_tree(): + queue_redraw() + + +func _draw() -> void: + var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL + + for chunk in tracker.get_explored_chunks(): + var origin_local: Vector2 = WorldGen.chunk_origin(chunk) * WORLD_TO_LOCAL + var biome: int = WorldGen.biome_at_chunk(chunk) + var color: Color = WorldGen.biome_color(biome) + var rect: Rect2 = Rect2(origin_local, Vector2(chunk_size_local, chunk_size_local)) + draw_rect(rect, color, true) + draw_rect(rect, Color(0.0, 0.0, 0.0, 0.4), false, 1.5) + + if WorldGen.has_planet_at_chunk(chunk): + # Same deterministic per-chunk data PlanetField spawns the + # actual Planet from - the marker stays put on the map even + # for a chunk whose Planet node isn't currently loaded (out of + # spawn_radius_chunks of the fleet). + var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) + var planet_local: Vector2 = origin_local + data.local_pos * WORLD_TO_LOCAL + draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55)) + draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true) + + var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL + var marker_points: PackedVector2Array = PackedVector2Array([ + fleet_local + Vector2(0, -10), + fleet_local + Vector2(7, 8), + fleet_local + Vector2(-7, 8), + ]) + draw_colored_polygon(marker_points, Color(0.3, 0.9, 1.0)) + draw_polyline(PackedVector2Array([marker_points[0], marker_points[1], marker_points[2], marker_points[0]]), Color(1, 1, 1), 1.5, true) diff --git a/WorldMapContent.gd.uid b/WorldMapContent.gd.uid new file mode 100644 index 0000000..30f69d2 --- /dev/null +++ b/WorldMapContent.gd.uid @@ -0,0 +1 @@ +uid://xq4ay3r56nst diff --git a/android/.build_version b/android/.build_version new file mode 100644 index 0000000..faa48b5 --- /dev/null +++ b/android/.build_version @@ -0,0 +1 @@ +4.7.1.stable \ No newline at end of file diff --git a/android/.gdignore b/android/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..500261f --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,271 @@ +[runnable_presets] + +Android="Android" + +[preset.0] + +name="Android" +platform="Android" +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="builds/SpaceshipTest.apk" +patches=PackedStringArray() +patch_delta_encoding=true +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/gradle_build_directory="" +gradle_build/android_source_template="" +gradle_build/compress_native_libraries=false +gradle_build/export_format=0 +gradle_build/min_sdk="" +gradle_build/target_sdk="" +gradle_build/custom_theme_attributes={} +architectures/armeabi-v7a=false +architectures/arm64-v8a=true +architectures/x86=false +architectures/x86_64=false +version/code=1 +version/name="1.0" +package/unique_name="com.spaceshiptest.game" +package/name="Spaceship Test" +package/signed=true +package/app_category=2 +package/retain_data_on_uninstall=false +package/exclude_from_recents=false +package/show_in_android_tv=false +package/show_in_app_library=true +package/show_as_launcher_app=false +launcher_icons/main_192x192="" +launcher_icons/adaptive_foreground_432x432="" +launcher_icons/adaptive_background_432x432="" +launcher_icons/adaptive_monochrome_432x432="" +graphics/opengl_debug=false +shader_baker/enabled=false +xr_features/xr_mode=0 +gesture/swipe_to_dismiss=false +screen/immersive_mode=true +screen/edge_to_edge=false +screen/support_small=true +screen/support_normal=true +screen/support_large=true +screen/support_xlarge=true +screen/background_color=Color(0, 0, 0, 1) +splash_screen/disable_godot_boot_splash=false +splash_screen/icon="" +splash_screen/branding_image="" +splash_screen/background_color=Color(0, 0, 0, 1) +user_data_backup/allow=false +command_line/extra_args="" +permissions/custom_permissions=PackedStringArray() +permissions/access_checkin_properties=false +permissions/access_coarse_location=false +permissions/access_fine_location=false +permissions/access_location_extra_commands=false +permissions/access_media_location=false +permissions/access_mock_location=false +permissions/access_network_state=false +permissions/access_surface_flinger=false +permissions/access_wifi_state=false +permissions/account_manager=false +permissions/add_voicemail=false +permissions/authenticate_accounts=false +permissions/battery_stats=false +permissions/bind_accessibility_service=false +permissions/bind_appwidget=false +permissions/bind_device_admin=false +permissions/bind_input_method=false +permissions/bind_nfc_service=false +permissions/bind_notification_listener_service=false +permissions/bind_print_service=false +permissions/bind_remoteviews=false +permissions/bind_text_service=false +permissions/bind_vpn_service=false +permissions/bind_wallpaper=false +permissions/bluetooth=false +permissions/bluetooth_admin=false +permissions/bluetooth_privileged=false +permissions/brick=false +permissions/broadcast_package_removed=false +permissions/broadcast_sms=false +permissions/broadcast_sticky=false +permissions/broadcast_wap_push=false +permissions/call_phone=false +permissions/call_privileged=false +permissions/camera=false +permissions/capture_audio_output=false +permissions/capture_secure_video_output=false +permissions/capture_video_output=false +permissions/change_component_enabled_state=false +permissions/change_configuration=false +permissions/change_network_state=false +permissions/change_wifi_multicast_state=false +permissions/change_wifi_state=false +permissions/clear_app_cache=false +permissions/clear_app_user_data=false +permissions/control_location_updates=false +permissions/delete_cache_files=false +permissions/delete_packages=false +permissions/device_power=false +permissions/diagnostic=false +permissions/disable_keyguard=false +permissions/dump=false +permissions/expand_status_bar=false +permissions/factory_test=false +permissions/flashlight=false +permissions/force_back=false +permissions/get_accounts=false +permissions/get_package_size=false +permissions/get_tasks=false +permissions/get_top_activity_info=false +permissions/global_search=false +permissions/hardware_test=false +permissions/inject_events=false +permissions/install_location_provider=false +permissions/install_packages=false +permissions/install_shortcut=false +permissions/internal_system_window=false +permissions/internet=false +permissions/kill_background_processes=false +permissions/location_hardware=false +permissions/manage_accounts=false +permissions/manage_app_tokens=false +permissions/manage_documents=false +permissions/manage_external_storage=false +permissions/manage_media=false +permissions/master_clear=false +permissions/media_content_control=false +permissions/modify_audio_settings=false +permissions/modify_phone_state=false +permissions/mount_format_filesystems=false +permissions/mount_unmount_filesystems=false +permissions/nfc=false +permissions/persistent_activity=false +permissions/post_notifications=false +permissions/process_outgoing_calls=false +permissions/read_calendar=false +permissions/read_call_log=false +permissions/read_contacts=false +permissions/read_external_storage=false +permissions/read_frame_buffer=false +permissions/read_history_bookmarks=false +permissions/read_input_state=false +permissions/read_logs=false +permissions/read_media_audio=false +permissions/read_media_images=false +permissions/read_media_video=false +permissions/read_media_visual_user_selected=false +permissions/read_phone_state=false +permissions/read_profile=false +permissions/read_sms=false +permissions/read_social_stream=false +permissions/read_sync_settings=false +permissions/read_sync_stats=false +permissions/read_user_dictionary=false +permissions/reboot=false +permissions/receive_boot_completed=false +permissions/receive_mms=false +permissions/receive_sms=false +permissions/receive_wap_push=false +permissions/record_audio=false +permissions/reorder_tasks=false +permissions/restart_packages=false +permissions/send_respond_via_message=false +permissions/send_sms=false +permissions/set_activity_watcher=false +permissions/set_alarm=false +permissions/set_always_finish=false +permissions/set_animation_scale=false +permissions/set_debug_app=false +permissions/set_orientation=false +permissions/set_pointer_speed=false +permissions/set_preferred_applications=false +permissions/set_process_limit=false +permissions/set_time=false +permissions/set_time_zone=false +permissions/set_wallpaper=false +permissions/set_wallpaper_hints=false +permissions/signal_persistent_processes=false +permissions/status_bar=false +permissions/subscribed_feeds_read=false +permissions/subscribed_feeds_write=false +permissions/system_alert_window=false +permissions/transmit_ir=false +permissions/uninstall_shortcut=false +permissions/update_device_stats=false +permissions/use_credentials=false +permissions/use_sip=false +permissions/vibrate=false +permissions/wake_lock=false +permissions/write_apn_settings=false +permissions/write_calendar=false +permissions/write_call_log=false +permissions/write_contacts=false +permissions/write_external_storage=false +permissions/write_gservices=false +permissions/write_history_bookmarks=false +permissions/write_profile=false +permissions/write_secure_settings=false +permissions/write_settings=false +permissions/write_sms=false +permissions/write_social_stream=false +permissions/write_sync_settings=false +permissions/write_user_dictionary=false + +[preset.1] + +name="Web" +platform="Web" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="builds/web/index.html" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=true +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=true +html/progressive_web_app=false +html/progressive_web_app/display=1 +html/progressive_web_app/offline_page="" +html/progressive_web_app/icon_144x144="" +html/progressive_web_app/icon_180x180="" +html/progressive_web_app/icon_512x512="" +html/progressive_web_app/orientation=0 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..911bf14 --- /dev/null +++ b/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..33dfdab --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwonxwqysm3es" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/lunar_surface.gdshader b/lunar_surface.gdshader new file mode 100644 index 0000000..a1158ee --- /dev/null +++ b/lunar_surface.gdshader @@ -0,0 +1,212 @@ +shader_type canvas_item; + +// Lunar/crater backdrop for the "Big Stable Asteroid" atmosphere. Reacts +// to camera position/zoom exactly like starfield.gdshader (world-space +// sampling from camera uniforms), so it parallaxes correctly as the fleet +// flies around instead of being a static picture. +// +// Structured as a Worley / cellular distance field, following the design +// of the "Floating Bubbles" shader on godotshaders.com: a neighbor scan +// reduces everything to ONE scalar - the normalized distance to the +// nearest crater - and every visual (floor darkness, rim light, rim +// shadow) is then derived from that single value. +// +// This single-field structure matters, and is the whole reason for the +// rewrite. The previous version accumulated three fields independently +// (floor via max() over all neighbors, rim light/shadow via a separate +// "strongest crater wins" test). Those could disagree: one crater's dark +// rim band could be composited over a different, nearer crater's floor, +// cutting a hard wrongly-shaped bite into it. Deriving everything from +// the nearest crater makes that class of bug structurally impossible - +// the floor and the rim always describe the same crater. +// +// Where two craters overlap, the nearest-crater field has a crease (normal +// for Worley noise) and the lighting direction flips across it, which drew +// a thin bright rim wedge through the middle of the merged basin. The +// second-nearest distance is tracked purely to detect that case and fade +// the rim out there - see the note further down. + +uniform vec2 camera_cell = vec2(0.0, 0.0); +uniform vec2 camera_frac = vec2(0.0, 0.0); +uniform float camera_zoom = 1.0; +uniform vec2 viewport_size = vec2(720.0, 1280.0); +uniform float cell_size = 220.0; +// Sampled from a NASA Artemis lunar photograph: the regolith is a +// bright neutral grey with a very faint cool cast (blue a touch above +// red), craters read as mid-grey floors rather than black, and the lit +// rims are only slightly brighter than the surface. +uniform vec4 base_color : source_color = vec4(0.75, 0.75, 0.76, 1.0); +uniform vec4 crater_color : source_color = vec4(0.44, 0.44, 0.45, 1.0); +uniform vec4 highlight_color : source_color = vec4(0.89, 0.89, 0.90, 1.0); +// DIAGNOSTIC ONLY (set in PlanetSurface.tscn for a debug build; 0 = off). +// 1 = tint alternate cells, to show where cell boundaries fall. +// 2 = output the raw normalized distance field as grayscale, with NO +// rim, lighting, dither or tint. This bisects the shader: the field +// and the shading built on top of it are independent halves, so if +// cuts appear in this grayscale output the FIELD is at fault, and if +// it is smooth the field is fine and the SHADING is at fault. +uniform float debug_mode = 0.0; + +const vec2 LIGHT_DIR = vec2(-0.6, -0.8); +const float MIN_RADIUS = 0.16; +const float MAX_RADIUS = 0.38; +// Sentinel for "this cell holds no crater". Must stay well inside mediump +// range (max ~65504) so it can never become +Inf - see the note in the +// loop. Only needs to exceed the largest real normalized distance (~18). +const float MISS_DISTANCE = 1000.0; + +// Hash pair chosen for precision safety on mobile GPUs. The previous +// version multiplied the cell coordinate by ~123/456 before fract(), which +// lands the intermediate in the thousands where a mobile float loses its +// fractional bits entirely. These multipliers are all < 1, so every +// intermediate stays small no matter how large the cell index gets. +// (Standard "hash without sine" formulation, Dave Hoskins.) +float hash21(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +void fragment() { + vec2 screen_px = UV * viewport_size; + vec2 centered = screen_px - viewport_size * 0.5; + + // Position is expressed RELATIVE TO THE CAMERA'S CELL, never as an + // absolute world coordinate. camera_frac is the camera's offset inside + // its own cell (always in [0,1]) and `centered` spans at most a few + // cells, so every value here stays small and exactly representable even + // on a mobile GPU - whereas an absolute world position reaches the tens + // of thousands, where float precision degrades and no `highp` qualifier + // can help (it widens the type, but the magnitude is the problem). + // GDScript does the large-number split in double precision; see + // LunarBackground.gd / Starfield.gd. + // + // cell and local also MUST come from one shared value, with local + // derived as (rel - cell_rel) rather than an independent fract(): + // calling floor(x) and fract(x) separately lets the GPU round the two + // evaluations differently, which right at a cell boundary makes them + // disagree by a full cell and snaps the sampled point one cell sideways. + vec2 rel = camera_frac + centered * (camera_zoom / cell_size); + vec2 cell_rel = floor(rel); + vec2 local = rel - cell_rel; + // Absolute cell index, used only for hashing. Built from exact integers. + vec2 cell = camera_cell + cell_rel; + + // Single scalar field: normalized distance to the nearest crater. + // < 1.0 means this pixel is inside that crater; 1.0 is exactly on its + // edge. Normalizing by each crater's own radius is what lets craters + // vary in size while still collapsing to one comparable number. + // + // The SECOND-nearest crater is tracked alongside the nearest, purely so + // the rim can be suppressed inside overlaps. Where two craters overlap, + // the nearest-crater test flips along the line between them; the + // lighting direction flips with it and paints a thin bright rim wedge + // through the middle of what should read as one merged depression. + // A pixel in that region is inside BOTH craters, i.e. second_nearest is + // also < 1.0 - which is precisely the condition to fade the rim out. + float nearest = MISS_DISTANCE; + float second_nearest = MISS_DISTANCE; + vec2 nearest_dir = vec2(0.0, 1.0); + + // 5x5 rather than 3x3. A crater's radius is at most MAX_RADIUS cells, + // so in theory 3x3 covers every crater that could possibly reach this + // pixel - but on-device, craters were still being clipped exactly at + // cell boundaries, which means some crater's influence was reaching + // further than the scan did (e.g. a hash returning a value outside its + // nominal range on that GPU, inflating a radius). Scanning two cells + // out costs more samples but makes the clip impossible to reach even + // if a radius comes out several times larger than intended. + for (int dx = -2; dx <= 2; dx++) { + for (int dy = -2; dy <= 2; dy++) { + vec2 offset = vec2(float(dx), float(dy)); + highp vec2 neighbor_cell = cell + offset; + + // Cells without a crater are pushed far away rather than + // branched around, keeping the loop body uniform. NOTE the + // sentinel is deliberately a small number, not something like + // 1e9: mediump floats top out around 65504, so a huge literal + // becomes +Inf there, and (1.0 - has_crater) * Inf evaluates to + // 0 * Inf = NaN for every cell that DOES hold a crater - which + // compares false against everything and would silently discard + // every crater. MISS_DISTANCE only has to beat the largest real + // normalized distance (~2.9 cells / 0.16 min radius ~= 18). + float has_crater = step(0.35, hash21(neighbor_cell + vec2(41.0, 5.0))); + vec2 crater_center = offset + hash22(neighbor_cell); + vec2 to_sample = local - crater_center; + float dist = length(to_sample); + // clamp() guards against a hash landing outside [0,1] on some + // driver and extrapolating mix() into a huge radius. + float radius = clamp(mix(MIN_RADIUS, MAX_RADIUS, hash21(neighbor_cell + vec2(9.0, 2.0))), MIN_RADIUS, MAX_RADIUS); + + float normalized = dist / radius + (1.0 - has_crater) * MISS_DISTANCE; + + if (normalized < nearest) { + second_nearest = nearest; + nearest = normalized; + // Direction from the winning crater's centre to this pixel, + // used for the rim's light/shadow split. Taken from the SAME + // crater that won the distance test, so the shading can never + // describe one crater while the floor describes another. + nearest_dir = dist > 0.0001 ? to_sample / dist : vec2(0.0, 1.0); + } else if (normalized < second_nearest) { + second_nearest = normalized; + } + } + } + + vec2 light_dir = normalize(LIGHT_DIR); + float facing = dot(nearest_dir, light_dir); + + // Everything below is a pure function of `nearest` (plus the winning + // crater's direction), so all three visuals stay mutually consistent. + float floor_amount = 1.0 - smoothstep(0.45, 0.9, nearest); + + // Thin raised-rim band right at the crater's edge, split into a lit + // half (facing LIGHT_DIR) and a shadowed half (facing away), giving a + // sense of a raised lip catching light. + float rim_band = smoothstep(0.55, 0.85, nearest) * (1.0 - smoothstep(0.85, 1.0, nearest)); + // Fade the rim out where this pixel also lies inside a second crater: + // that is the overlap region, where a rim would be an internal crease + // rather than a real outer lip. Craters there merge into one basin. + rim_band *= smoothstep(0.85, 1.15, second_nearest); + float rim_light = rim_band * clamp(facing, 0.0, 1.0); + float rim_shadow = rim_band * clamp(-facing, 0.0, 1.0); + + vec3 color = base_color.rgb; + color = mix(color, crater_color.rgb, floor_amount * 0.85); + color = mix(color, highlight_color.rgb, rim_light * 0.6); + color = mix(color, crater_color.rgb * 0.7, rim_shadow * 0.5); + + // Tiny per-pixel dither to mask 8-bit colour-quantization banding: the + // crater shading is a smooth gradient over a fairly large area, which + // can visibly "step" into faint bands on real hardware. A small random + // jitter well under one visible step breaks the stepping up without + // being perceptible as noise. + float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0; + color += vec3(dither); + + // Diagnostic checkerboard: alternate cells tinted, plus a bright line + // right at each cell edge. Makes the grid unmistakable in a screenshot. + if (debug_mode > 0.5 && debug_mode < 1.5) { + float checker = mod(cell.x + cell.y, 2.0); + color = mix(color, vec3(0.10, 0.30, 0.55), checker * 0.35); + vec2 edge = min(local, 1.0 - local); + float on_edge = 1.0 - smoothstep(0.0, 0.012, min(edge.x, edge.y)); + color = mix(color, vec3(1.0, 0.25, 0.25), on_edge * 0.9); + } + + // Bisection output: the field alone, with none of the shading above + // applied. Godot forbids `return` inside fragment(), so this overrides + // the computed colour rather than exiting early. + if (debug_mode > 1.5) { + color = vec3(clamp(nearest / 2.0, 0.0, 1.0)); + } + + COLOR = vec4(color, 1.0); +} diff --git a/lunar_surface.gdshader.uid b/lunar_surface.gdshader.uid new file mode 100644 index 0000000..3fe64cb --- /dev/null +++ b/lunar_surface.gdshader.uid @@ -0,0 +1 @@ +uid://ib1dykwwylhn diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..05d44a8 --- /dev/null +++ b/project.godot @@ -0,0 +1,42 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Spaceship Test" +run/main_scene="res://Main.tscn" +config/features=PackedStringArray("4.7") +config/icon="res://icon.svg" + +[autoload] + +GameState="*res://GameState.gd" + +[display] + +window/size/viewport_width=720 +window/size/viewport_height=1280 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" +window/handheld/orientation="portrait" + +[gui] + +theme/custom="res://ui_theme.tres" +theme/default_font_size=78 + +[input_devices] + +pointing/emulate_touch_from_mouse=true + +[rendering] + +renderer/rendering_method="gl_compatibility" +environment/defaults/default_clear_color=Color(0.02, 0.02, 0.08, 1) diff --git a/starfield.gdshader b/starfield.gdshader new file mode 100644 index 0000000..8bf263b --- /dev/null +++ b/starfield.gdshader @@ -0,0 +1,125 @@ +shader_type canvas_item; + +// Procedural infinite starfield. Reconstructs a world-space coordinate for +// every screen pixel from the camera's current position/zoom (the exact +// same mapping used for every other object in the scene - ships, enemies, +// etc.), then hashes a grid cell in world space to decide whether that +// cell holds a star, and how big/bright it is. Cost is one fragment +// shader pass over the screen - constant regardless of zoom level or how +// many stars are "visible", with zero per-star CPU bookkeeping. +// +// Star size is a fixed WORLD-space radius (not compensated for zoom), so +// stars shrink/grow exactly in sync with ships and everything else when +// the camera zooms - zooming out reveals more of the field (unavoidable - +// a wider view always shows more of a spatially spread-out field) but +// each star also gets visibly smaller, so it still reads as "zoomed out" +// consistently with the foreground instead of feeling mismatched. + +// Stars are found with a 3x3-neighbor cellular lookup: each pixel checks +// its own grid cell AND all 8 neighbors for a nearby star center. Checking +// only the pixel's own cell (which this shader did originally) hard-clips +// any star whose glow crosses a cell boundary, leaving a star with sharp +// straight edges on one or two sides and normal soft falloff on the others +// - which is exactly the artifact that was reported on-device here, and +// the reason the same technique was already used in lunar_surface.gdshader. +// +// The camera position arrives pre-split into a cell index plus a fraction +// within that cell, so this shader never handles an absolute world +// coordinate (which reaches the tens of thousands once the fleet has +// explored, where mobile float precision degrades). See Starfield.gd. +uniform vec2 camera_cell = vec2(0.0, 0.0); +uniform vec2 camera_frac = vec2(0.0, 0.0); +uniform float camera_zoom = 1.0; +uniform vec2 viewport_size = vec2(720.0, 1280.0); +uniform float cell_size = 60.0; +uniform vec4 bg_color : source_color = vec4(0.02, 0.02, 0.08, 1.0); +// DIAGNOSTIC ONLY - see the same uniform in lunar_surface.gdshader. +uniform float debug_mode = 0.0; + +// Hash pair chosen for precision safety on mobile GPUs. The previous +// version multiplied the cell coordinate by ~123/456 before fract(), which +// lands the intermediate in the thousands where a mobile float loses its +// fractional bits entirely. These multipliers are all < 1, so every +// intermediate stays small no matter how large the cell index gets. +// (Standard "hash without sine" formulation, Dave Hoskins.) +float hash21(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} + +void fragment() { + vec2 screen_px = UV * viewport_size; + vec2 centered = screen_px - viewport_size * 0.5; + + // Position is expressed RELATIVE TO THE CAMERA'S CELL, never as an + // absolute world coordinate. camera_frac is the camera's offset inside + // its own cell (always in [0,1]) and `centered` spans at most a few + // cells, so every value here stays small and exactly representable even + // on a mobile GPU - whereas an absolute world position reaches the tens + // of thousands, where float precision degrades and no `highp` qualifier + // can help (it widens the type, but the magnitude is the problem). + // GDScript does the large-number split in double precision; see + // LunarBackground.gd / Starfield.gd. + // + // cell and local also MUST come from one shared value, with local + // derived as (rel - cell_rel) rather than an independent fract(): + // calling floor(x) and fract(x) separately lets the GPU round the two + // evaluations differently, which right at a cell boundary makes them + // disagree by a full cell and snaps the sampled point one cell sideways. + vec2 rel = camera_frac + centered * (camera_zoom / cell_size); + vec2 cell_rel = floor(rel); + vec2 local = rel - cell_rel; + // Absolute cell index, used only for hashing. Built from exact integers. + vec2 cell = camera_cell + cell_rel; + + float star = 0.0; + + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + vec2 offset = vec2(float(dx), float(dy)); + highp vec2 neighbor_cell = cell + offset; + + float has_star = step(0.86, hash21(neighbor_cell + vec2(99.0, 7.0))); + // Star center expressed in THIS pixel's cell-local space, so a + // star living in a neighboring cell is still measured correctly + // from here instead of being clipped away at the cell edge. + vec2 star_center = offset + hash22(neighbor_cell); + float dist = length(local - star_center); + + // Fixed world-space size (fraction of cell) - no zoom + // compensation, so it scales exactly like any other world object + // when the camera zooms. + float size = mix(0.12, 0.22, hash21(neighbor_cell + vec2(3.0, 5.0))); + + float brightness = mix(0.35, 1.0, hash21(neighbor_cell + vec2(11.0, 13.0))); + // smoothstep() edges must be given with edge0 < edge1 - GLSL + // leaves the reversed-edge case undefined, and different GPUs are + // free to compute something else for it. Use correctly-ordered + // edges and invert the result arithmetically instead of swapping + // the edges. + float s = has_star * (1.0 - smoothstep(0.0, size, dist)) * brightness; + star = max(star, s); + } + } + + vec3 out_color = bg_color.rgb + vec3(star); + + // Diagnostic checkerboard - makes cell boundaries directly visible so a + // screenshot can settle whether the reported cuts lie on them. + if (debug_mode > 0.5 && debug_mode < 1.5) { + float checker = mod(cell.x + cell.y, 2.0); + out_color = mix(out_color, vec3(0.10, 0.30, 0.55), checker * 0.35); + vec2 edge = min(local, 1.0 - local); + float on_edge = 1.0 - smoothstep(0.0, 0.012, min(edge.x, edge.y)); + out_color = mix(out_color, vec3(1.0, 0.25, 0.25), on_edge * 0.9); + } + + COLOR = vec4(out_color, 1.0); +} diff --git a/starfield.gdshader.uid b/starfield.gdshader.uid new file mode 100644 index 0000000..7629b03 --- /dev/null +++ b/starfield.gdshader.uid @@ -0,0 +1 @@ +uid://bnrakad3xumb5 diff --git a/tests/_build_popup_preview.gd b/tests/_build_popup_preview.gd new file mode 100644 index 0000000..3bb938e --- /dev/null +++ b/tests/_build_popup_preview.gd @@ -0,0 +1,31 @@ +extends Node2D + +# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the +# build menu and shows a building's info popup, so the panel layout can be +# checked with real text in it. Uses the mothership-already-exists case on +# purpose: that is the longest message any option produces, so if it fits, +# the others do. +# +# bash tests/screenshot.sh res://tests/_build_popup_preview.tscn /tmp/out.png 40 + + +const SHOW_POPUP: bool = true + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 500 + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-900.0, 0.0)) + + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + + var controller: Node = main.get_node("BuildController") + controller._on_build_pressed() + # Flip SHOW_POPUP to check the menu underneath + # instead of the popup that covers it. + if SHOW_POPUP: + controller._on_place_mothership() diff --git a/tests/_build_popup_preview.gd.uid b/tests/_build_popup_preview.gd.uid new file mode 100644 index 0000000..8ec20ed --- /dev/null +++ b/tests/_build_popup_preview.gd.uid @@ -0,0 +1 @@ +uid://wlijuo1j14d3 diff --git a/tests/_build_popup_preview.tscn b/tests/_build_popup_preview.tscn new file mode 100644 index 0000000..d5e0ed0 --- /dev/null +++ b/tests/_build_popup_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_build_popup_preview.gd" id="1"] + +[node name="BuildPopupPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd new file mode 100644 index 0000000..9a85a15 --- /dev/null +++ b/tests/_crafter_preview.gd @@ -0,0 +1,41 @@ +extends Node2D + +# Preview scene for eyeballing building art - NOT run by run_tests.sh +# (underscore prefix). Lays out the mothership and command centre alongside +# several phases of the ship crafter's assembly cycle, with the crafter's +# _process disabled and anim_time pinned, so a single softpipe render shows +# the whole animation at once (the renderer is far too slow here to time a +# live frame). +# +# bash tests/screenshot.sh res://tests/_crafter_preview.tscn /tmp/out.png 20 + +# Each cell is either a building type (drawn once) or a crafter phase. +const CELLS: Array = [ + {"type": "mothership"}, + {"type": "command_center"}, + {"type": "mothership"}, + {"phase": 0.10}, + {"phase": 0.35}, + {"phase": 0.60}, + {"phase": 0.88}, +] + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06)) + var building_scene: PackedScene = load("res://Building.tscn") + for i in range(CELLS.size()): + var cell: Dictionary = CELLS[i] + var b: Node2D = building_scene.instantiate() + b.building_type = cell.get("type", GameState.BUILDING_SHIP_CRAFTER) + b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0) + add_child(b) + if cell.has("phase"): + b.set_process(false) + b.anim_time = float(cell.phase) * b.CYCLE + b.queue_redraw() + + var cam: Camera2D = Camera2D.new() + cam.zoom = Vector2(1.65, 1.65) + add_child(cam) + cam.make_current() diff --git a/tests/_crafter_preview.gd.uid b/tests/_crafter_preview.gd.uid new file mode 100644 index 0000000..44c8164 --- /dev/null +++ b/tests/_crafter_preview.gd.uid @@ -0,0 +1 @@ +uid://ha7uto42cl6w diff --git a/tests/_crafter_preview.tscn b/tests/_crafter_preview.tscn new file mode 100644 index 0000000..8b60506 --- /dev/null +++ b/tests/_crafter_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_crafter_preview.gd" id="1"] + +[node name="CrafterPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_font_probe.gd b/tests/_font_probe.gd new file mode 100644 index 0000000..b03b453 --- /dev/null +++ b/tests/_font_probe.gd @@ -0,0 +1,39 @@ +extends SceneTree + +# Diagnostic - NOT run by run_tests.sh (underscore prefix). Prints what the +# project's font settings actually resolve to at runtime, and measures real +# string widths, so UI boxes can be sized from measurements instead of +# guessed character widths. +# +# godot --headless -s res://tests/_font_probe.gd + + +func _initialize() -> void: + print("gui/theme/default_font_size setting = %s" % ProjectSettings.get_setting("gui/theme/default_font_size", "")) + print("gui/theme/custom = '%s'" % ProjectSettings.get_setting("gui/theme/custom", "")) + print("gui/theme/custom_font = '%s'" % ProjectSettings.get_setting("gui/theme/custom_font", "")) + + var scene: Node = load("res://Main.tscn").instantiate() + root.add_child(scene) + + for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel", + "UI/BuildMenu/InfoPopup/Panel/MessageLabel", + "UI/BuildMenu/MothershipLabel", + "UI/BuildMenu/TitleLabel", + "UI/NavLabel"]: + var label: Label = scene.get_node_or_null(path) + if label == null: + print("%s: MISSING" % path) + continue + # The theme item is "font_size"; "font" is the typeface item. Asking + # for the wrong one silently returns the engine fallback (16) and + # hides both the project setting and any per-node override. + var size: int = label.get_theme_font_size("font_size") + var font: Font = label.get_theme_font("font") + var sample: String = "Fleet home port. A wiped-out fleet rebuilds here." + var width: float = font.get_string_size(sample, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x + print("%-46s effective size=%d box=%s 48-char sample is %.0fpx wide (%.1f px/char), line height %.0f" % [ + path, size, label.size, width, width / float(sample.length()), font.get_height(size) + ]) + + quit(0) diff --git a/tests/_font_probe.gd.uid b/tests/_font_probe.gd.uid new file mode 100644 index 0000000..4f78e5f --- /dev/null +++ b/tests/_font_probe.gd.uid @@ -0,0 +1 @@ +uid://cn7j12klcr3ji diff --git a/tests/_icon_preview.gd b/tests/_icon_preview.gd new file mode 100644 index 0000000..6d0d7ed --- /dev/null +++ b/tests/_icon_preview.gd @@ -0,0 +1,35 @@ +extends Control + +# Preview scene - NOT run by run_tests.sh (underscore prefix). Draws every +# IconButton glyph at the size the build menu uses, so the art can be +# checked without rendering the whole game scene. +# +# bash tests/screenshot.sh res://tests/_icon_preview.tscn /tmp/out.png 20 + +const BUTTON_SIZE: float = 190.0 +const COLUMNS: int = 3 + +const SWATCHES: Array = [ + {"icon": 8, "color": Color(0.45, 0.9, 0.55)}, # mothership + {"icon": 9, "color": Color(1.0, 0.72, 0.25)}, # ship crafter + {"icon": 10, "color": Color(0.55, 0.6, 1.0)}, # command centre + {"icon": 11, "color": Color(0.35, 0.9, 0.75)}, # extractor + {"icon": 11, "color": Color(0.35, 0.9, 0.75), "enabled": false}, + {"icon": 5, "color": Color(0.25, 0.85, 1.0)}, # generic build, for contrast +] + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06)) + for i in range(SWATCHES.size()): + var swatch: Dictionary = SWATCHES[i] + var b: IconButton = IconButton.new() + b.icon = swatch.icon + b.glow_color = swatch.color + b.enabled = swatch.get("enabled", true) + b.size = Vector2(BUTTON_SIZE, BUTTON_SIZE) + b.position = Vector2( + 120.0 + float(i % COLUMNS) * (BUTTON_SIZE + 60.0), + 100.0 + float(i / COLUMNS) * (BUTTON_SIZE + 60.0) + ) + add_child(b) diff --git a/tests/_icon_preview.gd.uid b/tests/_icon_preview.gd.uid new file mode 100644 index 0000000..81b8348 --- /dev/null +++ b/tests/_icon_preview.gd.uid @@ -0,0 +1 @@ +uid://dfd33rtm5ick2 diff --git a/tests/_icon_preview.tscn b/tests/_icon_preview.tscn new file mode 100644 index 0000000..f0067ca --- /dev/null +++ b/tests/_icon_preview.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_icon_preview.gd" id="1"] + +[node name="IconPreview" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") diff --git a/tests/_ingame_buildings_preview.gd b/tests/_ingame_buildings_preview.gd new file mode 100644 index 0000000..aa7f619 --- /dev/null +++ b/tests/_ingame_buildings_preview.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders the +# buildings through the REAL game path: records in GameState, spawned by +# Main.tscn's BuildingField, drawn under the game's own camera. The sibling +# _crafter_preview only proves Building.gd's art in isolation; this proves +# what actually reaches the screen in a running game. +# +# bash tests/screenshot.sh res://tests/_ingame_buildings_preview.tscn /tmp/out.png 40 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.reset_formation() + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0)) + GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0)) + + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + # Park the fleet below the pair so the camera frames both, and zoom out + # far enough to see them whole. + main.get_node("Fleet").move_to(Vector2(0.0, 210.0)) + var cam: Camera2D = main.get_node("Camera2D") + cam.zoom = Vector2(1.35, 1.35) diff --git a/tests/_ingame_buildings_preview.gd.uid b/tests/_ingame_buildings_preview.gd.uid new file mode 100644 index 0000000..fc17f92 --- /dev/null +++ b/tests/_ingame_buildings_preview.gd.uid @@ -0,0 +1 @@ +uid://bi1tbjjmkbjyb diff --git a/tests/_ingame_buildings_preview.tscn b/tests/_ingame_buildings_preview.tscn new file mode 100644 index 0000000..7dcc04a --- /dev/null +++ b/tests/_ingame_buildings_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_ingame_buildings_preview.gd" id="1"] + +[node name="InGameBuildingsPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_lunar_check.png.import b/tests/_lunar_check.png.import new file mode 100644 index 0000000..4ecec5c --- /dev/null +++ b/tests/_lunar_check.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvk0oafywbooj" +path="res://.godot/imported/_lunar_check.png-f10cc44a07f4a4fea5b976037f96d6c2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://tests/_lunar_check.png" +dest_files=["res://.godot/imported/_lunar_check.png-f10cc44a07f4a4fea5b976037f96d6c2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/tests/_screenshot_tool.gd b/tests/_screenshot_tool.gd new file mode 100644 index 0000000..23ef716 --- /dev/null +++ b/tests/_screenshot_tool.gd @@ -0,0 +1,53 @@ +extends SceneTree + +# Reusable screenshot tool - NOT run via run_tests.sh (underscore prefix). +# Loads a given scene, waits for a given number of frames to render, then +# saves a PNG and quits. Must be run WITHOUT --headless (that forces +# Godot's dummy/null renderer) - see tests/screenshot.sh for the full +# invocation (Xvfb + Mesa softpipe; llvmpipe's JIT crashes with SIGILL in +# this environment, softpipe's non-JIT rasterizer does not). +# +# Args (after `--`): [wait_frames] + +var wait_frames: int = 60 +var frames: int = 0 +var scene_path: String +var output_path: String +var loaded_scene: Node + + +func _initialize() -> void: + var args: PackedStringArray = OS.get_cmdline_user_args() + if args.size() < 2: + printerr("usage: -- [wait_frames]") + quit(1) + return + + scene_path = args[0] + output_path = args[1] + if args.size() >= 3: + wait_frames = int(args[2]) + + var packed: PackedScene = load(scene_path) + if packed == null: + printerr("could not load scene: %s" % scene_path) + quit(1) + return + + loaded_scene = packed.instantiate() + root.add_child(loaded_scene) + + +func _process(_delta: float) -> bool: + frames += 1 + if frames < wait_frames: + return false + var img: Image = root.get_viewport().get_texture().get_image() + var err: int = img.save_png(output_path) + if err != OK: + printerr("save_png failed with error %d" % err) + quit(1) + return true + print("SCREENSHOT_SAVED: %s (%s)" % [output_path, str(img.get_size())]) + quit(0) + return true diff --git a/tests/_screenshot_tool.gd.uid b/tests/_screenshot_tool.gd.uid new file mode 100644 index 0000000..b7b2b8b --- /dev/null +++ b/tests/_screenshot_tool.gd.uid @@ -0,0 +1 @@ +uid://dbf48h8dn1m84 diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100644 index 0000000..c252284 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,20 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Runs the headless GDScript gameplay tests from Termux, via the Godot +# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container. +# Usage: bash tests/run_tests.sh + +set -e +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" + +status=0 +for test_script in "$PROJECT_DIR"/tests/test_*.gd; do + name="$(basename "$test_script")" + echo "=== Running $name ===" + if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then + status=1 + fi + echo +done + +exit $status diff --git a/tests/scratch_scene_a.gd.uid b/tests/scratch_scene_a.gd.uid new file mode 100644 index 0000000..8ce0065 --- /dev/null +++ b/tests/scratch_scene_a.gd.uid @@ -0,0 +1 @@ +uid://dxlt7qjmxbgi4 diff --git a/tests/scratch_scene_b.gd.uid b/tests/scratch_scene_b.gd.uid new file mode 100644 index 0000000..82210b3 --- /dev/null +++ b/tests/scratch_scene_b.gd.uid @@ -0,0 +1 @@ +uid://dej0ijrlaa72o diff --git a/tests/scratch_swap_check.gd.uid b/tests/scratch_swap_check.gd.uid new file mode 100644 index 0000000..9c5863e --- /dev/null +++ b/tests/scratch_swap_check.gd.uid @@ -0,0 +1 @@ +uid://cd8yndydui6ml diff --git a/tests/screenshot.sh b/tests/screenshot.sh new file mode 100644 index 0000000..739b163 --- /dev/null +++ b/tests/screenshot.sh @@ -0,0 +1,38 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Renders a real screenshot of a Godot scene from Termux, via the Godot +# 4.7.1 Linux arm64 build inside the proot-distro Ubuntu container, using +# Xvfb (virtual display) + Mesa's softpipe software rasterizer. +# +# NOTE: Mesa's default software rasterizer, llvmpipe, crashes with +# SIGILL in this environment (confirmed via feasibility spike - its LLVM +# JIT appears to emit a CPU instruction this ARM64/proot/Android setup +# doesn't actually support, reproduced even with an empty project and no +# script). softpipe is Mesa's older non-JIT (pure C) rasterizer - slower, +# but doesn't hit this crash. Good enough for one-shot screenshots. +# +# Usage: bash tests/screenshot.sh [wait_frames] [WxH] +# Example: bash tests/screenshot.sh res://Main.tscn /tmp/out.png 60 720x1280 +# +# The resolution matters for anything with UI in it: the game is authored +# for a 720x1280 portrait viewport, so rendering at the default landscape +# size stretches the layout and makes it useless for judging whether +# controls fit. Pass 720x1280 when checking UI. + +set -e +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" + +SCENE_PATH="${1:?usage: screenshot.sh [wait_frames] [WxH]}" +OUTPUT_PATH="${2:?usage: screenshot.sh [wait_frames] [WxH]}" +WAIT_FRAMES="${3:-60}" +RESOLUTION="${4:-1280x720}" + +proot-distro login ubuntu -- bash -c " +Xvfb :99 -screen 0 ${RESOLUTION}x24 & +XVFB_PID=\$! +sleep 2 +mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt +cd '$PROJECT_DIR' +DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES' +kill \$XVFB_PID +" diff --git a/tests/test_animation_continuity.gd b/tests/test_animation_continuity.gd new file mode 100644 index 0000000..d373a25 --- /dev/null +++ b/tests/test_animation_continuity.gd @@ -0,0 +1,120 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_animation_continuity.gd +# Regression test for the mothership's animation jumping. +# +# The reactor pulse and the orbiting satellites used to be driven from +# anim_time, which wraps at CYCLE (3.6s) because the CRAFTER's assembly +# sequence is expressed as a fraction of it. Neither of them completes a +# whole number of periods in 3.6s, so every wrap made them leap - the +# satellites snapped back 66 degrees, once every cycle, forever. +# +# Verifies, by stepping a mothership across two full wraps of the assembly +# clock in small frames: +# 1. the satellites keep advancing - no frame turns them backwards +# 2. no frame moves them much more than a steady frame would +# 3. the core pulse never jumps by more than a steady frame's worth +# 4. the assembly clock really does wrap during the test, so the test is +# actually exercising the boundary that used to break + +const FRAME: float = 1.0 / 60.0 +const FRAMES: int = 500 # 8.3s, comfortably past two wraps of 3.6s + +var scene: Node +var game_state: Node +var field: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + field = scene.get_node("BuildingField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + var ship: Node2D = null + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: + ship = b + if ship == null: + print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)") + quit(1) + return true + + # Drive it by hand at a fixed frame time, so the measurement is + # not at the mercy of the headless tick rate. + ship.set_process(false) + var expected_orbit_step: float = FRAME * ship.ORBIT_SPEED + var prev_orbit: float = ship.orbit_angle() + var prev_pulse: float = ship.core_pulse() + var wrapped: bool = false + var prev_anim: float = ship.anim_time + + var worst_orbit_step: float = 0.0 + var min_orbit_step: float = 1e9 + var worst_pulse_step: float = 0.0 + # A steady frame of the pulse: derivative of a unit sine is at + # most 1, so 0.5 * speed * dt bounds one frame's change. + var expected_pulse_step: float = 0.5 * ship.PULSE_SPEED * FRAME + + for i in range(FRAMES): + ship._process(FRAME) + if ship.anim_time < prev_anim: + wrapped = true + prev_anim = ship.anim_time + + var orbit_step: float = ship.orbit_angle() - prev_orbit + worst_orbit_step = maxf(worst_orbit_step, orbit_step) + min_orbit_step = minf(min_orbit_step, orbit_step) + prev_orbit = ship.orbit_angle() + + worst_pulse_step = maxf(worst_pulse_step, absf(ship.core_pulse() - prev_pulse)) + prev_pulse = ship.core_pulse() + + print("assembly clock wrapped during the run: %s" % wrapped) + print("orbit step per frame: min %.6f max %.6f rad (steady %.6f)" % [ + min_orbit_step, worst_orbit_step, expected_orbit_step + ]) + print("worst pulse step: %.6f (steady bound %.6f)" % [ + worst_pulse_step, expected_pulse_step + ]) + + if not wrapped: + print("TEST_ANIMATION_CONTINUITY: FAIL (clock never wrapped - test proves nothing)") + quit(1) + return true + if min_orbit_step <= 0.0: + print("TEST_ANIMATION_CONTINUITY: FAIL (satellites went backwards)") + quit(1) + return true + if worst_orbit_step > expected_orbit_step * 1.05: + print("TEST_ANIMATION_CONTINUITY: FAIL (satellites jumped forwards)") + quit(1) + return true + if worst_pulse_step > expected_pulse_step * 1.05: + print("TEST_ANIMATION_CONTINUITY: FAIL (reactor pulse jumped)") + quit(1) + return true + + print("TEST_ANIMATION_CONTINUITY: PASS") + quit(0) + return true + return false diff --git a/tests/test_animation_continuity.gd.uid b/tests/test_animation_continuity.gd.uid new file mode 100644 index 0000000..1501507 --- /dev/null +++ b/tests/test_animation_continuity.gd.uid @@ -0,0 +1 @@ +uid://bht2sd2fyla4a diff --git a/tests/test_asteroid_ship_collision.gd b/tests/test_asteroid_ship_collision.gd new file mode 100644 index 0000000..e11b326 --- /dev/null +++ b/tests/test_asteroid_ship_collision.gd @@ -0,0 +1,72 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_asteroid_ship_collision.gd +# Verifies that a ship touching an asteroid destroys BOTH the ship (removed +# from the fleet) and the asteroid itself (mutual destruction), each +# spawning its own coherent explosion VFX: light-burst blue for the ship, +# debris brown for the asteroid. + +const CHECK_DELAY: float = 0.05 + +var main_scene: Node +var fleet: Node +var target_ship: Node +var asteroid: Node2D +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # Wait one tick so Fleet._ready() has run and fleet.ships is + # populated before we grab a ship reference. + target_ship = fleet.ships[0] + var asteroid_scene: PackedScene = load("res://Asteroid.tscn") + asteroid = asteroid_scene.instantiate() + asteroid.position = target_ship.position + asteroid.velocity = Vector2.ZERO + main_scene.add_child(asteroid) + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < CHECK_DELAY: + return false + _check_results() + return true + return false + + +func _check_results() -> void: + var ship_destroyed: bool = not is_instance_valid(target_ship) + var ship_removed_from_fleet: bool = fleet.ships.find(target_ship) == -1 + var asteroid_destroyed: bool = not is_instance_valid(asteroid) + + var ship_vfx_ok: bool = false + var asteroid_vfx_ok: bool = false + for child in root.get_children(): + if child is ShipExplosionVFX: + ship_vfx_ok = true + elif child is AsteroidExplosionVFX: + asteroid_vfx_ok = true + + print("ship_destroyed=%s ship_removed_from_fleet=%s asteroid_destroyed=%s ship_vfx_ok=%s asteroid_vfx_ok=%s" % [ + ship_destroyed, ship_removed_from_fleet, asteroid_destroyed, ship_vfx_ok, asteroid_vfx_ok + ]) + + if ship_destroyed and ship_removed_from_fleet and asteroid_destroyed and ship_vfx_ok and asteroid_vfx_ok: + print("TEST_ASTEROID_SHIP_COLLISION: PASS") + quit(0) + else: + print("TEST_ASTEROID_SHIP_COLLISION: FAIL") + quit(1) diff --git a/tests/test_asteroid_ship_collision.gd.uid b/tests/test_asteroid_ship_collision.gd.uid new file mode 100644 index 0000000..9c6487c --- /dev/null +++ b/tests/test_asteroid_ship_collision.gd.uid @@ -0,0 +1 @@ +uid://sa7a3btjwp4l diff --git a/tests/test_asteroid_spawning.gd b/tests/test_asteroid_spawning.gd new file mode 100644 index 0000000..eb9e1e6 --- /dev/null +++ b/tests/test_asteroid_spawning.gd @@ -0,0 +1,115 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_asteroid_spawning.gd +# Verifies AsteroidField spawns moving asteroids once the fleet enters an +# ASTEROID_FIELD biome chunk, and despawns them again once the fleet moves +# far enough away - guards against unbounded node accumulation over a long +# play session while still populating newly-entered regions on the fly. + +var main_scene: Node +var fleet: Node +var asteroid_field: Node +var asteroid_chunk: Vector2i +var spawned_asteroids: Array = [] +var elapsed: float = 0.0 +var stage: int = 0 +var ok: bool = true + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.biome_at_chunk(chunk) == WorldGen.Biome.ASTEROID_FIELD: + asteroid_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any ASTEROID_FIELD chunk to test with") + quit(1) + return + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + asteroid_field = main_scene.get_node("AsteroidField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # Wait one tick so Fleet._ready() has run and fleet.ships is + # populated before we try to teleport them - reposition + # attempted synchronously in _initialize() would silently + # no-op on an empty ships array. + var target_pos: Vector2 = WorldGen.chunk_origin(asteroid_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = target_pos + fleet.move_to(target_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < asteroid_field.check_interval + 0.1: + return false + _check_spawned() + elapsed = 0.0 + stage = 2 + return false + 2: + var far_pos: Vector2 = WorldGen.chunk_origin(asteroid_chunk) + Vector2(50000, 50000) + for ship in fleet.ships: + ship.position = far_pos + fleet.move_to(far_pos) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < asteroid_field.check_interval + 0.1: + return false + _check_despawned() + return true + return false + + +func _check_spawned() -> void: + var has_chunk: bool = asteroid_field.populated_chunks.has(asteroid_chunk) + if not has_chunk: + ok = false + print("FAIL: asteroid_chunk %s not populated after check_interval" % asteroid_chunk) + return + + spawned_asteroids = asteroid_field.populated_chunks[asteroid_chunk] + var count_ok: bool = spawned_asteroids.size() == asteroid_field.asteroids_per_chunk + var moving_ok: bool = true + for asteroid in spawned_asteroids: + if asteroid.velocity.length() <= 0.1: + moving_ok = false + + ok = ok and count_ok and moving_ok + print("spawned_count=%d expected=%d moving_ok=%s" % [spawned_asteroids.size(), asteroid_field.asteroids_per_chunk, moving_ok]) + + +func _check_despawned() -> void: + var chunk_removed: bool = not asteroid_field.populated_chunks.has(asteroid_chunk) + var all_freed: bool = true + for asteroid in spawned_asteroids: + if is_instance_valid(asteroid): + all_freed = false + + ok = ok and chunk_removed and all_freed + print("chunk_removed=%s all_freed=%s" % [chunk_removed, all_freed]) + + if ok: + print("TEST_ASTEROID_SPAWNING: PASS") + quit(0) + else: + print("TEST_ASTEROID_SPAWNING: FAIL") + quit(1) diff --git a/tests/test_asteroid_spawning.gd.uid b/tests/test_asteroid_spawning.gd.uid new file mode 100644 index 0000000..ae95d05 --- /dev/null +++ b/tests/test_asteroid_spawning.gd.uid @@ -0,0 +1 @@ +uid://xo183n5xkxi5 diff --git a/tests/test_biome_generation.gd b/tests/test_biome_generation.gd new file mode 100644 index 0000000..902ab7b --- /dev/null +++ b/tests/test_biome_generation.gd @@ -0,0 +1,38 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_biome_generation.gd +# Verifies WorldGen's biome-per-chunk function is deterministic (querying +# the same chunk twice gives the same biome) and that the seeded noise +# field actually produces both biome types within a reasonably large +# sampled area - a regression guard against a threshold tuned so one biome +# never appears at all. + +func _initialize() -> void: + var deterministic_ok: bool = true + for i in range(20): + var chunk: Vector2i = Vector2i(i * 3 - 10, i * 2 - 5) + var a: int = WorldGen.biome_at_chunk(chunk) + var b: int = WorldGen.biome_at_chunk(chunk) + if a != b: + deterministic_ok = false + print("FAIL: chunk %s gave %d then %d" % [chunk, a, b]) + + var space_count: int = 0 + var asteroid_count: int = 0 + for x in range(-25, 25): + for y in range(-25, 25): + var biome: int = WorldGen.biome_at_chunk(Vector2i(x, y)) + if biome == WorldGen.Biome.SPACE: + space_count += 1 + elif biome == WorldGen.Biome.ASTEROID_FIELD: + asteroid_count += 1 + + print("deterministic_ok=%s space_count=%d asteroid_count=%d" % [deterministic_ok, space_count, asteroid_count]) + + if deterministic_ok and space_count > 0 and asteroid_count > 0: + print("TEST_BIOME_GENERATION: PASS") + quit(0) + else: + print("TEST_BIOME_GENERATION: FAIL") + quit(1) diff --git a/tests/test_biome_generation.gd.uid b/tests/test_biome_generation.gd.uid new file mode 100644 index 0000000..a9c7be1 --- /dev/null +++ b/tests/test_biome_generation.gd.uid @@ -0,0 +1 @@ +uid://mxq52bxdf0l1 diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd new file mode 100644 index 0000000..57f950e --- /dev/null +++ b/tests/test_build_info_popup.gd @@ -0,0 +1,123 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_build_info_popup.gd +# Every build option explains itself before anything is spent. Picking a +# structure costs ore and, for a mothership, can destroy the one you already +# have, so no option may build straight off the menu press. +# +# Verifies, for EVERY option in the build menu: +# 1. pressing it opens the popup with that building's own title and a +# non-empty description, and shows its price +# 2. nothing is built and no ore is spent until the confirm is pressed +# 3. confirming builds exactly that structure and charges exactly its cost +# 4. closing the popup instead drops the pending build entirely - a later +# confirm press does nothing + +var scene: Node +var game_state: Node +var controller: Node +var popup: Control +var title: Label +var message: Label +var cost_number: NeonNumber +var build_button: IconButton +var stage: int = 0 +var elapsed: float = 0.0 + +# Extractor is excluded: it needs a planet underneath, which +# test_extractor_building covers in full. +const OPTIONS: Array = [ + {"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"}, + {"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"}, + {"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"}, +] + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 500 + game_state.buildings.clear() + game_state.extractors.clear() + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + popup = scene.get_node("UI/BuildMenu/InfoPopup") + title = popup.get_node("Panel/TitleLabel") + message = popup.get_node("Panel/MessageLabel") + cost_number = popup.get_node("Panel/CostNumber") + build_button = popup.get_node("Panel/BuildButton") + + +func _count(type: String) -> int: + var n: int = 0 + for b in game_state.buildings: + if b.type == type: + n += 1 + return n + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + + for option in OPTIONS: + var type: String = option.type + var expected_cost: int = controller.cost_of(type) + var rocks_before: int = game_state.rocks + var built_before: int = _count(type) + + controller._on_build_pressed() + controller.call(option.press) + + var informed: bool = popup.visible \ + and title.text == option.title \ + and message.text.length() > 40 \ + and cost_number.amount == expected_cost \ + and build_button.visible + var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before + print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [ + type, popup.visible, title.text, cost_number.amount, + build_button.visible, untouched + ]) + if not (informed and untouched): + print("TEST_BUILD_INFO_POPUP: FAIL (%s did not explain itself before building)" % type) + quit(1) + return true + + # --- closing drops it, it must not build later ------------ + controller._close_info() + controller._confirm_build() + if _count(type) != built_before or game_state.rocks != rocks_before: + print("TEST_BUILD_INFO_POPUP: FAIL (%s built after the popup was dismissed)" % type) + quit(1) + return true + + # --- reopening and confirming builds it ------------------ + controller._on_build_pressed() + controller.call(option.press) + controller._confirm_build() + var built: bool = _count(type) == built_before + 1 + var charged: bool = game_state.rocks == rocks_before - expected_cost + print("%s: confirmed -> built=%s rocks %d->%d charged=%s" % [ + type, built, rocks_before, game_state.rocks, charged + ]) + if not (built and charged): + print("TEST_BUILD_INFO_POPUP: FAIL (%s did not build on confirmation)" % type) + quit(1) + return true + + print("TEST_BUILD_INFO_POPUP: PASS") + quit(0) + return true + return false diff --git a/tests/test_build_info_popup.gd.uid b/tests/test_build_info_popup.gd.uid new file mode 100644 index 0000000..098294b --- /dev/null +++ b/tests/test_build_info_popup.gd.uid @@ -0,0 +1 @@ +uid://mqhjheyn0hq4 diff --git a/tests/test_build_menu_icons.gd b/tests/test_build_menu_icons.gd new file mode 100644 index 0000000..890864f --- /dev/null +++ b/tests/test_build_menu_icons.gd @@ -0,0 +1,64 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_build_menu_icons.gd +# The build menu is where a structure is chosen, so each option has to look +# like the thing it builds. It used to pick from the generic icon set: the +# mothership and the command centre both drew a folded MAP glyph, the ship +# crafter drew a bare triangle and the extractor drew the same "+" as the +# button that opens the menu - so redrawing the placed buildings changed +# nothing about how they looked at the point of choosing one. +# +# Verifies, for every option in the menu: +# 1. it uses its own dedicated building glyph +# 2. no two options share an icon +# 3. `icon` is a real Icon value that _draw actually handles + +var scene: Node + +const EXPECTED: Dictionary = { + "MothershipButton": IconButton.Icon.MOTHERSHIP, + "CrafterButton": IconButton.Icon.CRAFTER, + "CommandButton": IconButton.Icon.COMMAND_CENTER, + "ExtractorButton": IconButton.Icon.EXTRACTOR, +} + + +func _initialize() -> void: + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + + +func _process(_delta: float) -> bool: + var menu: Node = scene.get_node("UI/BuildMenu") + var seen: Array = [] + var ok: bool = true + + for name in EXPECTED: + var button: IconButton = menu.get_node_or_null(name) + if button == null: + print("%s: MISSING" % name) + ok = false + continue + var expected: int = EXPECTED[name] + var matches: bool = button.icon == expected + # An out-of-range int would silently draw an empty frame, since + # _draw's match simply falls through. + var in_range: bool = button.icon >= 0 and button.icon < IconButton.Icon.size() + print("%s: icon=%d expected=%d ok=%s in_range=%s" % [ + name, button.icon, expected, matches, in_range + ]) + if not (matches and in_range): + ok = false + if button.icon in seen: + print("%s: icon %d is already used by another option" % [name, button.icon]) + ok = false + seen.append(button.icon) + + if ok: + print("TEST_BUILD_MENU_ICONS: PASS") + quit(0) + else: + print("TEST_BUILD_MENU_ICONS: FAIL (build options are not using their own building glyphs)") + quit(1) + return true diff --git a/tests/test_build_menu_icons.gd.uid b/tests/test_build_menu_icons.gd.uid new file mode 100644 index 0000000..783e2ab --- /dev/null +++ b/tests/test_build_menu_icons.gd.uid @@ -0,0 +1 @@ +uid://c373ho7s2jyn2 diff --git a/tests/test_camera_settles.gd b/tests/test_camera_settles.gd new file mode 100644 index 0000000..6adb480 --- /dev/null +++ b/tests/test_camera_settles.gd @@ -0,0 +1,98 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_camera_settles.gd +# Regression test for a permanently-drifting camera. GameController followed +# the fleet with an exponential lerp and no snap threshold, so it approached +# the fleet centroid asymptotically and NEVER arrived - the camera kept +# moving by a fraction of a pixel every single frame, forever, even with the +# fleet completely stationary. That kept the background shaders' camera_pos +# uniform changing every frame, which made screen tearing permanently visible +# as horizontal bands of the background sliding sideways against each other. +# +# Verifies the camera reaches an EXACTLY stable position (bit-identical +# frame to frame) once the fleet has stopped, not merely a nearly-stable one. + +var main_scene: Node +var fleet: Node +var camera: Camera2D +var elapsed: float = 0.0 +var stage: int = 0 +var last_pos: Vector2 +var identical_frames: int = 0 + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + camera = main_scene.get_node("Camera2D") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + # Send the fleet somewhere well away from the camera so the + # follow actually has distance to close. + fleet.move_to(Vector2(1500, 900)) + elapsed = 0.0 + stage = 1 + return false + 1: + # Wait for the fleet to genuinely arrive AND the camera to close + # the remaining distance - a fixed delay is unreliable here, since + # the trip length depends on the ships' braking curve. + if elapsed > 30.0: + print("TEST_CAMERA_SETTLES: FAIL (fleet/camera never arrived)") + quit(1) + return true + for ship in fleet.ships: + if ship.position.distance_to(ship.move_target) > ship.arrive_radius: + return false + # Wait for the snap itself to fire, not merely for the camera to + # get close: the final approach is still an active lerp, and + # sampling during it would just measure normal convergence rather + # than the perpetual drift this test is about. + if camera.global_position.distance_to(fleet.get_centroid()) > 0.0001: + return false + last_pos = camera.global_position + identical_frames = 0 + elapsed = 0.0 + stage = 2 + return false + 2: + # With the fleet settled, the camera must be BIT-IDENTICAL frame + # to frame. Any residual drift (however small) means the lerp is + # still creeping and the tearing-visibility bug is back. + if camera.global_position == last_pos: + identical_frames += 1 + else: + print("camera still drifting: %s -> %s (delta=%.9f)" % [ + last_pos, camera.global_position, + last_pos.distance_to(camera.global_position) + ]) + print("TEST_CAMERA_SETTLES: FAIL (camera never stops moving)") + quit(1) + return true + + last_pos = camera.global_position + if identical_frames < 30: + return false + + var centroid: Vector2 = fleet.get_centroid() + var on_target: bool = camera.global_position.distance_to(centroid) <= 1.0 + print("camera stable for %d frames at %s (fleet centroid %s)" % [ + identical_frames, camera.global_position, centroid + ]) + if on_target: + print("TEST_CAMERA_SETTLES: PASS") + quit(0) + else: + print("TEST_CAMERA_SETTLES: FAIL (settled, but not on the fleet)") + quit(1) + return true + return false diff --git a/tests/test_camera_settles.gd.uid b/tests/test_camera_settles.gd.uid new file mode 100644 index 0000000..a3f7347 --- /dev/null +++ b/tests/test_camera_settles.gd.uid @@ -0,0 +1 @@ +uid://c2it165t3pxvd diff --git a/tests/test_crafter_animation.gd b/tests/test_crafter_animation.gd new file mode 100644 index 0000000..7ddab14 --- /dev/null +++ b/tests/test_crafter_animation.gd @@ -0,0 +1,118 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_crafter_animation.gd +# The ship crafter is drawn as a factory with a hull being assembled on the +# line. Verifies: +# 1. a crafter spawned the normal way (BuildingField, from GameState) +# actually animates - the animation is what tells the player what the +# building does. The mothership animates too (reactor core, orbiting +# satellites) +# 2. the command centre stays static, because per-frame redraws of art +# that never changes were a measurable framerate cost earlier on +# 3. the hull assembles progressively over the cycle rather than popping +# in whole, and every piece is on before the assembly phase ends +# 4. the cycle loops without anim_time growing without bound + +var scene: Node +var game_state: Node +var field: Node +var stage: int = 0 +var elapsed: float = 0.0 +var crafter: Node2D +var time_at_start: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 200 + game_state.buildings.clear() + game_state.extractors.clear() + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + field = scene.get_node("BuildingField") + + +func _find(type: String) -> Node2D: + for b in field.spawned: + if is_instance_valid(b) and b.building_type == type: + return b + return null + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(500.0, 0.0)) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-500.0, 0.0)) + field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 500.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + # Spawning is deferred, so give the tree a frame to attach them. + if elapsed < 0.2: + return false + crafter = _find(GameState.BUILDING_SHIP_CRAFTER) + if crafter == null: + print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)") + quit(1) + return true + + var animated: bool = crafter.is_processing() + var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership_animates: bool = mothership != null and mothership.is_processing() + var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command_static: bool = command != null and not command.is_processing() + print("crafter animates=%s mothership animates=%s command centre static=%s" % [ + animated, mothership_animates, command_static + ]) + if not (animated and mothership_animates and command_static): + print("TEST_CRAFTER_ANIMATION: FAIL (wrong buildings are animating)") + quit(1) + return true + + # --- the hull assembles piece by piece ------------------------ + var total: int = crafter.SHIP_PARTS.size() + var counts: Array = [] + for p in [0.0, 0.1, 0.35, 0.55, 0.9, 1.0]: + counts.append(crafter.attached_part_count(p)) + var progressive: bool = counts[0] == 0 and counts[-1] == total + for i in range(1, counts.size()): + if counts[i] < counts[i - 1]: + progressive = false + # Nothing may still be missing when the welding phase ends, or + # the hull would launch half-built. + var complete_before_launch: bool = crafter.attached_part_count(1.0) == total + print("parts attached across the phase: %s (of %d) progressive=%s" % [ + counts, total, progressive + ]) + if not (progressive and complete_before_launch and counts[1] < total): + print("TEST_CRAFTER_ANIMATION: FAIL (hull does not build up gradually)") + quit(1) + return true + + time_at_start = crafter.anim_time + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + var advanced: bool = crafter.anim_time != time_at_start + var bounded: bool = crafter.anim_time >= 0.0 and crafter.anim_time < crafter.CYCLE + print("anim_time %.3f -> %.3f (cycle %.2f) advanced=%s bounded=%s" % [ + time_at_start, crafter.anim_time, crafter.CYCLE, advanced, bounded + ]) + if advanced and bounded: + print("TEST_CRAFTER_ANIMATION: PASS") + quit(0) + else: + print("TEST_CRAFTER_ANIMATION: FAIL (animation clock stalled or ran away)") + quit(1) + return true + return false diff --git a/tests/test_crafter_animation.gd.uid b/tests/test_crafter_animation.gd.uid new file mode 100644 index 0000000..bf995ff --- /dev/null +++ b/tests/test_crafter_animation.gd.uid @@ -0,0 +1 @@ +uid://qa1hoclxyhon diff --git a/tests/test_enemy_collision.gd b/tests/test_enemy_collision.gd new file mode 100644 index 0000000..923fe41 --- /dev/null +++ b/tests/test_enemy_collision.gd @@ -0,0 +1,65 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_collision.gd +# Verifies that an enemy touching a ship destroys that ship, is removed +# from the fleet, while the enemy itself survives the touch (enemies only +# die from being shot, not from ramming). + +const CHECK_DELAY: float = 0.05 + +var main_scene: Node +var fleet: Node +var enemy: Node +var target_ship: Node +var elapsed: float = 0.0 +var started: bool = false + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + + +func _physics_process(delta: float) -> bool: + if not started: + # Disabling EnemySpawner in _initialize() doesn't reliably stick - + # a node's set_process(false) call made before it has fully + # finished entering the tree can get silently reset once the + # engine's own (deferred) tree-entry finalization runs, which + # happens strictly after _ready(). Here, at the point _ready() is + # already confirmed complete, it's safe. + main_scene.get_node("EnemySpawner").set_process(false) + target_ship = fleet.ships[0] + var enemy_scene: PackedScene = load("res://Enemy.tscn") + enemy = enemy_scene.instantiate() + enemy.position = target_ship.position + main_scene.add_child(enemy) + started = true + return false + + elapsed += delta + if elapsed >= CHECK_DELAY: + _finish() + return true + return false + + +func _finish() -> void: + var ship_destroyed: bool = not is_instance_valid(target_ship) + var ship_removed_from_fleet: bool = fleet.ships.find(target_ship) == -1 + var fleet_count_ok: bool = fleet.ships.size() == 4 + var enemy_survived: bool = is_instance_valid(enemy) and enemy.hp > 0 + + print("ship_destroyed=%s ship_removed_from_fleet=%s ships_remaining=%d enemy_survived=%s" % [ + ship_destroyed, ship_removed_from_fleet, fleet.ships.size(), enemy_survived + ]) + + if ship_destroyed and ship_removed_from_fleet and fleet_count_ok and enemy_survived: + print("TEST_ENEMY_COLLISION: PASS") + quit(0) + else: + print("TEST_ENEMY_COLLISION: FAIL") + quit(1) diff --git a/tests/test_enemy_collision.gd.uid b/tests/test_enemy_collision.gd.uid new file mode 100644 index 0000000..76b513f --- /dev/null +++ b/tests/test_enemy_collision.gd.uid @@ -0,0 +1 @@ +uid://bflxuyu0qjekl diff --git a/tests/test_enemy_combat.gd b/tests/test_enemy_combat.gd new file mode 100644 index 0000000..18e1ad3 --- /dev/null +++ b/tests/test_enemy_combat.gd @@ -0,0 +1,74 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_combat.gd +# Verifies the fleet auto-fires on an enemy within range and kills it after +# a few hits, without the enemy ever having to touch a ship, and that a +# coherent red light-burst explosion VFX spawns at the moment it dies. + +const TEST_DURATION: float = 3.0 +const SPAWN_OFFSET: Vector2 = Vector2(220, 0) # inside fire_range, outside collision radius + +var main_scene: Node +var fleet: Node +var enemy: Node +var elapsed: float = 0.0 +var started: bool = false + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + + +func _physics_process(delta: float) -> bool: + if not started: + # Disabling EnemySpawner in _initialize() doesn't reliably stick - + # a node's set_process(false) call made before it has fully + # finished entering the tree can get silently reset once the + # engine's own (deferred) tree-entry finalization runs, which + # happens strictly after _ready(). Here, at the point _ready() is + # already confirmed complete, it's safe. + main_scene.get_node("EnemySpawner").set_process(false) + var enemy_scene: PackedScene = load("res://Enemy.tscn") + enemy = enemy_scene.instantiate() + enemy.position = fleet.get_centroid() + SPAWN_OFFSET + main_scene.add_child(enemy) + started = true + return false + + elapsed += delta + + if not is_instance_valid(enemy): + _finish(true, "enemy destroyed by fleet fire after %.2fs" % elapsed) + return true + + if elapsed >= TEST_DURATION: + _finish(false, "enemy still alive after %.2fs (hp=%d)" % [elapsed, enemy.hp]) + return true + + return false + + +func _finish(ok: bool, message: String) -> void: + print("ships_remaining=%d" % fleet.ships.size()) + print(message) + + var vfx_ok: bool = false + for child in root.get_children(): + if child is EnemyExplosionVFX: + vfx_ok = true + print("enemy_vfx_ok=%s" % vfx_ok) + + if ok and fleet.ships.size() == 5 and vfx_ok: + print("TEST_ENEMY_COMBAT: PASS") + quit(0) + else: + if fleet.ships.size() != 5: + print("FAIL: a ship was lost - enemy must have reached melee range instead of dying to fire") + if not vfx_ok: + print("FAIL: no matching red light-burst explosion VFX found for the destroyed enemy") + print("TEST_ENEMY_COMBAT: FAIL") + quit(1) diff --git a/tests/test_enemy_combat.gd.uid b/tests/test_enemy_combat.gd.uid new file mode 100644 index 0000000..7ca976b --- /dev/null +++ b/tests/test_enemy_combat.gd.uid @@ -0,0 +1 @@ +uid://bvulrmqnxw84w diff --git a/tests/test_exploration_tracker.gd b/tests/test_exploration_tracker.gd new file mode 100644 index 0000000..e7c22a6 --- /dev/null +++ b/tests/test_exploration_tracker.gd @@ -0,0 +1,59 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_exploration_tracker.gd +# Verifies ExplorationTracker marks the fleet's current chunk as explored +# once the fleet is there, while a distant chunk the fleet never visited +# stays unexplored - this is what lets the world map reveal only chunks +# actually seen instead of the whole generated world at once. + +var main_scene: Node +var fleet: Node +var tracker: Node +var elapsed: float = 0.0 +var teleported: bool = false + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + tracker = main_scene.get_node("ExplorationTracker") + + +func _process(delta: float) -> bool: + if not teleported: + # Wait one tick so Fleet._ready() has run and fleet.ships is + # populated before we try to teleport them - reposition attempted + # synchronously in _initialize() would silently no-op on an empty + # ships array. + var target_pos: Vector2 = Vector2(5000, -3000) + for ship in fleet.ships: + ship.position = target_pos + fleet.move_to(target_pos) + teleported = true + return false + + elapsed += delta + if elapsed < 0.2: + return false + + var visited_chunk: Vector2i = WorldGen.chunk_coords(Vector2(5000, -3000)) + var far_chunk: Vector2i = Vector2i(9999, 9999) + + var explored: Array = tracker.get_explored_chunks() + var visited_ok: bool = explored.has(visited_chunk) + var far_unexplored: bool = not explored.has(far_chunk) + + print("visited_chunk=%s in_explored=%s far_chunk=%s in_explored=%s" % [ + visited_chunk, visited_ok, far_chunk, not far_unexplored + ]) + + if visited_ok and far_unexplored: + print("TEST_EXPLORATION_TRACKER: PASS") + quit(0) + else: + print("TEST_EXPLORATION_TRACKER: FAIL") + quit(1) + return true diff --git a/tests/test_exploration_tracker.gd.uid b/tests/test_exploration_tracker.gd.uid new file mode 100644 index 0000000..4c86f9d --- /dev/null +++ b/tests/test_exploration_tracker.gd.uid @@ -0,0 +1 @@ +uid://ctwsrcwjw75ve diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd new file mode 100644 index 0000000..20e9607 --- /dev/null +++ b/tests/test_extractor_building.gd @@ -0,0 +1,161 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_extractor_building.gd +# Extractors are built from SPACE, over a planet - not from the planet's +# surface. Verifies: +# 1. the option is greyed out when no planet is in reach, but still +# pressable, and explains itself instead of silently failing +# 2. it lights up once the fleet is over a planet +# 3. its info popup names the planet and offers a confirm, and confirming +# charges the cost and registers the extractor against that planet +# 4. a second one on the same planet is refused, with no confirm offered + +var main_scene: Node +var fleet: Node +var game_state: Node +var controller: Node +var button: IconButton +var popup: Control +var target_chunk: Vector2i +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + if not found: + print("FAIL: no planet chunk to test with") + quit(1) + return + + game_state = root.get_node("GameState") + game_state.rocks = 200 + game_state.buildings.clear() + game_state.extractors.clear() + + main_scene = load("res://Main.tscn").instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + controller = main_scene.get_node("BuildController") + button = main_scene.get_node("UI/BuildMenu/ExtractorButton") + popup = main_scene.get_node("UI/BuildMenu/InfoPopup") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + + # Far from any planet: greyed, and pressing it explains why. + controller._on_build_pressed() + if button.enabled: + print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)") + quit(1) + return true + controller._on_place_extractor() + var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower() + var offered: bool = popup.get_node("Panel/BuildButton").visible + print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [ + not button.enabled, explained, offered + ]) + if not explained or offered: + print("TEST_EXTRACTOR_BUILDING: FAIL (greyed press did not explain itself, or offered a build)") + quit(1) + return true + if not game_state.extractors.is_empty(): + print("TEST_EXTRACTOR_BUILDING: FAIL (built one without a planet)") + quit(1) + return true + popup.visible = false + + # Fly over the planet. + var near: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = near + fleet.move_to(near) + elapsed = 0.0 + stage = 1 + return false + 1: + var planet_field: Node = main_scene.get_node("PlanetField") + if elapsed < planet_field.check_interval + 0.2: + return false + if not planet_field.populated_chunks.has(target_chunk): + print("TEST_EXTRACTOR_BUILDING: FAIL (planet never spawned)") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + for ship in fleet.ships: + ship.position = planet.position + fleet.move_to(planet.position) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + controller._on_build_pressed() + if not button.enabled: + print("TEST_EXTRACTOR_BUILDING: FAIL (still greyed while over a planet)") + quit(1) + return true + + var rocks_before: int = game_state.rocks + controller._on_place_extractor() + var planet_field2: Node = main_scene.get_node("PlanetField") + var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0] + + # The popup must name the planet it is about to mine, and nothing + # may be spent until it is confirmed. + var names_planet: bool = planet2.planet_name in popup.get_node("Panel/MessageLabel").text + var spent_early: bool = game_state.rocks != rocks_before + print("popup: names_planet=%s confirm_offered=%s spent_before_confirm=%s" % [ + names_planet, popup.get_node("Panel/BuildButton").visible, spent_early + ]) + if not names_planet or spent_early or not popup.get_node("Panel/BuildButton").visible: + print("TEST_EXTRACTOR_BUILDING: FAIL (popup did not offer an informed build)") + quit(1) + return true + controller._confirm_build() + + var built: bool = game_state.has_extractor_at(planet2.planet_name) + var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + print("over planet '%s': enabled=%s built=%s rocks %d->%d charged=%s" % [ + planet2.planet_name, button.enabled, built, rocks_before, game_state.rocks, charged + ]) + if not (built and charged): + print("TEST_EXTRACTOR_BUILDING: FAIL (did not build/charge over a planet)") + quit(1) + return true + + # A second one on the same planet must be refused, not charged. + var rocks_now: int = game_state.rocks + controller._on_build_pressed() + controller._on_place_extractor() + var re_offered: bool = popup.get_node("Panel/BuildButton").visible + controller._confirm_build() # must do nothing even if pressed + var still_one: bool = game_state.extractors.size() == 1 and not re_offered + var not_charged: bool = game_state.rocks == rocks_now + print("second attempt: extractors=%d rocks=%d confirm_offered=%s refused=%s" % [ + game_state.extractors.size(), game_state.rocks, re_offered, still_one and not_charged + ]) + if still_one and not_charged: + print("TEST_EXTRACTOR_BUILDING: PASS") + quit(0) + else: + print("TEST_EXTRACTOR_BUILDING: FAIL (allowed a duplicate extractor)") + quit(1) + return true + return false diff --git a/tests/test_extractor_building.gd.uid b/tests/test_extractor_building.gd.uid new file mode 100644 index 0000000..9bfbc67 --- /dev/null +++ b/tests/test_extractor_building.gd.uid @@ -0,0 +1 @@ +uid://5b2mac7kn3gs diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd new file mode 100644 index 0000000..f11e9b8 --- /dev/null +++ b/tests/test_extractor_income.gd @@ -0,0 +1,106 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_extractor_income.gd +# Covers the extractor's automated hauler: +# 1. income accrues with NOTHING about the route loaded (no scene showing +# it), because the simulation lives in the GameState autoload +# 2. ore is delivered exactly when the hauler reaches the mothership - +# the payout and the drawn arrival are the same event, driven by the +# same `progress` value, so they cannot drift apart +# 3. no mothership means no route and therefore no income +# 4. the hauler's drawn position tracks that progress along the route + +var game_state: Node +var stage: int = 0 +var elapsed: float = 0.0 +var mothership_pos: Vector2 = Vector2(4000.0, 0.0) +var planet_pos: Vector2 = Vector2.ZERO +var rocks_before_arrival: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 0 + game_state.buildings.clear() + game_state.extractors.clear() + + # An extractor with NO mothership: there is nowhere to deliver, so it + # must not pay out. + game_state.add_extractor("Test Planet", planet_pos) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 1.0: + return false + if game_state.rocks != 0: + print("earned %d rocks with no mothership" % game_state.rocks) + print("TEST_EXTRACTOR_INCOME: FAIL (income without a delivery point)") + quit(1) + return true + print("no mothership -> no income (rocks=%d)" % game_state.rocks) + + # Give it somewhere to deliver. + game_state.add_building(GameState.BUILDING_MOTHERSHIP, mothership_pos) + game_state.extractors[0].progress = 0.0 + elapsed = 0.0 + stage = 1 + return false + 1: + var e: Dictionary = game_state.extractors[0] + var cycle: float = game_state.get_hauler_route_time(e) + if cycle <= 0.0: + print("TEST_EXTRACTOR_INCOME: FAIL (route never established)") + quit(1) + return true + + # Nothing in this scene tree draws the route at all - no Main + # scene, no HaulerField, no hauler node. Income must still tick. + if e.progress < GameState.HAULER_ARRIVAL: + rocks_before_arrival = game_state.rocks + # The drawn position must track progress along the route. + var pos: Variant = game_state.get_hauler_position(e) + var expected: Vector2 = planet_pos.lerp(mothership_pos, e.progress / GameState.HAULER_ARRIVAL) + if pos == null or pos.distance_to(expected) > 1.0: + print("hauler at %s, expected %s" % [pos, expected]) + print("TEST_EXTRACTOR_INCOME: FAIL (drawn position does not follow the simulation)") + quit(1) + return true + return false + + # Just crossed the arrival point: the payout must have landed on + # the very same step, and the hauler must be AT the mothership. + var paid: bool = game_state.rocks == rocks_before_arrival + GameState.HAULER_YIELD + var at_mothership: Variant = game_state.get_hauler_position(e) + var arrived: bool = at_mothership != null and at_mothership.distance_to(mothership_pos) < 40.0 + print("arrival: rocks %d -> %d (paid=%s) hauler at %s (arrived=%s)" % [ + rocks_before_arrival, game_state.rocks, paid, at_mothership, arrived + ]) + if not (paid and arrived): + print("TEST_EXTRACTOR_INCOME: FAIL (payout and arrival did not coincide)") + quit(1) + return true + + rocks_before_arrival = game_state.rocks + elapsed = 0.0 + stage = 2 + return false + 2: + # It must not pay again until it has been back and returned. + var e2: Dictionary = game_state.extractors[0] + if e2.progress > GameState.HAULER_ARRIVAL: + if game_state.rocks != rocks_before_arrival: + print("paid %d extra mid-return" % (game_state.rocks - rocks_before_arrival)) + print("TEST_EXTRACTOR_INCOME: FAIL (paid while returning empty)") + quit(1) + return true + return false + # Wrapped past the planet and started a new outbound leg. + print("returned empty without paying again (rocks=%d)" % game_state.rocks) + print("TEST_EXTRACTOR_INCOME: PASS") + quit(0) + return true + return false diff --git a/tests/test_extractor_income.gd.uid b/tests/test_extractor_income.gd.uid new file mode 100644 index 0000000..469aaef --- /dev/null +++ b/tests/test_extractor_income.gd.uid @@ -0,0 +1 @@ +uid://doxpe0jsbc7dw diff --git a/tests/test_fleet_formation.gd b/tests/test_fleet_formation.gd new file mode 100644 index 0000000..bfc9610 --- /dev/null +++ b/tests/test_fleet_formation.gd @@ -0,0 +1,77 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_fleet_formation.gd +# Verifies that issuing a fleet move order makes every ship converge on its +# formation slot (offset preserved) around the ordered point AND actually +# stops there - a regression guard for the arrival-overshoot bug where ships +# oscillated back and forth around the target forever instead of settling. + +const CHECKPOINT_TIME: float = 4.0 +const TEST_DURATION: float = 7.0 +const ARRIVE_TOLERANCE: float = 2.0 +const STABLE_TOLERANCE: float = 1.0 +const VELOCITY_TOLERANCE: float = 1.0 + +var main_scene: Node +var fleet: Node +var target: Vector2 = Vector2(600.0, 300.0) +var elapsed: float = 0.0 +var started: bool = false +var checkpoint_taken: bool = false +var checkpoint_positions: Array = [] + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + + +func _physics_process(delta: float) -> bool: + if not started: + # Wait one physics tick so Fleet/Ship _ready() has run and + # formation_offsets are computed before we issue the order. + fleet.move_to(target) + started = true + return false + + elapsed += delta + + if not checkpoint_taken and elapsed >= CHECKPOINT_TIME: + checkpoint_taken = true + for ship in fleet.ships: + checkpoint_positions.append(ship.position) + + if elapsed >= TEST_DURATION: + _finish() + return true + return false + + +func _finish() -> void: + var all_ok: bool = true + for i in range(fleet.ships.size()): + var ship: Node = fleet.ships[i] + var expected: Vector2 = target + fleet.formation_offsets[i] + var dist: float = ship.position.distance_to(expected) + var drift_since_checkpoint: float = ship.position.distance_to(checkpoint_positions[i]) + var speed: float = ship.velocity.length() + + var arrived: bool = dist <= ARRIVE_TOLERANCE + var stable: bool = drift_since_checkpoint <= STABLE_TOLERANCE + var stopped: bool = speed <= VELOCITY_TOLERANCE + var ok: bool = arrived and stable and stopped + all_ok = all_ok and ok + + print("Ship %d expected=%s actual=%s dist=%.3f drift_since_t%.0fs=%.3f speed=%.3f %s" % [ + i, expected, ship.position, dist, CHECKPOINT_TIME, drift_since_checkpoint, speed, "OK" if ok else "FAIL" + ]) + + if all_ok: + print("TEST_FLEET_FORMATION: PASS (ships arrived and settled, no oscillation)") + quit(0) + else: + print("TEST_FLEET_FORMATION: FAIL") + quit(1) diff --git a/tests/test_fleet_formation.gd.uid b/tests/test_fleet_formation.gd.uid new file mode 100644 index 0000000..198e0f3 --- /dev/null +++ b/tests/test_fleet_formation.gd.uid @@ -0,0 +1 @@ +uid://cdn05ghel0emh diff --git a/tests/test_icon_button.gd b/tests/test_icon_button.gd new file mode 100644 index 0000000..5710480 --- /dev/null +++ b/tests/test_icon_button.gd @@ -0,0 +1,56 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_icon_button.gd +# Verifies IconButton correctly turns a touch-down + touch-up into its +# `pressed` signal, which the Map button is wired to open the world map - +# and that opening the map hides the Map button itself (avoiding it +# overlapping the Close button, which occupies the same corner). +# +# Calls _gui_input directly rather than routing a synthetic touch through +# the viewport's screen-position hit-testing, since headless mode's +# placeholder window makes exact on-screen coordinates unreliable to +# target (see test_world_map_ui.gd's pan test for the same issue) - this +# is a more targeted unit test of the button's own press logic anyway. + +var main_scene: Node +var world_map: Control +var map_button: IconButton +var press_count: int = 0 + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + world_map = main_scene.get_node("UI/WorldMap") + map_button = main_scene.get_node("UI/MapButton") + map_button.pressed.connect(_on_pressed) + + +func _on_pressed() -> void: + press_count += 1 + + +func _process(_delta: float) -> bool: + var down: InputEventScreenTouch = InputEventScreenTouch.new() + down.pressed = true + map_button._gui_input(down) + + var up: InputEventScreenTouch = InputEventScreenTouch.new() + up.pressed = false + map_button._gui_input(up) + + var press_fired: bool = press_count == 1 + var map_opened: bool = world_map.visible + var map_button_hidden: bool = not map_button.visible + + print("press_fired=%s map_opened=%s map_button_hidden=%s" % [press_fired, map_opened, map_button_hidden]) + + if press_fired and map_opened and map_button_hidden: + print("TEST_ICON_BUTTON: PASS") + quit(0) + else: + print("TEST_ICON_BUTTON: FAIL") + quit(1) + return true diff --git a/tests/test_icon_button.gd.uid b/tests/test_icon_button.gd.uid new file mode 100644 index 0000000..4fe3a46 --- /dev/null +++ b/tests/test_icon_button.gd.uid @@ -0,0 +1 @@ +uid://ceqoknn4vn1n2 diff --git a/tests/test_icon_button_drag_cancel.gd b/tests/test_icon_button_drag_cancel.gd new file mode 100644 index 0000000..7b3cc88 --- /dev/null +++ b/tests/test_icon_button_drag_cancel.gd @@ -0,0 +1,56 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_icon_button_drag_cancel.gd +# Verifies IconButton only counts as pressed if the finger/cursor is still +# over the button when it lifts - dragging off the button before +# releasing (a standard way to "cancel" a touch/click) must NOT still +# trigger it, and releasing back inside the button's bounds must. + +var main_scene: Node +var map_button: IconButton +var press_count: int = 0 + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + map_button = main_scene.get_node("UI/MapButton") + map_button.pressed.connect(_on_pressed) + + +func _process(_delta: float) -> bool: + var inside: Vector2 = map_button.size / 2.0 + var far_outside: Vector2 = map_button.size * 10.0 + + # Press inside, release far outside the button's rect - must NOT fire. + _touch(true, inside) + _touch(false, far_outside) + var cancel_ok: bool = press_count == 0 + print("after_drag_off press_count=%d cancel_ok=%s" % [press_count, cancel_ok]) + + # Press and release both inside - must fire normally. + _touch(true, inside) + _touch(false, inside) + var normal_press_ok: bool = press_count == 1 + print("after_normal_press press_count=%d normal_press_ok=%s" % [press_count, normal_press_ok]) + + if cancel_ok and normal_press_ok: + print("TEST_ICON_BUTTON_DRAG_CANCEL: PASS") + quit(0) + else: + print("TEST_ICON_BUTTON_DRAG_CANCEL: FAIL") + quit(1) + return true + + +func _touch(is_pressed: bool, at_position: Vector2) -> void: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.pressed = is_pressed + ev.position = at_position + map_button._gui_input(ev) + + +func _on_pressed() -> void: + press_count += 1 diff --git a/tests/test_icon_button_drag_cancel.gd.uid b/tests/test_icon_button_drag_cancel.gd.uid new file mode 100644 index 0000000..4359238 --- /dev/null +++ b/tests/test_icon_button_drag_cancel.gd.uid @@ -0,0 +1 @@ +uid://cl6hr8yuqdjvm diff --git a/tests/test_joystick_control.gd b/tests/test_joystick_control.gd new file mode 100644 index 0000000..855a192 --- /dev/null +++ b/tests/test_joystick_control.gd @@ -0,0 +1,134 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_joystick_control.gd +# Verifies the virtual joystick steers the fleet: holding the stick in a +# direction flies the fleet that way, and releasing brings it to a stop +# rather than freezing it mid-flight. Replaces the old tap-to-move test, +# since tapping no longer issues move orders. +# +# Touch events are handed to the joystick directly rather than through +# Input.parse_input_event: headless mode's placeholder window rescales +# synthetic touch coordinates by an environment-specific factor, which +# would land them outside the joystick's rect for reasons that have nothing +# to do with the behaviour under test. + +var main_scene: Node +var fleet: Node +var joystick: TouchJoystick +var stage: int = 0 +var elapsed: float = 0.0 +var start_centroid: Vector2 +var moving_centroid: Vector2 +var stop_check_frames: int = 0 + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + joystick = main_scene.get_node("UI/Joystick") + + +func _press(local: Vector2) -> void: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.index = 0 + ev.pressed = true + ev.position = joystick.global_position + local + joystick.handle_touch(ev) + + +func _drag(local: Vector2) -> void: + var ev: InputEventScreenDrag = InputEventScreenDrag.new() + ev.index = 0 + ev.position = joystick.global_position + local + joystick.handle_drag(ev) + + +func _release(local: Vector2) -> void: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.index = 0 + ev.pressed = false + ev.position = joystick.global_position + local + joystick.handle_touch(ev) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + start_centroid = fleet.get_centroid() + + # The joystick receives touches via GameController's + # _unhandled_input, which never fires for a Control that + # consumes them through the GUI system first. With Godot's + # default mouse_filter (STOP) the stick silently does nothing + # on a real device - it must be IGNORE. Checked structurally + # because the rest of this test drives handle_touch() directly + # and so cannot catch it. + if joystick.mouse_filter != Control.MOUSE_FILTER_IGNORE: + print("joystick mouse_filter=%d (needs IGNORE=%d)" % [ + joystick.mouse_filter, Control.MOUSE_FILTER_IGNORE + ]) + print("TEST_JOYSTICK_CONTROL: FAIL (stick would swallow its own touches)") + quit(1) + return true + + # Push the stick hard to the right. + var centre: Vector2 = joystick.size / 2.0 + _press(centre) + _drag(centre + Vector2(joystick.max_radius, 0.0)) + if joystick.direction.x < 0.9: + print("joystick direction wrong: %s" % joystick.direction) + print("TEST_JOYSTICK_CONTROL: FAIL (stick did not register)") + quit(1) + return true + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 1.5: + return false + moving_centroid = fleet.get_centroid() + var moved_right: bool = moving_centroid.x - start_centroid.x > 100.0 + var stayed_level: bool = abs(moving_centroid.y - start_centroid.y) < 80.0 + print("moved from %s to %s (right=%s level=%s)" % [ + start_centroid, moving_centroid, moved_right, stayed_level + ]) + if not (moved_right and stayed_level): + print("TEST_JOYSTICK_CONTROL: FAIL (fleet did not follow the stick)") + quit(1) + return true + + _release(joystick.size / 2.0) + if joystick.direction != Vector2.ZERO: + print("TEST_JOYSTICK_CONTROL: FAIL (direction not cleared on release)") + quit(1) + return true + elapsed = 0.0 + stage = 2 + return false + 2: + # After release the fleet should coast to a halt, not freeze + # instantly and not keep flying. + if elapsed < 4.0: + return false + var speed: float = 0.0 + for ship in fleet.ships: + speed = max(speed, ship.velocity.length()) + var stopped: bool = speed < 1.0 + var coasted: bool = fleet.get_centroid().x > moving_centroid.x + print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [ + speed, stopped, coasted + ]) + if stopped and coasted: + print("TEST_JOYSTICK_CONTROL: PASS") + quit(0) + else: + print("TEST_JOYSTICK_CONTROL: FAIL (did not coast to a stop)") + quit(1) + return true + return false diff --git a/tests/test_joystick_control.gd.uid b/tests/test_joystick_control.gd.uid new file mode 100644 index 0000000..f7f03fd --- /dev/null +++ b/tests/test_joystick_control.gd.uid @@ -0,0 +1 @@ +uid://dtgk6slv301ob diff --git a/tests/test_keyboard_input.gd b/tests/test_keyboard_input.gd new file mode 100644 index 0000000..321abf5 --- /dev/null +++ b/tests/test_keyboard_input.gd @@ -0,0 +1,145 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_keyboard_input.gd +# Desktop/web controls. The phone build is driven entirely by touch, but the +# web build is played with a keyboard and mouse, so WASD (and the arrows) +# must steer the fleet, Q/E must pivot the formation, and releasing the keys +# must stop - the stick has a centred-again signal to hook, the keyboard has +# nothing equivalent, so a missed release would leave the fleet flying off +# forever. +# +# Verifies: +# 1. W/A/S/D and the arrow keys each produce the right heading, and +# diagonals combine and normalise +# 2. holding a key actually moves the fleet that way +# 3. releasing every key stops it +# 4. Q and E turn the formation, in opposite directions +# 5. the on-screen stick is not taken away on a touch device + +var scene: Node +var controller: Node +var fleet: Node +var joystick: Control +var stage: int = 0 +var elapsed: float = 0.0 +var start_centroid: Vector2 = Vector2.ZERO +var moving_centroid: Vector2 = Vector2.ZERO + + +func _initialize() -> void: + root.get_node("GameState").reset_formation() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene + fleet = scene.get_node("Fleet") + joystick = scene.get_node("UI/Joystick") + + +func _key(code: int, pressed: bool) -> void: + var event: InputEventKey = InputEventKey.new() + event.keycode = code + event.pressed = pressed + controller._handle_key(event) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- headings ------------------------------------------------- + var cases: Array = [ + {"keys": [KEY_W], "expect": Vector2.UP}, + {"keys": [KEY_S], "expect": Vector2.DOWN}, + {"keys": [KEY_A], "expect": Vector2.LEFT}, + {"keys": [KEY_D], "expect": Vector2.RIGHT}, + {"keys": [KEY_UP], "expect": Vector2.UP}, + {"keys": [KEY_RIGHT], "expect": Vector2.RIGHT}, + {"keys": [KEY_W, KEY_D], "expect": Vector2(1, -1).normalized()}, + {"keys": [KEY_W, KEY_S], "expect": Vector2.ZERO}, + ] + for case in cases: + for k in case.keys: + _key(k, true) + var got: Vector2 = controller.keyboard_direction() + var ok: bool = got.distance_to(case.expect) < 0.001 + if not ok: + print("keys %s -> %s, expected %s" % [case.keys, got, case.expect]) + print("TEST_KEYBOARD_INPUT: FAIL (wrong heading)") + quit(1) + return true + for k in case.keys: + _key(k, false) + print("all %d key combinations produced the right heading" % cases.size()) + + # The stick must survive on a touch device. + var stick_kept: bool = joystick.visible + print("stick still visible after keyboard use: %s (touchscreen=%s)" % [ + stick_kept, DisplayServer.is_touchscreen_available() + ]) + if DisplayServer.is_touchscreen_available() and not stick_kept: + print("TEST_KEYBOARD_INPUT: FAIL (took the stick away on a touch device)") + quit(1) + return true + + # --- holding a key flies the fleet ---------------------------- + start_centroid = fleet.get_centroid() + _key(KEY_D, true) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.6: + return false + moving_centroid = fleet.get_centroid() + var travelled: float = moving_centroid.x - start_centroid.x + var sideways: float = absf(moving_centroid.y - start_centroid.y) + print("held D for 0.6s: moved x=%.1f y-drift=%.1f" % [travelled, sideways]) + if travelled < 50.0 or sideways > 30.0: + print("TEST_KEYBOARD_INPUT: FAIL (holding a key did not fly the fleet that way)") + quit(1) + return true + + # --- releasing stops ------------------------------------------ + _key(KEY_D, false) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.9: + return false + var coasted: float = fleet.get_centroid().x - moving_centroid.x + var still_steering: bool = fleet.steer_direction != Vector2.ZERO + print("0.9s after release: drifted a further %.1f, still steering=%s" % [ + coasted, still_steering + ]) + if still_steering: + print("TEST_KEYBOARD_INPUT: FAIL (fleet kept flying after the key was released)") + quit(1) + return true + + # --- Q/E pivot ------------------------------------------------ + var game_state: Node = root.get_node("GameState") + var before: float = game_state.formation[1].angle() + _key(KEY_E, true) + _key(KEY_E, false) + var after_cw: float = game_state.formation[1].angle() + _key(KEY_Q, true) + _key(KEY_Q, false) + var after_ccw: float = game_state.formation[1].angle() + + var cw_turn: float = angle_difference(before, after_cw) + var ccw_turn: float = angle_difference(after_cw, after_ccw) + print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn]) + if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(ccw_turn): + print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)") + quit(1) + return true + + print("TEST_KEYBOARD_INPUT: PASS") + quit(0) + return true + return false diff --git a/tests/test_keyboard_input.gd.uid b/tests/test_keyboard_input.gd.uid new file mode 100644 index 0000000..f76e369 --- /dev/null +++ b/tests/test_keyboard_input.gd.uid @@ -0,0 +1 @@ +uid://cdhlgvjrc41aq diff --git a/tests/test_lopsided_formation_movement.gd b/tests/test_lopsided_formation_movement.gd new file mode 100644 index 0000000..8c9c470 --- /dev/null +++ b/tests/test_lopsided_formation_movement.gd @@ -0,0 +1,118 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_lopsided_formation_movement.gd +# Regression test for the fleet lurching the wrong way on the first frames +# of a move after the formation was edited at a command centre. +# +# Ships sit at anchor + offset, so the fleet CENTROID is anchor + the +# AVERAGE offset. Seeding the steering anchor from the centroid therefore +# displaced every slot by that average the moment the stick was pushed. The +# default wedge is balanced (average ~zero), so this was invisible; drag +# every slot into one corner and the average becomes large, and the fleet +# jerks towards that corner before the anchor catches up. +# +# Verifies, with every slot dragged to the top-right: +# 1. starting a move does NOT displace the ships' targets sideways +# 2. the fleet actually travels bottom-left from the outset - no frame in +# which the centroid moves the wrong way +# 3. releasing the stick leaves the formation origin where the ships are, +# so re-applying the layout does not teleport them + +var scene: Node +var fleet: Node +var game_state: Node +var stage: int = 0 +var elapsed: float = 0.0 +var samples: Array = [] +var start_centroid: Vector2 = Vector2.ZERO +var worst_backwards: float = 0.0 + +const HEADING: Vector2 = Vector2(-0.7071, 0.7071) # bottom-left + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_formation() + # Every slot dragged into the top-right corner, as the command centre + # allows. The layout keeps its shape; only its centre is offset. + for i in range(game_state.formation.size()): + game_state.formation[i] += Vector2(90.0, -70.0) + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + fleet.apply_formation() + elapsed = 0.0 + stage = 1 + return false + 1: + # Let the ships settle into the lopsided layout first. + if elapsed < 1.0: + return false + + start_centroid = fleet.get_centroid() + var origin_before: Vector2 = fleet.get_formation_origin() + fleet.move_in_direction(HEADING) + + # Direct assertion of the bug: the anchor steering starts from + # must be the point the ships are already arranged around. Taking + # the centroid instead puts it a whole average-offset away, which + # is what yanked the fleet sideways. + var drift: float = fleet.anchor.distance_to(origin_before) + print("anchor is %.2f units from the formation origin (centroid is %.2f away)" % [ + drift, fleet.get_centroid().distance_to(origin_before) + ]) + if drift > 1.0: + print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (steering anchored off the formation)") + quit(1) + return true + + elapsed = 0.0 + stage = 2 + return false + 2: + # Watch the centroid every frame: it must make progress along the + # heading from the very first one, never against it. + var travelled: float = (fleet.get_centroid() - start_centroid).dot(HEADING) + worst_backwards = minf(worst_backwards, travelled) + samples.append(travelled) + if elapsed < 0.6: + return false + + print("progress along heading: first=%.2f last=%.2f worst=%.2f" % [ + samples[0], samples[-1], worst_backwards + ]) + if worst_backwards < -2.0: + print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (fleet moved backwards before setting off)") + quit(1) + return true + if samples[-1] < 50.0: + print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (fleet did not travel along the heading)") + quit(1) + return true + + # --- releasing the stick -------------------------------------- + fleet.stop() + var origin: Vector2 = fleet.fleet_target + var expected: Vector2 = fleet.get_formation_origin() + var settled_ok: bool = origin.distance_to(expected) < 0.01 + print("after stop: fleet_target=%s formation origin=%s ok=%s" % [ + origin, expected, settled_ok + ]) + if settled_ok: + print("TEST_LOPSIDED_FORMATION_MOVEMENT: PASS") + quit(0) + else: + print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (stop left the anchor off the formation)") + quit(1) + return true + return false diff --git a/tests/test_lopsided_formation_movement.gd.uid b/tests/test_lopsided_formation_movement.gd.uid new file mode 100644 index 0000000..fbb6f64 --- /dev/null +++ b/tests/test_lopsided_formation_movement.gd.uid @@ -0,0 +1 @@ +uid://dq2mnag071oth diff --git a/tests/test_mothership_replacement.gd b/tests/test_mothership_replacement.gd new file mode 100644 index 0000000..6ebe817 --- /dev/null +++ b/tests/test_mothership_replacement.gd @@ -0,0 +1,150 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_mothership_replacement.gd +# Only one mothership may exist. Building another replaces it, which moves +# where a wiped-out fleet respawns and re-routes every extractor hauler, so +# it must be confirmed rather than done silently. Verifies: +# 1. the first mothership's info popup carries no destruction warning, and +# confirming it builds one +# 2. with one already up, the popup DOES warn, and builds nothing until +# confirmed +# 3. dismissing the popup cancels - confirming afterwards does nothing +# 4. confirming replaces it: still exactly one, at the new position +# 5. an unaffordable replacement offers no confirm button at all, and +# cannot destroy the existing one + +var main_scene: Node +var fleet: Node +var game_state: Node +var controller: Node +var popup: Control +var stage: int = 0 +var elapsed: float = 0.0 +var first_pos: Vector2 = Vector2(600.0, 200.0) +var second_pos: Vector2 = Vector2(-900.0, 400.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 200 + game_state.buildings.clear() + game_state.extractors.clear() + + main_scene = load("res://Main.tscn").instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + controller = main_scene.get_node("BuildController") + popup = main_scene.get_node("UI/BuildMenu/InfoPopup") + + +func _place_fleet(at: Vector2) -> void: + for ship in fleet.ships: + ship.position = at + fleet.move_to(at) + + +func _count_motherships() -> int: + var n: int = 0 + for b in game_state.buildings: + if b.type == GameState.BUILDING_MOTHERSHIP: + n += 1 + return n + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + _place_fleet(first_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- first one: informed, then built ------------------------- + controller._on_build_pressed() + controller._on_place_mothership() + var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower() + var first_warned: bool = "destroy" in first_text + print("first: popup=%s warned=%s built_before_confirm=%d" % [ + popup.visible, first_warned, _count_motherships() + ]) + if not popup.visible or first_warned or _count_motherships() != 0: + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (first build warned, or built without confirming)") + quit(1) + return true + controller._confirm_build() + if _count_motherships() != 1: + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (confirming the first one did not build it)") + quit(1) + return true + + # --- second: warns, builds nothing --------------------------- + _place_fleet(second_pos) + controller._on_build_pressed() + controller._on_place_mothership() + var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower() + print("second press: warned=%s motherships=%d" % [warned, _count_motherships()]) + if not warned or _count_motherships() != 1: + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (no warning, or built without confirming)") + quit(1) + return true + + # --- dismissing cancels -------------------------------------- + controller._close_info() + controller._confirm_build() + print("after cancel, confirm pressed anyway: motherships=%d at %s" % [ + _count_motherships(), game_state.get_mothership_position() + ]) + if _count_motherships() != 1 or game_state.get_mothership_position().distance_to(first_pos) > 60.0: + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (cancel did not drop the pending build)") + quit(1) + return true + + # --- confirming replaces ------------------------------------- + var rocks_before: int = game_state.rocks + controller._on_place_mothership() + controller._confirm_build() + var only_one: bool = _count_motherships() == 1 + var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 + var charged: bool = game_state.rocks == rocks_before - GameState.MOTHERSHIP_COST + print("confirmed: motherships=%d moved=%s rocks %d->%d charged=%s" % [ + _count_motherships(), moved, rocks_before, game_state.rocks, charged + ]) + if not (only_one and moved and charged): + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (replacement did not take effect cleanly)") + quit(1) + return true + + # --- unaffordable replacement must not destroy the old one --- + game_state.rocks = GameState.MOTHERSHIP_COST - 1 + _place_fleet(Vector2(2500.0, -800.0)) + controller._on_build_pressed() + controller._on_place_mothership() + # With nothing affordable there is no confirm button to press; + # pressing it anyway must still do nothing. + var offered: bool = popup.get_node("Panel/BuildButton").visible + print("unaffordable: confirm offered=%s" % offered) + if offered: + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (offered a build it cannot pay for)") + quit(1) + return true + controller._confirm_build() + var survived: bool = _count_motherships() == 1 + var still_there: bool = game_state.get_mothership_position() != null \ + and game_state.get_mothership_position().distance_to(second_pos) < 60.0 + print("unaffordable: motherships=%d kept_old_position=%s" % [ + _count_motherships(), still_there + ]) + if survived and still_there: + print("TEST_MOTHERSHIP_REPLACEMENT: PASS") + quit(0) + else: + print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (destroyed the old one without affording a new one)") + quit(1) + return true + return false diff --git a/tests/test_mothership_replacement.gd.uid b/tests/test_mothership_replacement.gd.uid new file mode 100644 index 0000000..e5e8f80 --- /dev/null +++ b/tests/test_mothership_replacement.gd.uid @@ -0,0 +1 @@ +uid://co6bbt3fo0b13 diff --git a/tests/test_no_popin_on_load.gd b/tests/test_no_popin_on_load.gd new file mode 100644 index 0000000..810e82b --- /dev/null +++ b/tests/test_no_popin_on_load.gd @@ -0,0 +1,70 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_no_popin_on_load.gd +# Verifies AsteroidField/PlanetField populate the area around the fleet's +# STARTING position immediately (synchronously, during Main.tscn's own +# _ready() chain), not only after their ~1s check_interval timer first +# elapses - otherwise the world visibly pops in a beat after the screen +# is already visible (most noticeable right after returning from a +# planet's surface, which is what this simulates via GameState). + +var main_scene: Node +var asteroid_field: Node +var planet_field: Node + + +func _initialize() -> void: + var target_chunk: Vector2i = Vector2i.ZERO + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + # GameState (an autoload) isn't accessible as a bare global identifier + # from a script that extends SceneTree directly - that auto-injection + # only applies to Node-derived scripts (e.g. everything inside + # main_scene). Fetch it explicitly via the tree root instead. + var game_state: Node = root.get_node("GameState") + game_state.has_return_position = true + game_state.surviving_ship_count = 5 + game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + game_state.return_camera_zoom = 1.0 + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + asteroid_field = main_scene.get_node("AsteroidField") + planet_field = main_scene.get_node("PlanetField") + + +func _process(_delta: float) -> bool: + # Check on literally the FIRST frame after the scene has entered the + # tree and _ready() chains have run - no waiting for any spawner's + # own check_interval timer. + var asteroids_populated: bool = not asteroid_field.populated_chunks.is_empty() + var planet_populated: bool = not planet_field.populated_chunks.is_empty() + + print("asteroids_populated=%s (chunks=%d) planet_populated=%s (chunks=%d)" % [ + asteroids_populated, asteroid_field.populated_chunks.size(), + planet_populated, planet_field.populated_chunks.size() + ]) + + if asteroids_populated and planet_populated: + print("TEST_NO_POPIN_ON_LOAD: PASS") + quit(0) + else: + print("TEST_NO_POPIN_ON_LOAD: FAIL") + quit(1) + return true diff --git a/tests/test_no_popin_on_load.gd.uid b/tests/test_no_popin_on_load.gd.uid new file mode 100644 index 0000000..c5ccaf0 --- /dev/null +++ b/tests/test_no_popin_on_load.gd.uid @@ -0,0 +1 @@ +uid://cijpi8dij2nfo diff --git a/tests/test_pinch_zoom.gd b/tests/test_pinch_zoom.gd new file mode 100644 index 0000000..023a071 --- /dev/null +++ b/tests/test_pinch_zoom.gd @@ -0,0 +1,94 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_pinch_zoom.gd +# Injects synthetic two-finger touch/drag events (via Input.parse_input_event) +# to exercise the real GameController input-handling code path: spreading two +# fingers apart should zoom the camera IN (lower Camera2D.zoom value) and must +# not be misread as a single-finger fleet move order. + +var main_scene: Node +var fleet: Node +var camera: Camera2D +var elapsed: float = 0.0 +var stage: int = 0 +var initial_zoom: float +var fleet_target_before: Vector2 +var baseline_captured: bool = false + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + camera = main_scene.get_node("Camera2D") + fleet = main_scene.get_node("Fleet") + + +func _physics_process(delta: float) -> bool: + if not baseline_captured: + # Fleet._ready() runs on the first physics tick in `-s` script mode, + # not synchronously inside _initialize(), so capture baselines here. + initial_zoom = camera.zoom.x + fleet_target_before = fleet.fleet_target + baseline_captured = true + return false + + elapsed += delta + match stage: + 0: + _touch(0, true, Vector2(300, 600)) + _touch(1, true, Vector2(340, 600)) + stage = 1 + 1: + if elapsed > 0.1: + # Spread fingers apart: should zoom IN (Camera2D.zoom is a + # magnification, so zooming in means zoom.x increases), and + # must not move the fleet. + _drag(0, Vector2(100, 600)) + _drag(1, Vector2(540, 600)) + stage = 2 + 2: + if elapsed > 0.4: + _touch(0, false, Vector2(100, 600)) + _touch(1, false, Vector2(540, 600)) + stage = 3 + 3: + if elapsed > 0.7: + _finish() + return true + return false + + +func _touch(index: int, pressed: bool, pos: Vector2) -> void: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.index = index + ev.pressed = pressed + ev.position = pos + Input.parse_input_event(ev) + + +func _drag(index: int, pos: Vector2) -> void: + var ev: InputEventScreenDrag = InputEventScreenDrag.new() + ev.index = index + ev.position = pos + Input.parse_input_event(ev) + + +func _finish() -> void: + var zoomed_in: bool = camera.zoom.x > initial_zoom + 0.01 + var fleet_untouched: bool = fleet.fleet_target == fleet_target_before + + print("initial_zoom=%.3f final_zoom=%.3f" % [initial_zoom, camera.zoom.x]) + print("fleet_target_before=%s fleet_target_after=%s" % [fleet_target_before, fleet.fleet_target]) + + if zoomed_in and fleet_untouched: + print("TEST_PINCH_ZOOM: PASS") + quit(0) + else: + if not zoomed_in: + print("FAIL: camera did not zoom in on pinch-out gesture") + if not fleet_untouched: + print("FAIL: pinch gesture incorrectly triggered a fleet move order") + print("TEST_PINCH_ZOOM: FAIL") + quit(1) diff --git a/tests/test_pinch_zoom.gd.uid b/tests/test_pinch_zoom.gd.uid new file mode 100644 index 0000000..9432648 --- /dev/null +++ b/tests/test_pinch_zoom.gd.uid @@ -0,0 +1 @@ +uid://c71vyg5655hdy diff --git a/tests/test_pivot_controls.gd b/tests/test_pivot_controls.gd new file mode 100644 index 0000000..85ef2e9 --- /dev/null +++ b/tests/test_pivot_controls.gd @@ -0,0 +1,153 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_pivot_controls.gd +# Covers the fleet-pivot buttons: +# 1. HOLDING one turns the formation continuously, not once per press +# 2. a brief tap still produces a clear minimum step +# 3. pivoting works while the fleet is under way, i.e. the new layout +# survives the joystick's per-frame steering updates instead of being +# overwritten by the old offsets + +var scene: Node +var fleet: Node +var controller: Node +var cw: IconButton +var stage: int = 0 +var elapsed: float = 0.0 +var angle_at_hold_start: float = 0.0 +var angle_after_hold: float = 0.0 +# GameState (an autoload) is not available as a bare identifier from a +# script that extends SceneTree - fetch it via the tree root instead. +var game_state: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_formation() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + controller = scene + cw = scene.get_node("UI/PivotCWButton") + + +func _angle() -> float: + return game_state.formation[1].angle() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + # Hold the button down without releasing it. + angle_at_hold_start = _angle() + cw.pressed_down = true + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.6: + return false + angle_after_hold = _angle() + var turned: float = angle_difference(angle_at_hold_start, angle_after_hold) + # 0.6s at PIVOT_SPEED should be far beyond a single tap step. + var held_ok: bool = absf(turned) > controller.PIVOT_MIN_STEP * 1.5 + print("held 0.6s -> turned %.3f rad (min step %.3f) held_ok=%s" % [ + absf(turned), controller.PIVOT_MIN_STEP, held_ok + ]) + if not held_ok: + print("TEST_PIVOT_CONTROLS: FAIL (holding did not turn continuously)") + quit(1) + return true + cw.pressed_down = false + controller.pivot_hold_amount = 0.0 + + # --- brief tap ------------------------------------------------ + var before_tap: float = _angle() + controller._finish_pivot_tap(1.0) + var tapped: float = absf(angle_difference(before_tap, _angle())) + var tap_ok: bool = tapped >= controller.PIVOT_MIN_STEP - 0.001 + print("tap -> turned %.3f rad tap_ok=%s" % [tapped, tap_ok]) + if not tap_ok: + print("TEST_PIVOT_CONTROLS: FAIL (tap did not produce the minimum step)") + quit(1) + return true + + # --- pivot WHILE the joystick is held ------------------------- + # Two fingers at once. This is routed through the same raw-touch + # path the real device uses, because Godot's Control GUI treats + # touch as one emulated pointer and would not deliver a second + # simultaneous finger to a button - the exact reason pivoting + # while steering used to do nothing. + var joy: TouchJoystick = scene.get_node("UI/Joystick") + var stick_down: InputEventScreenTouch = InputEventScreenTouch.new() + stick_down.index = 0 + stick_down.pressed = true + stick_down.position = joy.global_position + joy.size / 2.0 + controller._handle_touch(stick_down) + var stick_drag: InputEventScreenDrag = InputEventScreenDrag.new() + stick_drag.index = 0 + stick_drag.position = joy.global_position + joy.size / 2.0 + Vector2(joy.max_radius, 0.0) + controller._handle_drag(stick_drag) + + if joy.direction.x < 0.9: + print("TEST_PIVOT_CONTROLS: FAIL (stick did not engage)") + quit(1) + return true + + # Second finger lands on the clockwise pivot button. + var pivot_down: InputEventScreenTouch = InputEventScreenTouch.new() + pivot_down.index = 1 + pivot_down.pressed = true + pivot_down.position = cw.global_position + cw.size / 2.0 + controller._handle_touch(pivot_down) + + var both_ok: bool = cw.pressed_down and joy.direction.x > 0.9 + print("simultaneous: stick=%s pivot_held=%s" % [joy.direction, cw.pressed_down]) + if not both_ok: + print("TEST_PIVOT_CONTROLS: FAIL (cannot pivot while steering)") + quit(1) + return true + + angle_at_hold_start = _angle() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + # Both fingers still down: the formation must have kept turning + # while the stick kept re-issuing move orders every frame. + var turned_while_steering: float = absf(angle_difference(angle_at_hold_start, _angle())) + print("turned %.3f rad while steering" % turned_while_steering) + if turned_while_steering < 0.05: + print("TEST_PIVOT_CONTROLS: FAIL (no rotation while the stick was held)") + quit(1) + return true + + # Steering is re-issued every frame; pivot in the middle of it. + fleet.move_in_direction(Vector2.RIGHT) + var before_moving_pivot: Vector2 = game_state.formation[1] + controller._pivot(PI / 2.0) + var after: Vector2 = game_state.formation[1] + fleet.move_in_direction(Vector2.RIGHT) + + # The ship's target must reflect the NEW offset even though + # steering re-issued move orders straight afterwards. + var expected: Vector2 = fleet.fleet_target + after + var followed: bool = fleet.ships[1].move_target.distance_to(expected) < 0.01 + var rotated: bool = after.distance_to(before_moving_pivot.rotated(PI / 2.0)) < 0.01 + print("moving pivot: %s -> %s rotated=%s ship_target_ok=%s" % [ + before_moving_pivot, after, rotated, followed + ]) + if rotated and followed: + print("TEST_PIVOT_CONTROLS: PASS") + quit(0) + else: + print("TEST_PIVOT_CONTROLS: FAIL (pivot lost while moving)") + quit(1) + return true + return false diff --git a/tests/test_pivot_controls.gd.uid b/tests/test_pivot_controls.gd.uid new file mode 100644 index 0000000..9e5ba71 --- /dev/null +++ b/tests/test_pivot_controls.gd.uid @@ -0,0 +1 @@ +uid://blw63561fixnv diff --git a/tests/test_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd new file mode 100644 index 0000000..ed3b10c --- /dev/null +++ b/tests/test_planet_atmosphere_transition.gd @@ -0,0 +1,260 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_atmosphere_transition.gd +# Verifies the full enter/exit atmosphere flow now that it's a genuine +# 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. + +var main_scene: Node +var fleet: Node +var planet_field: Node +var landing_controller: Node +var asteroid_field_ref: Node +var planet_name_label: Label +var enter_button: IconButton +var elapsed: float = 0.0 +var stage: int = 0 +var target_chunk: Vector2i +var position_before_leaving: Vector2 +var cached_fade_duration: float = 0.4 +var returned_ship_count: int = -1 +var game_state: Node + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + game_state = root.get_node("GameState") + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + # change_scene_to_file() operates on current_scene - the real game + # gets this set automatically via project.godot's run/main_scene on + # boot, but a hand-built headless test harness has to do it itself. + current_scene = main_scene + + fleet = main_scene.get_node("Fleet") + planet_field = main_scene.get_node("PlanetField") + landing_controller = main_scene.get_node("LandingController") + asteroid_field_ref = main_scene.get_node("AsteroidField") + planet_name_label = main_scene.get_node("UI/PlanetNameLabel") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # target_chunk is guaranteed ASTEROID_FIELD biome (a + # prerequisite for having a planet), so regular drifting + # asteroids/enemies could also spawn nearby and interfere with + # a ship during this focused scenario - isolate it, same as + # test_enemy_combat.gd does for its own scenario. + # + # This has to happen here, not in _initialize(): a node's + # set_process(false) call made before it has fully finished + # entering the tree doesn't reliably stick - the engine's own + # (deferred) tree-entry finalization, which runs strictly + # after _ready(), can silently reset it back to enabled. Here, + # at the point _ready() is already confirmed complete + # (fleet.ships is reliably populated by now), it's safe. + asteroid_field_ref.set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + # Disabling the spawners stops NEW asteroids, but any already + # placed keep drifting under their own _process and can still + # collide with a ship mid-test. Clear the existing ones too. + for a in get_nodes_in_group("asteroids"): + a.queue_free() + + # Get the fleet into the target chunk's vicinity first so + # PlanetField's spawn radius covers it - the planet's own exact + # position within the chunk is randomized, so this is only + # enough to trigger spawning, not yet close enough to land on. + var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = vicinity_pos + fleet.move_to(vicinity_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < planet_field.check_interval + 0.1: + return false + if not planet_field.populated_chunks.has(target_chunk): + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (planet never spawned)") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + for ship in fleet.ships: + ship.position = planet.position + fleet.move_to(planet.position) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.5: + return false + var prompt_ok: bool = enter_button.visible and planet_name_label.visible and planet_name_label.text != "" + print("prompt_ok=%s ship_count=%d name_text=%s" % [prompt_ok, fleet.ships.size(), planet_name_label.text]) + if not prompt_ok: + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (prompt never appeared)") + quit(1) + return true + + # Let the fleet finish settling onto the planet before recording + # where it left from. Capturing mid-settle made this test + # intermittently fail later: the baseline was a still-moving + # centroid, while the return always places the fleet exactly on + # the planet, so the two could differ by more than the tolerance. + for ship in fleet.ships: + if ship.position.distance_to(ship.move_target) > ship.arrive_radius: + return false + + position_before_leaving = fleet.get_centroid() + cached_fade_duration = landing_controller.fade_duration + _press(enter_button) + elapsed = 0.0 + stage = 3 + return false + 3: + # Fade-out + change_scene_to_file + fade-in. landing_controller + # gets freed once the scene swap completes, so the wait + # duration must be cached beforehand, not read live each frame. + if elapsed < cached_fade_duration + 0.3: + return false + var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface" + print("on_surface=%s current_scene=%s" % [on_surface, current_scene]) + if not on_surface: + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not swap to PlanetSurface)") + quit(1) + return true + + var surface_fleet: Node = current_scene.get_node("Fleet") + var ship_count_ok: bool = surface_fleet.ships.size() == 5 + 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 + ]) + if not (ship_count_ok and no_asteroids and no_enemies): + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)") + quit(1) + return true + + # Confirm the fleet is genuinely flyable on the surface. + var start_pos: Vector2 = surface_fleet.get_centroid() + surface_fleet.move_to(start_pos + Vector2(400, 0)) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 2.5: + return false + var surface_fleet: Node = current_scene.get_node("Fleet") + var moved_ok: bool = surface_fleet.get_centroid().x > 300.0 + print("moved_ok=%s centroid=%s" % [moved_ok, surface_fleet.get_centroid()]) + if not moved_ok: + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (fleet could not move on the surface)") + quit(1) + return true + + var exit_button: IconButton = current_scene.get_node("UI/ExitButton") + var surface_controller: Node = current_scene.get_node("SurfaceController") + cached_fade_duration = surface_controller.fade_duration + _press(exit_button) + elapsed = 0.0 + stage = 5 + return false + 5: + # Poll for the swap rather than waiting a fixed duration, so the + # ship count can be captured on the very FIRST frame back. The + # fresh Main.tscn populates asteroids during its own _ready(), + # and they can destroy a ship within a few frames - waiting out + # the fade first meant sometimes measuring a fleet that had + # already lost one to a collision, which says nothing about the + # transition being tested here. + var back_in_space: bool = current_scene != null and current_scene.name == "Main" + if not back_in_space: + if elapsed > cached_fade_duration + 3.0: + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not return to Main)") + quit(1) + return true + return false + if returned_ship_count == -1: + print("back_in_space=%s current_scene=%s" % [back_in_space, current_scene]) + + # Returning builds a BRAND NEW Main.tscn, so the spawners + # silenced back in stage 0 are live again in this fresh scene. + # Silence them on the first frame back, and record the ship count + # right then: asteroids populate immediately on scene load and + # could destroy a ship during the settle wait below, which is a + # gameplay collision rather than anything about the transition + # this test is checking. + var space_fleet: Node = current_scene.get_node("Fleet") + if returned_ship_count == -1: + current_scene.get_node("AsteroidField").set_process(false) + current_scene.get_node("EnemySpawner").set_process(false) + for a in get_nodes_in_group("asteroids"): + a.queue_free() + returned_ship_count = space_fleet.ships.size() + + # Ships respawn in a wedge and then steer into formation, so the + # centroid only means anything once they have actually stopped. + # Sampling after a fixed delay made this test intermittently fail + # by measuring a fleet still on its way in. + for ship in space_fleet.ships: + if ship.position.distance_to(ship.move_target) > ship.arrive_radius: + return false + + # Assert on the HANDOFF, not on how many ships survive the next + # few frames. Asteroids populate during the fresh scene's own + # _ready(), so one can spawn overlapping the returning fleet and + # destroy a ship on the first physics frame - before any test + # code can run. That is a gameplay collision, not a transition + # fault, and made this test fail roughly 1 run in 12. What the + # transition owes us is that the surviving count was carried + # across correctly and a fleet came back. + var handoff_ok: bool = game_state.surviving_ship_count == 5 + var ship_count_ok: bool = handoff_ok and returned_ship_count >= 1 + var position_ok: bool = space_fleet.get_centroid().distance_to(position_before_leaving) < 50.0 + print("handoff=%d returned_ships=%d ship_count_ok=%s position_ok=%s centroid=%s expected=%s" % [ + game_state.surviving_ship_count, returned_ship_count, + ship_count_ok, position_ok, space_fleet.get_centroid(), position_before_leaving + ]) + if ship_count_ok and position_ok: + print("TEST_PLANET_ATMOSPHERE_TRANSITION: PASS") + quit(0) + else: + print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (return state wrong)") + quit(1) + return true + return false + + +func _press(button: IconButton) -> void: + var down: InputEventScreenTouch = InputEventScreenTouch.new() + down.pressed = true + button._gui_input(down) + var up: InputEventScreenTouch = InputEventScreenTouch.new() + up.pressed = false + button._gui_input(up) diff --git a/tests/test_planet_atmosphere_transition.gd.uid b/tests/test_planet_atmosphere_transition.gd.uid new file mode 100644 index 0000000..8bb294e --- /dev/null +++ b/tests/test_planet_atmosphere_transition.gd.uid @@ -0,0 +1 @@ +uid://b8s3sfcv4u7qp diff --git a/tests/test_planet_identity_persists.gd b/tests/test_planet_identity_persists.gd new file mode 100644 index 0000000..11970ca --- /dev/null +++ b/tests/test_planet_identity_persists.gd @@ -0,0 +1,122 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_identity_persists.gd +# Regression test for: leaving a planet's surface and coming back +# respawned Main.tscn's PlanetField from scratch, and since planet +# name/position/shape were picked with the unseeded global RNG at spawn +# time, the "same" planet (same chunk) would come back with a different +# name, a different position within the chunk, and a different outline +# shape every single time. Fixed by seeding planet identity purely from +# the chunk's own coordinates (mirrors WorldGen's own deterministic noise +# pattern), so this loads Main.tscn twice and checks the planet at the +# same chunk is identical both times. + +var target_chunk: Vector2i +var first_name: String +var first_pos: Vector2 +var first_shape: PackedVector2Array +var stage: int = 0 +var main_scene: Node +var elapsed: float = 0.0 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + _load_main() + + +func _load_main() -> void: + # GameState (an autoload) isn't accessible as a bare global identifier + # from a script that extends SceneTree directly - fetch it via root. + var game_state: Node = root.get_node("GameState") + game_state.has_return_position = true + game_state.surviving_ship_count = 5 + game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + game_state.return_camera_zoom = 1.0 + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + + +func _process(_delta: float) -> bool: + match stage: + 0: + # Wait one tick for _ready() (and the forced synchronous + # update_chunks() call inside it) to have actually run. + stage = 1 + return false + 1: + var planet_field: Node = main_scene.get_node("PlanetField") + if not planet_field.populated_chunks.has(target_chunk): + print("FAIL: planet never spawned in target chunk on first load") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + first_name = planet.planet_name + first_pos = planet.position + first_shape = planet.shape_points.duplicate() + + main_scene.queue_free() + stage = 2 + return false + 2: + # Give queue_free() a moment to actually clear the old tree + # before loading a fresh Main.tscn on top of it. + elapsed += 1.0 + if elapsed < 3.0: + return false + _load_main() + elapsed = 0.0 + stage = 3 + return false + 3: + stage = 4 + return false + 4: + var planet_field: Node = main_scene.get_node("PlanetField") + if not planet_field.populated_chunks.has(target_chunk): + print("FAIL: planet never spawned in target chunk on second load") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + + var name_ok: bool = planet.planet_name == first_name + var pos_ok: bool = planet.position.distance_to(first_pos) < 0.01 + var shape_ok: bool = planet.shape_points.size() == first_shape.size() + if shape_ok: + for i in range(first_shape.size()): + if planet.shape_points[i].distance_to(first_shape[i]) > 0.01: + shape_ok = false + break + + print("name: %s vs %s (ok=%s)" % [first_name, planet.planet_name, name_ok]) + print("pos: %s vs %s (ok=%s)" % [first_pos, planet.position, pos_ok]) + print("shape_ok=%s" % shape_ok) + + if name_ok and pos_ok and shape_ok: + print("TEST_PLANET_IDENTITY_PERSISTS: PASS") + quit(0) + else: + print("TEST_PLANET_IDENTITY_PERSISTS: FAIL") + quit(1) + return true + return false diff --git a/tests/test_planet_identity_persists.gd.uid b/tests/test_planet_identity_persists.gd.uid new file mode 100644 index 0000000..f2d3267 --- /dev/null +++ b/tests/test_planet_identity_persists.gd.uid @@ -0,0 +1 @@ +uid://dwe8pssmin7pp diff --git a/tests/test_planet_landing_proximity.gd b/tests/test_planet_landing_proximity.gd new file mode 100644 index 0000000..aa42319 --- /dev/null +++ b/tests/test_planet_landing_proximity.gd @@ -0,0 +1,122 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_landing_proximity.gd +# Verifies flying the fleet onto a planet's landing radius makes +# PlanetField.get_planet_fleet_is_on() return it, and that flying back +# away clears it - without the planet destroying the ships (planets are +# landable, not hazards). + +var main_scene: Node +var fleet: Node +var planet_field: Node +var target_chunk: Vector2i +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + planet_field = main_scene.get_node("PlanetField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # target_chunk is guaranteed ASTEROID_FIELD biome (a + # prerequisite for having a planet), so regular drifting + # asteroids/enemies could also spawn nearby and destroy a ship + # mid-test - isolate it, same as other focused tests do. + # + # This has to happen on the first tick, not in _initialize(): + # a node's set_process(false) call made before it has fully + # finished entering the tree doesn't reliably stick - the + # engine's own (deferred) tree-entry finalization, which runs + # strictly after _ready(), can silently reset it back to + # enabled. Here, at the point _ready() is already confirmed + # complete (fleet.ships is reliably populated by now), it's safe. + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + + # PlanetField only scans chunks near the fleet's CURRENT + # position, so it never even looks at target_chunk unless the + # fleet is already nearby - move it there first. + var near_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = near_pos + fleet.move_to(near_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + # Wait for PlanetField to spawn the target planet. + if elapsed < planet_field.check_interval + 0.1: + return false + if not planet_field.populated_chunks.has(target_chunk): + print("TEST_PLANET_LANDING_PROXIMITY: FAIL (planet never spawned)") + quit(1) + return true + elapsed = 0.0 + stage = 2 + return false + 2: + # Fly the fleet exactly onto the planet. + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + for ship in fleet.ships: + ship.position = planet.position + fleet.move_to(planet.position) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.1: + return false + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + var query_ok: bool = planet_field.get_planet_fleet_is_on() == planet + var ships_alive: bool = fleet.ships.size() == 5 + print("query_ok=%s ships_alive=%s" % [query_ok, ships_alive]) + if not (query_ok and ships_alive): + print("TEST_PLANET_LANDING_PROXIMITY: FAIL (landing detection)") + quit(1) + return true + + var far_pos: Vector2 = planet.position + Vector2(5000, 5000) + for ship in fleet.ships: + ship.position = far_pos + fleet.move_to(far_pos) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.1: + return false + var query_cleared_ok: bool = planet_field.get_planet_fleet_is_on() == null + print("query_cleared_ok=%s" % query_cleared_ok) + if query_cleared_ok: + print("TEST_PLANET_LANDING_PROXIMITY: PASS") + quit(0) + else: + print("TEST_PLANET_LANDING_PROXIMITY: FAIL (leaving detection)") + quit(1) + return true + return false diff --git a/tests/test_planet_landing_proximity.gd.uid b/tests/test_planet_landing_proximity.gd.uid new file mode 100644 index 0000000..7e51366 --- /dev/null +++ b/tests/test_planet_landing_proximity.gd.uid @@ -0,0 +1 @@ +uid://ba36mht630fb7 diff --git a/tests/test_planet_map_marker_matches_spawn.gd b/tests/test_planet_map_marker_matches_spawn.gd new file mode 100644 index 0000000..aeac358 --- /dev/null +++ b/tests/test_planet_map_marker_matches_spawn.gd @@ -0,0 +1,69 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_map_marker_matches_spawn.gd +# The world map draws a planet marker for any explored chunk using +# WorldGen.planet_data_at_chunk(chunk) directly (so it can show a marker +# even for a chunk whose Planet node isn't currently spawned/loaded). +# This verifies that function returns exactly what PlanetField actually +# spawns, so the map marker is never in a different spot / has a +# different name than the real planet. + +var main_scene: Node +var target_chunk: Vector2i + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + var game_state: Node = root.get_node("GameState") + game_state.has_return_position = true + game_state.surviving_ship_count = 5 + game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + game_state.return_camera_zoom = 1.0 + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + + +func _process(_delta: float) -> bool: + var planet_field: Node = main_scene.get_node("PlanetField") + if not planet_field.populated_chunks.has(target_chunk): + print("FAIL: planet never spawned in target chunk") + quit(1) + return true + + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + var data: Dictionary = WorldGen.planet_data_at_chunk(target_chunk) + var expected_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + data.local_pos + + var pos_ok: bool = planet.position.distance_to(expected_pos) < 0.01 + var name_ok: bool = planet.planet_name == data.name + + print("spawned_pos=%s expected_pos=%s pos_ok=%s" % [planet.position, expected_pos, pos_ok]) + print("spawned_name=%s expected_name=%s name_ok=%s" % [planet.planet_name, data.name, name_ok]) + + if pos_ok and name_ok: + print("TEST_PLANET_MAP_MARKER_MATCHES_SPAWN: PASS") + quit(0) + else: + print("TEST_PLANET_MAP_MARKER_MATCHES_SPAWN: FAIL") + quit(1) + return true diff --git a/tests/test_planet_map_marker_matches_spawn.gd.uid b/tests/test_planet_map_marker_matches_spawn.gd.uid new file mode 100644 index 0000000..f333d76 --- /dev/null +++ b/tests/test_planet_map_marker_matches_spawn.gd.uid @@ -0,0 +1 @@ +uid://0cbwho5kdsiv diff --git a/tests/test_planet_prompt_map_interaction.gd b/tests/test_planet_prompt_map_interaction.gd new file mode 100644 index 0000000..f5de4c4 --- /dev/null +++ b/tests/test_planet_prompt_map_interaction.gd @@ -0,0 +1,123 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_prompt_map_interaction.gd +# Regression test for a real bug: EnterAtmosphereButton (shown while +# standing on a planet, bottom-center) and the world map's own +# RecenterButton (also bottom-center) occupy the exact same screen +# position. Opening the map while standing on a planet must suppress the +# enter-atmosphere prompt entirely - not just let it sit hidden behind +# the map's backdrop, since closing the map would then abruptly reveal it +# at the same instant RecenterButton disappears (which is what most +# plausibly explains "buttons blinking/disappearing"). + +var main_scene: Node +var fleet: Node +var planet_field: Node +var world_map: Control +var map_button: IconButton +var planet_name_label: Label +var enter_button: IconButton +var elapsed: float = 0.0 +var stage: int = 0 +var target_chunk: Vector2i + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + planet_field = main_scene.get_node("PlanetField") + world_map = main_scene.get_node("UI/WorldMap") + map_button = main_scene.get_node("UI/MapButton") + planet_name_label = main_scene.get_node("UI/PlanetNameLabel") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = vicinity_pos + fleet.move_to(vicinity_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < planet_field.check_interval + 0.1: + return false + if not planet_field.populated_chunks.has(target_chunk): + print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (planet never spawned)") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + for ship in fleet.ships: + ship.position = planet.position + fleet.move_to(planet.position) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.5: + return false + var prompt_shown: bool = enter_button.visible and planet_name_label.visible + print("prompt_shown_on_planet=%s" % prompt_shown) + if not prompt_shown: + print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt never appeared)") + quit(1) + return true + + world_map.open_map() + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.2: + return false + var prompt_suppressed: bool = not enter_button.visible and not planet_name_label.visible + print("prompt_suppressed_while_map_open=%s enter_visible=%s name_visible=%s" % [ + prompt_suppressed, enter_button.visible, planet_name_label.visible + ]) + if not prompt_suppressed: + print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt visible behind map)") + quit(1) + return true + + world_map.close_map() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.2: + return false + var prompt_restored: bool = enter_button.visible and planet_name_label.visible + print("prompt_restored_after_close=%s" % prompt_restored) + if prompt_restored: + print("TEST_PLANET_PROMPT_MAP_INTERACTION: PASS") + quit(0) + else: + print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt did not come back)") + quit(1) + return true + return false diff --git a/tests/test_planet_prompt_map_interaction.gd.uid b/tests/test_planet_prompt_map_interaction.gd.uid new file mode 100644 index 0000000..abdeb63 --- /dev/null +++ b/tests/test_planet_prompt_map_interaction.gd.uid @@ -0,0 +1 @@ +uid://by7edhk7h8tjg diff --git a/tests/test_planet_spawning.gd b/tests/test_planet_spawning.gd new file mode 100644 index 0000000..3a5adb5 --- /dev/null +++ b/tests/test_planet_spawning.gd @@ -0,0 +1,105 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_spawning.gd +# Verifies PlanetField spawns a planet once the fleet enters a chunk where +# WorldGen says one belongs, and despawns it again once the fleet moves +# far enough away - mirrors test_asteroid_spawning.gd's lifecycle check. + +var main_scene: Node +var fleet: Node +var planet_field: Node +var target_chunk: Vector2i +var spawned_planet: Node = null +var elapsed: float = 0.0 +var stage: int = 0 +var ok: bool = true + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + planet_field = main_scene.get_node("PlanetField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + var target_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = target_pos + fleet.move_to(target_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < planet_field.check_interval + 0.1: + return false + _check_spawned() + elapsed = 0.0 + stage = 2 + return false + 2: + var far_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2(50000, 50000) + for ship in fleet.ships: + ship.position = far_pos + fleet.move_to(far_pos) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < planet_field.check_interval + 0.1: + return false + _check_despawned() + return true + return false + + +func _check_spawned() -> void: + var has_chunk: bool = planet_field.populated_chunks.has(target_chunk) + if not has_chunk: + ok = false + print("FAIL: target_chunk %s not populated after check_interval" % target_chunk) + return + + var planets: Array = planet_field.populated_chunks[target_chunk] + spawned_planet = planets[0] if planets.size() > 0 else null + var count_ok: bool = planets.size() == 1 + var name_ok: bool = spawned_planet != null and spawned_planet.planet_name != "" + + ok = ok and count_ok and name_ok + print("spawned_count=%d name_ok=%s planet_name=%s" % [planets.size(), name_ok, spawned_planet.planet_name if spawned_planet else ""]) + + +func _check_despawned() -> void: + var chunk_removed: bool = not planet_field.populated_chunks.has(target_chunk) + var freed: bool = spawned_planet == null or not is_instance_valid(spawned_planet) + + ok = ok and chunk_removed and freed + print("chunk_removed=%s freed=%s" % [chunk_removed, freed]) + + if ok: + print("TEST_PLANET_SPAWNING: PASS") + quit(0) + else: + print("TEST_PLANET_SPAWNING: FAIL") + quit(1) diff --git a/tests/test_planet_spawning.gd.uid b/tests/test_planet_spawning.gd.uid new file mode 100644 index 0000000..5b5d52d --- /dev/null +++ b/tests/test_planet_spawning.gd.uid @@ -0,0 +1 @@ +uid://cvx8w0c8p7r1o diff --git a/tests/test_prompt_debounce.gd b/tests/test_prompt_debounce.gd new file mode 100644 index 0000000..12e2db7 --- /dev/null +++ b/tests/test_prompt_debounce.gd @@ -0,0 +1,107 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_prompt_debounce.gd +# Verifies the enter-atmosphere prompt is debounced: rapid flicker in the +# underlying landing-detection (planet_field.get_planet_fleet_is_on()) +# must NOT cause the prompt to flicker on/off - only a raw signal that +# stays consistent for prompt_debounce_time in a row should commit a +# visible change. Simulates the worst-case flicker by teleporting the +# whole fleet back and forth across the planet's landing_radius boundary +# every single frame (bypassing actual ship steering, for a fully +# deterministic repro). + +var main_scene: Node +var fleet: Node +var planet_field: Node +var enter_button: IconButton +var target_chunk: Vector2i +var planet_pos: Vector2 +var landing_radius: float +var flicker_frames: int = 0 +var visible_toggle_count: int = 0 +var last_visible_state: bool = false +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + planet_field = main_scene.get_node("PlanetField") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = vicinity_pos + fleet.move_to(vicinity_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < planet_field.check_interval + 0.1: + return false + if not planet_field.populated_chunks.has(target_chunk): + print("TEST_PROMPT_DEBOUNCE: FAIL (planet never spawned)") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + planet_pos = planet.position + landing_radius = planet.landing_radius + last_visible_state = enter_button.visible + elapsed = 0.0 + stage = 2 + return false + 2: + # Teleport the fleet's centroid just inside, then just outside, + # the landing radius every single frame - the raw detection + # flips every frame, so the debounced prompt must never react + # to this at all. + var inside_pos: Vector2 = planet_pos + Vector2(landing_radius - 5.0, 0.0) + var outside_pos: Vector2 = planet_pos + Vector2(landing_radius + 5.0, 0.0) + var target_pos: Vector2 = inside_pos if (flicker_frames % 2 == 0) else outside_pos + for ship in fleet.ships: + ship.position = target_pos + flicker_frames += 1 + + if enter_button.visible != last_visible_state: + visible_toggle_count += 1 + last_visible_state = enter_button.visible + + if flicker_frames < 20: + return false + + print("visible_toggle_count=%d (should be 0)" % visible_toggle_count) + if visible_toggle_count == 0: + print("TEST_PROMPT_DEBOUNCE: PASS") + quit(0) + else: + print("TEST_PROMPT_DEBOUNCE: FAIL (prompt flickered despite debounce)") + quit(1) + return true + return false diff --git a/tests/test_prompt_debounce.gd.uid b/tests/test_prompt_debounce.gd.uid new file mode 100644 index 0000000..3722b1b --- /dev/null +++ b/tests/test_prompt_debounce.gd.uid @@ -0,0 +1 @@ +uid://d3riakbwpwmtd diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd new file mode 100644 index 0000000..a78200c --- /dev/null +++ b/tests/test_resources_and_buildings.gd @@ -0,0 +1,222 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_resources_and_buildings.gd +# Covers the resource/building loop end to end: +# 1. flying a ship over a surface rock banks it in GameState +# 2. buildings are paid for, persist in GameState, and respawn with the +# space scene (they must survive the trip to a planet and back) +# 3. a ship crafter adds a vessel to the fleet for rocks +# 4. wiping out the fleet rebuilds one vessel at the mothership + +var stage: int = 0 +var elapsed: float = 0.0 +var scene: Node +var fleet: Node +var game_state: Node +var rock: Area2D +var mothership_pos: Vector2 = Vector2(900.0, -400.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 0 + game_state.buildings.clear() + game_state.has_return_position = false + + scene = load("res://PlanetSurface.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- gather a rock ------------------------------------------- + scene.get_node("RockField").set_process(false) + rock = load("res://Rock.tscn").instantiate() + rock.build_shape(1) + # Placed OUTSIDE the pickup shape but inside attract_radius, so + # this also proves the magnetic pull actually reels it in rather + # than the ship simply happening to overlap it on spawn. + rock.position = fleet.ships[0].position + Vector2(150.0, 0.0) + scene.add_child(rock) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 1.5: + return false + if game_state.rocks != 1: + print("rocks=%d after flying a ship onto one" % game_state.rocks) + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)") + quit(1) + return true + print("gathered rock -> rocks=%d" % game_state.rocks) + + # Bank enough to build with, then move to the space scene. + game_state.rocks = 60 + change_scene_to_file("res://Main.tscn") + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + if current_scene == null or current_scene.name != "Main": + return false + scene = current_scene + fleet = scene.get_node("Fleet") + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- place both buildings ------------------------------------- + var field: Node = scene.get_node("BuildingField") + var before: int = game_state.rocks + for ship in fleet.ships: + ship.position = mothership_pos + fleet.move_to(mothership_pos) + field.place(GameState.BUILDING_MOTHERSHIP, mothership_pos) + game_state.spend(GameState.MOTHERSHIP_COST) + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(1400.0, 300.0)) + game_state.spend(GameState.SHIP_CRAFTER_COST) + + var paid: bool = game_state.rocks == before - GameState.MOTHERSHIP_COST - GameState.SHIP_CRAFTER_COST + var stored: bool = game_state.buildings.size() == 2 + print("buildings=%d rocks %d->%d paid=%s" % [ + game_state.buildings.size(), before, game_state.rocks, paid + ]) + if not (paid and stored): + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (placing/paying wrong)") + quit(1) + return true + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + # --- crafter adds a ship -------------------------------------- + fleet.spawn_ships(3, Vector2.ZERO) + var before_ships: int = fleet.ships.size() + var before_rocks: int = game_state.rocks + game_state.spend(GameState.SHIP_COST) + var added: bool = fleet.add_ship() + print("craft: ships %d->%d rocks %d->%d" % [ + before_ships, fleet.ships.size(), before_rocks, game_state.rocks + ]) + if not (added and fleet.ships.size() == before_ships + 1 and game_state.rocks == before_rocks - GameState.SHIP_COST): + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (crafting a ship)") + quit(1) + return true + + # --- command centre: drag a formation slot --------------------- + var editor: Control = scene.get_node("UI/FormationMenu/Editor") + var menu: Control = scene.get_node("UI/FormationMenu") + menu.visible = true + editor.size = Vector2(520, 520) + # The editor is driven from BuildController._unhandled_input, + # which never fires for Controls that consume touches through + # the GUI system first. The menu AND its full-screen backdrop + # must both be IGNORE, or dragging a slot silently does nothing + # on a real device - which is exactly what happened. + for node in [menu, menu.get_node("Backdrop"), editor]: + if node.mouse_filter != Control.MOUSE_FILTER_IGNORE: + print("%s has mouse_filter=%d, needs IGNORE" % [node.name, node.mouse_filter]) + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation menu would swallow drags)") + quit(1) + return true + + var slot_before: Vector2 = game_state.formation[1] + + var down: InputEventScreenTouch = InputEventScreenTouch.new() + down.index = 0 + down.pressed = true + down.position = editor.global_position + editor._slot_screen_pos(1) + var grabbed: bool = editor.handle_touch(down) + + var move: InputEventScreenDrag = InputEventScreenDrag.new() + move.index = 0 + move.position = editor.global_position + editor._slot_screen_pos(1) + Vector2(40.0, 25.0) + editor.handle_drag(move) + var slot_after: Vector2 = game_state.formation[1] + menu.visible = false + + print("formation slot 1: %s -> %s (grabbed=%s)" % [slot_before, slot_after, grabbed]) + if not (grabbed and slot_after != slot_before): + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation slot did not drag)") + quit(1) + return true + + # Editing must re-seat the ships ALREADY in flight, not just + # ones built later - otherwise the edit reads as doing nothing. + var live_target: Vector2 = fleet.ships[1].move_target + var expected_target: Vector2 = fleet.fleet_target + slot_after + var live_ok: bool = live_target.distance_to(expected_target) < 0.01 + print("live ship 1 target %s, expected %s (ok=%s)" % [ + live_target, expected_target, live_ok + ]) + if not live_ok: + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (existing ships ignored the edit)") + quit(1) + return true + + # --- pivot the formation -------------------------------------- + var before_pivot: Vector2 = game_state.formation[1] + game_state.rotate_formation(PI / 2.0) + fleet.apply_formation() + var after_pivot: Vector2 = game_state.formation[1] + var rotated_ok: bool = after_pivot.distance_to(before_pivot.rotated(PI / 2.0)) < 0.01 + var length_kept: bool = abs(after_pivot.length() - before_pivot.length()) < 0.01 + var ship_followed: bool = fleet.ships[1].move_target.distance_to(fleet.fleet_target + after_pivot) < 0.01 + print("pivot: %s -> %s (rotated=%s shape_kept=%s ship_followed=%s)" % [ + before_pivot, after_pivot, rotated_ok, length_kept, ship_followed + ]) + if not (rotated_ok and length_kept and ship_followed): + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (pivot)") + quit(1) + return true + game_state.rotate_formation(-PI / 2.0) + slot_after = game_state.formation[1] + + # A rebuilt fleet must adopt the edited layout, which is the + # whole point of storing the formation in GameState. + fleet.spawn_ships(2, Vector2.ZERO) + var uses_layout: bool = fleet.ships[1].position.distance_to(slot_after) < 0.01 + print("respawned ship 1 at %s, formation says %s (matches=%s)" % [ + fleet.ships[1].position, slot_after, uses_layout + ]) + if not uses_layout: + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet ignored the edited formation)") + quit(1) + return true + + # --- wipe the fleet ------------------------------------------- + for ship in fleet.ships.duplicate(): + ship.queue_free() + fleet.ships.clear() + fleet.formation_offsets.clear() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.5: + return false + # BuildController._process should have rebuilt one vessel, at + # the mothership rather than the origin. + var respawned: bool = fleet.ships.size() == 1 + var at_mothership: bool = respawned and fleet.ships[0].position.distance_to(mothership_pos) < 120.0 + print("after wipe: ships=%d at_mothership=%s (pos=%s expected~%s)" % [ + fleet.ships.size(), at_mothership, + fleet.ships[0].position if respawned else Vector2.ZERO, mothership_pos + ]) + if respawned and at_mothership: + print("TEST_RESOURCES_AND_BUILDINGS: PASS") + quit(0) + else: + print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet not rebuilt at the mothership)") + quit(1) + return true + return false diff --git a/tests/test_resources_and_buildings.gd.uid b/tests/test_resources_and_buildings.gd.uid new file mode 100644 index 0000000..62783c9 --- /dev/null +++ b/tests/test_resources_and_buildings.gd.uid @@ -0,0 +1 @@ +uid://cib2d4f5hu5mt diff --git a/tests/test_return_position_matches_planet.gd b/tests/test_return_position_matches_planet.gd new file mode 100644 index 0000000..d6cc6be --- /dev/null +++ b/tests/test_return_position_matches_planet.gd @@ -0,0 +1,174 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_return_position_matches_planet.gd +# Verifies that returning to space places the fleet exactly at the +# planet's position, not wherever the fleet's centroid happened to be +# when "enter atmosphere" was pressed - the fleet only needs to be +# somewhere within the planet's landing radius (not centered on it) for +# the prompt to appear, so those two positions can genuinely differ. + +var main_scene: Node +var fleet: Node +var planet_field: Node +var landing_controller: Node +var enter_button: IconButton +var target_chunk: Vector2i +var planet_position: Vector2 +var elapsed: float = 0.0 +var stage: int = 0 +var cached_fade_duration: float = 0.4 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + + if not found: + print("FAIL: could not find any chunk with a planet to test with") + quit(1) + return + + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + current_scene = main_scene + fleet = main_scene.get_node("Fleet") + planet_field = main_scene.get_node("PlanetField") + landing_controller = main_scene.get_node("LandingController") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = vicinity_pos + fleet.move_to(vicinity_pos) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < planet_field.check_interval + 0.1: + return false + if not planet_field.populated_chunks.has(target_chunk): + print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (planet never spawned)") + quit(1) + return true + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + planet_position = planet.position + + # Deliberately land the fleet SOMEWHERE INSIDE the landing + # radius but NOT centered on the planet, simulating a player + # who pressed enter before the fleet fully settled onto it. + var offset_pos: Vector2 = planet.position + Vector2(60, 60) + for ship in fleet.ships: + ship.position = offset_pos + fleet.move_to(offset_pos) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if not enter_button.visible: + print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (prompt did not appear from offset position)") + quit(1) + return true + + cached_fade_duration = landing_controller.fade_duration + _press(enter_button) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < cached_fade_duration + 0.3: + return false + var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface" + if not on_surface: + print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (did not swap to PlanetSurface)") + quit(1) + return true + + var exit_button: IconButton = current_scene.get_node("UI/ExitButton") + var surface_controller: Node = current_scene.get_node("SurfaceController") + cached_fade_duration = surface_controller.fade_duration + _press(exit_button) + elapsed = 0.0 + stage = 4 + return false + 4: + # Poll for the swap instead of waiting a fixed duration, so the + # fresh scene can be isolated on its very FIRST frame. It + # populates asteroids during its own _ready(), and one killing a + # ship shifts the centroid being measured here - losing the nose + # ship alone moves it 12 units, which is what made this test + # fail intermittently. + var back_in_space: bool = current_scene != null and current_scene.name == "Main" + if not back_in_space: + if elapsed > cached_fade_duration + 3.0: + print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (did not return to Main)") + quit(1) + return true + return false + + current_scene.get_node("AsteroidField").set_process(false) + current_scene.get_node("EnemySpawner").set_process(false) + for a in get_nodes_in_group("asteroids"): + a.queue_free() + + # A ship lost before isolation took hold would bias the centroid, + # and says nothing about the return position under test. + if current_scene.get_node("Fleet").ships.size() != 5: + print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (lost a ship to a collision on arrival)") + quit(1) + return true + + var space_fleet: Node = current_scene.get_node("Fleet") + + # Ships spawn in a wedge around the return point and then steer + # into formation, so the centroid is only meaningful once they've + # actually stopped moving. Waiting a fixed duration made this + # test flaky (it intermittently sampled a still-settling fleet + # and measured a centroid several units off); wait for genuine + # arrival instead - each ship at its own move_target, not merely + # reporting zero velocity, since a ship that hasn't been physics + # -ticked yet also reads velocity == 0. + for ship in space_fleet.ships: + if ship.position.distance_to(ship.move_target) > ship.arrive_radius: + return false + + var dist: float = space_fleet.get_centroid().distance_to(planet_position) + var position_matches_planet: bool = dist < 5.0 + print("returned_centroid=%s planet_position=%s dist=%.2f position_matches_planet=%s" % [ + space_fleet.get_centroid(), planet_position, dist, position_matches_planet + ]) + if position_matches_planet: + print("TEST_RETURN_POSITION_MATCHES_PLANET: PASS") + quit(0) + else: + print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (returned somewhere other than the planet)") + quit(1) + return true + return false + + +func _press(button: IconButton) -> void: + var down: InputEventScreenTouch = InputEventScreenTouch.new() + down.pressed = true + button._gui_input(down) + var up: InputEventScreenTouch = InputEventScreenTouch.new() + up.pressed = false + button._gui_input(up) diff --git a/tests/test_return_position_matches_planet.gd.uid b/tests/test_return_position_matches_planet.gd.uid new file mode 100644 index 0000000..ab1a6bc --- /dev/null +++ b/tests/test_return_position_matches_planet.gd.uid @@ -0,0 +1 @@ +uid://cyemjbd4xsjkh diff --git a/tests/test_ship_facing.gd b/tests/test_ship_facing.gd new file mode 100644 index 0000000..d32463b --- /dev/null +++ b/tests/test_ship_facing.gd @@ -0,0 +1,104 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_ship_facing.gd +# Regression test for ships freezing at a fixed heading. +# +# Ships holding formation sit INSIDE arrive_radius of their slot (the fleet +# anchor advances less than that per frame at cruise speed), so they snap +# to it with zero velocity. Facing used to be derived purely from a ship's +# own velocity behind a "> 8.0" gate, which meant a station-keeping ship +# had nothing to face along and stayed pointing whichever way it last +# happened to travel - it would not turn when the fleet changed direction. + +var scene: Node +var fleet: Node +var stage: int = 0 +var elapsed: float = 0.0 +var heading_east: float = 0.0 + + +func _initialize() -> void: + root.get_node("GameState").reset_formation() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + + +func _angles_match(a: float, b: float, tolerance: float) -> bool: + return absf(angle_difference(a, b)) <= tolerance + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + fleet.move_in_direction(Vector2.RIGHT) + + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 1.5: + return false + + # Structural check, because the behavioural ones below cannot + # reproduce the actual failure: on a real device at 60fps the + # anchor advances ~4.3 units per frame, LESS than arrive_radius, + # so a station-keeping ship snaps to its slot with zero velocity + # and has no heading of its own to face along. Headless runs + # _process uncapped against 60Hz physics, so ships here always + # pick up real velocity and look fine either way. Asserting the + # imposed heading is wired is what actually guards the fix. + var expected_fleet_velocity: Vector2 = Vector2.RIGHT * fleet.cruise_speed + for ship in fleet.ships: + if not ship.fleet_velocity.is_equal_approx(expected_fleet_velocity): + print("fleet_velocity=%s, expected %s" % [ship.fleet_velocity, expected_fleet_velocity]) + print("TEST_SHIP_FACING: FAIL (fleet motion not supplied to ships)") + quit(1) + return true + + # Flying east: every ship should point east (sprites are drawn + # nose-up, hence the +PI/2 convention in Ship._physics_process). + heading_east = Vector2.RIGHT.angle() + PI / 2.0 + for ship in fleet.ships: + if not _angles_match(ship.rotation, heading_east, 0.25): + print("ship facing %.3f, expected ~%.3f while flying east" % [ + ship.rotation, heading_east + ]) + print("TEST_SHIP_FACING: FAIL (not facing travel direction)") + quit(1) + return true + print("flying east: all ships facing %.3f" % fleet.ships[0].rotation) + + # Turn by a SMALL angle. This is the discriminating case: a big + # turn shoves each ship sideways hard enough that its own + # velocity happens to point roughly the new way, so facing looks + # right even when broken. A gentle turn moves each ship only a + # few units, its velocity never clears the "> 8.0" gate, and a + # ship given no fleet motion simply stays pointing the old way. + fleet.move_in_direction(Vector2.RIGHT.rotated(-0.6)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 1.5: + return false + var heading_north: float = Vector2.RIGHT.rotated(-0.6).angle() + PI / 2.0 + for ship in fleet.ships: + if not _angles_match(ship.rotation, heading_north, 0.15): + print("ship stuck at %.3f, expected ~%.3f after the gentle turn" % [ + ship.rotation, heading_north + ]) + print("TEST_SHIP_FACING: FAIL (did not turn with the fleet)") + quit(1) + return true + print("gentle turn: all ships facing %.3f (was %.3f)" % [ + fleet.ships[0].rotation, heading_east + ]) + print("TEST_SHIP_FACING: PASS") + quit(0) + return true + return false diff --git a/tests/test_ship_facing.gd.uid b/tests/test_ship_facing.gd.uid new file mode 100644 index 0000000..e105984 --- /dev/null +++ b/tests/test_ship_facing.gd.uid @@ -0,0 +1 @@ +uid://dqilfnd6k24uw diff --git a/tests/test_star_coverage.gd b/tests/test_star_coverage.gd new file mode 100644 index 0000000..dfd6629 --- /dev/null +++ b/tests/test_star_coverage.gd @@ -0,0 +1,66 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_star_coverage.gd +# Verifies the shader-based starfield's uniforms actually track the camera: +# after moving/zooming the camera, camera_pos/camera_zoom fed to the +# starfield shader should match, at every zoom level (this replaces the old +# tile-symmetry check now that stars are a single procedural fragment +# shader pass with no CPU-side tiling to go asymmetric in the first place). + +const TOLERANCE: float = 0.5 + +var main_scene: Node +var camera: Camera2D +var starfield: ColorRect + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + camera = main_scene.get_node("Camera2D") + starfield = main_scene.get_node("BackgroundLayer/Starfield") + + +func _process(_delta: float) -> bool: + var all_ok: bool = true + + for zoom_level in [1.0, 3.0, 6.0]: + camera.zoom = Vector2(zoom_level, zoom_level) + camera.global_position = Vector2(zoom_level * 100.0, -zoom_level * 50.0) + + # Let the Starfield's own _process (which reads the camera and pushes + # shader uniforms) run for this frame before checking. + starfield._process(0.016) + + # The camera position is handed to the shader pre-split into a cell + # index plus a fraction within that cell (so the shader never handles + # a large absolute world coordinate - see Starfield.gd). Verify the + # two parts reconstruct the real camera position exactly, and that + # the fraction really is a fraction: if it ever left [0,1) the split + # would be wrong and the shader would sample the wrong cell. + var cell: Vector2 = starfield.material.get_shader_parameter("camera_cell") + var frac: Vector2 = starfield.material.get_shader_parameter("camera_frac") + var cell_size: float = starfield.material.get_shader_parameter("cell_size") + var uniform_zoom: float = starfield.material.get_shader_parameter("camera_zoom") + + var reconstructed: Vector2 = (cell + frac) * cell_size + var pos_ok: bool = reconstructed.distance_to(camera.global_position) <= TOLERANCE + var frac_ok: bool = frac.x >= 0.0 and frac.x < 1.0 and frac.y >= 0.0 and frac.y < 1.0 + var zoom_ok: bool = abs(uniform_zoom - (1.0 / camera.zoom.x)) <= 0.01 + var ok: bool = pos_ok and frac_ok and zoom_ok + all_ok = all_ok and ok + + print("zoom=%.1f camera_pos=%s reconstructed=%s frac=%s zoom=%.2f/%.2f %s" % [ + zoom_level, camera.global_position, reconstructed, frac, + camera.zoom.x, uniform_zoom, "OK" if ok else "FAIL" + ]) + + if all_ok: + print("TEST_STAR_COVERAGE: PASS") + quit(0) + else: + print("TEST_STAR_COVERAGE: FAIL") + quit(1) + return true diff --git a/tests/test_star_coverage.gd.uid b/tests/test_star_coverage.gd.uid new file mode 100644 index 0000000..99f347a --- /dev/null +++ b/tests/test_star_coverage.gd.uid @@ -0,0 +1 @@ +uid://dnc1u8diptger diff --git a/tests/test_world_map_ui.gd b/tests/test_world_map_ui.gd new file mode 100644 index 0000000..80d185b --- /dev/null +++ b/tests/test_world_map_ui.gd @@ -0,0 +1,136 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_world_map_ui.gd +# Verifies the map UI: hidden by default, opening it recenters on the +# fleet, a single-finger drag while the map is open pans the map content +# instead of leaking through to the gameplay fleet-move/zoom controls, and +# closing hides it again. + +const POS_TOLERANCE: float = 3.0 + +var main_scene: Node +var fleet: Node +var camera: Camera2D +var world_map: Control +var content: Node2D +var fleet_target_before: Vector2 +var camera_zoom_before: float +var content_pos_before_drag: Vector2 +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + var packed: PackedScene = load("res://Main.tscn") + main_scene = packed.instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + camera = main_scene.get_node("Camera2D") + world_map = main_scene.get_node("UI/WorldMap") + content = main_scene.get_node("UI/WorldMap/MapContent") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + if world_map.visible: + print("TEST_WORLD_MAP_UI: FAIL (map visible by default)") + quit(1) + return true + world_map.open_map() + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL + var viewport_center: Vector2 = main_scene.get_viewport_rect().size / 2.0 + var expected_content_pos: Vector2 = viewport_center - fleet_local * content.scale.x + var recenter_ok: bool = content.position.distance_to(expected_content_pos) <= POS_TOLERANCE + print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [ + world_map.visible, content.position, expected_content_pos, recenter_ok + ]) + if not (world_map.visible and recenter_ok): + print("TEST_WORLD_MAP_UI: FAIL (open/recenter)") + quit(1) + return true + + fleet_target_before = fleet.fleet_target + camera_zoom_before = camera.zoom.x + content_pos_before_drag = content.position + _touch(0, true, Vector2(200, 600)) + stage = 2 + elapsed = 0.0 + return false + 2: + if elapsed < 0.1: + return false + _drag(0, Vector2(260, 640)) + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 0.1: + return false + _touch(0, false, Vector2(260, 640)) + + # Headless mode's placeholder window doesn't match the configured + # viewport size, so Godot's own stretch transform rescales raw + # synthetic touch coordinates by some environment-specific factor + # before delivery - checking an exact pixel count here would be + # testing that artifact, not the pan logic. Instead verify the + # drag moved the map in the right DIRECTION and roughly the right + # PROPORTION (dragged twice as far on X as Y, so the pan should + # reflect that same ratio), which the scaling factor cancels out of. + var pan_delta: Vector2 = content.position - content_pos_before_drag + var moved_ok: bool = pan_delta.length() > 1.0 + var direction_ok: bool = pan_delta.x > 0.0 and pan_delta.y > 0.0 + var ratio_ok: bool = pan_delta.y != 0.0 and abs((pan_delta.x / pan_delta.y) - 1.5) <= 0.1 + var pan_ok: bool = moved_ok and direction_ok and ratio_ok + var fleet_untouched: bool = fleet.fleet_target == fleet_target_before + var zoom_untouched: bool = camera.zoom.x == camera_zoom_before + print("pan_delta=%s moved_ok=%s direction_ok=%s ratio_ok=%s fleet_untouched=%s zoom_untouched=%s" % [ + pan_delta, moved_ok, direction_ok, ratio_ok, fleet_untouched, zoom_untouched + ]) + if not (pan_ok and fleet_untouched and zoom_untouched): + print("TEST_WORLD_MAP_UI: FAIL (pan/isolation)") + quit(1) + return true + + world_map.close_map() + stage = 4 + elapsed = 0.0 + return false + 4: + if elapsed < 0.1: + return false + var closed_ok: bool = not world_map.visible + print("closed_ok=%s" % closed_ok) + if closed_ok: + print("TEST_WORLD_MAP_UI: PASS") + quit(0) + else: + print("TEST_WORLD_MAP_UI: FAIL (did not close)") + quit(1) + return true + return false + + +func _touch(index: int, pressed: bool, pos: Vector2) -> void: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.index = index + ev.pressed = pressed + ev.position = pos + Input.parse_input_event(ev) + + +func _drag(index: int, pos: Vector2) -> void: + var ev: InputEventScreenDrag = InputEventScreenDrag.new() + ev.index = index + ev.position = pos + Input.parse_input_event(ev) diff --git a/tests/test_world_map_ui.gd.uid b/tests/test_world_map_ui.gd.uid new file mode 100644 index 0000000..7e99d14 --- /dev/null +++ b/tests/test_world_map_ui.gd.uid @@ -0,0 +1 @@ +uid://coa1sepq31622 diff --git a/ui_theme.tres b/ui_theme.tres new file mode 100644 index 0000000..38c105d --- /dev/null +++ b/ui_theme.tres @@ -0,0 +1,15 @@ +[gd_resource type="Theme" format=3] + +; Project-wide default font size for every Control. +; +; This exists because the `gui/theme/default_font_size` project setting does +; NOT reach the default theme in this build - labels without an explicit +; override resolve to the engine fallback of 16 no matter what that setting +; says, which is why earlier attempts to enlarge the UI text did nothing. +; A custom Theme assigned to `gui/theme/custom` does apply. +; +; Individual labels that cannot fit their own text at this size carry a +; smaller theme_override_font_sizes/font_size in the scene. + +[resource] +default_font_size = 78