Compose planet surfaces from profiles

This commit is contained in:
Loïc Wisniewski 2026-08-14 16:05:32 +02:00
commit 2f68515f33
19 changed files with 123 additions and 39 deletions

View file

@ -1,5 +1,6 @@
extends Node extends Node
# Autoload singleton - the one thing that survives change_scene_to_file() # Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface # scene swaps between space (Main.tscn) and a planet's surface
# (PlanetSurface.tscn). Everything else in either scene gets torn down # (PlanetSurface.tscn). Everything else in either scene gets torn down
@ -13,6 +14,7 @@ var return_camera_zoom: float = 1.0
var planet_name: String = "" var planet_name: String = ""
var planet_type: String = "" var planet_type: String = ""
var surface_profile_id: String = "rocky"
# Explored-chunk history for the world map's fog-of-war reveal. Lives # Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on # here (not on ExplorationTracker, which gets destroyed and recreated on

7
GasSurfaceFeatures.tscn Normal file
View file

@ -0,0 +1,7 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://GasVentField.gd" id="1"]
[node name="ResourceProvider" type="Node2D"]
script = ExtResource("1")
fleet_path = NodePath("../../Fleet")

14
GasSurfaceProfile.tres Normal file
View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasSurfaceFeatures.tscn" id="2"]
[resource]
script = ExtResource("1")
profile_id = "gas"
features_scene = ExtResource("2")
base_color = Color(0.13, 0.025, 0.22, 1)
crater_color = Color(0.055, 0.008, 0.11, 1)
highlight_color = Color(0.52, 0.17, 0.7, 1)
caption_color = Color(0.78, 0.88, 0.94, 1)
caption_outline_color = Color(0.02, 0.01, 0.05, 0.9)

View file

@ -1,5 +1,6 @@
extends Node2D extends Node2D
@onready var fleet: Node2D = get_node("../Fleet") @export var fleet_path: NodePath = NodePath("../Fleet")
@onready var fleet: Node2D = get_node(fleet_path)
var vents: Array[Vector2] = [] var vents: Array[Vector2] = []
var progress: float = 0.0 var progress: float = 0.0
var emission_phase: float = 0.0 var emission_phase: float = 0.0

View file

@ -1,5 +1,6 @@
extends Node2D extends Node2D
# A landable world-object - unlike regular Asteroid.gd, planets are # A landable world-object - unlike regular Asteroid.gd, planets are
# stationary ("Big Stable Asteroid" - stable means it doesn't drift or # stationary ("Big Stable Asteroid" - stable means it doesn't drift or
# destroy ships on touch). Whether the fleet counts as "landed" is decided # destroy ships on touch). Whether the fleet counts as "landed" is decided
@ -13,6 +14,7 @@ extends Node2D
@export var planet_name: String = "" @export var planet_name: String = ""
@export var planet_type: String = "rocky" @export var planet_type: String = "rocky"
@export var resource_type: String = "ore" @export var resource_type: String = "ore"
@export var surface_profile_id: String = "rocky"
@export var landing_radius: float = 140.0 @export var landing_radius: float = 140.0
# Set by whoever spawns this planet (PlanetField, seeded from the chunk's # Set by whoever spawns this planet (PlanetField, seeded from the chunk's
# own coordinates) so the outline shape is stable across respawns of the # own coordinates) so the outline shape is stable across respawns of the

View file

@ -114,6 +114,7 @@ func _on_enter_pressed() -> void:
GameState.return_camera_zoom = camera.zoom.x GameState.return_camera_zoom = camera.zoom.x
GameState.planet_name = current_planet.planet_name GameState.planet_name = current_planet.planet_name
GameState.planet_type = current_planet.planet_type GameState.planet_type = current_planet.planet_type
GameState.surface_profile_id = current_planet.surface_profile_id
var tween: Tween = create_tween() var tween: Tween = create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) tween.tween_property(fade_rect, "color:a", 1.0, fade_duration)

