#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
baac11ae8b
commit
2f161621c3
495 changed files with 571 additions and 567 deletions
45
surface/CaptureLabel.gd
Normal file
45
surface/CaptureLabel.gd
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
extends Label
|
||||
|
||||
# Polls GameState directly, same convention as ResourceReadout, so it stays
|
||||
# correct without anyone having to push updates into it. Hidden
|
||||
# entirely when the current planet has no capture points (e.g. no setup
|
||||
# resolved for it) rather than showing "0/0".
|
||||
|
||||
# A direct script preload, not the bare "PlanetDefinitions" global class_name
|
||||
# - the same load-order hazard PlanetSurfaceController's setup_host comment
|
||||
# already documents: the global identifier is only guaranteed registered
|
||||
# after an editor/import pass, a direct preload never depends on that.
|
||||
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
||||
|
||||
var _was_conquered: bool = false
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
var planet_id: String = GameState.planet_id
|
||||
var total: int = GameState.total_capture_points(planet_id)
|
||||
if total <= 0:
|
||||
visible = false
|
||||
return
|
||||
visible = true
|
||||
|
||||
var conquered: bool = GameState.is_planet_conquered(planet_id)
|
||||
if conquered:
|
||||
# #76: a research_lab's whole reward is invisible otherwise - the
|
||||
# research point balance lives in a HUD number the player has no
|
||||
# reason to be watching at the exact moment it changes. Naming the
|
||||
# reward right on the same flash that already announces conquest
|
||||
# is the same "explain it where it happens" idiom BuildController
|
||||
# uses for a refused build.
|
||||
text = "CONQUERED - +1 RESEARCH POINT" \
|
||||
if GameState.planet_definition_id == PLANET_DEFINITIONS.RESEARCH_LAB_ID else "CONQUERED"
|
||||
if not _was_conquered:
|
||||
_flash()
|
||||
else:
|
||||
text = "CAPTURED %d/%d" % [GameState.captured_point_count(planet_id), total]
|
||||
_was_conquered = conquered
|
||||
|
||||
|
||||
func _flash() -> void:
|
||||
modulate.a = 0.0
|
||||
var tween: Tween = create_tween()
|
||||
tween.tween_property(self, "modulate:a", 1.0, 1.2)
|
||||
1
surface/CaptureLabel.gd.uid
Normal file
1
surface/CaptureLabel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dxuwfa5j7oh80
|
||||
119
surface/CapturePoint.gd
Normal file
119
surface/CapturePoint.gd
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
class_name CapturePoint
|
||||
extends Area2D
|
||||
|
||||
# One point on a landed planet's surface that must be held (fleet ship
|
||||
# nearby, no enemy nearby) for capture_time seconds. Progress is polled by
|
||||
# distance each frame rather than through physics signals - same idiom as
|
||||
# RockField/GasVentField - since no collision response is needed, only
|
||||
# proximity.
|
||||
|
||||
signal captured
|
||||
|
||||
enum State { NEUTRAL, BLOCKED, CAPTURING, CAPTURED }
|
||||
|
||||
@export var point_index: int = 0
|
||||
@export var capture_radius: float = 220.0
|
||||
@export var capture_time: float = 8.0
|
||||
@export var enemy_clear_radius: float = 700.0
|
||||
@export var drain_rate: float = 0.5
|
||||
|
||||
const NEUTRAL_COLOR: Color = Color(0.55, 0.85, 1.0)
|
||||
const BLOCKED_COLOR: Color = Color(1.0, 0.3, 0.3)
|
||||
const CAPTURING_COLOR: Color = Color(0.4, 1.0, 0.55)
|
||||
const CAPTURED_COLOR: Color = Color(1.0, 0.85, 0.3)
|
||||
const CORE_RADIUS: float = 60.0
|
||||
|
||||
var progress: float = 0.0
|
||||
var state: int = State.NEUTRAL
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Behind the fleet and enemies, same convention as every other world
|
||||
# object that must never occlude them (Planet.gd, Rock.gd, Building.gd,
|
||||
# Hauler.gd, Transporter.gd, VesselDelivery.gd all set this in _ready()).
|
||||
# "Is an enemy inside my capture radius" is the core read of this
|
||||
# mechanic, so a ship or enemy sitting in the ring must stay fully visible.
|
||||
z_index = -1
|
||||
queue_redraw()
|
||||
|
||||
|
||||
# Called by PlanetSetupHost when this point was already captured on a
|
||||
# previous visit, so re-landing restores it instead of starting the fill
|
||||
# over. Permanent for the run - _process() below returns early once
|
||||
# CAPTURED and never re-evaluates it.
|
||||
func mark_captured() -> void:
|
||||
progress = 1.0
|
||||
state = State.CAPTURED
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if state == State.CAPTURED:
|
||||
return
|
||||
|
||||
var enemy_nearby: bool = _enemy_nearby()
|
||||
var ship_in_range: bool = _ship_in_range()
|
||||
|
||||
if enemy_nearby:
|
||||
state = State.BLOCKED
|
||||
progress = maxf(progress - drain_rate * delta / capture_time, 0.0)
|
||||
elif ship_in_range:
|
||||
state = State.CAPTURING
|
||||
progress = minf(progress + delta / capture_time, 1.0)
|
||||
if progress >= 1.0:
|
||||
state = State.CAPTURED
|
||||
GameState.mark_point_captured(GameState.planet_id, point_index)
|
||||
captured.emit()
|
||||
else:
|
||||
state = State.NEUTRAL
|
||||
progress = maxf(progress - drain_rate * delta / capture_time, 0.0)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _ship_in_range() -> bool:
|
||||
for ship in get_tree().get_nodes_in_group("player_ships"):
|
||||
if is_instance_valid(ship) and global_position.distance_to(ship.global_position) <= capture_radius:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _enemy_nearby() -> bool:
|
||||
for enemy in get_tree().get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(enemy) and global_position.distance_to(enemy.global_position) <= enemy_clear_radius:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _state_color() -> Color:
|
||||
match state:
|
||||
State.BLOCKED:
|
||||
return BLOCKED_COLOR
|
||||
State.CAPTURING:
|
||||
return CAPTURING_COLOR
|
||||
State.CAPTURED:
|
||||
return CAPTURED_COLOR
|
||||
_:
|
||||
return NEUTRAL_COLOR
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var color: Color = _state_color()
|
||||
# Outer ring: the actual capture radius, faint - tells the player how
|
||||
# close they need to stay without dominating the screen.
|
||||
_draw_ring(capture_radius, 0.0, TAU, color, 0.35)
|
||||
# Inner beacon: fixed size regardless of capture_radius, so it reads
|
||||
# clearly even when zoomed out. A filling arc sweeps clockwise from the
|
||||
# top as progress advances, same "fill toward completion" idea as
|
||||
# VesselGasBomberComponent's hull fill, adapted to a ring since a
|
||||
# capture point has no hull.
|
||||
_draw_ring(CORE_RADIUS, 0.0, TAU, color, 1.0)
|
||||
if progress > 0.001:
|
||||
var sweep: float = TAU * progress
|
||||
_draw_ring(CORE_RADIUS, -PI / 2.0, -PI / 2.0 + sweep, color, 1.0, 10.0)
|
||||
|
||||
|
||||
func _draw_ring(radius: float, from_angle: float, to_angle: float, color: Color, alpha: float,
|
||||
core_width: float = 6.0) -> void:
|
||||
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(color, 0.16 * alpha), core_width * 2.6, true)
|
||||
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(color, 0.5 * alpha), core_width, true)
|
||||
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(1.0, 0.96, 0.9, 0.9 * alpha), core_width * 0.3, true)
|
||||
1
surface/CapturePoint.gd.uid
Normal file
1
surface/CapturePoint.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dwt256xl1txjo
|
||||
8
surface/CapturePoint.tscn
Normal file
8
surface/CapturePoint.tscn
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/CapturePoint.gd" id="1"]
|
||||
|
||||
[node name="CapturePoint" type="Area2D"]
|
||||
script = ExtResource("1")
|
||||
monitoring = false
|
||||
monitorable = false
|
||||
7
surface/GasSurfaceFeatures.tscn
Normal file
7
surface/GasSurfaceFeatures.tscn
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/GasVentField.gd" id="1"]
|
||||
|
||||
[node name="ResourceProvider" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
fleet_path = NodePath("../../Fleet")
|
||||
16
surface/GasSurfaceProfile.tres
Normal file
16
surface/GasSurfaceProfile.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/PlanetSurfaceProfile.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://surface/GasSurfaceFeatures.tscn" id="2"]
|
||||
[ext_resource type="Shader" path="res://shaders/gas_surface.gdshader" id="3"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
profile_id = "gas"
|
||||
features_scene = ExtResource("2")
|
||||
background_shader = ExtResource("3")
|
||||
base_color = Color(0.1, 0.04, 0.22, 1)
|
||||
crater_color = Color(0.045, 0.015, 0.13, 1)
|
||||
highlight_color = Color(0.62, 0.42, 0.85, 1)
|
||||
caption_color = Color(0.78, 0.88, 0.94, 1)
|
||||
caption_outline_color = Color(0.02, 0.01, 0.05, 0.9)
|
||||
76
surface/GasVentField.gd
Normal file
76
surface/GasVentField.gd
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
extends Node2D
|
||||
|
||||
# By path, not by its `ResourcePickupVFX` global name: a bare class-name
|
||||
# reference needs the global class cache already warm, which it is not when
|
||||
# this script is reached indirectly - loading Main.tscn from inside a preview
|
||||
# scene made it a hard parse error. Same workaround PlanetSetups and
|
||||
# PlanetSetupEditor already use.
|
||||
const RESOURCE_PICKUP_VFX = preload("res://vfx/ResourcePickupVFX.gd")
|
||||
|
||||
@export var fleet_path: NodePath = NodePath("../Fleet")
|
||||
@onready var fleet: Node2D = get_node(fleet_path)
|
||||
var vents: Array[Vector2] = []
|
||||
var progress: float = 0.0
|
||||
var emission_phase: float = 0.0
|
||||
func _ready() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name)
|
||||
for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650)))
|
||||
queue_redraw()
|
||||
func _process(delta: float) -> void:
|
||||
emission_phase = fmod(emission_phase + delta * 0.55, 1.0)
|
||||
queue_redraw()
|
||||
for vent in vents:
|
||||
if fleet.get_centroid().distance_to(vent) < 90.0:
|
||||
progress += delta
|
||||
if progress >= 1.0:
|
||||
# #56: a full fleet hold rejects the deposit - keep the
|
||||
# undelivered whole units in progress rather than discarding
|
||||
# them, so gas is only ever lost by not gathering it.
|
||||
var whole: int = int(progress)
|
||||
var taken: int = GameState.collect_gas_cargo(whole)
|
||||
progress -= float(taken)
|
||||
RESOURCE_PICKUP_VFX.spawn(get_tree().root,
|
||||
to_global(vent), GameState.RESOURCE_GAS, taken)
|
||||
return
|
||||
progress = 0.0
|
||||
func _draw() -> void:
|
||||
for vent in vents:
|
||||
_draw_gas_source(vent)
|
||||
_draw_emission(vent)
|
||||
|
||||
|
||||
func _draw_gas_source(centre: Vector2) -> void:
|
||||
var colour := Color(0.85, 0.52, 1.0)
|
||||
var core := Color(0.15, 0.03, 0.22)
|
||||
var bubbles: Array = [
|
||||
[centre + Vector2(-20.0, 8.0), 20.0],
|
||||
[centre + Vector2(18.0, 14.0), 27.0],
|
||||
[centre + Vector2(17.0, -22.0), 15.0],
|
||||
]
|
||||
for bubble in bubbles:
|
||||
var ring := PackedVector2Array()
|
||||
for i in range(12):
|
||||
var angle: float = TAU * float(i) / 12.0
|
||||
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
|
||||
NeonDraw.draw_glowing_polygon(self, ring, colour, 0.9, core, 0.6)
|
||||
|
||||
|
||||
func _draw_emission(centre: Vector2) -> void:
|
||||
# Small bubbles continuously rise out of every source. This remains
|
||||
# animated even when the fleet is not collecting, so the vent reads as
|
||||
# an active gas generator rather than a static decoration.
|
||||
var core := Color(0.15, 0.03, 0.22)
|
||||
for i in range(4):
|
||||
var travel: float = fmod(emission_phase + float(i) * 0.25, 1.0)
|
||||
var bubble_centre := centre + Vector2(
|
||||
sin((travel + float(i)) * TAU) * 11.0,
|
||||
-48.0 - travel * 92.0
|
||||
)
|
||||
var radius: float = lerpf(5.0, 14.0, travel)
|
||||
var colour := Color(0.92, 0.68, 1.0, 1.0 - travel * 0.72)
|
||||
var ring := PackedVector2Array()
|
||||
for point in range(10):
|
||||
var angle: float = TAU * float(point) / 10.0
|
||||
ring.append(bubble_centre + Vector2(cos(angle), sin(angle)) * radius)
|
||||
NeonDraw.draw_glowing_polygon(self, ring, colour, 0.75, core, 0.45)
|
||||
1
surface/GasVentField.gd.uid
Normal file
1
surface/GasVentField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://di1kvjt0dwvqc
|
||||
23
surface/PlanetSetup.gd
Normal file
23
surface/PlanetSetup.gd
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
class_name PlanetSetup
|
||||
extends Resource
|
||||
|
||||
# A hand-authored capture-point/enemy layout for a planet surface. Chosen
|
||||
# deterministically per planet (see PlanetSetups.setup_for_planet) rather
|
||||
# than generated - #40 wants curated setups, not procedural ones - and
|
||||
# stored as a committed .tres under res://PlanetSetups/, not in a save.
|
||||
|
||||
@export var setup_id: String = ""
|
||||
@export var display_name: String = ""
|
||||
# Which PlanetSurfaceProfiles id (e.g. "rocky", "gas") this setup was
|
||||
# authored for; only setups matching a planet's own profile are eligible.
|
||||
@export var surface_profile_id: String = "rocky"
|
||||
# Surface-local positions (the fleet lands at Vector2.ZERO).
|
||||
@export var capture_points: PackedVector2Array = PackedVector2Array()
|
||||
@export var enemy_spawns: Array[Resource] = []
|
||||
# Research points awarded for conquering this map, set in the map editor.
|
||||
# A harder lab is worth more, and how hard a lab IS depends on the layout
|
||||
# authored here - how many capture points, how many and what kind of
|
||||
# defenders - which the planet type cannot know. 0 means "use whatever the
|
||||
# planet type awards", so every setup written before this existed keeps its
|
||||
# old value rather than silently dropping to nothing.
|
||||
@export var research_point_reward: int = 0
|
||||
1
surface/PlanetSetup.gd.uid
Normal file
1
surface/PlanetSetup.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d1govxko60n03
|
||||
645
surface/PlanetSetupEditor.gd
Normal file
645
surface/PlanetSetupEditor.gd
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
extends Node2D
|
||||
|
||||
# Authoring tool for PlanetSetup resources (#40/#48): place/move/delete
|
||||
# capture points and enemy spawns for a chosen surface profile, then save
|
||||
# to res://PlanetSetups/<id>.tres so the game picks it up the same way it
|
||||
# picks up every other authored setup. Deliberately does not touch
|
||||
# GameState - this tool only edits committed resources, never gameplay
|
||||
# state.
|
||||
#
|
||||
# The mutation methods below (new_setup/load_setup/add_*/move_*/remove_*/
|
||||
# save_setup) are the editor's whole model and are safe to call directly
|
||||
# without any touch input, which is how tests/test_setup_editor.gd drives
|
||||
# them; the touch handlers at the bottom are a thin input layer on top.
|
||||
|
||||
# Preloaded by path rather than referenced by global class name, same as
|
||||
# every other cross-file lookup added this session (see #42/#44's
|
||||
# load-order notes) - a bare class-name reference needs the global
|
||||
# class_name cache already warm, which an explicit script path never does.
|
||||
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
|
||||
# By path, not by its `EnemyMarker` global name: the class_name cache is not
|
||||
# guaranteed warm before an import pass has run, the same hazard PlanetSetups
|
||||
# and PlanetSurfaceController already work around.
|
||||
const ENEMY_MARKER = preload("res://enemies/EnemyMarker.gd")
|
||||
const PLANET_SETUP_SCRIPT = preload("res://surface/PlanetSetup.gd")
|
||||
const PLANET_SETUP_SPAWN_SCRIPT = preload("res://surface/PlanetSetupSpawn.gd")
|
||||
const PALETTE_ENTRY_BUTTON_SCRIPT = preload("res://ui/PaletteEntryButton.gd")
|
||||
|
||||
const MIN_ZOOM: float = 0.3
|
||||
const MAX_ZOOM: float = 4.0
|
||||
const WHEEL_ZOOM_STEP: float = 1.1
|
||||
const HIT_RADIUS: float = 50.0
|
||||
# #65: how far a single-finger press has to move before it counts as a pan
|
||||
# rather than a tap-to-place, once it's confirmed not to be starting on an
|
||||
# existing marker.
|
||||
const PAN_DEADZONE: float = 24.0
|
||||
const ORIGIN_COLOR: Color = Color(1.0, 0.9, 0.4)
|
||||
# Duplicated from PaletteEntryButton.RING_COLOR rather than referenced
|
||||
# cross-class - a global class_name lookup at parse time needs the class
|
||||
# cache already warm (see #42/#44's load-order notes), which a plain
|
||||
# local const sidesteps entirely.
|
||||
const CAPTURE_MARKER_COLOR: Color = Color(0.55, 0.85, 1.0)
|
||||
const PALETTE_STEP: float = 92.0
|
||||
|
||||
@onready var camera: Camera2D = $Camera2D
|
||||
@onready var background: ColorRect = $BackgroundLayer/Background
|
||||
@onready var item_host: Node2D = $ItemHost
|
||||
@onready var back_button: IconButton = $UI/BackButton
|
||||
@onready var profile_button: IconButton = $UI/ProfileButton
|
||||
@onready var profile_value_label: Label = $UI/ProfileValueLabel
|
||||
@onready var reward_button: IconButton = $UI/RewardButton
|
||||
@onready var reward_value_label: Label = $UI/RewardValueLabel
|
||||
@onready var name_input: LineEdit = $UI/NameInput
|
||||
@onready var new_button: IconButton = $UI/NewButton
|
||||
@onready var save_button: IconButton = $UI/SaveButton
|
||||
@onready var load_button: IconButton = $UI/LoadButton
|
||||
@onready var delete_button: IconButton = $UI/DeleteButton
|
||||
@onready var status_label: Label = $UI/StatusLabel
|
||||
@onready var selected_label: Label = $UI/SelectedLabel
|
||||
@onready var palette_row: Control = $UI/PaletteRow
|
||||
@onready var load_panel: Control = $UI/LoadPanel
|
||||
@onready var load_list: ItemList = $UI/LoadPanel/List
|
||||
@onready var load_close_button: IconButton = $UI/LoadPanel/CloseButton
|
||||
@onready var load_title_label: Label = $UI/LoadPanel/TitleLabel
|
||||
@onready var delete_map_button: IconButton = $UI/LoadPanel/DeleteMapButton
|
||||
|
||||
var current_setup: Resource
|
||||
# The file the open setup was loaded from, or "" for one that has never been
|
||||
# saved. Saving writes HERE rather than to a path derived from setup_id - see
|
||||
# PlanetSetups.path_for_id for why deriving it forks a duplicate.
|
||||
var current_setup_path: String = ""
|
||||
var selected_palette: String = "capture_point"
|
||||
var selected_item: Dictionary = {}
|
||||
var _profile_ids: Array = []
|
||||
var _profile_cursor: int = 0
|
||||
# #64: replaced the old "Load cycles to the next saved setup" behaviour,
|
||||
# which gave no way to see what existed or jump straight to one by name -
|
||||
# kept only as the id list _load_list_setup_ids indexes into when the
|
||||
# panel's own selection fires.
|
||||
var _load_list_setup_ids: PackedStringArray = PackedStringArray()
|
||||
# While armed, tapping a row in the load panel DELETES that setup instead of
|
||||
# loading it. One button and one meaning per tap, rather than a second list
|
||||
# or a per-row delete affordance that would be easy to hit by accident on a
|
||||
# phone - and the panel says plainly which mode it is in.
|
||||
var _delete_map_armed: bool = false
|
||||
var _palette_buttons: Array = []
|
||||
var _origin_marker: NeonPiece
|
||||
|
||||
var touches: Dictionary = {}
|
||||
var pinch_active: bool = false
|
||||
var pinch_start_distance: float = 0.0
|
||||
var pinch_start_zoom: float = 1.0
|
||||
var drag_touch_index: int = -1
|
||||
var drag_mode: String = ""
|
||||
var dragging_item: Dictionary = {}
|
||||
var pan_start_screen: Vector2 = Vector2.ZERO
|
||||
var pan_start_camera: Vector2 = Vector2.ZERO
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_profile_ids = PlanetSurfaceProfiles.PROFILES.keys()
|
||||
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://ui/MainMenu.tscn"))
|
||||
profile_button.pressed.connect(_on_profile_pressed)
|
||||
reward_button.pressed.connect(_on_reward_pressed)
|
||||
new_button.pressed.connect(_on_new_pressed)
|
||||
save_button.pressed.connect(_on_save_pressed)
|
||||
load_button.pressed.connect(_on_load_pressed)
|
||||
delete_button.pressed.connect(_on_delete_pressed)
|
||||
name_input.text_changed.connect(_on_name_changed)
|
||||
load_close_button.pressed.connect(_on_load_close_pressed)
|
||||
delete_map_button.pressed.connect(_on_delete_map_pressed)
|
||||
load_list.item_selected.connect(_on_load_list_item_selected)
|
||||
|
||||
_origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR)
|
||||
var cross_size: float = 26.0
|
||||
_origin_marker.add_line(Vector2(-cross_size, 0), Vector2(cross_size, 0), 0.6)
|
||||
_origin_marker.add_line(Vector2(0, -cross_size), Vector2(0, cross_size), 0.6)
|
||||
_origin_marker.queue_redraw()
|
||||
|
||||
_build_palette()
|
||||
new_setup("untitled", "Untitled", _profile_ids[0])
|
||||
|
||||
|
||||
# --- model: setup lifecycle -------------------------------------------------
|
||||
|
||||
func new_setup(setup_id: String, display_name: String, profile_id: String) -> void:
|
||||
current_setup = PLANET_SETUP_SCRIPT.new()
|
||||
current_setup_path = ""
|
||||
current_setup.setup_id = setup_id
|
||||
current_setup.display_name = display_name
|
||||
current_setup.surface_profile_id = profile_id
|
||||
# capture_points/enemy_spawns are left at PlanetSetup's own declared
|
||||
# defaults (already empty) rather than reassigned here - assigning a
|
||||
# plain `[]` to enemy_spawns from outside PlanetSetup.gd's own file
|
||||
# fails at runtime, since the compiler here only sees current_setup as
|
||||
# a plain Resource and can't confirm the target is Array[Resource].
|
||||
selected_item = {}
|
||||
_refresh_selected_label()
|
||||
_profile_cursor = maxi(_profile_ids.find(profile_id), 0)
|
||||
if name_input != null:
|
||||
name_input.text = display_name
|
||||
_refresh_background()
|
||||
_rebuild_markers()
|
||||
_set_status("")
|
||||
|
||||
|
||||
func load_setup(setup_id: String) -> bool:
|
||||
for setup in PlanetSetups.all():
|
||||
if setup.setup_id == setup_id:
|
||||
# Edit a duplicate so in-progress edits never mutate the live
|
||||
# registry entry (which other code may be reading) until an
|
||||
# explicit save.
|
||||
current_setup = setup.duplicate(true)
|
||||
# duplicate() deliberately has no resource_path of its own, so the
|
||||
# source file is remembered separately - saving must go back to
|
||||
# the file this was opened from.
|
||||
current_setup_path = String(setup.resource_path)
|
||||
selected_item = {}
|
||||
_refresh_selected_label()
|
||||
_profile_cursor = maxi(_profile_ids.find(current_setup.surface_profile_id), 0)
|
||||
if name_input != null:
|
||||
name_input.text = current_setup.display_name
|
||||
_refresh_background()
|
||||
_rebuild_markers()
|
||||
_set_status("Loaded '%s'." % setup_id)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func save_setup() -> Dictionary:
|
||||
if current_setup == null:
|
||||
return {"ok": false, "message": "No setup to save."}
|
||||
if current_setup.setup_id.strip_edges() == "":
|
||||
return {"ok": false, "message": "Name the setup before saving."}
|
||||
if not OS.has_feature("editor"):
|
||||
return {"ok": false, "message": "Setups can only be saved when running from the project."}
|
||||
var path: String = current_setup_path if current_setup_path != "" \
|
||||
else "res://PlanetSetups/%s.tres" % current_setup.setup_id
|
||||
var err: int = ResourceSaver.save(current_setup, path)
|
||||
if err != OK:
|
||||
return {"ok": false, "message": "Save failed (error %d)." % err}
|
||||
current_setup_path = path
|
||||
PlanetSetups.rescan()
|
||||
return {"ok": true, "message": "Saved '%s'." % current_setup.setup_id}
|
||||
|
||||
|
||||
func delete_setup(setup_id: String) -> Dictionary:
|
||||
# Deliberately mirrors save_setup(): same editor-only guard, same
|
||||
# {ok, message} shape, and the same rescan afterwards so the in-memory
|
||||
# registry stops offering a file that is no longer there.
|
||||
if setup_id.strip_edges() == "":
|
||||
return {"ok": false, "message": "No setup to delete."}
|
||||
if not OS.has_feature("editor"):
|
||||
return {"ok": false, "message": "Setups can only be deleted when running from the project."}
|
||||
# Asked of the registry, not assembled from the id - see
|
||||
# PlanetSetups.path_for_id. A setup whose filename differs from its id
|
||||
# could not be deleted at all before this, which is why the first entry in
|
||||
# the list appeared to be undeletable: it is the one with the mismatch.
|
||||
var path: String = PlanetSetups.path_for_id(setup_id)
|
||||
if not ResourceLoader.exists(path):
|
||||
return {"ok": false, "message": "No setup file for '%s'." % setup_id}
|
||||
var err: int = DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
if err != OK:
|
||||
return {"ok": false, "message": "Delete failed (error %d)." % err}
|
||||
# Godot writes a .uid sidecar next to a saved resource; leaving it behind
|
||||
# makes the next import complain about a uid with no file.
|
||||
var uid_path: String = ProjectSettings.globalize_path(path + ".uid")
|
||||
if FileAccess.file_exists(uid_path):
|
||||
DirAccess.remove_absolute(uid_path)
|
||||
PlanetSetups.rescan()
|
||||
# The deleted setup may be the one on screen. Leaving it loaded would let
|
||||
# the next save silently recreate the file the player just removed.
|
||||
if current_setup != null and String(current_setup.setup_id) == setup_id:
|
||||
new_setup("untitled", "Untitled", _profile_ids[_profile_cursor])
|
||||
elif current_setup_path == path:
|
||||
# Same file under a different id - the open copy has nowhere to go
|
||||
# back to either.
|
||||
current_setup_path = ""
|
||||
return {"ok": true, "message": "Deleted '%s'." % setup_id}
|
||||
|
||||
|
||||
# --- model: capture points ---------------------------------------------
|
||||
|
||||
func add_capture_point(at: Vector2) -> int:
|
||||
if current_setup == null:
|
||||
return -1
|
||||
var points: PackedVector2Array = current_setup.capture_points
|
||||
points.append(at)
|
||||
current_setup.capture_points = points
|
||||
_rebuild_markers()
|
||||
return points.size() - 1
|
||||
|
||||
|
||||
func move_capture_point(index: int, at: Vector2) -> void:
|
||||
if current_setup == null or index < 0 or index >= current_setup.capture_points.size():
|
||||
return
|
||||
current_setup.capture_points[index] = at
|
||||
_rebuild_markers()
|
||||
|
||||
|
||||
func remove_capture_point(index: int) -> void:
|
||||
if current_setup == null or index < 0 or index >= current_setup.capture_points.size():
|
||||
return
|
||||
var points: PackedVector2Array = current_setup.capture_points
|
||||
points.remove_at(index)
|
||||
current_setup.capture_points = points
|
||||
_rebuild_markers()
|
||||
|
||||
|
||||
# --- model: enemy spawns ------------------------------------------------
|
||||
|
||||
func add_enemy(enemy_id: String, at: Vector2) -> int:
|
||||
if current_setup == null:
|
||||
return -1
|
||||
var spawn: Resource = PLANET_SETUP_SPAWN_SCRIPT.new()
|
||||
spawn.enemy_id = enemy_id
|
||||
spawn.position = at
|
||||
current_setup.enemy_spawns.append(spawn)
|
||||
_rebuild_markers()
|
||||
return current_setup.enemy_spawns.size() - 1
|
||||
|
||||
|
||||
func move_enemy(index: int, at: Vector2) -> void:
|
||||
if current_setup == null or index < 0 or index >= current_setup.enemy_spawns.size():
|
||||
return
|
||||
current_setup.enemy_spawns[index].position = at
|
||||
_rebuild_markers()
|
||||
|
||||
|
||||
func remove_enemy(index: int) -> void:
|
||||
if current_setup == null or index < 0 or index >= current_setup.enemy_spawns.size():
|
||||
return
|
||||
current_setup.enemy_spawns.remove_at(index)
|
||||
_rebuild_markers()
|
||||
|
||||
|
||||
# --- background/markers --------------------------------------------------
|
||||
|
||||
func _refresh_background() -> void:
|
||||
if current_setup == null or background == null:
|
||||
return
|
||||
# The exact call PlanetSurfaceController makes when landing, on the exact
|
||||
# same instanced scene. Setting the colours here by hand is what let the
|
||||
# editor and the game drift: this side never swapped in the profile's own
|
||||
# SHADER, so a gas giant authored here was drawn as recoloured craters
|
||||
# while the game drew cloud bands. See the header of SurfaceBackground.gd.
|
||||
var profile: Resource = PlanetSurfaceProfiles.get_profile(current_setup.surface_profile_id)
|
||||
background.apply_profile(profile)
|
||||
if profile_value_label != null:
|
||||
profile_value_label.text = current_setup.surface_profile_id.to_upper()
|
||||
_refresh_reward_label()
|
||||
|
||||
|
||||
func _rebuild_markers() -> void:
|
||||
if item_host == null:
|
||||
return
|
||||
for child in item_host.get_children():
|
||||
if child != _origin_marker:
|
||||
child.queue_free()
|
||||
if current_setup == null:
|
||||
return
|
||||
for i in range(current_setup.capture_points.size()):
|
||||
_add_capture_marker(i, current_setup.capture_points[i])
|
||||
for i in range(current_setup.enemy_spawns.size()):
|
||||
_add_enemy_marker(i, current_setup.enemy_spawns[i])
|
||||
|
||||
|
||||
func _add_capture_marker(index: int, at: Vector2) -> void:
|
||||
var is_selected: bool = selected_item.get("kind", "") == "capture" and selected_item.get("index", -1) == index
|
||||
var color: Color = Color(1.0, 0.95, 0.9) if is_selected else CAPTURE_MARKER_COLOR
|
||||
var marker: NeonPiece = NeonPiece.create(item_host, at, color)
|
||||
var points: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(16):
|
||||
var angle: float = float(i) / 16.0 * TAU
|
||||
points.append(Vector2(cos(angle), sin(angle)) * 60.0)
|
||||
marker.add_polygon(points, 0.6)
|
||||
marker.queue_redraw()
|
||||
|
||||
|
||||
func _add_enemy_marker(index: int, spawn: Resource) -> void:
|
||||
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
|
||||
var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index
|
||||
var color: Color = Color(1.0, 0.95, 0.9) if is_selected else definition.glow_color
|
||||
# Drawn through the same routine the enemy itself uses, rather than from
|
||||
# hull_points alone - a marker that does not look like what it places is
|
||||
# worse than no picture at all.
|
||||
var marker: Node2D = ENEMY_MARKER.new()
|
||||
marker.position = spawn.position
|
||||
marker.definition = definition
|
||||
marker.glow_color = color
|
||||
item_host.add_child(marker)
|
||||
|
||||
|
||||
# --- palette ---------------------------------------------------------------
|
||||
|
||||
func _build_palette() -> void:
|
||||
var entries: Array = [{"id": "capture_point", "is_point": true, "definition": null}]
|
||||
for definition in ENEMY_DEFINITIONS.all():
|
||||
entries.append({"id": definition.enemy_id, "is_point": false, "definition": definition})
|
||||
|
||||
var x: float = 0.0
|
||||
for entry in entries:
|
||||
var button: Control = PALETTE_ENTRY_BUTTON_SCRIPT.new()
|
||||
button.position = Vector2(x, 0)
|
||||
button.size = Vector2(84, 84)
|
||||
button.is_capture_point = entry.is_point
|
||||
button.enemy_definition = entry.definition
|
||||
button.selected = entry.id == selected_palette
|
||||
button.pressed.connect(_on_palette_pressed.bind(entry.id))
|
||||
palette_row.add_child(button)
|
||||
_palette_buttons.append(button)
|
||||
x += PALETTE_STEP
|
||||
|
||||
|
||||
func _on_palette_pressed(id: String) -> void:
|
||||
selected_palette = id
|
||||
_refresh_palette_selection()
|
||||
|
||||
|
||||
func _refresh_palette_selection() -> void:
|
||||
for button in _palette_buttons:
|
||||
var entry_id: String = "capture_point" if button.is_capture_point else button.enemy_definition.enemy_id
|
||||
button.selected = entry_id == selected_palette
|
||||
|
||||
|
||||
# --- UI handlers -------------------------------------------------------
|
||||
|
||||
# What conquering this map is worth in research points. 0 keeps the planet
|
||||
# type's own flat rate, so a setup that says nothing behaves as it always
|
||||
# did; the rest let an author price a hard lab above an easy one.
|
||||
const REWARD_OPTIONS: Array[int] = [0, 1, 2, 3, 5, 8]
|
||||
|
||||
|
||||
func _on_reward_pressed() -> void:
|
||||
if current_setup == null:
|
||||
return
|
||||
var index: int = REWARD_OPTIONS.find(int(current_setup.research_point_reward))
|
||||
if REWARD_OPTIONS.is_empty():
|
||||
return
|
||||
current_setup.research_point_reward = REWARD_OPTIONS[(index + 1) % REWARD_OPTIONS.size()]
|
||||
_refresh_reward_label()
|
||||
|
||||
|
||||
func _refresh_reward_label() -> void:
|
||||
if reward_value_label == null or current_setup == null:
|
||||
return
|
||||
var reward: int = int(current_setup.research_point_reward)
|
||||
reward_value_label.text = "DEFAULT RP" if reward <= 0 else "%d RESEARCH PT" % reward
|
||||
|
||||
|
||||
func _on_profile_pressed() -> void:
|
||||
if _profile_ids.is_empty():
|
||||
return
|
||||
if _profile_ids.is_empty():
|
||||
return
|
||||
_profile_cursor = (_profile_cursor + 1) % _profile_ids.size()
|
||||
current_setup.surface_profile_id = _profile_ids[_profile_cursor]
|
||||
_refresh_background()
|
||||
|
||||
|
||||
func _on_new_pressed() -> void:
|
||||
new_setup("untitled", "Untitled", _profile_ids[_profile_cursor])
|
||||
|
||||
|
||||
func _on_save_pressed() -> void:
|
||||
var result: Dictionary = save_setup()
|
||||
_set_status(result.message)
|
||||
|
||||
|
||||
func _on_load_pressed() -> void:
|
||||
if not _refresh_load_list():
|
||||
return
|
||||
_delete_map_armed = false
|
||||
_refresh_load_panel_mode()
|
||||
load_panel.visible = true
|
||||
|
||||
|
||||
func _refresh_load_list() -> bool:
|
||||
var setups: Array = PlanetSetups.all()
|
||||
if setups.is_empty():
|
||||
_set_status("No saved setups yet.")
|
||||
load_panel.visible = false
|
||||
return false
|
||||
load_list.clear()
|
||||
_load_list_setup_ids = PackedStringArray()
|
||||
for setup in setups:
|
||||
load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id])
|
||||
_load_list_setup_ids.append(setup.setup_id)
|
||||
return true
|
||||
|
||||
|
||||
func _refresh_load_panel_mode() -> void:
|
||||
if load_title_label != null:
|
||||
load_title_label.text = "Tap a setup to DELETE" if _delete_map_armed else "Load a setup"
|
||||
|
||||
|
||||
func _on_delete_map_pressed() -> void:
|
||||
_delete_map_armed = not _delete_map_armed
|
||||
_refresh_load_panel_mode()
|
||||
|
||||
|
||||
func _on_load_close_pressed() -> void:
|
||||
load_panel.visible = false
|
||||
_delete_map_armed = false
|
||||
_refresh_load_panel_mode()
|
||||
|
||||
|
||||
func _on_load_list_item_selected(index: int) -> void:
|
||||
if index < 0 or index >= _load_list_setup_ids.size():
|
||||
return
|
||||
var setup_id: String = _load_list_setup_ids[index]
|
||||
if _delete_map_armed:
|
||||
var outcome: Dictionary = delete_setup(setup_id)
|
||||
_set_status(outcome.message)
|
||||
# Disarmed either way: a delete that failed should not leave the next
|
||||
# tap primed to try again on a different setup.
|
||||
_delete_map_armed = false
|
||||
if not _refresh_load_list():
|
||||
return
|
||||
_refresh_load_panel_mode()
|
||||
return
|
||||
load_setup(setup_id)
|
||||
load_panel.visible = false
|
||||
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
if selected_item.is_empty():
|
||||
_set_status("Nothing selected to delete.")
|
||||
return
|
||||
if selected_item.kind == "capture":
|
||||
remove_capture_point(selected_item.index)
|
||||
else:
|
||||
remove_enemy(selected_item.index)
|
||||
selected_item = {}
|
||||
_refresh_selected_label()
|
||||
|
||||
|
||||
func _on_name_changed(new_text: String) -> void:
|
||||
if current_setup == null:
|
||||
return
|
||||
current_setup.display_name = new_text
|
||||
current_setup.setup_id = _sanitize_id(new_text)
|
||||
|
||||
|
||||
func _sanitize_id(text: String) -> String:
|
||||
var lowered: String = text.strip_edges().to_lower()
|
||||
var result: String = ""
|
||||
for c in lowered:
|
||||
result += c if (c.to_lower() != c.to_upper() or c == "_" or (c >= "0" and c <= "9")) else "_"
|
||||
return result if result != "" else "untitled"
|
||||
|
||||
|
||||
func _set_status(message: String) -> void:
|
||||
if status_label != null:
|
||||
status_label.text = message
|
||||
|
||||
|
||||
# #66: names the currently-selected marker so a tap actually confirms what
|
||||
# got picked, instead of the player having to infer it from which dot on
|
||||
# screen turned white.
|
||||
func _refresh_selected_label() -> void:
|
||||
if selected_label == null:
|
||||
return
|
||||
if selected_item.is_empty() or current_setup == null:
|
||||
selected_label.text = ""
|
||||
return
|
||||
var index: int = int(selected_item.index)
|
||||
if selected_item.kind == "capture":
|
||||
selected_label.text = "Selected: Capture Point #%d" % (index + 1)
|
||||
else:
|
||||
var spawn: Resource = current_setup.enemy_spawns[index]
|
||||
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
|
||||
selected_label.text = "Selected: %s #%d" % [definition.display_name, index + 1]
|
||||
|
||||
|
||||
# --- placement/selection helpers ----------------------------------------
|
||||
|
||||
func _place_selected(world_pos: Vector2) -> void:
|
||||
if selected_palette == "capture_point":
|
||||
add_capture_point(world_pos)
|
||||
else:
|
||||
add_enemy(selected_palette, world_pos)
|
||||
|
||||
|
||||
func _find_marker_near(world_pos: Vector2) -> Dictionary:
|
||||
if current_setup == null:
|
||||
return {}
|
||||
var best: Dictionary = {}
|
||||
var best_dist: float = HIT_RADIUS
|
||||
for i in range(current_setup.capture_points.size()):
|
||||
var d: float = world_pos.distance_to(current_setup.capture_points[i])
|
||||
if d <= best_dist:
|
||||
best_dist = d
|
||||
best = {"kind": "capture", "index": i}
|
||||
for i in range(current_setup.enemy_spawns.size()):
|
||||
var d: float = world_pos.distance_to(current_setup.enemy_spawns[i].position)
|
||||
if d <= best_dist:
|
||||
best_dist = d
|
||||
best = {"kind": "enemy", "index": i}
|
||||
return best
|
||||
|
||||
|
||||
func _move_selected(world_pos: Vector2) -> void:
|
||||
if dragging_item.is_empty():
|
||||
return
|
||||
if dragging_item.kind == "capture":
|
||||
move_capture_point(dragging_item.index, world_pos)
|
||||
else:
|
||||
move_enemy(dragging_item.index, world_pos)
|
||||
|
||||
|
||||
func _screen_to_world(screen_pos: Vector2) -> Vector2:
|
||||
var viewport_size: Vector2 = get_viewport_rect().size
|
||||
return camera.position + (screen_pos - viewport_size / 2.0) / camera.zoom.x
|
||||
|
||||
|
||||
# --- touch/pan/zoom (mirrors GameController's pinch idiom) ---------------
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventScreenTouch:
|
||||
_handle_touch(event)
|
||||
elif event is InputEventScreenDrag:
|
||||
_handle_drag(event)
|
||||
elif event is InputEventMagnifyGesture:
|
||||
_set_zoom(camera.zoom.x * event.factor)
|
||||
elif event is InputEventMouseButton and event.pressed:
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_set_zoom(camera.zoom.x / WHEEL_ZOOM_STEP)
|
||||
|
||||
|
||||
func _handle_touch(event: InputEventScreenTouch) -> void:
|
||||
if event.pressed:
|
||||
touches[event.index] = event.position
|
||||
if touches.size() == 2:
|
||||
_start_pinch()
|
||||
drag_touch_index = -1
|
||||
drag_mode = ""
|
||||
return
|
||||
if touches.size() == 1:
|
||||
var world_pos: Vector2 = _screen_to_world(event.position)
|
||||
var hit: Dictionary = _find_marker_near(world_pos)
|
||||
drag_touch_index = event.index
|
||||
pan_start_screen = event.position
|
||||
pan_start_camera = camera.position
|
||||
if not hit.is_empty():
|
||||
selected_item = hit
|
||||
dragging_item = hit
|
||||
drag_mode = "item"
|
||||
_rebuild_markers()
|
||||
_refresh_selected_label()
|
||||
else:
|
||||
# #65: a press over empty canvas no longer commits to placing
|
||||
# an item immediately - it might be the start of a pan. Which
|
||||
# one it turns out to be is only known on release (still
|
||||
# under PAN_DEADZONE = a tap = place) or once a drag actually
|
||||
# crosses that deadzone (= a pan), same tap-vs-drag idiom
|
||||
# GasBombController uses for its own AIM_DEADZONE.
|
||||
dragging_item = {}
|
||||
drag_mode = "pending"
|
||||
else:
|
||||
if event.index == drag_touch_index:
|
||||
if drag_mode == "pending":
|
||||
_place_selected(_screen_to_world(event.position))
|
||||
drag_touch_index = -1
|
||||
drag_mode = ""
|
||||
dragging_item = {}
|
||||
touches.erase(event.index)
|
||||
if touches.size() < 2:
|
||||
pinch_active = false
|
||||
|
||||
|
||||
func _handle_drag(event: InputEventScreenDrag) -> void:
|
||||
if not touches.has(event.index):
|
||||
return
|
||||
touches[event.index] = event.position
|
||||
|
||||
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
|
||||
var positions: Array = touches.values()
|
||||
var current_distance: float = positions[0].distance_to(positions[1])
|
||||
_set_zoom(pinch_start_zoom * (current_distance / maxf(pinch_start_distance, 1.0)))
|
||||
return
|
||||
|
||||
if event.index != drag_touch_index:
|
||||
return
|
||||
if drag_mode == "pending" and event.position.distance_to(pan_start_screen) >= PAN_DEADZONE:
|
||||
drag_mode = "pan"
|
||||
match drag_mode:
|
||||
"item":
|
||||
_move_selected(_screen_to_world(event.position))
|
||||
"pan":
|
||||
var delta_screen: Vector2 = event.position - pan_start_screen
|
||||
camera.position = pan_start_camera - delta_screen / camera.zoom.x
|
||||
|
||||
|
||||
func _start_pinch() -> void:
|
||||
pinch_active = true
|
||||
var positions: Array = touches.values()
|
||||
pinch_start_distance = positions[0].distance_to(positions[1])
|
||||
pinch_start_zoom = camera.zoom.x
|
||||
|
||||
|
||||
func _set_zoom(raw_zoom: float) -> void:
|
||||
var z: float = clampf(raw_zoom, MIN_ZOOM, MAX_ZOOM)
|
||||
camera.zoom = Vector2(z, z)
|
||||
1
surface/PlanetSetupEditor.gd.uid
Normal file
1
surface/PlanetSetupEditor.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bprbw56kcmdsk
|
||||
248
surface/PlanetSetupEditor.tscn
Normal file
248
surface/PlanetSetupEditor.tscn
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/PlanetSetupEditor.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"]
|
||||
[ext_resource type="Script" path="res://ui/IconButton.gd" id="4"]
|
||||
|
||||
[node name="PlanetSetupEditor" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="BackgroundLayer" parent="." instance=ExtResource("3")]
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position = Vector2(0, 0)
|
||||
zoom = Vector2(1, 1)
|
||||
|
||||
[node name="ItemHost" type="Node2D" parent="."]
|
||||
|
||||
[node name="UI" type="CanvasLayer" parent="."]
|
||||
layer = 1
|
||||
|
||||
[node name="BackButton" type="Control" parent="UI"]
|
||||
offset_left = 20
|
||||
offset_top = 20
|
||||
offset_right = 92
|
||||
offset_bottom = 92
|
||||
script = ExtResource("4")
|
||||
icon = 1
|
||||
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||
|
||||
[node name="BackCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 20
|
||||
offset_top = 96
|
||||
offset_right = 92
|
||||
offset_bottom = 122
|
||||
theme_override_font_sizes/font_size = 18
|
||||
horizontal_alignment = 1
|
||||
text = "Back"
|
||||
|
||||
[node name="ProfileButton" type="Control" parent="UI"]
|
||||
offset_left = 110
|
||||
offset_top = 20
|
||||
offset_right = 182
|
||||
offset_bottom = 92
|
||||
script = ExtResource("4")
|
||||
icon = 0
|
||||
glow_color = Color(0.5, 0.75, 1, 1)
|
||||
|
||||
[node name="ProfileValueLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 190
|
||||
offset_top = 36
|
||||
offset_right = 340
|
||||
offset_bottom = 76
|
||||
theme_override_font_sizes/font_size = 26
|
||||
text = "ROCKY"
|
||||
|
||||
[node name="NameInput" type="LineEdit" parent="UI"]
|
||||
offset_left = 350
|
||||
offset_top = 36
|
||||
offset_right = 696
|
||||
offset_bottom = 76
|
||||
placeholder_text = "Setup name"
|
||||
text = "Untitled"
|
||||
|
||||
[node name="NewButton" type="Control" parent="UI"]
|
||||
offset_left = 20
|
||||
offset_top = 140
|
||||
offset_right = 92
|
||||
offset_bottom = 212
|
||||
script = ExtResource("4")
|
||||
icon = 5
|
||||
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||
|
||||
[node name="NewCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 20
|
||||
offset_top = 216
|
||||
offset_right = 92
|
||||
offset_bottom = 242
|
||||
theme_override_font_sizes/font_size = 18
|
||||
horizontal_alignment = 1
|
||||
text = "New"
|
||||
|
||||
[node name="SaveButton" type="Control" parent="UI"]
|
||||
offset_left = 110
|
||||
offset_top = 140
|
||||
offset_right = 182
|
||||
offset_bottom = 212
|
||||
script = ExtResource("4")
|
||||
icon = 4
|
||||
glow_color = Color(0.45, 0.9, 0.55, 1)
|
||||
|
||||
[node name="SaveCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 110
|
||||
offset_top = 216
|
||||
offset_right = 182
|
||||
offset_bottom = 242
|
||||
theme_override_font_sizes/font_size = 18
|
||||
horizontal_alignment = 1
|
||||
text = "Save"
|
||||
|
||||
[node name="LoadButton" type="Control" parent="UI"]
|
||||
offset_left = 200
|
||||
offset_top = 140
|
||||
offset_right = 272
|
||||
offset_bottom = 212
|
||||
script = ExtResource("4")
|
||||
icon = 3
|
||||
glow_color = Color(0.5, 0.75, 1, 1)
|
||||
|
||||
[node name="LoadCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 200
|
||||
offset_top = 216
|
||||
offset_right = 272
|
||||
offset_bottom = 242
|
||||
theme_override_font_sizes/font_size = 18
|
||||
horizontal_alignment = 1
|
||||
text = "Load"
|
||||
|
||||
[node name="DeleteButton" type="Control" parent="UI"]
|
||||
offset_left = 290
|
||||
offset_top = 140
|
||||
offset_right = 362
|
||||
offset_bottom = 212
|
||||
script = ExtResource("4")
|
||||
icon = 28
|
||||
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||
|
||||
[node name="DeleteCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 290
|
||||
offset_top = 216
|
||||
offset_right = 362
|
||||
offset_bottom = 242
|
||||
theme_override_font_sizes/font_size = 18
|
||||
horizontal_alignment = 1
|
||||
text = "Delete"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 380
|
||||
offset_top = 140
|
||||
offset_right = 696
|
||||
offset_bottom = 242
|
||||
theme_override_font_sizes/font_size = 20
|
||||
autowrap_mode = 2
|
||||
text = ""
|
||||
|
||||
[node name="RewardButton" type="Control" parent="UI"]
|
||||
offset_left = 350
|
||||
offset_top = 20
|
||||
offset_right = 422
|
||||
offset_bottom = 92
|
||||
script = ExtResource("4")
|
||||
icon = 29
|
||||
glow_color = Color(0.55, 0.9, 0.65, 1)
|
||||
|
||||
[node name="RewardValueLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 430
|
||||
offset_top = 36
|
||||
offset_right = 690
|
||||
offset_bottom = 76
|
||||
theme_override_font_sizes/font_size = 26
|
||||
theme_override_colors/font_color = Color(0.55, 0.9, 0.65, 1)
|
||||
text = "DEFAULT RP"
|
||||
|
||||
[node name="SelectedLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 20
|
||||
offset_top = 250
|
||||
offset_right = 696
|
||||
offset_bottom = 286
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_colors/font_color = Color(1, 0.95, 0.9, 1)
|
||||
autowrap_mode = 2
|
||||
text = ""
|
||||
|
||||
[node name="PaletteRow" type="Control" parent="UI"]
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 20
|
||||
offset_top = -110
|
||||
offset_right = 696
|
||||
offset_bottom = -20
|
||||
|
||||
[node name="LoadPanel" type="Control" parent="UI"]
|
||||
visible = false
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="UI/LoadPanel"]
|
||||
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/LoadPanel"]
|
||||
mouse_filter = 2
|
||||
anchor_right = 1.0
|
||||
offset_left = 24
|
||||
offset_top = 36
|
||||
offset_right = -24
|
||||
offset_bottom = 90
|
||||
theme_override_font_sizes/font_size = 34
|
||||
horizontal_alignment = 1
|
||||
text = "Load a setup"
|
||||
|
||||
[node name="List" type="ItemList" parent="UI/LoadPanel"]
|
||||
offset_left = 24
|
||||
offset_top = 110
|
||||
offset_right = 696
|
||||
offset_bottom = 1080
|
||||
theme_override_font_sizes/font_size = 34
|
||||
theme_override_constants/v_separation = 30
|
||||
allow_reselect = true
|
||||
|
||||
[node name="DeleteMapButton" type="Control" parent="UI/LoadPanel"]
|
||||
offset_left = 24
|
||||
offset_top = 1096
|
||||
offset_right = 96
|
||||
offset_bottom = 1168
|
||||
script = ExtResource("4")
|
||||
icon = 28
|
||||
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||
|
||||
[node name="DeleteMapCaption" type="Label" parent="UI/LoadPanel"]
|
||||
mouse_filter = 2
|
||||
offset_left = 112
|
||||
offset_top = 1108
|
||||
offset_right = 690
|
||||
offset_bottom = 1160
|
||||
theme_override_font_sizes/font_size = 24
|
||||
vertical_alignment = 1
|
||||
text = "Delete a setup"
|
||||
|
||||
[node name="CloseButton" type="Control" parent="UI/LoadPanel"]
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -92
|
||||
offset_top = 20
|
||||
offset_right = -20
|
||||
offset_bottom = 92
|
||||
script = ExtResource("4")
|
||||
icon = 1
|
||||
glow_color = Color(0.9, 0.35, 0.35, 1)
|
||||
60
surface/PlanetSetupHost.gd
Normal file
60
surface/PlanetSetupHost.gd
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
class_name PlanetSetupHost
|
||||
extends Node2D
|
||||
|
||||
# Instantiates the contents of a PlanetSetup onto a landed surface: one
|
||||
# CapturePoint per authored point - registering/restoring conquest progress
|
||||
# through GameState so re-landing on a partly-taken planet keeps its
|
||||
# progress - and one enemy per authored spawn.
|
||||
|
||||
const CAPTURE_POINT_SCENE: PackedScene = preload("res://surface/CapturePoint.tscn")
|
||||
const ENEMY_SCENE: PackedScene = preload("res://enemies/Enemy.tscn")
|
||||
|
||||
var setup: Resource
|
||||
|
||||
|
||||
func apply_setup(new_setup: Resource) -> void:
|
||||
setup = new_setup
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
if setup == null:
|
||||
return
|
||||
|
||||
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(),
|
||||
GameState.planet_definition_id, int(setup.research_point_reward))
|
||||
for i in range(setup.capture_points.size()):
|
||||
_add_capture_point(i, setup.capture_points[i])
|
||||
if _garrison_is_defeated():
|
||||
return
|
||||
for spawn in setup.enemy_spawns:
|
||||
_add_enemy(spawn)
|
||||
|
||||
|
||||
# A conquered planet stays conquered. Its garrison was destroyed to take it,
|
||||
# so re-landing must not quietly restore it - the capture points already come
|
||||
# back marked captured, and enemies standing on a planet the player owns read
|
||||
# as the conquest having been undone.
|
||||
#
|
||||
# Guarded on there being capture points to take, not on the conquered flag
|
||||
# alone: register_capture_points() treats a setup with none as vacuously
|
||||
# conquered (see PlanetSurfaceController's note on unregistered planets), and
|
||||
# a setup that authored enemies but no capture points is a garrison nobody
|
||||
# can ever have beaten. Suppressing those would delete them on first sight.
|
||||
func _garrison_is_defeated() -> bool:
|
||||
return not setup.capture_points.is_empty() \
|
||||
and GameState.is_planet_conquered(GameState.planet_id)
|
||||
|
||||
|
||||
func _add_capture_point(index: int, at: Vector2) -> void:
|
||||
var point: Node = CAPTURE_POINT_SCENE.instantiate()
|
||||
point.point_index = index
|
||||
point.position = at
|
||||
add_child(point)
|
||||
if GameState.is_point_captured(GameState.planet_id, index):
|
||||
point.mark_captured()
|
||||
|
||||
|
||||
func _add_enemy(spawn: Resource) -> void:
|
||||
var enemy: Node = ENEMY_SCENE.instantiate()
|
||||
enemy.enemy_id = spawn.enemy_id
|
||||
enemy.position = spawn.position
|
||||
add_child(enemy)
|
||||
1
surface/PlanetSetupHost.gd.uid
Normal file
1
surface/PlanetSetupHost.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ckxmhx54fi5s1
|
||||
6
surface/PlanetSetupHost.tscn
Normal file
6
surface/PlanetSetupHost.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/PlanetSetupHost.gd" id="1"]
|
||||
|
||||
[node name="PlanetSetupHost" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
7
surface/PlanetSetupSpawn.gd
Normal file
7
surface/PlanetSetupSpawn.gd
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class_name PlanetSetupSpawn
|
||||
extends Resource
|
||||
|
||||
# One authored enemy placement within a PlanetSetup.
|
||||
|
||||
@export var enemy_id: String = "chaser"
|
||||
@export var position: Vector2 = Vector2.ZERO
|
||||
1
surface/PlanetSetupSpawn.gd.uid
Normal file
1
surface/PlanetSetupSpawn.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c8spn4nio735d
|
||||
143
surface/PlanetSetups.gd
Normal file
143
surface/PlanetSetups.gd
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
class_name PlanetSetups
|
||||
extends RefCounted
|
||||
|
||||
# Registry of authored PlanetSetup resources. Deliberately scans
|
||||
# res://PlanetSetups/ at first use instead of a hardcoded preload
|
||||
# dictionary (contrast PlanetDefinitions.gd), so dropping a new .tres in
|
||||
# that folder is the only step needed to add a setup - the map editor
|
||||
# (#48) writes there directly.
|
||||
|
||||
const SETUPS_DIR: String = "res://PlanetSetups"
|
||||
# Compared against get_script() rather than an `is PlanetSetup` check - the
|
||||
# same load-order hazard as PlanetSurfaceController's setup_host: a global
|
||||
# class_name identifier can be looked up before an import pass has
|
||||
# registered it, while a direct script preload never depends on that cache.
|
||||
const PLANET_SETUP_SCRIPT: Script = preload("res://surface/PlanetSetup.gd")
|
||||
|
||||
static var _all: Array = []
|
||||
static var _scanned: bool = false
|
||||
# Which setup each planet drew, once it has drawn one. See setup_for_planet.
|
||||
static var _assigned: Dictionary = {}
|
||||
|
||||
|
||||
static func _ensure_scanned() -> void:
|
||||
if _scanned:
|
||||
return
|
||||
_scanned = true
|
||||
var dir: DirAccess = DirAccess.open(SETUPS_DIR)
|
||||
if dir == null:
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var file_name: String = dir.get_next()
|
||||
while file_name != "":
|
||||
if not dir.current_is_dir() and file_name.ends_with(".tres"):
|
||||
# CACHE_MODE_REPLACE, not a plain load(). Godot caches resources by
|
||||
# path, so after the map editor saved a setup this handed back the
|
||||
# CACHED copy - still holding the values from before the save. The
|
||||
# editor called rescan() and the pool dutifully re-served the stale
|
||||
# data, which is what "I changed the research points and it was not
|
||||
# saved" actually was: the file on disk was correct all along.
|
||||
#
|
||||
# REPLACE rather than IGNORE deliberately: it refreshes the
|
||||
# existing instance in place instead of minting a new one, so
|
||||
# object identity across a rescan survives - which the per-planet
|
||||
# map assignments depend on (see setup_for_planet).
|
||||
var res: Resource = ResourceLoader.load(SETUPS_DIR + "/" + file_name,
|
||||
"", ResourceLoader.CACHE_MODE_REPLACE)
|
||||
if res != null and res.get_script() == PLANET_SETUP_SCRIPT:
|
||||
_all.append(res)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
static func rescan() -> void:
|
||||
# The map editor calls this after saving so a freshly written setup is
|
||||
# visible without restarting the process.
|
||||
_all = []
|
||||
_scanned = false
|
||||
_ensure_scanned()
|
||||
# Assignments are PRUNED, not cleared. Clearing them would undo the whole
|
||||
# point of remembering: saving one map in the editor would silently re-deal
|
||||
# every planet the player had already visited. What has to go is only the
|
||||
# entries pointing at a setup that no longer exists - a deleted map must
|
||||
# not stay pinned to a planet.
|
||||
var live: Dictionary = {}
|
||||
for setup in _all:
|
||||
live[setup] = true
|
||||
for key in _assigned.keys():
|
||||
if not live.has(_assigned[key]):
|
||||
_assigned.erase(key)
|
||||
|
||||
|
||||
static func all() -> Array:
|
||||
_ensure_scanned()
|
||||
return _all
|
||||
|
||||
|
||||
static func find_by_id(setup_id: String) -> Resource:
|
||||
for setup in all():
|
||||
if String(setup.setup_id) == setup_id:
|
||||
return setup
|
||||
return null
|
||||
|
||||
|
||||
static func path_for_id(setup_id: String) -> String:
|
||||
# The file a setup actually LIVES in, which is not reliably
|
||||
# "PlanetSetups/<setup_id>.tres". Authored resources carry whatever
|
||||
# filename they were created with, and at least one shipped setup has an
|
||||
# id that differs from its filename outright (ResearchLabSetup.tres holds
|
||||
# setup_id "research_lab_1"). Deriving the path from the id instead of
|
||||
# asking the resource broke two things at once: deleting such a setup
|
||||
# looked for a file that does not exist and silently refused, and saving
|
||||
# one wrote a SECOND file under the derived name, leaving the original
|
||||
# behind with the old contents - which reads exactly like "my change was
|
||||
# not saved", because the stale twin is still in the pool.
|
||||
var setup: Resource = find_by_id(setup_id)
|
||||
if setup != null and String(setup.resource_path) != "":
|
||||
return String(setup.resource_path)
|
||||
return "%s/%s.tres" % [SETUPS_DIR, setup_id]
|
||||
|
||||
|
||||
static func setups_for_profile(profile_id: String) -> Array:
|
||||
var result: Array = []
|
||||
for setup in all():
|
||||
if setup.surface_profile_id == profile_id:
|
||||
result.append(setup)
|
||||
# Sorted by id, because the order they arrive in is whatever order the
|
||||
# filesystem listed them in - which is not something to seed a choice
|
||||
# with. The pick below indexes into this array, so an unstable order
|
||||
# means the same planet can land on a different map from one run to the
|
||||
# next, and the "this is MY planet's layout" illusion goes with it.
|
||||
result.sort_custom(func(a, b): return String(a.setup_id) < String(b.setup_id))
|
||||
return result
|
||||
|
||||
|
||||
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
|
||||
# Every planet gets a map of its own type, drawn at random, and keeps it.
|
||||
#
|
||||
# The draw is seeded purely from the planet's own stable id - same pattern
|
||||
# as WorldGen.planet_data_at_chunk - so leaving a planet and coming back
|
||||
# lands on the same layout without anything having to be saved.
|
||||
#
|
||||
# The result is then remembered anyway, because the seed alone is not
|
||||
# quite enough: the pool it indexes into changes the moment the map editor
|
||||
# saves a new setup, which would silently re-deal every planet in the
|
||||
# middle of a session. Once a planet has been visited its map is pinned
|
||||
# for as long as the process lives.
|
||||
#
|
||||
# Keyed on the profile too, not the planet alone: the memo must never be
|
||||
# able to hand back a gas map for a rocky query just because that planet
|
||||
# id was seen first.
|
||||
var key: String = "%s|%s" % [planet_id, profile_id]
|
||||
var remembered: Resource = _assigned.get(key)
|
||||
if remembered != null:
|
||||
return remembered
|
||||
|
||||
var candidates: Array = setups_for_profile(profile_id)
|
||||
if candidates.is_empty():
|
||||
return null
|
||||
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
rng.seed = hash(planet_id)
|
||||
var chosen: Resource = candidates[rng.randi() % candidates.size()]
|
||||
_assigned[key] = chosen
|
||||
return chosen
|
||||
1
surface/PlanetSetups.gd.uid
Normal file
1
surface/PlanetSetups.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c5ndxvompklip
|
||||
286
surface/PlanetSurface.tscn
Normal file
286
surface/PlanetSurface.tscn
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
[gd_scene load_steps=16 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://controllers/GameController.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://surface/SurfaceBackground.tscn" id="3"]
|
||||
[ext_resource type="Script" path="res://vessels/Fleet.gd" id="4"]
|
||||
[ext_resource type="Script" path="res://surface/PlanetSurfaceController.gd" id="5"]
|
||||
[ext_resource type="Script" path="res://ui/IconButton.gd" id="6"]
|
||||
[ext_resource type="Script" path="res://ui/TouchJoystick.gd" id="7"]
|
||||
[ext_resource type="Script" path="res://ui/ResourceReadout.gd" id="9"]
|
||||
[ext_resource type="Script" path="res://controllers/MouseCombatController.gd" id="mouse_ctrl"]
|
||||
[ext_resource type="Script" path="res://controllers/FireStickController.gd" id="fire_ctrl"]
|
||||
[ext_resource type="Script" path="res://controllers/GasBombController.gd" id="14"]
|
||||
[ext_resource type="Script" path="res://controllers/LaserController.gd" id="16"]
|
||||
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="17"]
|
||||
[ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"]
|
||||
|
||||
[node name="PlanetSurface" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="BackgroundLayer" parent="." instance=ExtResource("3")]
|
||||
|
||||
[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="GasBombController" type="Node2D" parent="."]
|
||||
script = ExtResource("14")
|
||||
|
||||
[node name="FireStickController" type="Node2D" parent="."]
|
||||
script = ExtResource("fire_ctrl")
|
||||
|
||||
[node name="MouseCombatController" type="Node2D" parent="."]
|
||||
script = ExtResource("mouse_ctrl")
|
||||
|
||||
[node name="LaserController" type="Node2D" parent="."]
|
||||
script = ExtResource("16")
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position = Vector2(0, 0)
|
||||
zoom = Vector2(1, 1)
|
||||
|
||||
[node name="SurfaceFeatures" type="Node2D" parent="."]
|
||||
|
||||
[node name="SurfaceController" type="Node2D" parent="."]
|
||||
script = ExtResource("5")
|
||||
|
||||
[node name="UI" type="CanvasLayer" parent="."]
|
||||
layer = 1
|
||||
|
||||
[node name="ResourceReadout" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 28
|
||||
offset_top = 38
|
||||
offset_right = 696
|
||||
offset_bottom = 154
|
||||
script = ExtResource("9")
|
||||
digit_height = 38.0
|
||||
bright_background = true
|
||||
|
||||
[node name="BankCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 28
|
||||
offset_top = 6
|
||||
offset_right = 220
|
||||
offset_bottom = 34
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.08, 0.16, 0.2, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.9, 0.95, 0.96, 0.8)
|
||||
theme_override_constants/outline_size = 4
|
||||
text = "BANKED"
|
||||
|
||||
[node name="FleetCaption" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 356
|
||||
offset_top = 6
|
||||
offset_right = 620
|
||||
offset_bottom = 34
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.24, 0.14, 0.02, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.96, 0.93, 0.82, 0.85)
|
||||
theme_override_constants/outline_size = 4
|
||||
text = "FLEET HOLD"
|
||||
|
||||
[node name="PlanetNameLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 400
|
||||
offset_top = 40
|
||||
offset_right = 696
|
||||
offset_bottom = 148
|
||||
text = ""
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 0
|
||||
autowrap_mode = 2
|
||||
theme_override_font_sizes/font_size = 30
|
||||
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
|
||||
theme_override_constants/outline_size = 5
|
||||
|
||||
[node name="CaptureLabel" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
offset_left = 24
|
||||
offset_top = 286
|
||||
offset_right = 696
|
||||
offset_bottom = 326
|
||||
theme_override_font_sizes/font_size = 28
|
||||
theme_override_colors/font_color = Color(0.08, 0.16, 0.2, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.9, 0.95, 0.96, 0.8)
|
||||
theme_override_constants/outline_size = 4
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
script = ExtResource("15")
|
||||
|
||||
[node name="ExitButton" type="Control" parent="UI"]
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
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="Joystick" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
script = ExtResource("7")
|
||||
glow_color = Color(0.06, 0.32, 0.5, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 12
|
||||
offset_right = 252
|
||||
offset_top = -330
|
||||
offset_bottom = -80
|
||||
|
||||
[node name="FireStick" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
script = ExtResource("7")
|
||||
glow_color = Color(0.62, 0.16, 0.08, 1)
|
||||
core_color = Color(0.15, 0.03, 0.02, 1)
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -252
|
||||
offset_right = -12
|
||||
offset_top = -330
|
||||
offset_bottom = -80
|
||||
|
||||
[node name="SpeedFader" type="Control" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -94
|
||||
offset_right = -24
|
||||
offset_top = -720
|
||||
offset_bottom = -560
|
||||
script = ExtResource("17")
|
||||
glow_color = Color(0.5, 0.18, 0.6, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
|
||||
[node name="Label" type="Label" parent="UI/SpeedFader"]
|
||||
mouse_filter = 2
|
||||
offset_left = -20
|
||||
offset_right = 90
|
||||
offset_top = -32
|
||||
offset_bottom = -4
|
||||
theme_override_font_sizes/font_size = 20
|
||||
theme_override_colors/font_color = Color(0.7, 0.3, 1, 1)
|
||||
horizontal_alignment = 1
|
||||
text = "x1.0"
|
||||
|
||||
[node name="LaserButton" type="Control" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
script = ExtResource("6")
|
||||
round_frame = true
|
||||
icon = 36
|
||||
glow_color = Color(0.3, 0.9, 1, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -196
|
||||
offset_right = -100
|
||||
offset_top = -492
|
||||
offset_bottom = -396
|
||||
|
||||
[node name="PivotCCWButton" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
script = ExtResource("6")
|
||||
round_frame = true
|
||||
icon = 7
|
||||
glow_color = Color(0.06, 0.32, 0.5, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -468
|
||||
offset_right = -364
|
||||
offset_top = -130
|
||||
offset_bottom = -26
|
||||
|
||||
[node name="GasBombButton" type="Control" parent="UI"]
|
||||
visible = false
|
||||
mouse_filter = 2
|
||||
script = ExtResource("6")
|
||||
round_frame = true
|
||||
icon = 32
|
||||
glow_color = Color(0.82, 0.08, 0.48, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -324
|
||||
offset_right = -228
|
||||
offset_top = -424
|
||||
offset_bottom = -328
|
||||
|
||||
[node name="GasBombStatus" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.82, 0.08, 0.48, 1)
|
||||
autowrap_mode = 2
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -420
|
||||
offset_right = -24
|
||||
offset_top = -560
|
||||
offset_bottom = -512
|
||||
|
||||
[node name="LaserStatus" type="Label" parent="UI"]
|
||||
mouse_filter = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.2, 0.6, 0.68, 1)
|
||||
autowrap_mode = 2
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -420
|
||||
offset_right = -24
|
||||
offset_top = -616
|
||||
offset_bottom = -568
|
||||
|
||||
[node name="PivotCWButton" type="Control" parent="UI"]
|
||||
mouse_filter = 2
|
||||
script = ExtResource("6")
|
||||
round_frame = true
|
||||
icon = 6
|
||||
glow_color = Color(0.06, 0.32, 0.5, 1)
|
||||
core_color = Color(0.02, 0.09, 0.15, 1)
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -356
|
||||
offset_right = -252
|
||||
offset_top = -130
|
||||
offset_bottom = -26
|
||||
|
||||
[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)
|
||||
93
surface/PlanetSurfaceController.gd
Normal file
93
surface/PlanetSurfaceController.gd
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
extends Node2D
|
||||
|
||||
const SURFACE_PROFILES = preload("res://surface/PlanetSurfaceProfiles.gd")
|
||||
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
||||
const PLANET_SETUPS = preload("res://surface/PlanetSetups.gd")
|
||||
const SETUP_HOST_SCENE: PackedScene = preload("res://surface/PlanetSetupHost.tscn")
|
||||
|
||||
# Bootstraps the surviving fleet, including each vessel type, on this scene's
|
||||
# own startup (from GameState, since this is a fresh scene tree - nothing here
|
||||
# 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")
|
||||
@onready var background: ColorRect = get_node("../BackgroundLayer/Background")
|
||||
@onready var feature_host: Node2D = get_node("../SurfaceFeatures")
|
||||
|
||||
var transitioning: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var planet_definition: Resource = PLANET_DEFINITIONS.get_definition(GameState.planet_definition_id)
|
||||
_compose_surface(SURFACE_PROFILES.get_profile(planet_definition.surface_profile_id))
|
||||
fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO,
|
||||
GameState.surviving_vessel_types)
|
||||
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 _compose_surface(profile: Resource) -> void:
|
||||
# Shared with PlanetSetupEditor, deliberately: the backdrop is one
|
||||
# instanced scene configured through one method, so what an author sees
|
||||
# while placing markers is what a player sees after landing. See the
|
||||
# header of SurfaceBackground.gd.
|
||||
background.apply_profile(profile)
|
||||
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption", "../UI/CaptureLabel"]:
|
||||
var caption: Label = get_node(caption_path)
|
||||
caption.add_theme_color_override("font_color", profile.caption_color)
|
||||
caption.add_theme_color_override("font_outline_color", profile.caption_outline_color)
|
||||
if profile.features_scene != null:
|
||||
var features: Node = profile.features_scene.instantiate()
|
||||
feature_host.add_child(features)
|
||||
|
||||
var setup: Resource = PLANET_SETUPS.setup_for_planet(GameState.planet_id, profile.profile_id)
|
||||
if setup != null:
|
||||
# Untyped on purpose: a static PlanetSetupHost annotation here would
|
||||
# need the global class_name cache to already know about it, which
|
||||
# is only true after an editor/import pass has run (see #31/#35's
|
||||
# load-order notes) - a plain Node reference plus duck-typed
|
||||
# apply_setup() sidesteps that entirely, same as BuildingDefinitions
|
||||
# and VesselDefinitions already do for their own resource types.
|
||||
var setup_host: Node = SETUP_HOST_SCENE.instantiate()
|
||||
feature_host.add_child(setup_host)
|
||||
setup_host.apply_setup(setup)
|
||||
else:
|
||||
# No PlanetSetup exists for this profile at all (#55: "void" has
|
||||
# none authored) - register zero required points directly, rather
|
||||
# than leaving this planet's conquest state unregistered forever.
|
||||
# capture_state()'s fallback for an unregistered planet_id defaults
|
||||
# to conquered=false (see test_planet_conquest_gate.gd's own "a
|
||||
# fresh planet reported conquered" check), which would otherwise
|
||||
# permanently block extractor placement here - register_capture_
|
||||
# points() treats a total of 0 as vacuously already conquered.
|
||||
GameState.register_capture_points(GameState.planet_id, 0)
|
||||
|
||||
|
||||
func _on_exit_pressed() -> void:
|
||||
if transitioning:
|
||||
return
|
||||
transitioning = true
|
||||
|
||||
GameState.surviving_ship_count = fleet.ships.size()
|
||||
GameState.surviving_vessel_types = fleet.vessel_types()
|
||||
|
||||
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://world/Main.tscn")
|
||||
1
surface/PlanetSurfaceController.gd.uid
Normal file
1
surface/PlanetSurfaceController.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://17thrioymdls
|
||||
18
surface/PlanetSurfaceProfile.gd
Normal file
18
surface/PlanetSurfaceProfile.gd
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
class_name PlanetSurfaceProfile
|
||||
extends Resource
|
||||
|
||||
# Declarative recipe for a landed planet surface. The host scene only knows
|
||||
# how to apply one of these; the feature scene owns the actual resource nodes.
|
||||
|
||||
@export var profile_id: String = "rocky"
|
||||
@export var features_scene: PackedScene
|
||||
# Which shader draws this surface's backdrop. Every profile names one - the
|
||||
# old "null means keep whatever the scene already had" convention is what let
|
||||
# the game and the setup editor drift apart, since each scene "already had" a
|
||||
# different shader. SurfaceBackground.apply_profile() is the only consumer.
|
||||
@export var background_shader: Shader = null
|
||||
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
|
||||
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)
|
||||
@export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0)
|
||||
@export var caption_color: Color = Color(0.08, 0.16, 0.2, 1.0)
|
||||
@export var caption_outline_color: Color = Color(0.9, 0.95, 0.96, 0.8)
|
||||
1
surface/PlanetSurfaceProfile.gd.uid
Normal file
1
surface/PlanetSurfaceProfile.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b31vef6ge1tjh
|
||||
14
surface/PlanetSurfaceProfiles.gd
Normal file
14
surface/PlanetSurfaceProfiles.gd
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class_name PlanetSurfaceProfiles
|
||||
extends RefCounted
|
||||
|
||||
const DEFAULT_PROFILE_ID: String = "rocky"
|
||||
const PROFILES: Dictionary = {
|
||||
"rocky": preload("res://surface/RockySurfaceProfile.tres"),
|
||||
"gas": preload("res://surface/GasSurfaceProfile.tres"),
|
||||
"void": preload("res://surface/VoidSurfaceProfile.tres"),
|
||||
"research_lab": preload("res://surface/ResearchLabSurfaceProfile.tres"),
|
||||
}
|
||||
|
||||
|
||||
static func get_profile(profile_id: String) -> Resource:
|
||||
return PROFILES.get(profile_id, PROFILES[DEFAULT_PROFILE_ID])
|
||||
1
surface/PlanetSurfaceProfiles.gd.uid
Normal file
1
surface/PlanetSurfaceProfiles.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bufr6mnwbobxb
|
||||
14
surface/ResearchLabSurfaceProfile.tres
Normal file
14
surface/ResearchLabSurfaceProfile.tres
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/PlanetSurfaceProfile.gd" id="1"]
|
||||
[ext_resource type="Shader" path="res://shaders/research_lab_surface.gdshader" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
profile_id = "research_lab"
|
||||
background_shader = ExtResource("2")
|
||||
base_color = Color(0.17, 0.13, 0.13, 1)
|
||||
crater_color = Color(0.045, 0.028, 0.03, 1)
|
||||
highlight_color = Color(0.85, 0.24, 0.16, 1)
|
||||
caption_color = Color(0.9, 0.6, 0.55, 1)
|
||||
caption_outline_color = Color(0.05, 0.02, 0.02, 0.9)
|
||||
133
surface/Rock.gd
Normal file
133
surface/Rock.gd
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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 now DO appear together - shooting an
|
||||
# asteroid drops one of these right next to its neighbours - which makes the
|
||||
# shape language matter more than when they were kept apart by scene: jagged
|
||||
# and irregular 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
|
||||
|
||||
# By path, not by its `ResourcePickupVFX` global name: a bare class-name
|
||||
# reference needs the global class cache already warm, which it is not when
|
||||
# this script is reached indirectly - loading Main.tscn from inside a preview
|
||||
# scene made it a hard parse error. Same workaround PlanetSetups and
|
||||
# PlanetSetupEditor already use.
|
||||
const RESOURCE_PICKUP_VFX = preload("res://vfx/ResourcePickupVFX.gd")
|
||||
|
||||
# Seconds before an uncollected rock disappears. 0 means never, which is what
|
||||
# hand-placed surface rocks use - a planet surface is bounded and authored, so
|
||||
# nothing accumulates. Ore dropped by a shot asteroid sets a real lifetime,
|
||||
# because open space is unbounded and a field shot up and left behind would
|
||||
# otherwise leak nodes for the rest of the session.
|
||||
@export var lifetime: float = 0.0
|
||||
|
||||
@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
|
||||
var age: float = 0.0
|
||||
|
||||
|
||||
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.
|
||||
if lifetime > 0.0:
|
||||
age += delta
|
||||
if age >= lifetime:
|
||||
queue_free()
|
||||
return
|
||||
|
||||
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"):
|
||||
# Into the fleet's hold, not the bank (#14). It only counts as
|
||||
# spendable once the fleet has flown it back to the mothership.
|
||||
# #56: the hold has a finite total_capacity now - a full fleet must
|
||||
# leave the rock behind rather than despawning it for nothing.
|
||||
var taken: int = GameState.collect_cargo(1)
|
||||
if taken <= 0:
|
||||
return
|
||||
# #79: say what was picked up, where it was picked up. Parented to the
|
||||
# root rather than to this node, which is about to be freed.
|
||||
RESOURCE_PICKUP_VFX.spawn(get_tree().root, global_position,
|
||||
GameState.RESOURCE_ORE, taken)
|
||||
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
surface/Rock.gd.uid
Normal file
1
surface/Rock.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://db2ifh5f1pjwo
|
||||
12
surface/Rock.tscn
Normal file
12
surface/Rock.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/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")
|
||||
72
surface/RockField.gd
Normal file
72
surface/RockField.gd
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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
|
||||
|
||||
@export var fleet_path: NodePath = NodePath("../Fleet")
|
||||
@onready var fleet: Node2D = get_node(fleet_path)
|
||||
|
||||
var rock_scene: PackedScene = preload("res://surface/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())
|
||||
# Keep pickups under this field so its visibility and lifecycle really
|
||||
# own them. Deferred is still required because this can run from _ready().
|
||||
add_child.call_deferred(rock)
|
||||
rocks.append(rock)
|
||||
1
surface/RockField.gd.uid
Normal file
1
surface/RockField.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://vb0rgd6a5w52
|
||||
7
surface/RockySurfaceFeatures.tscn
Normal file
7
surface/RockySurfaceFeatures.tscn
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/RockField.gd" id="1"]
|
||||
|
||||
[node name="ResourceProvider" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
fleet_path = NodePath("../../Fleet")
|
||||
16
surface/RockySurfaceProfile.tres
Normal file
16
surface/RockySurfaceProfile.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/PlanetSurfaceProfile.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://surface/RockySurfaceFeatures.tscn" id="2"]
|
||||
[ext_resource type="Shader" path="res://shaders/lunar_tiled.gdshader" id="3"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
profile_id = "rocky"
|
||||
features_scene = ExtResource("2")
|
||||
background_shader = ExtResource("3")
|
||||
base_color = Color(0.75, 0.75, 0.76, 1)
|
||||
crater_color = Color(0.44, 0.44, 0.45, 1)
|
||||
highlight_color = Color(0.89, 0.89, 0.9, 1)
|
||||
caption_color = Color(0.08, 0.16, 0.2, 1)
|
||||
caption_outline_color = Color(0.9, 0.95, 0.96, 0.8)
|
||||
192
surface/SurfaceBackground.gd
Normal file
192
surface/SurfaceBackground.gd
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
extends ColorRect
|
||||
|
||||
# THE surface backdrop. Not "the one PlanetSurface.tscn happens to use" -
|
||||
# both the landed surface and the setup editor instance the SAME
|
||||
# SurfaceBackground.tscn, and both configure it through the SAME entry point
|
||||
# (apply_profile below). That is deliberate: what the editor shows is only
|
||||
# useful if it is what the player will actually see, and the two drifted
|
||||
# apart the moment they were two hand-written ShaderMaterials in two scene
|
||||
# files - the editor kept drawing recoloured craters for a gas giant,
|
||||
# because swapping in the profile's own shader had only ever been written on
|
||||
# the game's side. There is now one place to change, so there is nothing
|
||||
# left to keep in sync by hand.
|
||||
#
|
||||
# Feeds the 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.
|
||||
#
|
||||
# Forgejo #68: also bakes the crater field once, at load, instead of letting
|
||||
# it be re-derived per pixel per frame. The field is a fixed function of
|
||||
# world position, so every frame was recomputing an identical answer. It is
|
||||
# rendered once into a square texture covering one period of the field (the
|
||||
# field is periodic - see the cell wrap in lunar_surface.gdshader) and
|
||||
# lunar_tiled.gdshader then scrolls that texture with repeat wrapping, which
|
||||
# keeps the canvas infinite while the per-frame cost drops to one fetch.
|
||||
|
||||
# Single source of truth for the tile period, shared with BOTH shaders. The
|
||||
# field repeats every TILE_CELLS * cell_size world units - at 32 cells of
|
||||
# 220 that is 7040 units, several screens wide at default zoom.
|
||||
const TILE_CELLS: float = 32.0
|
||||
# Texture resolution for the bake, in pixels per cell. One cell is cell_size
|
||||
# (220) world units, so this is a ~3.4x magnification at default zoom. That
|
||||
# is affordable because the field is smooth by construction: the sharpest
|
||||
# feature is the rim band, roughly 0.12 cells wide, which still lands across
|
||||
# ~8 texels here and so survives bilinear magnification as a gradient rather
|
||||
# than as steps.
|
||||
const BAKE_PIXELS_PER_CELL: int = 64
|
||||
|
||||
# The palette the scene starts with, before any profile is applied. Every
|
||||
# host calls apply_profile() and overwrites these; they exist so the node
|
||||
# still draws something sane on its own (and so the editor's own first frame
|
||||
# is not undefined).
|
||||
@export var base_color: Color = Color(0.75, 0.75, 0.76)
|
||||
@export var crater_color: Color = Color(0.44, 0.44, 0.45)
|
||||
@export var highlight_color: Color = Color(0.89, 0.89, 0.90)
|
||||
|
||||
var camera: Camera2D = null
|
||||
# What the current baked texture was baked FROM. A bake is only worth
|
||||
# redoing when one of these changed - the editor re-applies a profile on
|
||||
# every cycle of the profile button, and rocky and void share a shader while
|
||||
# differing only in colour, so keying on the shader alone would show void's
|
||||
# palette with rocky's crater texture.
|
||||
var _baked_key: Array = []
|
||||
|
||||
|
||||
# Applies a surface profile to this backdrop: which shader draws it, and the
|
||||
# three colours that shader shades with. The single place either host is
|
||||
# allowed to configure the background from.
|
||||
#
|
||||
# The profile's shader is authoritative and always applied. There used to be
|
||||
# a "null means keep whatever the scene already had" convention here, which
|
||||
# is precisely how the two scenes came apart: "whatever the scene already
|
||||
# had" was a different shader in each of them.
|
||||
func apply_profile(profile: Resource) -> void:
|
||||
if profile == null or material == null:
|
||||
return
|
||||
base_color = profile.base_color
|
||||
crater_color = profile.crater_color
|
||||
highlight_color = profile.highlight_color
|
||||
if profile.background_shader != null:
|
||||
material.shader = profile.background_shader
|
||||
material.set_shader_parameter("base_color", base_color)
|
||||
material.set_shader_parameter("crater_color", crater_color)
|
||||
material.set_shader_parameter("highlight_color", highlight_color)
|
||||
_bake_field()
|
||||
|
||||
|
||||
# Asked of the shader itself rather than by comparing against a known path,
|
||||
# so this keeps working whichever shader a surface profile swaps in: the
|
||||
# question is only ever "does what is drawing this background actually
|
||||
# sample a baked field".
|
||||
func _material_wants_baked_field() -> bool:
|
||||
if material == null or material.shader == null:
|
||||
return false
|
||||
for uniform in material.shader.get_shader_uniform_list():
|
||||
if String(uniform.get("name", "")) == "field_texture":
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Resolved lazily from the viewport rather than by a hardcoded sibling
|
||||
# path: this scene is instanced under two different hosts, and a path
|
||||
# that happens to be right in both is a coincidence waiting to be broken
|
||||
# by the next host.
|
||||
if not is_instance_valid(camera):
|
||||
camera = get_viewport().get_camera_2d()
|
||||
if camera == null:
|
||||
return
|
||||
|
||||
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))
|
||||
|
||||
# The tiled shader samples a repeating texture, so only the camera's
|
||||
# position WITHIN the tile matters. Wrapping here rather than in the
|
||||
# shader keeps the raw cell index - which reaches the thousands after a
|
||||
# while - out of a mobile float entirely: GDScript does this in double
|
||||
# precision. fposmod, not fmod, so flying into negative coordinates
|
||||
# wraps to the far side of the tile instead of mirroring.
|
||||
#
|
||||
# The procedural shaders (gas, research lab) get the same wrapped index.
|
||||
# They hash it rather than sampling a texture with it, so the wrap makes
|
||||
# their fields periodic too - which is invisible at this period, and is
|
||||
# what keeps every value they hash small enough to stay exact in a
|
||||
# mobile float.
|
||||
material.set_shader_parameter("camera_cell", Vector2(
|
||||
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
|
||||
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)
|
||||
material.set_shader_parameter("tile_cells", TILE_CELLS)
|
||||
|
||||
|
||||
# Renders exactly one period of the procedural field into a texture and hands
|
||||
# it to the tiled shader. Runs when a profile that samples a baked field is
|
||||
# applied - during the planet-arrival fade, where a single expensive frame is
|
||||
# invisible.
|
||||
func _bake_field() -> void:
|
||||
# Headless runs - every scene-based test - rasterize nothing, so a bake
|
||||
# would allocate a render target, await a draw that never produces
|
||||
# anything readable, and leave this coroutine suspended forever holding
|
||||
# the SubViewport alive. Nothing is lost by skipping: there is no display
|
||||
# for the background to appear on.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
if not _material_wants_baked_field():
|
||||
return
|
||||
|
||||
var key: Array = [material.shader, base_color, crater_color, highlight_color]
|
||||
if key == _baked_key:
|
||||
return
|
||||
_baked_key = key
|
||||
|
||||
var procedural: Shader = load("res://shaders/lunar_surface.gdshader")
|
||||
if procedural == null:
|
||||
push_warning("surface background: could not load the field shader to bake")
|
||||
return
|
||||
|
||||
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
|
||||
var bake_material := ShaderMaterial.new()
|
||||
bake_material.shader = procedural
|
||||
# Carry the look across from whatever the profile configured, so the tile
|
||||
# is baked with the colours actually in use rather than the shader's own
|
||||
# defaults.
|
||||
bake_material.set_shader_parameter("base_color", base_color)
|
||||
bake_material.set_shader_parameter("crater_color", crater_color)
|
||||
bake_material.set_shader_parameter("highlight_color", highlight_color)
|
||||
bake_material.set_shader_parameter("cell_size", material.get_shader_parameter("cell_size"))
|
||||
bake_material.set_shader_parameter("tile_cells", TILE_CELLS)
|
||||
# Non-zero switches the field shader into bake mode: UV maps straight
|
||||
# onto one tile and the per-screen-pixel dither is left out.
|
||||
bake_material.set_shader_parameter("bake_cells", TILE_CELLS)
|
||||
|
||||
var sub_viewport := SubViewport.new()
|
||||
sub_viewport.size = Vector2i(side, side)
|
||||
sub_viewport.disable_3d = true
|
||||
sub_viewport.transparent_bg = false
|
||||
sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
||||
var bake_rect := ColorRect.new()
|
||||
bake_rect.material = bake_material
|
||||
bake_rect.size = Vector2(side, side)
|
||||
sub_viewport.add_child(bake_rect)
|
||||
add_child(sub_viewport)
|
||||
|
||||
# One frame for the SubViewport to actually draw, then read it back.
|
||||
await RenderingServer.frame_post_draw
|
||||
var image: Image = sub_viewport.get_texture().get_image()
|
||||
sub_viewport.queue_free()
|
||||
|
||||
if image == null or image.is_empty():
|
||||
# Nothing rasterized, so nothing was learned - drop the key so a
|
||||
# later apply of the same profile tries again rather than assuming
|
||||
# a texture is already in place.
|
||||
_baked_key = []
|
||||
return
|
||||
|
||||
# Mipmaps are what keep a crater field from shimmering when the camera
|
||||
# zooms out and each screen pixel starts covering many texels.
|
||||
image.generate_mipmaps()
|
||||
var texture := ImageTexture.create_from_image(image)
|
||||
material.set_shader_parameter("field_texture", texture)
|
||||
1
surface/SurfaceBackground.gd.uid
Normal file
1
surface/SurfaceBackground.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://jovl7hn41sup
|
||||
27
surface/SurfaceBackground.tscn
Normal file
27
surface/SurfaceBackground.tscn
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/SurfaceBackground.gd" id="1"]
|
||||
[ext_resource type="Shader" path="res://shaders/lunar_tiled.gdshader" id="2"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="1"]
|
||||
resource_local_to_scene = true
|
||||
shader = ExtResource("2")
|
||||
shader_parameter/camera_cell = Vector2(0, 0)
|
||||
shader_parameter/camera_frac = Vector2(0, 0)
|
||||
shader_parameter/camera_zoom = 1.0
|
||||
shader_parameter/viewport_size = Vector2(720, 1280)
|
||||
shader_parameter/cell_size = 220.0
|
||||
shader_parameter/tile_cells = 32.0
|
||||
|
||||
[node name="SurfaceBackground" type="CanvasLayer"]
|
||||
layer = -10
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
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")
|
||||
14
surface/VoidSurfaceProfile.tres
Normal file
14
surface/VoidSurfaceProfile.tres
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://surface/PlanetSurfaceProfile.gd" id="1"]
|
||||
[ext_resource type="Shader" path="res://shaders/lunar_tiled.gdshader" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
profile_id = "void"
|
||||
background_shader = ExtResource("2")
|
||||
base_color = Color(0.04, 0.02, 0.07, 1)
|
||||
crater_color = Color(0.01, 0.005, 0.02, 1)
|
||||
highlight_color = Color(0.45, 0.15, 0.65, 1)
|
||||
caption_color = Color(0.85, 0.7, 0.95, 1)
|
||||
caption_outline_color = Color(0.03, 0.01, 0.05, 0.9)
|
||||
Loading…
Reference in a new issue