Drive planets from definitions

This commit is contained in:
Loïc Wisniewski 2026-08-14 16:13:24 +02:00
commit 02643c76c4
22 changed files with 183 additions and 82 deletions

View file

@ -13,8 +13,7 @@ var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_type: String = ""
var surface_profile_id: String = "rocky"
var planet_definition_id: String = "rocky"
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on

14
GasGiantDefinition.tres Normal file
View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasGiantVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "gas_giant"
display_name = "Gas giant"
names = PackedStringArray("Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper")
visual_scene = ExtResource("2")
surface_profile_id = "gas"
resource_id = "gas"
map_color = Color(0.65, 0.42, 0.9, 1)

31
GasGiantVisual.gd Normal file
View file

@ -0,0 +1,31 @@
extends Node2D
var gas_phase: float = 0.0
func configure(_shape_seed: int) -> void:
queue_redraw()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -118.0)
func _process(delta: float) -> void:
gas_phase += delta
queue_redraw()
func _draw() -> void:
var rim := PackedVector2Array()
for i in range(32):
var angle := TAU * float(i) / 32.0
rim.append(Vector2(cos(angle) * 118.0, sin(angle) * 118.0))
NeonDraw.draw_glowing_polygon(self, rim, Color(0.4, 0.78, 1.0), 0.9)
for row in [-62.0, -28.0, 8.0, 43.0, 73.0]:
var band := PackedVector2Array()
var half_width: float = sqrt(maxf(0.0, 118.0 * 118.0 - row * row))
for i in range(15):
var x := lerpf(-half_width, half_width, float(i) / 14.0)
band.append(Vector2(x, row + sin(x * 0.045 + gas_phase * 1.6 + row) * 7.0))
NeonDraw.draw_glowing_path(self, band, Color(0.85, 0.52, 1.0), 0.75, 0.65)

1
GasGiantVisual.gd.uid Normal file
View file

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

6
GasGiantVisual.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://GasGiantVisual.gd" id="1"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")

View file

@ -1,5 +1,7 @@
extends Node2D
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# A landable world-object - unlike regular Asteroid.gd, planets are
# stationary ("Big Stable Asteroid" - stable means it doesn't drift or
@ -12,16 +14,33 @@ extends Node2D
# while the fleet as a whole was clearly sitting on the planet.
@export var planet_name: String = ""
@export var planet_type: String = "rocky"
@export var resource_type: String = "ore"
@export var surface_profile_id: String = "rocky"
@export var definition_id: String = PLANET_DEFINITIONS.DEFAULT_DEFINITION_ID
@export var landing_radius: float = 140.0
# Set by whoever spawns this planet (PlanetField, seeded from the chunk's
# own coordinates) so the outline shape is stable across respawns of the
# same chunk, instead of reading as a "different" planet every time.
@export var shape_seed: int = 0
var shape_points: PackedVector2Array = PackedVector2Array()
var definition: Resource
var visual: Node2D
var resource_type: String:
get: return String(definition.resource_id) if definition != null else "ore"
var surface_profile_id: String:
get: return String(definition.surface_profile_id) if definition != null else "rocky"
var shape_points: PackedVector2Array:
get:
if visual != null and "shape_points" in visual:
return visual.shape_points
return PackedVector2Array()
var gas_phase: float:
get:
if visual != null and "gas_phase" in visual:
return float(visual.gas_phase)
return 0.0
# An extractor is built down on this planet's SURFACE, in a different scene
# the player is no longer looking at, so from space nothing distinguished a
@ -39,31 +58,20 @@ const DERRICK_HALF_WIDTH: float = 16.0
# only at _ready() left the planet looking untouched until it happened to
# despawn and respawn.
var shows_extractor: bool = false
var gas_phase: float = 0.0
func _ready() -> void:
add_to_group("planets")
z_index = -1
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = shape_seed
var point_count: int = 10
for i in range(point_count):
var angle: float = (float(i) / point_count) * TAU
var radius: float = rng.randf_range(70.0, 100.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
definition = PLANET_DEFINITIONS.get_definition(definition_id)
visual = definition.visual_scene.instantiate()
visual.configure(shape_seed)
get_node("VisualHost").add_child(visual)
shows_extractor = GameState.has_extractor_at(planet_name)
GameState.extractor_added.connect(_on_extractor_added)
queue_redraw()
func _process(delta: float) -> void:
if planet_type == "gas_giant":
gas_phase += delta
queue_redraw()
func _on_extractor_added(built_on: String) -> void:
# Only the planet it was actually built on, and only a redraw when the
# answer really changed - every other planet in range gets this too.
@ -74,14 +82,9 @@ func _on_extractor_added(built_on: String) -> void:
func extractor_derrick_anchor() -> Vector2:
# The rim vertex the derrick stands on: the shape's own topmost point,
# rather than a fixed radius, so it sits ON the outline whatever lumpy
# shape the seed drew instead of floating off it or sinking inside it.
var base: Vector2 = Vector2(0.0, -100.0)
for p in shape_points:
if p.y < base.y:
base = p
return base
if visual != null and visual.has_method("extractor_anchor"):
return visual.extractor_anchor()
return Vector2(0.0, -100.0)
func extractor_derrick_tip() -> Vector2:
@ -113,28 +116,5 @@ func extractor_derrick_points() -> PackedVector2Array:
func _draw() -> void:
if planet_type == "gas_giant":
_draw_gas_giant()
if shows_extractor:
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0)
return
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)
if shows_extractor:
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0)
func _draw_gas_giant() -> void:
# A round, animated giant with drifting latitudinal bands. It is visibly
# unlike the rocky outline and keeps moving even while the fleet is idle.
var rim := PackedVector2Array()
for i in range(32):
var a := TAU * float(i) / 32.0
rim.append(Vector2(cos(a) * 118.0, sin(a) * 118.0))
NeonDraw.draw_glowing_polygon(self, rim, Color(0.4, 0.78, 1.0), 0.9)
for row in [-62.0, -28.0, 8.0, 43.0, 73.0]:
var band := PackedVector2Array()
var half_width: float = sqrt(maxf(0.0, 118.0 * 118.0 - row * row))
for i in range(15):
var x := lerpf(-half_width, half_width, float(i) / 14.0)
band.append(Vector2(x, row + sin(x * 0.045 + gas_phase * 1.6 + row) * 7.0))
NeonDraw.draw_glowing_path(self, band, Color(0.85, 0.52, 1.0), 0.75, 0.65)