View file

@ -1,5 +1,6 @@
extends Node2D extends Node2D
# Spawns "Big Stable Asteroid" planets in ASTEROID_FIELD biome chunks near # Spawns "Big Stable Asteroid" planets in ASTEROID_FIELD biome chunks near
# the fleet where WorldGen says one belongs, and despawns them once the # the fleet where WorldGen says one belongs, and despawns them once the
# fleet has moved far enough away - mirrors AsteroidField's chunk-lifecycle # fleet has moved far enough away - mirrors AsteroidField's chunk-lifecycle
@ -85,6 +86,7 @@ func _spawn_chunk_planet(chunk: Vector2i) -> void:
planet.shape_seed = data.shape_seed planet.shape_seed = data.shape_seed
planet.planet_type = String(data.get("planet_type", "rocky")) planet.planet_type = String(data.get("planet_type", "rocky"))
planet.resource_type = String(data.get("resource_type", "ore")) planet.resource_type = String(data.get("resource_type", "ore"))
planet.surface_profile_id = String(data.get("surface_profile_id", "rocky"))
# Deferred: update_chunks() can run synchronously from inside a # Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet is # parent's own _ready() chain (forced once right after the fleet is
# positioned, to avoid a pop-in on scene load) - Godot refuses a # positioned, to avoid a pop-in on scene load) - Godot refuses a

View file

@ -7,9 +7,7 @@
[ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"] [ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"]
[ext_resource type="Script" path="res://IconButton.gd" id="6"] [ext_resource type="Script" path="res://IconButton.gd" id="6"]
[ext_resource type="Script" path="res://TouchJoystick.gd" id="7"] [ext_resource type="Script" path="res://TouchJoystick.gd" id="7"]
[ext_resource type="Script" path="res://RockField.gd" id="8"]
[ext_resource type="Script" path="res://ResourceCounter.gd" id="9"] [ext_resource type="Script" path="res://ResourceCounter.gd" id="9"]
[ext_resource type="Script" path="res://GasVentField.gd" id="10"]
[ext_resource type="Script" path="res://GasCounter.gd" id="11"] [ext_resource type="Script" path="res://GasCounter.gd" id="11"]
[ext_resource type="Script" path="res://CargoCounter.gd" id="12"] [ext_resource type="Script" path="res://CargoCounter.gd" id="12"]
[ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"] [ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"]
@ -49,12 +47,7 @@ ship_core_color = Color(0.02, 0.09, 0.15, 1)
position = Vector2(0, 0) position = Vector2(0, 0)
zoom = Vector2(1, 1) zoom = Vector2(1, 1)
[node name="RockField" type="Node2D" parent="."] [node name="SurfaceFeatures" type="Node2D" parent="."]
script = ExtResource("8")
[node name="GasVentField" type="Node2D" parent="."]
visible = false
script = ExtResource("10")
[node name="SurfaceController" type="Node2D" parent="."] [node name="SurfaceController" type="Node2D" parent="."]
script = ExtResource("5") script = ExtResource("5")

View file

