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 <noreply@anthropic.com>
This commit is contained in:
commit
85814ce88c
192 changed files with 9602 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
40
Asteroid.gd
Normal file
40
Asteroid.gd
Normal file
|
|
@ -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)
|
||||
1
Asteroid.gd.uid
Normal file
1
Asteroid.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cfakkuki24iks
|
||||
12
Asteroid.tscn
Normal file
12
Asteroid.tscn
Normal file
|
|
@ -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")
|
||||
29
AsteroidExplosionVFX.gd
Normal file
29
AsteroidExplosionVFX.gd
Normal file
|
|
@ -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))
|
||||
1
AsteroidExplosionVFX.gd.uid
Normal file
1
AsteroidExplosionVFX.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dfy47yln6oyhx
|
||||
86
AsteroidField.gd
Normal file
86
AsteroidField.gd
Normal file
|
|
@ -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
|
||||
1
AsteroidField.gd.uid
Normal file
1
AsteroidField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bmwogtrwsr7kh
|
||||
351
BuildController.gd
Normal file
351
BuildController.gd
Normal file
|
|
@ -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()
|
||||
1
BuildController.gd.uid
Normal file
1
BuildController.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://csfvnrer8sb4a
|
||||
364
Building.gd
Normal file
364
Building.gd
Normal file
|
|
@ -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)
|
||||
1
Building.gd.uid
Normal file
1
Building.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c8udgpiqd4rul
|
||||
6
Building.tscn
Normal file
6
Building.tscn
Normal file
|
|
@ -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")
|
||||
50
BuildingField.gd
Normal file
50
BuildingField.gd
Normal file
|
|
@ -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)
|
||||
1
BuildingField.gd.uid
Normal file
1
BuildingField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bomt0q8eour38
|
||||
64
Enemy.gd
Normal file
64
Enemy.gd
Normal file
|
|
@ -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)
|
||||
1
Enemy.gd.uid
Normal file
1
Enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b7b4c2nvmevyl
|
||||
12
Enemy.tscn
Normal file
12
Enemy.tscn
Normal file
|
|
@ -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")
|
||||
12
EnemyExplosionVFX.gd
Normal file
12
EnemyExplosionVFX.gd
Normal file
|
|
@ -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)
|
||||
1
EnemyExplosionVFX.gd.uid
Normal file
1
EnemyExplosionVFX.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cblt8bqc6mc3q
|
||||
42
EnemySpawner.gd
Normal file
42
EnemySpawner.gd
Normal file
|
|
@ -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)
|
||||
1
EnemySpawner.gd.uid
Normal file
1
EnemySpawner.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://g3c73d6givuo
|
||||
23
ExplorationTracker.gd
Normal file
23
ExplorationTracker.gd
Normal file
|
|
@ -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()
|
||||
1
ExplorationTracker.gd.uid
Normal file
1
ExplorationTracker.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://gsuymyal5kyd
|
||||
58
ExplosionVFXBase.gd
Normal file
58
ExplosionVFXBase.gd
Normal file
|
|
@ -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
|
||||
1
ExplosionVFXBase.gd.uid
Normal file
1
ExplosionVFXBase.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cpb6vf3j6og6r
|
||||
195
Fleet.gd
Normal file
195
Fleet.gd
Normal file
|
|
@ -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()
|
||||
1
Fleet.gd.uid
Normal file
1
Fleet.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ckf3enlke7dol
|
||||
138
FormationEditor.gd
Normal file
138
FormationEditor.gd
Normal file
|
|
@ -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)
|
||||
1
FormationEditor.gd.uid
Normal file
1
FormationEditor.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dv12yrraa6oer
|
||||
313
GameController.gd
Normal file
313
GameController.gd
Normal file
|
|
@ -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)
|
||||
1
GameController.gd.uid
Normal file
1
GameController.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://5tvbknhs0afu
|
||||
194
GameState.gd
Normal file
194
GameState.gd
Normal file
|
|
@ -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
|
||||
1
GameState.gd.uid
Normal file
1
GameState.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://xyukiutybufd
|
||||
39
Hauler.gd
Normal file
39
Hauler.gd
Normal file
|
|
@ -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)
|
||||
1
Hauler.gd.uid
Normal file
1
Hauler.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dpmpi0bq7ncjb
|
||||
6
Hauler.tscn
Normal file
6
Hauler.tscn
Normal file
|
|
@ -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")
|
||||
57
HaulerField.gd
Normal file
57
HaulerField.gd
Normal file
|
|
@ -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)
|
||||
1
HaulerField.gd.uid
Normal file
1
HaulerField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cpjtke7w4pv0a
|
||||
277
IconButton.gd
Normal file
277
IconButton.gd
Normal file
|
|
@ -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)
|
||||
1
IconButton.gd.uid
Normal file
1
IconButton.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dcvdmc68b3nve
|
||||
15
LightBurstVFX.gd
Normal file
15
LightBurstVFX.gd
Normal file
|
|
@ -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)
|
||||
1
LightBurstVFX.gd.uid
Normal file
1
LightBurstVFX.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b4awmav5ftmua
|
||||
19
LunarBackground.gd
Normal file
19
LunarBackground.gd
Normal file
|
|
@ -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)
|
||||
1
LunarBackground.gd.uid
Normal file
1
LunarBackground.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://jovl7hn41sup
|
||||
698
Main.tscn
Normal file
698
Main.tscn
Normal file
|
|
@ -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)
|
||||
42
NeonDraw.gd
Normal file
42
NeonDraw.gd
Normal file
|
|
@ -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)
|
||||
1
NeonDraw.gd.uid
Normal file
1
NeonDraw.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d1rylxo301xin
|
||||
84
NeonNumber.gd
Normal file
84
NeonNumber.gd
Normal file
|
|
@ -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)
|
||||
1
NeonNumber.gd.uid
Normal file
1
NeonNumber.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://on4hmiwsv7yu
|
||||
47
NeonPanel.gd
Normal file
47
NeonPanel.gd
Normal file
|
|
@ -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)
|
||||
1
NeonPanel.gd.uid
Normal file
1
NeonPanel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c8abuj0i78t06
|
||||
39
Planet.gd
Normal file
39
Planet.gd
Normal file
|
|
@ -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)
|
||||
1
Planet.gd.uid
Normal file
1
Planet.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://berbdl8nqskqa
|
||||
6
Planet.tscn
Normal file
6
Planet.tscn
Normal file
|
|
@ -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")
|
||||
124
PlanetAtmosphereController.gd
Normal file
124
PlanetAtmosphereController.gd
Normal file
|
|
@ -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")
|
||||
1
PlanetAtmosphereController.gd.uid
Normal file
1
PlanetAtmosphereController.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dfof82skt2f4d
|
||||
93
PlanetField.gd
Normal file
93
PlanetField.gd
Normal file
|
|
@ -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]
|
||||
1
PlanetField.gd.uid
Normal file
1
PlanetField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cxgnqiqa11o0f
|
||||
172
PlanetSurface.tscn
Normal file
172
PlanetSurface.tscn
Normal file
|
|
@ -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)
|
||||
45
PlanetSurfaceController.gd
Normal file
45
PlanetSurfaceController.gd
Normal file
|
|
@ -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")
|
||||
1
PlanetSurfaceController.gd.uid
Normal file
1
PlanetSurfaceController.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://17thrioymdls
|
||||
47
Projectile.gd
Normal file
47
Projectile.gd
Normal file
|
|
@ -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)
|
||||
1
Projectile.gd.uid
Normal file
1
Projectile.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bv5epiw5tu65j
|
||||
12
Projectile.tscn
Normal file
12
Projectile.tscn
Normal file
|
|
@ -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")
|
||||
10
ResourceCounter.gd
Normal file
10
ResourceCounter.gd
Normal file
|
|
@ -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)
|
||||
1
ResourceCounter.gd.uid
Normal file
1
ResourceCounter.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://5u8a3kx3gsh1
|
||||
102
Rock.gd
Normal file
102
Rock.gd
Normal file
|
|
@ -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)
|
||||
1
Rock.gd.uid
Normal file
1
Rock.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://db2ifh5f1pjwo
|
||||
12
Rock.tscn
Normal file
12
Rock.tscn
Normal file
|
|
@ -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")
|
||||
71
RockField.gd
Normal file
71
RockField.gd
Normal file
|
|
@ -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)
|
||||
1
RockField.gd.uid
Normal file
1
RockField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://vb0rgd6a5w52
|
||||
139
Ship.gd
Normal file
139
Ship.gd
Normal file
|
|
@ -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)
|
||||
1
Ship.gd.uid
Normal file
1
Ship.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dd78hwrpks1d5
|
||||
12
Ship.tscn
Normal file
12
Ship.tscn
Normal file
|
|
@ -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")
|
||||
12
ShipExplosionVFX.gd
Normal file
12
ShipExplosionVFX.gd
Normal file
|
|
@ -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)
|
||||
1
ShipExplosionVFX.gd.uid
Normal file
1
ShipExplosionVFX.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c20i5a56ib4pw
|
||||
28
Starfield.gd
Normal file
28
Starfield.gd
Normal file
|
|
@ -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)
|
||||
1
Starfield.gd.uid
Normal file
1
Starfield.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://buhc1b6iqy30n
|
||||
110
TouchJoystick.gd
Normal file
110
TouchJoystick.gd
Normal file
|
|
@ -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)
|
||||
1
TouchJoystick.gd.uid
Normal file
1
TouchJoystick.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bfd1d4343vg1d
|
||||
31
ViewUtils.gd
Normal file
31
ViewUtils.gd
Normal file
|
|
@ -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)
|
||||
1
ViewUtils.gd.uid
Normal file
1
ViewUtils.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bw2emlbm86eje
|
||||
90
WorldGen.gd
Normal file
90
WorldGen.gd
Normal file
|
|
@ -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)
|
||||
1
WorldGen.gd.uid
Normal file
1
WorldGen.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d1xpshlxsv0ap
|
||||
93
WorldMap.gd
Normal file
93
WorldMap.gd
Normal file
|
|
@ -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)
|
||||
1
WorldMap.gd.uid
Normal file
1
WorldMap.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://1ava7qm2fian
|
||||
42
WorldMapContent.gd
Normal file
42
WorldMapContent.gd
Normal file
|
|
@ -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)
|
||||
1
WorldMapContent.gd.uid
Normal file
1
WorldMapContent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://xq4ay3r56nst
|
||||
1
android/.build_version
Normal file
1
android/.build_version
Normal file
|
|
@ -0,0 +1 @@
|
|||
4.7.1.stable
|
||||
0
android/.gdignore
Normal file
0
android/.gdignore
Normal file
271
export_presets.cfg
Normal file
271
export_presets.cfg
Normal file
|
|
@ -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
|
||||
4
icon.svg
Normal file
4
icon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="20" fill="#05050f"/>
|
||||
<polygon points="64,20 92,100 64,84 36,100" fill="none" stroke="#40d9ff" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
43
icon.svg.import
Normal file
43
icon.svg.import
Normal file
|
|
@ -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
|
||||
212
lunar_surface.gdshader
Normal file
212
lunar_surface.gdshader
Normal file
|
|
@ -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);
|
||||
}
|
||||
1
lunar_surface.gdshader.uid
Normal file
1
lunar_surface.gdshader.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ib1dykwwylhn
|
||||
42
project.godot
Normal file
42
project.godot
Normal file
|
|
@ -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)
|
||||
125
starfield.gdshader
Normal file
125
starfield.gdshader
Normal file
|
|
@ -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);
|
||||
}
|
||||
1
starfield.gdshader.uid
Normal file
1
starfield.gdshader.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bnrakad3xumb5
|
||||
31
tests/_build_popup_preview.gd
Normal file
31
tests/_build_popup_preview.gd
Normal file
|
|
@ -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()
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue