Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65fe19aeb6 | ||
|
|
a1fb239a50 | ||
|
|
f14ee656ea | ||
|
|
5326852807 | ||
|
|
a1ad6cc844 |
59 changed files with 1289 additions and 51 deletions
|
|
@ -543,10 +543,13 @@ func _refresh_build_status() -> void:
|
||||||
|
|
||||||
# Prices are shown under each option now, so this only needs to say
|
# Prices are shown under each option now, so this only needs to say
|
||||||
# whether the fleet is somewhere an extractor could go.
|
# whether the fleet is somewhere an extractor could go.
|
||||||
if _planet_under_fleet() != null:
|
var planet: Node2D = _planet_under_fleet()
|
||||||
build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name
|
if planet == null:
|
||||||
else:
|
|
||||||
build_status.text = ""
|
build_status.text = ""
|
||||||
|
elif not GameState.is_planet_conquered(planet.planet_id):
|
||||||
|
build_status.text = "Over %s - capture it before building an extractor" % planet.planet_name
|
||||||
|
else:
|
||||||
|
build_status.text = "Over %s - an extractor can be built here" % planet.planet_name
|
||||||
|
|
||||||
|
|
||||||
func _refresh_build_option_states() -> void:
|
func _refresh_build_option_states() -> void:
|
||||||
|
|
@ -554,7 +557,8 @@ func _refresh_build_option_states() -> void:
|
||||||
var definition: Resource = entry[4]
|
var definition: Resource = entry[4]
|
||||||
var enabled: bool = GameState.is_building_unlocked(definition.definition_id)
|
var enabled: bool = GameState.is_building_unlocked(definition.definition_id)
|
||||||
if definition.placement_mode == "planet":
|
if definition.placement_mode == "planet":
|
||||||
enabled = enabled and _planet_under_fleet() != null
|
var planet: Node2D = _planet_under_fleet()
|
||||||
|
enabled = enabled and planet != null and GameState.is_planet_conquered(planet.planet_id)
|
||||||
entry[1].enabled = enabled
|
entry[1].enabled = enabled
|
||||||
entry[1].queue_redraw()
|
entry[1].queue_redraw()
|
||||||
|
|
||||||
|
|
@ -593,6 +597,9 @@ func _show_building_info(type: String) -> void:
|
||||||
if planet == null:
|
if planet == null:
|
||||||
lines.append("No planet in reach - fly over one.")
|
lines.append("No planet in reach - fly over one.")
|
||||||
can_build = false
|
can_build = false
|
||||||
|
elif not GameState.is_planet_conquered(planet.planet_id):
|
||||||
|
lines.append("%s is not captured yet - take all its capture points." % planet.planet_name)
|
||||||
|
can_build = false
|
||||||
elif GameState.has_extractor_at(planet.planet_id):
|
elif GameState.has_extractor_at(planet.planet_id):
|
||||||
lines.append("%s already has an extractor." % planet.planet_name)
|
lines.append("%s already has an extractor." % planet.planet_name)
|
||||||
can_build = false
|
can_build = false
|
||||||
|
|
@ -639,6 +646,9 @@ func _confirm_build() -> void:
|
||||||
if planet == null or GameState.has_extractor_at(planet.planet_id):
|
if planet == null or GameState.has_extractor_at(planet.planet_id):
|
||||||
build_status.text = "No planet in reach any more"
|
build_status.text = "No planet in reach any more"
|
||||||
return
|
return
|
||||||
|
if not GameState.is_planet_conquered(planet.planet_id):
|
||||||
|
build_status.text = "%s is not captured yet" % planet.planet_name
|
||||||
|
return
|
||||||
if not GameState.spend(definition.cost):
|
if not GameState.spend(definition.cost):
|
||||||
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
|
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
|
||||||
return
|
return
|
||||||
|
|
|
||||||
16
ChaserEnemyDefinition.tres
Normal file
16
ChaserEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
display_name = "Chaser"
|
||||||
|
max_hp = 3
|
||||||
|
speed = 140.0
|
||||||
|
acceleration = 260.0
|
||||||
|
rotation_speed = 6.0
|
||||||
|
hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0)
|
||||||
|
glow_color = Color(1, 0.15, 0.2, 1)
|
||||||
|
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||||
76
Enemy.gd
76
Enemy.gd
|
|
@ -2,50 +2,53 @@ extends Area2D
|
||||||
|
|
||||||
signal destroyed
|
signal destroyed
|
||||||
|
|
||||||
@export var speed: float = 140.0
|
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
|
||||||
@export var acceleration: float = 260.0
|
|
||||||
@export var rotation_speed: float = 6.0
|
|
||||||
@export var max_hp: int = 3
|
|
||||||
|
|
||||||
var velocity: Vector2 = Vector2.ZERO
|
@export var enemy_id: String = "chaser"
|
||||||
|
|
||||||
|
var definition: Resource
|
||||||
var hp: int
|
var hp: int
|
||||||
|
|
||||||
@onready var fleet: Node2D = get_node("../Fleet")
|
# Mirrors Ship.gd's own max_speed/acceleration: copied from the definition
|
||||||
|
# once, then mutable, so a test/preview can pin an enemy in place (e.g.
|
||||||
|
# `enemy.speed = 0.0`) without needing to know about definitions at all.
|
||||||
|
var speed: float
|
||||||
|
var acceleration: float
|
||||||
|
var rotation_speed: float
|
||||||
|
var max_hp: int
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
hp = max_hp
|
|
||||||
add_to_group("enemies")
|
add_to_group("enemies")
|
||||||
|
definition = ENEMY_DEFINITIONS.get_definition(enemy_id)
|
||||||
|
speed = definition.speed
|
||||||
|
acceleration = definition.acceleration
|
||||||
|
rotation_speed = definition.rotation_speed
|
||||||
|
max_hp = definition.max_hp
|
||||||
|
hp = max_hp
|
||||||
|
for component_scene in definition.component_scenes:
|
||||||
|
var component: Node = component_scene.instantiate()
|
||||||
|
add_child(component)
|
||||||
|
if component.has_method("setup"):
|
||||||
|
component.setup()
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func get_component(capability: String) -> Node:
|
||||||
|
for child in get_children():
|
||||||
|
if child.get("capability_id") == capability:
|
||||||
|
return child
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func has_capability(capability: String) -> bool:
|
||||||
|
return get_component(capability) != null
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
var target_pos: Vector2 = _nearest_ship_position()
|
for child in get_children():
|
||||||
var to_target: Vector2 = target_pos - position
|
if child.has_method("tick"):
|
||||||
|
child.tick(delta)
|
||||||
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:
|
func take_damage(amount: int) -> void:
|
||||||
|
|
@ -57,8 +60,9 @@ func take_damage(amount: int) -> void:
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
var points: PackedVector2Array = PackedVector2Array([
|
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
|
||||||
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0)
|
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
|
||||||
])
|
])
|
||||||
|
var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2)
|
||||||
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
|
# 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)
|
NeonDraw.draw_glowing_polygon(self, points, glow, 1.0)
|
||||||
|
|
|
||||||
19
EnemyDefinition.gd
Normal file
19
EnemyDefinition.gd
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
class_name EnemyDefinition
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
# Data-driven enemy stats and appearance, mirroring VesselDefinition's role
|
||||||
|
# for player ships. Enemy.gd instantiates component_scenes exactly like
|
||||||
|
# Ship.gd does, so behaviour (pursuit, ranged fire, ...) lives in capability
|
||||||
|
# components rather than in a type switch here.
|
||||||
|
|
||||||
|
@export var enemy_id: String = "chaser"
|
||||||
|
@export var display_name: String = "Chaser"
|
||||||
|
@export var max_hp: int = 3
|
||||||
|
@export var speed: float = 140.0
|
||||||
|
@export var acceleration: float = 260.0
|
||||||
|
@export var rotation_speed: float = 6.0
|
||||||
|
@export var hull_points: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
|
||||||
|
])
|
||||||
|
@export var glow_color: Color = Color(1.0, 0.15, 0.2)
|
||||||
|
@export var component_scenes: Array[PackedScene] = []
|
||||||
1
EnemyDefinition.gd.uid
Normal file
1
EnemyDefinition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bkgt6o1m12l0g
|
||||||
19
EnemyDefinitions.gd
Normal file
19
EnemyDefinitions.gd
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
class_name EnemyDefinitions
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const DEFAULT_ID: String = "chaser"
|
||||||
|
const ORDER: PackedStringArray = ["chaser"]
|
||||||
|
const DEFINITIONS: Dictionary = {
|
||||||
|
"chaser": preload("res://ChaserEnemyDefinition.tres"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func get_definition(enemy_id: String) -> Resource:
|
||||||
|
return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID])
|
||||||
|
|
||||||
|
|
||||||
|
static func all() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for enemy_id in ORDER:
|
||||||
|
result.append(DEFINITIONS[enemy_id])
|
||||||
|
return result
|
||||||
1
EnemyDefinitions.gd.uid
Normal file
1
EnemyDefinitions.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cfc4w3atpf8yj
|
||||||
45
EnemyProjectile.gd
Normal file
45
EnemyProjectile.gd
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
# Mirrors Projectile.gd, but travels the other direction: fired by an enemy
|
||||||
|
# component (see EnemyRangedWeaponComponent, #45) and damages player_ships.
|
||||||
|
# This is the half of ranged combat that never existed before #44 - enemies
|
||||||
|
# could previously only hurt the fleet by physical contact.
|
||||||
|
|
||||||
|
@export var speed: float = 260.0
|
||||||
|
@export var lifetime: float = 3.0
|
||||||
|
@export var damage: int = 1
|
||||||
|
@export var bolt_length: float = 20.0
|
||||||
|
@export var color: Color = Color(1.0, 0.4, 0.35)
|
||||||
|
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
var age: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("enemy_projectiles")
|
||||||
|
area_entered.connect(_on_area_entered)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
if age >= lifetime:
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_area_entered(area: Area2D) -> void:
|
||||||
|
if area.is_in_group("player_ships"):
|
||||||
|
area.take_damage(damage)
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
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.5)
|
||||||
1
EnemyProjectile.gd.uid
Normal file
1
EnemyProjectile.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b4hja3oj3rdrc
|
||||||
12
EnemyProjectile.tscn
Normal file
12
EnemyProjectile.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyProjectile.gd" id="1"]
|
||||||
|
|
||||||
|
[sub_resource type="CircleShape2D" id="1"]
|
||||||
|
radius = 4.0
|
||||||
|
|
||||||
|
[node name="EnemyProjectile" type="Area2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
shape = SubResource("1")
|
||||||
38
EnemyPursuitComponent.gd
Normal file
38
EnemyPursuitComponent.gd
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
class_name EnemyPursuitComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Chases the nearest player ship. Moved out of Enemy.gd verbatim so the
|
||||||
|
# behaviour is opt-in per definition rather than hardcoded into every enemy.
|
||||||
|
|
||||||
|
var capability_id: String = "pursuit"
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
var target_pos: Vector2 = _nearest_ship_position(enemy)
|
||||||
|
var to_target: Vector2 = target_pos - enemy.position
|
||||||
|
|
||||||
|
if to_target.length() > 1.0:
|
||||||
|
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
|
||||||
|
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||||
|
enemy.position += velocity * delta
|
||||||
|
|
||||||
|
if velocity.length() > 5.0:
|
||||||
|
var target_angle: float = velocity.angle() + PI / 2.0
|
||||||
|
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_ship_position(enemy: Node2D) -> Vector2:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return enemy.position
|
||||||
|
|
||||||
|
var nearest: Node2D = fleet.ships[0]
|
||||||
|
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||||
|
for ship in fleet.ships:
|
||||||
|
var d: float = enemy.position.distance_to(ship.position)
|
||||||
|
if d < best_dist:
|
||||||
|
best_dist = d
|
||||||
|
nearest = ship
|
||||||
|
return nearest.position
|
||||||
1
EnemyPursuitComponent.gd.uid
Normal file
1
EnemyPursuitComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://n8wohkrnl42e
|
||||||
6
EnemyPursuitComponent.tscn
Normal file
6
EnemyPursuitComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyPursuitComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
4
Fleet.gd
4
Fleet.gd
|
|
@ -32,6 +32,10 @@ var steer_direction: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling
|
||||||
|
# path - enemies parented under different hosts (e.g. a planet surface's
|
||||||
|
# SurfaceFeatures) need this to find their target.
|
||||||
|
add_to_group("player_fleet")
|
||||||
# Researching a vessel upgrade improves the ships already flying, not
|
# Researching a vessel upgrade improves the ships already flying, not
|
||||||
# just the next one built (#5).
|
# just the next one built (#5).
|
||||||
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
|
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
|
||||||
|
|
|
||||||
87
GameState.gd
87
GameState.gd
|
|
@ -344,6 +344,33 @@ func reset_progression() -> void:
|
||||||
vessel_levels.clear()
|
vessel_levels.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Called from the main menu's Play button (#47): the autoload survives
|
||||||
|
# change_scene_to_file(), so without this a second playthrough in the same
|
||||||
|
# process would start from wherever the last one left off instead of a
|
||||||
|
# fresh game.
|
||||||
|
func reset_new_game() -> void:
|
||||||
|
has_return_position = false
|
||||||
|
surviving_ship_count = BASE_FLEET_SIZE
|
||||||
|
surviving_vessel_types = PackedStringArray()
|
||||||
|
return_world_position = Vector2.ZERO
|
||||||
|
return_camera_zoom = 1.0
|
||||||
|
planet_name = ""
|
||||||
|
planet_id = ""
|
||||||
|
planet_definition_id = "rocky"
|
||||||
|
explored_chunks.clear()
|
||||||
|
bank_inventory = INVENTORY.new({RESOURCE_ORE: 500})
|
||||||
|
fleet_inventory = INVENTORY.new()
|
||||||
|
buildings.clear()
|
||||||
|
next_building_id = 1
|
||||||
|
stored_vessels.clear()
|
||||||
|
vessel_deliveries.clear()
|
||||||
|
extractors.clear()
|
||||||
|
transporters.clear()
|
||||||
|
planet_capture.clear()
|
||||||
|
reset_progression()
|
||||||
|
reset_formation()
|
||||||
|
|
||||||
|
|
||||||
# --- building stock and production queue (#16) -----------------------------
|
# --- building stock and production queue (#16) -----------------------------
|
||||||
|
|
||||||
# Since #12, a building cannot just deduct from the bank at the moment of
|
# Since #12, a building cannot just deduct from the bank at the moment of
|
||||||
|
|
@ -1020,6 +1047,66 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted:
|
||||||
return inventory
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
|
# Planet conquest: a planet must have every one of its capture points held
|
||||||
|
# before an extractor can be built there (see BuildController's placement
|
||||||
|
# gates). Keyed by the same stable planet_id as `extractors`, entirely
|
||||||
|
# separate from it - a planet can be conquered long before anyone builds an
|
||||||
|
# extractor on it, or never get one at all.
|
||||||
|
var planet_capture: Dictionary = {}
|
||||||
|
|
||||||
|
signal planet_conquered(planet_id: String)
|
||||||
|
|
||||||
|
|
||||||
|
func register_capture_points(planet_key: String, total: int) -> void:
|
||||||
|
# Idempotent: landing again on a planet must not wipe points already
|
||||||
|
# captured on a previous visit.
|
||||||
|
if planet_capture.has(planet_key):
|
||||||
|
return
|
||||||
|
var captured: Array = []
|
||||||
|
captured.resize(total)
|
||||||
|
captured.fill(false)
|
||||||
|
planet_capture[planet_key] = {"captured": captured, "conquered": false}
|
||||||
|
|
||||||
|
|
||||||
|
func capture_state(planet_key: String) -> Dictionary:
|
||||||
|
return planet_capture.get(planet_key, {"captured": [], "conquered": false})
|
||||||
|
|
||||||
|
|
||||||
|
func is_point_captured(planet_key: String, index: int) -> bool:
|
||||||
|
var captured: Array = capture_state(planet_key).get("captured", [])
|
||||||
|
if index < 0 or index >= captured.size():
|
||||||
|
return false
|
||||||
|
return bool(captured[index])
|
||||||
|
|
||||||
|
|
||||||
|
func mark_point_captured(planet_key: String, index: int) -> void:
|
||||||
|
if not planet_capture.has(planet_key):
|
||||||
|
return
|
||||||
|
var state: Dictionary = planet_capture[planet_key]
|
||||||
|
var captured: Array = state.captured
|
||||||
|
if index < 0 or index >= captured.size():
|
||||||
|
return
|
||||||
|
captured[index] = true
|
||||||
|
if state.conquered:
|
||||||
|
return
|
||||||
|
if captured.all(func(c): return bool(c)):
|
||||||
|
state.conquered = true
|
||||||
|
planet_conquered.emit(planet_key)
|
||||||
|
|
||||||
|
|
||||||
|
func is_planet_conquered(planet_key: String) -> bool:
|
||||||
|
return bool(capture_state(planet_key).get("conquered", false))
|
||||||
|
|
||||||
|
|
||||||
|
func captured_point_count(planet_key: String) -> int:
|
||||||
|
var captured: Array = capture_state(planet_key).get("captured", [])
|
||||||
|
var count: int = 0
|
||||||
|
for c in captured:
|
||||||
|
if c:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
# Runs in the autoload, so mining and transport continue while the player
|
# Runs in the autoload, so mining and transport continue while the player
|
||||||
# is on a planet's surface or nowhere near either.
|
# is on a planet's surface or nowhere near either.
|
||||||
|
|
|
||||||
1
GasCargoCounter.gd.uid
Normal file
1
GasCargoCounter.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dn5wt1hyaui81
|
||||||
1
GasCounter.gd.uid
Normal file
1
GasCounter.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://wykjjb3qc01h
|
||||||
1
GasVentField.gd.uid
Normal file
1
GasVentField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://di1kvjt0dwvqc
|
||||||
33
MainMenu.gd
Normal file
33
MainMenu.gd
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
@onready var play_button: IconButton = $UI/PlayButton
|
||||||
|
@onready var editor_button: IconButton = $UI/EditorButton
|
||||||
|
@onready var quit_button: IconButton = $UI/QuitButton
|
||||||
|
@onready var status_label: Label = $UI/StatusLabel
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
play_button.pressed.connect(_on_play_pressed)
|
||||||
|
editor_button.pressed.connect(_on_editor_pressed)
|
||||||
|
# #48 wires the real map editor scene up to this button; until then it
|
||||||
|
# stays visible but explains itself instead of silently doing nothing
|
||||||
|
# or pointing at a scene that doesn't exist yet.
|
||||||
|
editor_button.enabled = false
|
||||||
|
quit_button.pressed.connect(_on_quit_pressed)
|
||||||
|
quit_button.visible = not OS.has_feature("web")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_play_pressed() -> void:
|
||||||
|
GameState.reset_new_game()
|
||||||
|
get_tree().change_scene_to_file("res://Main.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_editor_pressed() -> void:
|
||||||
|
if editor_button.enabled:
|
||||||
|
get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn")
|
||||||
|
return
|
||||||
|
status_label.text = "Map editor coming soon"
|
||||||
|
|
||||||
|
|
||||||
|
func _on_quit_pressed() -> void:
|
||||||
|
get_tree().quit()
|
||||||
1
MainMenu.gd.uid
Normal file
1
MainMenu.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://p7yl0hc43yai
|
||||||
139
MainMenu.tscn
Normal file
139
MainMenu.tscn
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
[gd_scene load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://MainMenu.gd" id="1"]
|
||||||
|
[ext_resource type="Script" path="res://IconButton.gd" id="2"]
|
||||||
|
[ext_resource type="Shader" path="res://starfield.gdshader" id="3"]
|
||||||
|
|
||||||
|
[sub_resource type="ShaderMaterial" id="1"]
|
||||||
|
shader = ExtResource("3")
|
||||||
|
shader_parameter/camera_cell = Vector2(0, 0)
|
||||||
|
shader_parameter/camera_frac = 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="MainMenu" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||
|
[node name="UI" type="CanvasLayer" parent="."]
|
||||||
|
|
||||||
|
[node name="TitleLabel" type="Label" parent="UI"]
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -300
|
||||||
|
offset_right = 300
|
||||||
|
offset_top = -560
|
||||||
|
offset_bottom = -460
|
||||||
|
theme_override_font_sizes/font_size = 60
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "SPACESHIP TEST"
|
||||||
|
|
||||||
|
[node name="PlayButton" type="Control" parent="UI"]
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -64
|
||||||
|
offset_right = 64
|
||||||
|
offset_top = -300
|
||||||
|
offset_bottom = -172
|
||||||
|
script = ExtResource("2")
|
||||||
|
icon = 2
|
||||||
|
glow_color = Color(0.35, 0.95, 0.55, 1)
|
||||||
|
|
||||||
|
[node name="PlayLabel" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -160
|
||||||
|
offset_right = 160
|
||||||
|
offset_top = -160
|
||||||
|
offset_bottom = -94
|
||||||
|
theme_override_font_sizes/font_size = 32
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Play"
|
||||||
|
|
||||||
|
[node name="EditorButton" type="Control" parent="UI"]
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -64
|
||||||
|
offset_right = 64
|
||||||
|
offset_top = -20
|
||||||
|
offset_bottom = 108
|
||||||
|
script = ExtResource("2")
|
||||||
|
icon = 0
|
||||||
|
glow_color = Color(0.5, 0.75, 1, 1)
|
||||||
|
|
||||||
|
[node name="EditorLabel" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -200
|
||||||
|
offset_right = 200
|
||||||
|
offset_top = 120
|
||||||
|
offset_bottom = 186
|
||||||
|
theme_override_font_sizes/font_size = 32
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Map Editor"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="UI"]
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -260
|
||||||
|
offset_right = 260
|
||||||
|
offset_top = 200
|
||||||
|
offset_bottom = 260
|
||||||
|
theme_override_font_sizes/font_size = 26
|
||||||
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 2
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
[node name="QuitButton" type="Control" parent="UI"]
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -64
|
||||||
|
offset_right = 64
|
||||||
|
offset_top = 320
|
||||||
|
offset_bottom = 448
|
||||||
|
script = ExtResource("2")
|
||||||
|
icon = 1
|
||||||
|
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||||
|
|
||||||
|
[node name="QuitLabel" type="Label" parent="UI"]
|
||||||
|
mouse_filter = 2
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -160
|
||||||
|
offset_right = 160
|
||||||
|
offset_top = 460
|
||||||
|
offset_bottom = 526
|
||||||
|
theme_override_font_sizes/font_size = 32
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "Quit"
|
||||||
16
PlanetSetup.gd
Normal file
16
PlanetSetup.gd
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
class_name PlanetSetup
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
# A hand-authored capture-point/enemy layout for a planet surface. Chosen
|
||||||
|
# deterministically per planet (see PlanetSetups.setup_for_planet) rather
|
||||||
|
# than generated - #40 wants curated setups, not procedural ones - and
|
||||||
|
# stored as a committed .tres under res://PlanetSetups/, not in a save.
|
||||||
|
|
||||||
|
@export var setup_id: String = ""
|
||||||
|
@export var display_name: String = ""
|
||||||
|
# Which PlanetSurfaceProfiles id (e.g. "rocky", "gas") this setup was
|
||||||
|
# authored for; only setups matching a planet's own profile are eligible.
|
||||||
|
@export var surface_profile_id: String = "rocky"
|
||||||
|
# Surface-local positions (the fleet lands at Vector2.ZERO).
|
||||||
|
@export var capture_points: PackedVector2Array = PackedVector2Array()
|
||||||
|
@export var enemy_spawns: Array[Resource] = []
|
||||||
1
PlanetSetup.gd.uid
Normal file
1
PlanetSetup.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://d1govxko60n03
|
||||||
33
PlanetSetupHost.gd
Normal file
33
PlanetSetupHost.gd
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
class_name PlanetSetupHost
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# Instantiates the contents of a PlanetSetup onto a landed surface. Capture
|
||||||
|
# points are placeholder markers here; #43 replaces them with the real
|
||||||
|
# CapturePoint scene through the same apply_setup() entry point, and enemy
|
||||||
|
# spawning is filled in by the enemy-foundation ticket (#44). Keeping this
|
||||||
|
# seam stable is the point of this ticket.
|
||||||
|
|
||||||
|
const MARKER_COLOR: Color = Color(0.55, 0.85, 1.0)
|
||||||
|
const MARKER_RADIUS: float = 36.0
|
||||||
|
|
||||||
|
var setup: Resource
|
||||||
|
|
||||||
|
|
||||||
|
func apply_setup(new_setup: Resource) -> void:
|
||||||
|
setup = new_setup
|
||||||
|
for child in get_children():
|
||||||
|
child.queue_free()
|
||||||
|
if setup == null:
|
||||||
|
return
|
||||||
|
for point in setup.capture_points:
|
||||||
|
_add_capture_point_marker(point)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_capture_point_marker(at: Vector2) -> void:
|
||||||
|
var marker: NeonPiece = NeonPiece.create(self, at, MARKER_COLOR)
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for i in range(16):
|
||||||
|
var angle: float = float(i) / 16.0 * TAU
|
||||||
|
points.append(Vector2(cos(angle), sin(angle)) * MARKER_RADIUS)
|
||||||
|
marker.add_polygon(points, 0.6)
|
||||||
|
marker.queue_redraw()
|
||||||
1
PlanetSetupHost.gd.uid
Normal file
1
PlanetSetupHost.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ckxmhx54fi5s1
|
||||||
6
PlanetSetupHost.tscn
Normal file
6
PlanetSetupHost.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetupHost.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="PlanetSetupHost" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
7
PlanetSetupSpawn.gd
Normal file
7
PlanetSetupSpawn.gd
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
class_name PlanetSetupSpawn
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
# One authored enemy placement within a PlanetSetup.
|
||||||
|
|
||||||
|
@export var enemy_id: String = "chaser"
|
||||||
|
@export var position: Vector2 = Vector2.ZERO
|
||||||
1
PlanetSetupSpawn.gd.uid
Normal file
1
PlanetSetupSpawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c8spn4nio735d
|
||||||
69
PlanetSetups.gd
Normal file
69
PlanetSetups.gd
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
class_name PlanetSetups
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Registry of authored PlanetSetup resources. Deliberately scans
|
||||||
|
# res://PlanetSetups/ at first use instead of a hardcoded preload
|
||||||
|
# dictionary (contrast PlanetDefinitions.gd), so dropping a new .tres in
|
||||||
|
# that folder is the only step needed to add a setup - the map editor
|
||||||
|
# (#48) writes there directly.
|
||||||
|
|
||||||
|
const SETUPS_DIR: String = "res://PlanetSetups"
|
||||||
|
# Compared against get_script() rather than an `is PlanetSetup` check - the
|
||||||
|
# same load-order hazard as PlanetSurfaceController's setup_host: a global
|
||||||
|
# class_name identifier can be looked up before an import pass has
|
||||||
|
# registered it, while a direct script preload never depends on that cache.
|
||||||
|
const PLANET_SETUP_SCRIPT: Script = preload("res://PlanetSetup.gd")
|
||||||
|
|
||||||
|
static var _all: Array = []
|
||||||
|
static var _scanned: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
static func _ensure_scanned() -> void:
|
||||||
|
if _scanned:
|
||||||
|
return
|
||||||
|
_scanned = true
|
||||||
|
var dir: DirAccess = DirAccess.open(SETUPS_DIR)
|
||||||
|
if dir == null:
|
||||||
|
return
|
||||||
|
dir.list_dir_begin()
|
||||||
|
var file_name: String = dir.get_next()
|
||||||
|
while file_name != "":
|
||||||
|
if not dir.current_is_dir() and file_name.ends_with(".tres"):
|
||||||
|
var res: Resource = load(SETUPS_DIR + "/" + file_name)
|
||||||
|
if res != null and res.get_script() == PLANET_SETUP_SCRIPT:
|
||||||
|
_all.append(res)
|
||||||
|
file_name = dir.get_next()
|
||||||
|
dir.list_dir_end()
|
||||||
|
|
||||||
|
|
||||||
|
static func rescan() -> void:
|
||||||
|
# The map editor calls this after saving so a freshly written setup is
|
||||||
|
# visible without restarting the process.
|
||||||
|
_all = []
|
||||||
|
_scanned = false
|
||||||
|
_ensure_scanned()
|
||||||
|
|
||||||
|
|
||||||
|
static func all() -> Array:
|
||||||
|
_ensure_scanned()
|
||||||
|
return _all
|
||||||
|
|
||||||
|
|
||||||
|
static func setups_for_profile(profile_id: String) -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for setup in all():
|
||||||
|
if setup.surface_profile_id == profile_id:
|
||||||
|
result.append(setup)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
|
||||||
|
# Same pattern as WorldGen.planet_data_at_chunk: seeded purely from the
|
||||||
|
# planet's own stable id, so the same planet always resolves to the
|
||||||
|
# same setup from anywhere, with nothing stored.
|
||||||
|
var candidates: Array = setups_for_profile(profile_id)
|
||||||
|
if candidates.is_empty():
|
||||||
|
return null
|
||||||
|
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||||
|
rng.seed = hash(planet_id)
|
||||||
|
return candidates[rng.randi() % candidates.size()]
|
||||||
1
PlanetSetups.gd.uid
Normal file
1
PlanetSetups.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c5ndxvompklip
|
||||||
27
PlanetSetups/gas_default.tres
Normal file
27
PlanetSetups/gas_default.tres
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetup.gd" id="1"]
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_g1"]
|
||||||
|
script = ExtResource("2")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
position = Vector2(500, -300)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_g2"]
|
||||||
|
script = ExtResource("2")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
position = Vector2(-450, 400)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_g3"]
|
||||||
|
script = ExtResource("2")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
position = Vector2(200, 700)
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
setup_id = "gas_default"
|
||||||
|
display_name = "Gas Default"
|
||||||
|
surface_profile_id = "gas"
|
||||||
|
capture_points = PackedVector2Array(600, -200, -550, -250, 0, 650, -700, 450)
|
||||||
|
enemy_spawns = Array[Resource]([SubResource("Resource_g1"), SubResource("Resource_g2"), SubResource("Resource_g3")])
|
||||||
22
PlanetSetups/rocky_default.tres
Normal file
22
PlanetSetups/rocky_default.tres
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetup.gd" id="1"]
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_r1"]
|
||||||
|
script = ExtResource("2")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
position = Vector2(450, 250)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_r2"]
|
||||||
|
script = ExtResource("2")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
position = Vector2(-600, -350)
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
setup_id = "rocky_default"
|
||||||
|
display_name = "Rocky Default"
|
||||||
|
surface_profile_id = "rocky"
|
||||||
|
capture_points = PackedVector2Array(700, 100, -650, 300, 50, -750)
|
||||||
|
enemy_spawns = Array[Resource]([SubResource("Resource_r1"), SubResource("Resource_r2")])
|
||||||
|
|
@ -2,6 +2,8 @@ extends Node2D
|
||||||
|
|
||||||
const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd")
|
const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd")
|
||||||
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
|
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
|
||||||
|
const PLANET_SETUPS = preload("res://PlanetSetups.gd")
|
||||||
|
const SETUP_HOST_SCENE: PackedScene = preload("res://PlanetSetupHost.tscn")
|
||||||
|
|
||||||
# Bootstraps the surviving fleet, including each vessel type, on this scene's
|
# Bootstraps the surviving fleet, including each vessel type, on this scene's
|
||||||
# own startup (from GameState, since this is a fresh scene tree - nothing here
|
# own startup (from GameState, since this is a fresh scene tree - nothing here
|
||||||
|
|
@ -46,11 +48,22 @@ func _compose_surface(profile: Resource) -> void:
|
||||||
var caption: Label = get_node(caption_path)
|
var caption: Label = get_node(caption_path)
|
||||||
caption.add_theme_color_override("font_color", profile.caption_color)
|
caption.add_theme_color_override("font_color", profile.caption_color)
|
||||||
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
||||||
if profile.features_scene == null:
|
if profile.features_scene != null:
|
||||||
return
|
|
||||||
var features: Node = profile.features_scene.instantiate()
|
var features: Node = profile.features_scene.instantiate()
|
||||||
feature_host.add_child(features)
|
feature_host.add_child(features)
|
||||||
|
|
||||||
|
var setup: Resource = PLANET_SETUPS.setup_for_planet(GameState.planet_id, profile.profile_id)
|
||||||
|
if setup != null:
|
||||||
|
# Untyped on purpose: a static PlanetSetupHost annotation here would
|
||||||
|
# need the global class_name cache to already know about it, which
|
||||||
|
# is only true after an editor/import pass has run (see #31/#35's
|
||||||
|
# load-order notes) - a plain Node reference plus duck-typed
|
||||||
|
# apply_setup() sidesteps that entirely, same as BuildingDefinitions
|
||||||
|
# and VesselDefinitions already do for their own resource types.
|
||||||
|
var setup_host: Node = SETUP_HOST_SCENE.instantiate()
|
||||||
|
feature_host.add_child(setup_host)
|
||||||
|
setup_host.apply_setup(setup)
|
||||||
|
|
||||||
|
|
||||||
func _on_exit_pressed() -> void:
|
func _on_exit_pressed() -> void:
|
||||||
if transitioning:
|
if transitioning:
|
||||||
|
|
|
||||||
1
ResourceGlyph.gd.uid
Normal file
1
ResourceGlyph.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://beff2kjshg4vf
|
||||||
1
ResourceTransferList.gd.uid
Normal file
1
ResourceTransferList.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bv6u4t3oabews
|
||||||
22
Ship.gd
22
Ship.gd
|
|
@ -115,17 +115,27 @@ func _physics_process(delta: float) -> void:
|
||||||
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
||||||
|
|
||||||
|
|
||||||
func _on_area_entered(area: Area2D) -> void:
|
func take_damage(_amount: int) -> bool:
|
||||||
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
# Ships have no hit points - any unabsorbed hit is lethal. A shield
|
||||||
return
|
# charge absorbs a hit entirely regardless of its amount, matching
|
||||||
|
# VesselShieldComponent's existing all-or-nothing semantics; `_amount`
|
||||||
|
# is accepted (not `int`-clamped away) so callers like EnemyProjectile
|
||||||
|
# have a normal damage parameter to pass even though it isn't graded here.
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child.has_method("absorb_hit") and child.absorb_hit():
|
if child.has_method("absorb_hit") and child.absorb_hit():
|
||||||
if area.is_in_group("asteroids") and is_instance_valid(area):
|
return true
|
||||||
area.queue_free()
|
|
||||||
return
|
|
||||||
ShipExplosionVFX.spawn(get_tree().root, position)
|
ShipExplosionVFX.spawn(get_tree().root, position)
|
||||||
destroyed.emit()
|
destroyed.emit()
|
||||||
queue_free()
|
queue_free()
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_area_entered(area: Area2D) -> void:
|
||||||
|
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
||||||
|
return
|
||||||
|
var absorbed: bool = take_damage(1)
|
||||||
|
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
|
||||||
|
area.queue_free()
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|
|
||||||
1
VesselDelivery.gd.uid
Normal file
1
VesselDelivery.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ft0puvels7lg
|
||||||
1
VesselDeliveryField.gd.uid
Normal file
1
VesselDeliveryField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://clgmyishafevr
|
||||||
|
|
@ -11,7 +11,7 @@ config_version=5
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Spaceship Test"
|
config/name="Spaceship Test"
|
||||||
run/main_scene="res://Main.tscn"
|
run/main_scene="res://MainMenu.tscn"
|
||||||
config/features=PackedStringArray("4.7")
|
config/features=PackedStringArray("4.7")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,5 +39,5 @@ func _ready() -> void:
|
||||||
for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]:
|
for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]:
|
||||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||||
enemy.position = skill.aim_target + offset
|
enemy.position = skill.aim_target + offset
|
||||||
enemy.speed = 0.0
|
|
||||||
main.add_child(enemy)
|
main.add_child(enemy)
|
||||||
|
enemy.speed = 0.0 # after add_child - _ready() sets it from the definition
|
||||||
|
|
|
||||||
1
tests/_gas_surface_preview.gd.uid
Normal file
1
tests/_gas_surface_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://x0rvhwfnneqp
|
||||||
12
tests/_setup_markers_preview.gd
Normal file
12
tests/_setup_markers_preview.gd
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
GameState.planet_name = "Kepler's Rest"
|
||||||
|
GameState.planet_id = "chunk:3:7"
|
||||||
|
GameState.planet_definition_id = "rocky"
|
||||||
|
GameState.surviving_ship_count = 3
|
||||||
|
GameState.rocks = 128
|
||||||
|
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
surface.get_node("SurfaceController").fade_duration = 0.0
|
||||||
|
add_child(surface)
|
||||||
1
tests/_setup_markers_preview.gd.uid
Normal file
1
tests/_setup_markers_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dtpta85omp8cm
|
||||||
6
tests/_setup_markers_preview.tscn
Normal file
6
tests/_setup_markers_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://tests/_setup_markers_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="SetupMarkersPreview" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
78
tests/test_enemy_composition.gd
Normal file
78
tests/test_enemy_composition.gd
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_enemy_composition.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. an Enemy resolves its stats/hull/color from EnemyDefinitions, not
|
||||||
|
# hardcoded fields
|
||||||
|
# 2. an enemy instantiated under a node that is NOT a sibling of Fleet
|
||||||
|
# still finds the fleet and pursues it (the old `../Fleet` path would
|
||||||
|
# break here - this is exactly the planet-surface hazard #44 exists
|
||||||
|
# to close before #45/#46 land)
|
||||||
|
|
||||||
|
var main_scene: Node
|
||||||
|
var fleet: Node
|
||||||
|
var nested_host: Node2D
|
||||||
|
var enemy: Node
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var stage: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var chaser_def: Resource = EnemyDefinitions.get_definition("chaser")
|
||||||
|
if chaser_def.enemy_id != "chaser" or chaser_def.max_hp != 3:
|
||||||
|
return _fail("EnemyDefinitions did not resolve the chaser definition")
|
||||||
|
|
||||||
|
main_scene = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(main_scene)
|
||||||
|
fleet = main_scene.get_node("Fleet")
|
||||||
|
main_scene.get_node("EnemySpawner").set_process(false)
|
||||||
|
|
||||||
|
# Deliberately NOT a sibling of Fleet - two levels deep under an
|
||||||
|
# unrelated node, the way a planet surface's SurfaceFeatures parents
|
||||||
|
# things.
|
||||||
|
nested_host = Node2D.new()
|
||||||
|
nested_host.name = "NestedHost"
|
||||||
|
main_scene.add_child(nested_host)
|
||||||
|
var inner: Node2D = Node2D.new()
|
||||||
|
inner.name = "Inner"
|
||||||
|
nested_host.add_child(inner)
|
||||||
|
|
||||||
|
enemy = load("res://Enemy.tscn").instantiate()
|
||||||
|
enemy.position = fleet.get_centroid() + Vector2(600, 0)
|
||||||
|
inner.add_child(enemy)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
if enemy.definition == null or enemy.definition.enemy_id != "chaser":
|
||||||
|
return _fail("enemy did not resolve its definition on _ready")
|
||||||
|
if enemy.hp != enemy.definition.max_hp:
|
||||||
|
return _fail("enemy hp did not come from the definition")
|
||||||
|
if not enemy.has_capability("pursuit"):
|
||||||
|
return _fail("chaser did not compose a pursuit component")
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# Give the pursuit component a few physics ticks to close distance.
|
||||||
|
if elapsed < 1.0:
|
||||||
|
return false
|
||||||
|
var distance: float = enemy.position.distance_to(fleet.get_centroid())
|
||||||
|
print("distance_after_1s=%.1f (started at 600)" % distance)
|
||||||
|
if distance >= 550.0:
|
||||||
|
return _fail("enemy under a non-sibling host never approached the fleet")
|
||||||
|
print("TEST_ENEMY_COMPOSITION: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_ENEMY_COMPOSITION: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_enemy_composition.gd.uid
Normal file
1
tests/test_enemy_composition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dltu3a0n318lo
|
||||||
91
tests/test_enemy_projectile_damage.gd
Normal file
91
tests/test_enemy_projectile_damage.gd
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_enemy_projectile_damage.gd
|
||||||
|
# Verifies the ranged damage path #44 adds: an EnemyProjectile destroys an
|
||||||
|
# unshielded ship on contact, and is absorbed by a shielded ship, removing
|
||||||
|
# exactly one charge rather than destroying it.
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var scene: Node
|
||||||
|
var fleet: Node
|
||||||
|
var interceptor: Node
|
||||||
|
var shield_ship: 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()
|
||||||
|
game_state.transporters.clear()
|
||||||
|
game_state.stored_vessels.clear()
|
||||||
|
game_state.build_time_scale = 0.02
|
||||||
|
game_state.reset_progression()
|
||||||
|
game_state.rocks = 2000
|
||||||
|
scene = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
fleet = scene.get_node("Fleet")
|
||||||
|
|
||||||
|
|
||||||
|
func _fire_at(ship: Node) -> void:
|
||||||
|
var projectile: Area2D = load("res://EnemyProjectile.tscn").instantiate()
|
||||||
|
projectile.global_position = ship.global_position
|
||||||
|
projectile.launch(Vector2.ZERO) # already on top of the target
|
||||||
|
scene.add_child(projectile)
|
||||||
|
|
||||||
|
|
||||||
|
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.spawn_ships(1, Vector2.ZERO)
|
||||||
|
interceptor = fleet.ships[0]
|
||||||
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
||||||
|
shield_ship = fleet.ships[1]
|
||||||
|
shield_ship.position = Vector2(2000.0, 0.0)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
if not shield_ship.has_shield():
|
||||||
|
return _fail("a fresh shield vessel has no shield")
|
||||||
|
_fire_at(interceptor)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
if elapsed < 0.4:
|
||||||
|
return false
|
||||||
|
if is_instance_valid(interceptor):
|
||||||
|
return _fail("an unshielded ship survived an enemy projectile")
|
||||||
|
print("unshielded interceptor destroyed by an enemy projectile")
|
||||||
|
|
||||||
|
_fire_at(shield_ship)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
if elapsed < 0.4:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(shield_ship):
|
||||||
|
return _fail("a shielded ship was destroyed by an enemy projectile")
|
||||||
|
if shield_ship.shield_charges != 0:
|
||||||
|
return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" %
|
||||||
|
shield_ship.shield_charges)
|
||||||
|
print("shielded ship absorbed the hit and lost exactly one charge")
|
||||||
|
print("TEST_ENEMY_PROJECTILE_DAMAGE: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_ENEMY_PROJECTILE_DAMAGE: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_enemy_projectile_damage.gd.uid
Normal file
1
tests/test_enemy_projectile_damage.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cx7f3e82jkumb
|
||||||
|
|
@ -42,6 +42,12 @@ func _initialize() -> void:
|
||||||
game_state.rocks = 200
|
game_state.rocks = 200
|
||||||
game_state.buildings.clear()
|
game_state.buildings.clear()
|
||||||
game_state.extractors.clear()
|
game_state.extractors.clear()
|
||||||
|
game_state.planet_capture.clear()
|
||||||
|
# This test is about the planet-proximity/duplicate rules, not conquest
|
||||||
|
# (see test_planet_conquest_gate), so pre-conquer the target planet.
|
||||||
|
var target_planet_id: String = "chunk:%d:%d" % [target_chunk.x, target_chunk.y]
|
||||||
|
game_state.register_capture_points(target_planet_id, 1)
|
||||||
|
game_state.mark_point_captured(target_planet_id, 0)
|
||||||
# Extractors are gated behind research (see test_skill_tree). This test
|
# Extractors are gated behind research (see test_skill_tree). This test
|
||||||
# is about the planet-proximity rule, so grant the research up front.
|
# is about the planet-proximity rule, so grant the research up front.
|
||||||
game_state.reset_progression()
|
game_state.reset_progression()
|
||||||
|
|
|
||||||
|
|
@ -107,16 +107,19 @@ func _process(delta: float) -> bool:
|
||||||
if main.get_node("Camera2D").zoom.x >= original_zoom:
|
if main.get_node("Camera2D").zoom.x >= original_zoom:
|
||||||
return _fail_now("long aim did not zoom out")
|
return _fail_now("long aim did not zoom out")
|
||||||
|
|
||||||
|
# speed is set AFTER add_child(): _ready() copies it from the
|
||||||
|
# enemy's definition, which would otherwise clobber a
|
||||||
|
# pre-add_child override the instant the enemy entered the tree.
|
||||||
for offset in [Vector2(-60, 0), Vector2(70, 35)]:
|
for offset in [Vector2(-60, 0), Vector2(70, 35)]:
|
||||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||||
enemy.position = skill.aim_target + offset
|
enemy.position = skill.aim_target + offset
|
||||||
enemy.speed = 0.0
|
|
||||||
main.add_child(enemy)
|
main.add_child(enemy)
|
||||||
|
enemy.speed = 0.0
|
||||||
inside_enemies.append(enemy)
|
inside_enemies.append(enemy)
|
||||||
outside_enemy = load("res://Enemy.tscn").instantiate()
|
outside_enemy = load("res://Enemy.tscn").instantiate()
|
||||||
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
|
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
|
||||||
outside_enemy.speed = 0.0
|
|
||||||
main.add_child(outside_enemy)
|
main.add_child(outside_enemy)
|
||||||
|
outside_enemy.speed = 0.0
|
||||||
|
|
||||||
var release := InputEventScreenTouch.new()
|
var release := InputEventScreenTouch.new()
|
||||||
release.index = 7
|
release.index = 7
|
||||||
|
|
|
||||||
1
tests/test_gas_system.gd.uid
Normal file
1
tests/test_gas_system.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ce1d4f81i7iqp
|
||||||
1
tests/test_lab_resources.gd.uid
Normal file
1
tests/test_lab_resources.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dbp0236qseh1x
|
||||||
95
tests/test_main_menu.gd
Normal file
95
tests/test_main_menu.gd
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_main_menu.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. the menu loads with Play/Map Editor/Quit, the editor button starts
|
||||||
|
# disabled and explains itself instead of silently doing nothing
|
||||||
|
# 2. Quit's visibility follows OS.has_feature("web") (this platform is
|
||||||
|
# not a web export, so this run exercises the "visible" branch; the
|
||||||
|
# condition itself is what's under test, not the concrete boolean)
|
||||||
|
# 3. Play resets GameState to a fresh game AND reaches Main.tscn
|
||||||
|
|
||||||
|
var menu: Node
|
||||||
|
var game_state: Node
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var stage: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
menu = load("res://MainMenu.tscn").instantiate()
|
||||||
|
root.add_child(menu)
|
||||||
|
current_scene = menu
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
# A scene added to the tree inside _initialize() has not had its
|
||||||
|
# _ready() flushed yet - see test_planet_setups.gd for the same
|
||||||
|
# hazard - so the menu's @onready buttons aren't valid until a
|
||||||
|
# frame has actually processed.
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
if menu.editor_button.enabled:
|
||||||
|
return _fail("map editor button is not disabled ahead of #48")
|
||||||
|
if menu.quit_button.visible != (not OS.has_feature("web")):
|
||||||
|
return _fail("quit button visibility did not follow OS.has_feature('web')")
|
||||||
|
|
||||||
|
_press(menu.editor_button)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
if "coming soon" not in menu.status_label.text.to_lower():
|
||||||
|
return _fail("disabled editor button press did not explain itself")
|
||||||
|
if current_scene != menu:
|
||||||
|
return _fail("pressing the disabled editor button changed scenes")
|
||||||
|
|
||||||
|
# Dirty the game state exactly like an in-progress run would,
|
||||||
|
# so a genuine reset is distinguishable from a coincidental match.
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999)
|
||||||
|
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
|
||||||
|
game_state.surviving_ship_count = 1
|
||||||
|
game_state.planet_capture["chunk:1:1"] = {"captured": [true], "conquered": true}
|
||||||
|
|
||||||
|
_press(menu.play_button)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
var on_main: bool = current_scene != null and current_scene.name == "Main"
|
||||||
|
var reset_ok: bool = game_state.rocks == 500 and game_state.buildings.is_empty() \
|
||||||
|
and game_state.surviving_ship_count == GameState.BASE_FLEET_SIZE \
|
||||||
|
and game_state.planet_capture.is_empty()
|
||||||
|
print("on_main=%s reset_ok=%s rocks=%d buildings=%d ship_count=%d" % [
|
||||||
|
on_main, reset_ok, game_state.rocks, game_state.buildings.size(),
|
||||||
|
game_state.surviving_ship_count
|
||||||
|
])
|
||||||
|
if not on_main or not reset_ok:
|
||||||
|
return _fail("Play did not reset to a fresh game before reaching Main.tscn")
|
||||||
|
print("TEST_MAIN_MENU: PASS")
|
||||||
|
quit(0)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_MAIN_MENU: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_main_menu.gd.uid
Normal file
1
tests/test_main_menu.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dgf2ichdb7d4j
|
||||||
157
tests/test_planet_conquest_gate.gd
Normal file
157
tests/test_planet_conquest_gate.gd
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_planet_conquest_gate.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. GameState starts every planet unconquered, and register_capture_points
|
||||||
|
# is idempotent (a second call with the same total doesn't wipe progress)
|
||||||
|
# 2. mark_point_captured only conquers once every point is captured, and
|
||||||
|
# the planet_conquered signal fires exactly once
|
||||||
|
# 3. over an unconquered planet, the extractor build option is disabled,
|
||||||
|
# the info popup explains why, and confirming spends nothing and builds
|
||||||
|
# nothing
|
||||||
|
# 4. once conquered, the same build flow works and charges the cost
|
||||||
|
|
||||||
|
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
|
||||||
|
var conquered_signal_count: 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:
|
||||||
|
return _fail("no planet chunk to test with")
|
||||||
|
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.rocks = 200
|
||||||
|
game_state.buildings.clear()
|
||||||
|
game_state.extractors.clear()
|
||||||
|
game_state.planet_capture.clear()
|
||||||
|
game_state.reset_progression()
|
||||||
|
for node in GameState.SKILL_TREE:
|
||||||
|
game_state.unlocked.append(node.id)
|
||||||
|
|
||||||
|
var target_planet_id: String = "chunk:%d:%d" % [target_chunk.x, target_chunk.y]
|
||||||
|
|
||||||
|
# Unit-level checks on GameState itself, before any scene involvement.
|
||||||
|
if game_state.is_planet_conquered(target_planet_id):
|
||||||
|
return _fail("a fresh planet reported conquered")
|
||||||
|
game_state.register_capture_points(target_planet_id, 3)
|
||||||
|
game_state.mark_point_captured(target_planet_id, 0)
|
||||||
|
game_state.register_capture_points(target_planet_id, 3) # idempotent re-registration
|
||||||
|
if game_state.captured_point_count(target_planet_id) != 1:
|
||||||
|
return _fail("re-registering wiped already-captured progress")
|
||||||
|
game_state.planet_conquered.connect(func(_id): conquered_signal_count += 1)
|
||||||
|
game_state.mark_point_captured(target_planet_id, 1)
|
||||||
|
if game_state.is_planet_conquered(target_planet_id):
|
||||||
|
return _fail("conquered with points still missing")
|
||||||
|
game_state.mark_point_captured(target_planet_id, 2)
|
||||||
|
if not game_state.is_planet_conquered(target_planet_id):
|
||||||
|
return _fail("all points captured but planet not marked conquered")
|
||||||
|
# Marking an already-captured point again must not double-fire the signal.
|
||||||
|
game_state.mark_point_captured(target_planet_id, 2)
|
||||||
|
if conquered_signal_count != 1:
|
||||||
|
return _fail("planet_conquered fired %d times, expected exactly 1" % conquered_signal_count)
|
||||||
|
|
||||||
|
# Reset conquest for the scene-level gate check below - this run tests
|
||||||
|
# the unconquered path first, then conquers it.
|
||||||
|
game_state.planet_capture.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)
|
||||||
|
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):
|
||||||
|
return _fail("planet never spawned")
|
||||||
|
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
|
||||||
|
var planet_field: Node = main_scene.get_node("PlanetField")
|
||||||
|
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
|
||||||
|
|
||||||
|
controller._on_build_pressed()
|
||||||
|
if button.enabled:
|
||||||
|
return _fail("extractor option enabled over an unconquered planet")
|
||||||
|
controller._show_building_info(GameState.BUILDING_EXTRACTOR)
|
||||||
|
var explains_capture: bool = popup.visible \
|
||||||
|
and "captur" in popup.get_node("Panel/MessageLabel").text.to_lower()
|
||||||
|
var offered: bool = popup.get_node("Panel/BuildButton").visible
|
||||||
|
if not explains_capture or offered:
|
||||||
|
return _fail("unconquered popup did not explain itself, or offered a build")
|
||||||
|
var rocks_before: int = game_state.rocks
|
||||||
|
controller._confirm_build() # must do nothing even if somehow pressed
|
||||||
|
if game_state.rocks != rocks_before or not game_state.extractors.is_empty():
|
||||||
|
return _fail("build proceeded over an unconquered planet")
|
||||||
|
popup.visible = false
|
||||||
|
|
||||||
|
# Now conquer it and confirm the same flow succeeds.
|
||||||
|
game_state.register_capture_points(planet.planet_id, 2)
|
||||||
|
game_state.mark_point_captured(planet.planet_id, 0)
|
||||||
|
game_state.mark_point_captured(planet.planet_id, 1)
|
||||||
|
|
||||||
|
controller._on_build_pressed()
|
||||||
|
if not button.enabled:
|
||||||
|
return _fail("extractor option stayed disabled after conquest")
|
||||||
|
controller._show_building_info(GameState.BUILDING_EXTRACTOR)
|
||||||
|
if not popup.get_node("Panel/BuildButton").visible:
|
||||||
|
return _fail("conquered planet did not offer a build")
|
||||||
|
controller._confirm_build()
|
||||||
|
var built: bool = game_state.has_extractor_at(planet.planet_id)
|
||||||
|
var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST
|
||||||
|
if not built or not charged:
|
||||||
|
return _fail("conquered planet did not build/charge correctly")
|
||||||
|
|
||||||
|
print("TEST_PLANET_CONQUEST_GATE: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_PLANET_CONQUEST_GATE: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_planet_conquest_gate.gd.uid
Normal file
1
tests/test_planet_conquest_gate.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://k4l7u3r84jch
|
||||||
129
tests/test_planet_setups.gd
Normal file
129
tests/test_planet_setups.gd
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_planet_setups.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. setup_for_planet is deterministic per planet_id and only returns
|
||||||
|
# setups matching the requested surface profile
|
||||||
|
# 2. dropping a new .tres into res://PlanetSetups/ makes it appear via
|
||||||
|
# PlanetSetups.rescan() with no other code change
|
||||||
|
# 3. landing on a rocky/gas planet composes a PlanetSetupHost carrying the
|
||||||
|
# resolved setup, with one marker per capture point
|
||||||
|
|
||||||
|
const SCRATCH_SETUP_PATH: String = "res://PlanetSetups/_test_scratch_setup.tres"
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var rocky_setup: Resource
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var pending_surface: Node
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
# --- 1. determinism and profile filtering ---
|
||||||
|
rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky")
|
||||||
|
if rocky_setup == null:
|
||||||
|
return _fail("no rocky setup resolved for a rocky planet id")
|
||||||
|
if rocky_setup.surface_profile_id != "rocky":
|
||||||
|
return _fail("resolved setup does not match the requested profile")
|
||||||
|
var rocky_setup_again: Resource = PlanetSetups.setup_for_planet("chunk:3:7", "rocky")
|
||||||
|
if rocky_setup != rocky_setup_again:
|
||||||
|
return _fail("setup_for_planet was not deterministic for the same planet id")
|
||||||
|
|
||||||
|
var gas_setup: Resource = PlanetSetups.setup_for_planet("chunk:3:7", "gas")
|
||||||
|
if gas_setup == null:
|
||||||
|
return _fail("no gas setup resolved for the same planet id under the gas profile")
|
||||||
|
if gas_setup.surface_profile_id != "gas":
|
||||||
|
return _fail("gas request returned a mismatched-profile setup")
|
||||||
|
|
||||||
|
for setup in PlanetSetups.setups_for_profile("rocky"):
|
||||||
|
if setup.surface_profile_id != "rocky":
|
||||||
|
return _fail("setups_for_profile('rocky') leaked a non-rocky setup")
|
||||||
|
|
||||||
|
# --- 2. dropping a new .tres is picked up by rescan() ---
|
||||||
|
var before_count: int = PlanetSetups.setups_for_profile("rocky").size()
|
||||||
|
var scratch: Resource = PlanetSetup.new()
|
||||||
|
scratch.setup_id = "_test_scratch_setup"
|
||||||
|
scratch.display_name = "Scratch"
|
||||||
|
scratch.surface_profile_id = "rocky"
|
||||||
|
scratch.capture_points = PackedVector2Array([Vector2(100, 100)])
|
||||||
|
var save_err: int = ResourceSaver.save(scratch, SCRATCH_SETUP_PATH)
|
||||||
|
if save_err != OK:
|
||||||
|
return _fail("could not save the scratch setup for the pickup check (err %d)" % save_err)
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
var after_count: int = PlanetSetups.setups_for_profile("rocky").size()
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH))
|
||||||
|
var import_file: String = SCRATCH_SETUP_PATH + ".import"
|
||||||
|
if FileAccess.file_exists(import_file):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(import_file))
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
if after_count != before_count + 1:
|
||||||
|
return _fail("registry did not pick up a newly saved setup (before=%d after=%d)" % [
|
||||||
|
before_count, after_count
|
||||||
|
])
|
||||||
|
|
||||||
|
# --- 3. landing composes a PlanetSetupHost with the resolved setup ---
|
||||||
|
# Deferred to _process(): a scene instantiated and added to the tree
|
||||||
|
# inside _initialize() has not had its _ready() chain flushed yet (this
|
||||||
|
# runs before the engine's first frame), so children added during
|
||||||
|
# PlanetSurfaceController._ready() are not observable until a frame has
|
||||||
|
# actually processed - see test_gas_system.gd for the same pattern.
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.planet_name = "Setup Test"
|
||||||
|
game_state.planet_id = "chunk:3:7"
|
||||||
|
game_state.planet_definition_id = "rocky"
|
||||||
|
pending_surface = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
root.add_child(pending_surface)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
var host: Node = pending_surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost")
|
||||||
|
if host == null:
|
||||||
|
pending_surface.free()
|
||||||
|
return _fail("landing did not compose a PlanetSetupHost")
|
||||||
|
if host.setup != rocky_setup:
|
||||||
|
pending_surface.free()
|
||||||
|
return _fail("host did not receive the deterministically resolved setup")
|
||||||
|
var marker_count: int = 0
|
||||||
|
for child in host.get_children():
|
||||||
|
if child is NeonPiece:
|
||||||
|
marker_count += 1
|
||||||
|
if marker_count != rocky_setup.capture_points.size():
|
||||||
|
pending_surface.free()
|
||||||
|
return _fail("host placed %d markers for %d capture points" % [
|
||||||
|
marker_count, rocky_setup.capture_points.size()
|
||||||
|
])
|
||||||
|
pending_surface.free()
|
||||||
|
|
||||||
|
game_state.planet_definition_id = "gas_giant"
|
||||||
|
pending_surface = load("res://PlanetSurface.tscn").instantiate()
|
||||||
|
root.add_child(pending_surface)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
var gas_host: Node = pending_surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost")
|
||||||
|
if gas_host == null or gas_host.setup.surface_profile_id != "gas":
|
||||||
|
pending_surface.free()
|
||||||
|
return _fail("gas landing did not compose a matching-profile setup host")
|
||||||
|
pending_surface.free()
|
||||||
|
|
||||||
|
print("TEST_PLANET_SETUPS: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_PLANET_SETUPS: FAIL (%s)" % message)
|
||||||
|
if FileAccess.file_exists(SCRATCH_SETUP_PATH):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH))
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_planet_setups.gd.uid
Normal file
1
tests/test_planet_setups.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://d2ekw3y7ipgy2
|
||||||
1
tests/test_vessel_delivery.gd.uid
Normal file
1
tests/test_vessel_delivery.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bbqmyl26uqrbj
|
||||||
Loading…
Reference in a new issue