@ -1,5 +1,7 @@
extends Node2D extends Node2D
const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd")
# Bootstraps the surviving fleet on this scene's own startup (from # Bootstraps the surviving fleet on this scene's own startup (from
# GameState, since this is a fresh scene tree - nothing here carries # GameState, since this is a fresh scene tree - nothing here carries
# over automatically) and handles the always-available exit prompt that # over automatically) and handles the always-available exit prompt that
@ -13,29 +15,14 @@ extends Node2D
@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel") @onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel")
@onready var exit_button: IconButton = get_node("../UI/ExitButton") @onready var exit_button: IconButton = get_node("../UI/ExitButton")
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect") @onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
@onready var background: ColorRect = get_node("../BackgroundLayer/LunarBackground")
@onready var feature_host: Node2D = get_node("../SurfaceFeatures")
var transitioning: bool = false var transitioning: bool = false
func _ready() -> void: func _ready() -> void:
if GameState.planet_type == "gas_giant": _compose_surface(SURFACE_PROFILES.get_profile(GameState.surface_profile_id))
# A gas giant is not lunar terrain: its surface view uses a deep,
# coloured atmosphere instead of the rocky field. The background is
# shader-driven, so changing ColorRect.color does not tint it; update
# the actual palette uniforms.
get_node("../RockField").visible = false
get_node("../GasVentField").visible = true
var background: ColorRect = get_node("../BackgroundLayer/LunarBackground")
var atmosphere: ShaderMaterial = background.material
atmosphere.set_shader_parameter("base_color", Color(0.13, 0.025, 0.22, 1.0))
atmosphere.set_shader_parameter("crater_color", Color(0.055, 0.008, 0.11, 1.0))
atmosphere.set_shader_parameter("highlight_color", Color(0.52, 0.17, 0.7, 1.0))
# The lunar surface uses dark captions on a bright ground. Flip only
# those captions for the night-dark gas atmosphere.
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]:
var caption: Label = get_node(caption_path)
caption.add_theme_color_override("font_color", Color(0.78, 0.88, 0.94))
caption.add_theme_color_override("font_outline_color", Color(0.02, 0.01, 0.05, 0.9))
fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO) fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO)
camera.position = Vector2.ZERO camera.position = Vector2.ZERO
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
@ -47,6 +34,21 @@ func _ready() -> void:
tween.tween_property(fade_rect, "color:a", 0.0, fade_duration) tween.tween_property(fade_rect, "color:a", 0.0, fade_duration)
func _compose_surface(profile: Resource) -> void:
var atmosphere: ShaderMaterial = background.material
atmosphere.set_shader_parameter("base_color", profile.base_color)
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]:
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:
return
var features: Node = profile.features_scene.instantiate()
feature_host.add_child(features)
func _on_exit_pressed() -> void: func _on_exit_pressed() -> void:
if transitioning: if transitioning:
return return

13
PlanetSurfaceProfile.gd Normal file
View file

@ -0,0 +1,13 @@
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
@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)

View file

@ -0,0 +1 @@
uid://b31vef6ge1tjh

12
PlanetSurfaceProfiles.gd Normal file
View file

@ -0,0 +1,12 @@
class_name PlanetSurfaceProfiles
extends RefCounted
const DEFAULT_PROFILE_ID: String = "rocky"
const PROFILES: Dictionary = {
"rocky": preload("res://RockySurfaceProfile.tres"),
"gas": preload("res://GasSurfaceProfile.tres"),
}
static func get_profile(profile_id: String) -> Resource:
return PROFILES.get(profile_id, PROFILES[DEFAULT_PROFILE_ID])

View file

@ -0,0 +1 @@
uid://bufr6mnwbobxb

View file

@ -17,7 +17,8 @@ extends Node2D
@export var despawn_radius: float = 2600.0 @export var despawn_radius: float = 2600.0
@export var check_interval: float = 0.5 @export var check_interval: float = 0.5
@onready var fleet: Node2D = get_node("../Fleet") @export var fleet_path: NodePath = NodePath("../Fleet")
@onready var fleet: Node2D = get_node(fleet_path)
var rock_scene: PackedScene = preload("res://Rock.tscn") var rock_scene: PackedScene = preload("res://Rock.tscn")
var rocks: Array = [] var rocks: Array = []
@ -26,12 +27,6 @@ var rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void: func _ready() -> void:
# Gas giants have their own gas vents and must never receive ore pickups.
# This check happens before the initial population pass, unlike merely
# hiding the field later from PlanetSurfaceController.
if GameState.planet_type == "gas_giant":
set_process(false)
return
rng.randomize() rng.randomize()
# Populate immediately rather than waiting out the first interval, so # Populate immediately rather than waiting out the first interval, so
# the surface isn't visibly empty for half a second on arrival. # the surface isn't visibly empty for half a second on arrival.