View file

@ -4,3 +4,5 @@
[node name="Planet" type="Node2D"]
script = ExtResource("1")
[node name="VisualHost" type="Node2D" parent="."]

View file

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

10
PlanetDefinition.gd Normal file
View file

@ -0,0 +1,10 @@
class_name PlanetDefinition
extends Resource
@export var definition_id: String = "rocky"
@export var display_name: String = "Rocky planet"
@export var names: PackedStringArray = []
@export var visual_scene: PackedScene
@export var surface_profile_id: String = "rocky"
@export var resource_id: String = "ore"
@export var map_color: Color = Color(0.7, 0.65, 0.55)

1
PlanetDefinition.gd.uid Normal file
View file

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

14
PlanetDefinitions.gd Normal file
View file

@ -0,0 +1,14 @@
class_name PlanetDefinitions
extends RefCounted
const ROCKY_ID: String = "rocky"
const GAS_GIANT_ID: String = "gas_giant"
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
const DEFINITIONS: Dictionary = {
ROCKY_ID: preload("res://RockyPlanetDefinition.tres"),
GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:
return DEFINITIONS.get(definition_id, DEFINITIONS[DEFAULT_DEFINITION_ID])

1
PlanetDefinitions.gd.uid Normal file
View file

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

View file

@ -84,9 +84,7 @@ func _spawn_chunk_planet(chunk: Vector2i) -> void:
planet.position = origin + data.local_pos
planet.planet_name = data.name
planet.shape_seed = data.shape_seed
planet.planet_type = String(data.get("planet_type", "rocky"))
planet.resource_type = String(data.get("resource_type", "ore"))
planet.surface_profile_id = String(data.get("surface_profile_id", "rocky"))
planet.definition_id = String(data.get("definition_id", "rocky"))
# Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet is
# positioned, to avoid a pop-in on scene load) - Godot refuses a

View file

@ -1,6 +1,7 @@
extends Node2D
const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd")
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# Bootstraps the surviving fleet on this scene's own startup (from
# GameState, since this is a fresh scene tree - nothing here carries
@ -22,7 +23,8 @@ var transitioning: bool = false
func _ready() -> void:
_compose_surface(SURFACE_PROFILES.get_profile(GameState.surface_profile_id))
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)
camera.position = Vector2.ZERO
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)

View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://RockyPlanetVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "rocky"
display_name = "Rocky planet"
names = PackedStringArray("Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold", "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", "Baros Rock", "Umbra Point")
visual_scene = ExtResource("2")
surface_profile_id = "rocky"
resource_id = "ore"
map_color = Color(0.7, 0.65, 0.55, 1)

26
RockyPlanetVisual.gd Normal file
View file

@ -0,0 +1,26 @@
extends Node2D
var shape_points: PackedVector2Array = PackedVector2Array()
func configure(shape_seed: int) -> void:
shape_points.clear()
var rng := RandomNumberGenerator.new()
rng.seed = shape_seed
for i in range(10):
var angle: float = (float(i) / 10.0) * TAU
var radius: float = rng.randf_range(70.0, 100.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
queue_redraw()
func extractor_anchor() -> Vector2:
var anchor := Vector2(0.0, -100.0)
for point in shape_points:
if point.y < anchor.y:
anchor = point
return anchor
func _draw() -> void:
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)

1
RockyPlanetVisual.gd.uid Normal file
View file

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

6
RockyPlanetVisual.tscn Normal file
View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://RockyPlanetVisual.gd" id="1"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")

View file

@ -1,6 +1,8 @@
class_name WorldGen
extends RefCounted
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# Deterministic procedural world generation: the world is divided into
# square chunks, and each chunk's biome is derived from a seeded
# low-frequency noise field sampled at the chunk's own coordinates - so
@ -22,13 +24,6 @@ const GAS_GIANT_THRESHOLD: float = 0.55
const NEARBY_GAS_GIANT_CHUNK: Vector2i = Vector2i.ZERO
const NEARBY_GAS_GIANT_LOCAL_POS: Vector2 = Vector2(900.0, 600.0)
const PLANET_NAMES: Array = [
"Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold",
"Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry",
"Baros Rock", "Umbra Point",
]
const GAS_GIANT_NAMES: Array = ["Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper"]
static var _noise: FastNoiseLite
static var _planet_noise: FastNoiseLite
@ -88,16 +83,16 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
var guaranteed_nearby_giant: bool = chunk == NEARBY_GAS_GIANT_CHUNK
var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \
else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()]
var shape_seed: int = rng.randi()
# A stable per-chunk roll makes gas giants persistent landmarks rather
# than a different resource whenever the scene reloads.
var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE
if gas_giant:
planet_name = GAS_GIANT_NAMES[rng.randi() % GAS_GIANT_NAMES.size()]
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var names: PackedStringArray = definition.names
var planet_name: String = names[rng.randi() % names.size()]
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",
"surface_profile_id": "gas" if gas_giant else "rocky"}
"definition_id": definition_id}
static func biome_color(biome: int) -> Color:

View file

@ -1,5 +1,7 @@
extends Node2D
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
const WORLD_TO_LOCAL: float = 0.04
# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a
@ -105,7 +107,8 @@ func _draw() -> void:
# spawn_radius_chunks of the fleet).
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var planet_local: Vector2 = origin_local + data.local_pos * WORLD_TO_LOCAL
draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55))
var definition: Resource = PLANET_DEFINITIONS.get_definition(String(data.definition_id))
draw_circle(planet_local, 5.0, definition.map_color)
draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true)
if chunk_has_extractor_badge(chunk):

View file

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

View file

@ -27,8 +27,7 @@ func _process(delta: float) -> bool:
return _fail("nearby gas giant chunk does not contain a planet")
if WorldGen.biome_at_chunk(nearby_chunk) != WorldGen.Biome.SPACE:
return _fail("nearby gas giant is not in the normal space biome")
if String(nearby_data.get("planet_type", "")) != "gas_giant" \
or String(nearby_data.get("resource_type", "")) != "gas":
if String(nearby_data.get("definition_id", "")) != "gas_giant":
return _fail("nearby guaranteed planet is not a gas source")
if nearby_position.length() > WorldGen.CHUNK_SIZE * 2.0:
return _fail("nearby gas giant spawned too far away: %s" % nearby_position)
@ -36,8 +35,7 @@ func _process(delta: float) -> bool:
# Gas surfaces have vents only: no deferred ore pickups, and the vent
# emission advances continuously so the source visibly looks active.
game_state.planet_name = "Gas Test"
game_state.planet_type = "gas_giant"
game_state.surface_profile_id = "gas"
game_state.planet_definition_id = "gas_giant"
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
root.add_child(surface)
var gas_field: Node = surface.get_node("SurfaceFeatures/ResourceProvider")
@ -53,7 +51,7 @@ func _process(delta: float) -> bool:
return _fail("gas-source emission animation did not advance")
surface.free()
game_state.surface_profile_id = "rocky"
game_state.planet_definition_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")
@ -69,16 +67,17 @@ func _process(delta: float) -> bool:
for x in range(-12, 13):
for y in range(-12, 13):
var data: Dictionary = WorldGen.planet_data_at_chunk(Vector2i(x, y))
if String(data.get("planet_type", "")) == "gas_giant":
found_giant = String(data.get("resource_type", "")) == "gas"
if String(data.get("definition_id", "")) == "gas_giant":
found_giant = true
break
if found_giant:
break
if not found_giant:
return _fail("world generation produced no deterministic gas giant")
var giant: Node2D = load("res://Planet.tscn").instantiate()
giant.planet_type = "gas_giant"
giant._process(0.5)
giant.definition_id = "gas_giant"
root.add_child(giant)
giant.visual._process(0.5)
if giant.gas_phase <= 0.0:
return _fail("gas giant animation did not advance")
giant.free()