View file

@ -0,0 +1,7 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://RockField.gd" id="1"]
[node name="ResourceProvider" type="Node2D"]
script = ExtResource("1")
fleet_path = NodePath("../../Fleet")

14
RockySurfaceProfile.tres Normal file
View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="PackedScene" path="res://RockySurfaceFeatures.tscn" id="2"]
[resource]
script = ExtResource("1")
profile_id = "rocky"
features_scene = ExtResource("2")
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)

View file

@ -96,7 +96,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
if gas_giant: if gas_giant:
planet_name = GAS_GIANT_NAMES[rng.randi() % GAS_GIANT_NAMES.size()] planet_name = GAS_GIANT_NAMES[rng.randi() % GAS_GIANT_NAMES.size()]
return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed, return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed,
"planet_type": "gas_giant" if gas_giant else "rocky", "resource_type": "gas" if gas_giant else "ore"} "planet_type": "gas_giant" if gas_giant else "rocky", "resource_type": "gas" if gas_giant else "ore",
"surface_profile_id": "gas" if gas_giant else "rocky"}
static func biome_color(biome: int) -> Color: static func biome_color(biome: int) -> Color:

View file

@ -4,6 +4,7 @@ extends Node2D
func _ready() -> void: func _ready() -> void:
GameState.planet_name = "Violet Tempest" GameState.planet_name = "Violet Tempest"
GameState.planet_type = "gas_giant" GameState.planet_type = "gas_giant"
GameState.surface_profile_id = "gas"
GameState.surviving_ship_count = 3 GameState.surviving_ship_count = 3
GameState.rocks = 128 GameState.rocks = 128
GameState.gas = 46 GameState.gas = 46

View file

@ -37,19 +37,34 @@ func _process(delta: float) -> bool:
# emission advances continuously so the source visibly looks active. # emission advances continuously so the source visibly looks active.
game_state.planet_name = "Gas Test" game_state.planet_name = "Gas Test"
game_state.planet_type = "gas_giant" game_state.planet_type = "gas_giant"
game_state.surface_profile_id = "gas"
var surface: Node = load("res://PlanetSurface.tscn").instantiate() var surface: Node = load("res://PlanetSurface.tscn").instantiate()
root.add_child(surface) root.add_child(surface)
var rock_field: Node = surface.get_node("RockField") var gas_field: Node = surface.get_node("SurfaceFeatures/ResourceProvider")
var gas_field: Node = surface.get_node("GasVentField") if gas_field.get_script() != load("res://GasVentField.gd"):
if not rock_field.rocks.is_empty() or rock_field.is_processing():
surface.free() surface.free()
return _fail("gas surface spawned or continued processing ore rocks") return _fail("gas surface did not compose the gas feature scene")
if surface.get_node_or_null("RockField") != null:
surface.free()
return _fail("gas surface tree contains an ore producer")
gas_field._process(0.5) gas_field._process(0.5)
if gas_field.emission_phase <= 0.0: if gas_field.emission_phase <= 0.0:
surface.free() surface.free()
return _fail("gas-source emission animation did not advance") return _fail("gas-source emission animation did not advance")
surface.free() surface.free()
game_state.surface_profile_id = "rocky"
var rocky_surface: Node = load("res://PlanetSurface.tscn").instantiate()
root.add_child(rocky_surface)
var rocky_features: Node = rocky_surface.get_node("SurfaceFeatures/ResourceProvider")
if rocky_features.get_script() != load("res://RockField.gd"):
rocky_surface.free()
return _fail("rocky surface did not compose the ore feature scene")
if rocky_surface.get_node_or_null("GasVentField") != null:
rocky_surface.free()
return _fail("rocky surface tree contains a gas producer")
rocky_surface.free()
var found_giant: bool = false var found_giant: bool = false
for x in range(-12, 13): for x in range(-12, 13):
for y in range(-12, 13): for y in range(-12, 13):