Add black hole planet and dark matter resource (#55)

A rare, deterministically-seeded landable body yielding a fourth
resource, dark matter, obtainable only through an extractor - never a
surface pickup. Reuses every existing generic system (resource/planet
definitions, surface profiles, extractor/transporter pipeline) with no
type-switch branching.

Also fixes a latent conquest-gating gap: a surface profile with no
authored PlanetSetup (like the black hole's "void") never registered
capture points at all, permanently blocking extractor placement.
register_capture_points() now treats zero required points as vacuously
conquered, and PlanetSurfaceController registers that zero-point state
explicitly when no setup exists.
This commit is contained in:
Loïc Wisniewski 2026-08-16 13:13:38 +02:00
commit cbe07bbb55
20 changed files with 421 additions and 2 deletions

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://BlackHoleVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "black_hole"
display_name = "Black hole"
names = PackedStringArray("Event Zero", "Umbral Well", "The Sinkhole", "Lightless Reach", "Null Point")
visual_scene = ExtResource("2")
surface_profile_id = "void"
resource_id = "dark_matter"
map_color = Color(0.55, 0.15, 0.85, 1)

49
BlackHoleVisual.gd Normal file
View file

@ -0,0 +1,49 @@
extends Node2D
# Same informal interface as GasGiantVisual/RockyPlanetVisual - configure(),
# extractor_anchor(), animates via its own _process(). No solid body: the
# core stays genuinely empty (nothing drawn inside CORE_RADIUS), and an
# accretion disc of rotating arc segments - not closed rings - stands in
# for a surface, brighter and thicker toward the centre.
var disc_phase: float = 0.0
const CORE_RADIUS: float = 46.0
const RING_COUNT: int = 5
const RING_STEP: float = 16.0
const DISC_COLOR: Color = Color(0.55, 0.15, 0.85)
const DISC_HOT_COLOR: Color = Color(0.9, 0.6, 1.0)
func configure(_shape_seed: int) -> void:
queue_redraw()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -(CORE_RADIUS + float(RING_COUNT - 1) * RING_STEP + 12.0))
func _process(delta: float) -> void:
disc_phase += delta
queue_redraw()
func _draw() -> void:
for i in range(RING_COUNT):
var radius: float = CORE_RADIUS + float(i) * RING_STEP
# 1.0 at the innermost ring, fading to 0.0 at the outermost - drives
# both colour (hotter inward) and stroke weight/brightness.
var t: float = 1.0 - float(i) / float(maxi(RING_COUNT - 1, 1))
var ring_color: Color = DISC_COLOR.lerp(DISC_HOT_COLOR, t * 0.65)
# Alternating spin direction per ring and a speed tied to t reads as
# genuine turbulence rather than one disc rotating as a rigid whole.
var spin: float = disc_phase * (0.12 + t * 0.22) * (1.0 if i % 2 == 0 else -1.0)
for arc_i in range(2):
var start: float = spin + float(arc_i) * PI + 0.15
var end: float = start + PI - 0.3
var points := PackedVector2Array()
var steps: int = 24
for j in range(steps + 1):
var angle: float = lerpf(start, end, float(j) / float(steps))
points.append(Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_path(self, points, ring_color, 0.5 + t * 0.5, 0.45 + t * 0.35)

1
BlackHoleVisual.gd.uid Normal file
View file

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

6
BlackHoleVisual.tscn Normal file
View file

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

View file

@ -0,0 +1,11 @@
[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://DarkMatterResourceGlyph.tscn" id="2"]
[resource]
script = ExtResource("1")
resource_id = "dark_matter"
display_name = "Dark Matter"
color = Color(0.55, 0.15, 0.85, 1)
glyph_scene = ExtResource("2")

View file

@ -0,0 +1,26 @@
extends Control
@export var colour: Color = Color(0.55, 0.15, 0.85)
func _draw() -> void:
var centre: Vector2 = size * 0.5
var radius: float = minf(size.x, size.y) * 0.34
var points := PackedVector2Array()
for i in range(8):
var angle: float = TAU * float(i) / 8.0 - PI * 0.5
points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
# Deep violet glow around a near-black core - not colour.darkened() like
# every other glyph's core, deliberately: dark matter has to read as an
# absence even against deep space, where a merely-darker version of the
# glow colour would still show up as a faint fill instead of a void
# with a rim.
NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, Color(0.04, 0.01, 0.06), maxf(0.5, radius * 0.035))
# A tighter inner ring hints at an event horizon rather than a solid
# body - the same outline-only idiom every glyph already uses, just
# nested once more.
var inner := PackedVector2Array()
for i in range(8):
var angle: float = TAU * float(i) / 8.0 - PI * 0.5
inner.append(centre + Vector2(cos(angle), sin(angle)) * radius * 0.5)
NeonDraw.draw_glowing_polygon(self, inner, colour.lightened(0.25), 0.75, Color(0.04, 0.01, 0.06), maxf(0.35, radius * 0.02))

View file

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

View file

@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://DarkMatterResourceGlyph.gd" id="1"]
[node name="GlyphVisual" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")

View file

@ -33,6 +33,7 @@ var explored_chunks: Dictionary = {}
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID
const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_ID
const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
# All resource-bearing locations use the same container. The old public
@ -1115,7 +1116,11 @@ func register_capture_points(planet_key: String, total: int) -> void:
var captured: Array = []
captured.resize(total)
captured.fill(false)
planet_capture[planet_key] = {"captured": captured, "conquered": false}
# A planet demanding zero points is vacuously already held (#55: a
# black hole's "void" profile has no authored PlanetSetup at all, so
# this is the only registration it ever gets) - every planet WITH real
# points still starts unconquered, exactly as before.
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0}
func capture_state(planet_key: String) -> Dictionary:

View file

@ -3,10 +3,12 @@ extends RefCounted
const ROCKY_ID: String = "rocky"
const GAS_GIANT_ID: String = "gas_giant"
const BLACK_HOLE_ID: String = "black_hole"
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
const DEFINITIONS: Dictionary = {
ROCKY_ID: preload("res://RockyPlanetDefinition.tres"),
GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"),
BLACK_HOLE_ID: preload("res://BlackHolePlanetDefinition.tres"),
}

View file

@ -65,6 +65,16 @@ func _compose_surface(profile: Resource) -> void:
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:

View file

@ -5,6 +5,7 @@ const DEFAULT_PROFILE_ID: String = "rocky"
const PROFILES: Dictionary = {
"rocky": preload("res://RockySurfaceProfile.tres"),
"gas": preload("res://GasSurfaceProfile.tres"),
"void": preload("res://VoidSurfaceProfile.tres"),
}

View file

@ -4,11 +4,13 @@ extends RefCounted
const ORE_ID: String = "ore"
const GAS_ID: String = "gas"
const ENERGY_ID: String = "energy"
const ORDER: PackedStringArray = [ORE_ID, GAS_ID, ENERGY_ID]
const DARK_MATTER_ID: String = "dark_matter"
const ORDER: PackedStringArray = [ORE_ID, GAS_ID, ENERGY_ID, DARK_MATTER_ID]
const DEFINITIONS: Dictionary = {
ORE_ID: preload("res://OreResourceDefinition.tres"),
GAS_ID: preload("res://GasResourceDefinition.tres"),
ENERGY_ID: preload("res://EnergyResourceDefinition.tres"),
DARK_MATTER_ID: preload("res://DarkMatterResourceDefinition.tres"),
}

12
VoidSurfaceProfile.tres Normal file
View file

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

View file

@ -16,6 +16,12 @@ const NOISE_SEED: int = 1337
const ASTEROID_FIELD_THRESHOLD: float = 0.15
const PLANET_THRESHOLD: float = 0.33
const GAS_GIANT_THRESHOLD: float = 0.55
# A flat per-planet-chunk probability rather than another noise field: gas
# giants already cover every SPACE-biome planet chunk (see GAS_GIANT_THRESHOLD
# above), so making black holes noticeably rarer just needs a small, uniform
# chance independent of biome - not a second smooth region to tune against
# the first.
const BLACK_HOLE_CHANCE: float = 0.05
# A predictable early gas source for play-testing. The origin chunk is
# deterministically normal space. Its explicit in-chunk position puts it
@ -84,10 +90,20 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \
else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
var shape_seed: int = rng.randi()
# Drawn unconditionally, in the same fixed position every call,
# regardless of biome or the gas-giant roll below - so adding this
# roll doesn't shift which random values any LATER draw (planet_name,
# below) consumes for chunks that would have existed before it.
var black_hole_roll: float = rng.randf()
# 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
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
# A rare third outcome, seeded the same deterministic way as everything
# else here. Never overrides the guaranteed early gas giant landmark -
# that chunk's whole purpose is to always be the same reachable source.
if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var names: PackedStringArray = definition.names
var planet_name: String = names[rng.randi() % names.size()]

View file

@ -0,0 +1,23 @@
extends Node2D
# Preview, not a test - a black hole planet as seen in space, next to a
# rocky planet for scale/contrast.
#
# bash tests/screenshot.sh res://tests/_black_hole_space_preview.tscn /tmp/black_hole.png 15 720x1280
func _ready() -> void:
GameState.extractors.clear()
GameState.buildings.clear()
_spawn("Event Zero", "black_hole", Vector2(360.0, 420.0), 12345)
_spawn("Dustworn", "rocky", Vector2(360.0, 950.0), 4242)
func _spawn(planet_name: String, definition_id: String, at: Vector2, shape_seed: int) -> void:
var planet: Node2D = load("res://Planet.tscn").instantiate()
planet.planet_name = planet_name
planet.definition_id = definition_id
planet.shape_seed = shape_seed
planet.position = at
add_child(planet)

View file

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

View file

@ -0,0 +1,15 @@
extends Node2D
# Preview, not a test - a black hole's landed "void" surface: no craters, no
# resource pickups, nothing but the empty field itself.
#
# bash tests/screenshot.sh res://tests/_void_surface_preview.tscn /tmp/void.png 15 720x1280
func _ready() -> void:
GameState.planet_name = "Event Zero"
GameState.planet_definition_id = "black_hole"
GameState.surviving_ship_count = 3
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
surface.get_node("SurfaceController").fade_duration = 0.0
add_child(surface)

View file

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

200
tests/test_black_hole.gd Normal file
View file

@ -0,0 +1,200 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_black_hole.gd
# Forgejo #55: a rare landable body yielding dark matter, obtainable only
# through an extractor - never a surface pickup.
#
# Covers:
# A. WorldGen.planet_data_at_chunk() is a pure, deterministic function of
# chunk coordinates, and black holes are noticeably rarer than gas
# giants across a wide scan
# B. dark matter is fully generic - in resource_types(), its own
# ResourceDefinition/glyph/colour, distinct from every other resource
# C. landing on a black hole spawns no resource provider and no capture
# points at all (void's features_scene is null, and it has no
# authored PlanetSetup) - yet the planet is immediately conquered
# (register_capture_points treats zero required points as vacuous),
# so an extractor is buildable there right away
# D. an extractor on a black hole mines dark matter locally exactly like
# any other extractor, and a hub transporter banks it through the
# normal collection path with no black-hole-specific code
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var scene: Node
var extractor_index: int = 0
var load_limit: int = 0
var baseline: Dictionary = {}
const BLACK_HOLE_AT := Vector2(500.0, 0.0)
const MOTHERSHIP_AT := Vector2.ZERO
const HUB_AT := Vector2(150.0, 0.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
# --- A. deterministic and rare -----------------------------------------
var black_hole_count: int = 0
var gas_giant_count: int = 0
var total_planets: int = 0
for x in range(-40, 40):
for y in range(-40, 40):
var chunk: Vector2i = Vector2i(x, y)
if not WorldGen.has_planet_at_chunk(chunk):
continue
total_planets += 1
var first: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var second: Dictionary = WorldGen.planet_data_at_chunk(chunk)
if first.definition_id != second.definition_id or first.local_pos != second.local_pos \
or first.shape_seed != second.shape_seed or first.name != second.name:
_fail("planet_data_at_chunk(%s) is not deterministic" % chunk)
return
if first.definition_id == "black_hole":
black_hole_count += 1
elif first.definition_id == "gas_giant":
gas_giant_count += 1
print("scanned %d planet chunks: %d black holes, %d gas giants" % [
total_planets, black_hole_count, gas_giant_count
])
if black_hole_count == 0:
_fail("no black holes generated across a wide scan - the rare outcome never fires")
return
if black_hole_count >= gas_giant_count:
_fail("black holes (%d) are not rarer than gas giants (%d)" % [black_hole_count, gas_giant_count])
return
# --- B. dark matter is fully generic -----------------------------------
if not game_state.resource_types().has(GameState.RESOURCE_DARK_MATTER):
_fail("dark_matter is not in resource_types() - every resource menu would miss it")
return
var definition: Resource = game_state.resource_definition(GameState.RESOURCE_DARK_MATTER)
if definition == null or definition.glyph_scene == null:
_fail("dark_matter has no ResourceDefinition or glyph_scene")
return
var dm_color: Color = game_state.resource_color(GameState.RESOURCE_DARK_MATTER)
for other in [GameState.RESOURCE_ORE, GameState.RESOURCE_GAS, GameState.RESOURCE_ENERGY]:
if dm_color == game_state.resource_color(other):
_fail("dark_matter shares a colour with %s" % other)
return
print("dark matter is generic: in resource_types(), own definition, own colour %s" % dm_color)
# --- structural: the black hole definition itself -----------------------
var black_hole_def: Resource = load("res://PlanetDefinitions.gd").get_definition("black_hole")
if black_hole_def.resource_id != "dark_matter":
_fail("black_hole planet's resource_id is '%s', expected 'dark_matter'" % black_hole_def.resource_id)
return
if black_hole_def.surface_profile_id != "void":
_fail("black_hole planet's surface_profile_id is '%s', expected 'void'" % black_hole_def.surface_profile_id)
return
var void_profile: Resource = load("res://PlanetSurfaceProfiles.gd").get_profile("void")
if void_profile.features_scene != null:
_fail("void surface profile has a features_scene - dark matter must never be a surface pickup")
return
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.planet_capture.clear()
game_state.planet_id = "test_black_hole"
game_state.planet_name = "Event Zero"
game_state.planet_definition_id = "black_hole"
scene = load("res://PlanetSurface.tscn").instantiate()
root.add_child(scene)
current_scene = scene
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- C. no resource provider, no capture points, yet conquered ---
if elapsed < 0.3:
return false
var feature_host: Node = scene.get_node("SurfaceFeatures")
if feature_host.get_child_count() != 0:
return _fail("black hole surface spawned %d children under SurfaceFeatures - expected none" % feature_host.get_child_count())
if not game_state.is_planet_conquered(game_state.planet_id):
return _fail("a black hole with no capture points required is not marked conquered")
print("no resource provider, no capture points, already conquered")
# Snapshot rather than assert > 0: bank_inventory starts with 500
# ore by default (a fresh game's starting stock), unrelated to
# this planet - "nothing was picked up" has to mean "unchanged",
# not "still zero".
for resource_type in game_state.resource_types():
baseline[resource_type] = game_state.banked_resource(resource_type) + game_state.fleet_resource(resource_type)
var fleet: Node = scene.get_node("Fleet")
for ship in fleet.ships:
ship.position += Vector2(300.0, 200.0)
fleet.move_to(fleet.get_centroid())
elapsed = 0.0
stage = 1
return false
1:
# --- C continued: flying around picks up nothing, ever -----------
if elapsed < 2.0:
return false
for resource_type in game_state.resource_types():
var total: int = game_state.banked_resource(resource_type) + game_state.fleet_resource(resource_type)
if total != baseline.get(resource_type, 0):
return _fail("something (%s) was picked up over a black hole surface" % resource_type)
print("flew around for %.1fs, nothing picked up" % elapsed)
# --- D: extractor mining + transporter banking --------------------
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
game_state.add_extractor("Event Zero", BLACK_HOLE_AT, GameState.RESOURCE_DARK_MATTER, "test_black_hole_extractor")
extractor_index = game_state.extractor_index_at("test_black_hole_extractor")
if extractor_index < 0:
return _fail("add_extractor did not register the black hole extractor")
load_limit = game_state.transporter_load_limit()
elapsed = 0.0
stage = 2
return false
2:
# Mining is local, exactly like ore - no black-hole-specific code.
if elapsed < 1.2:
return false
var mined: int = game_state.extractor_resource(extractor_index, GameState.RESOURCE_DARK_MATTER)
if mined <= 0:
return _fail("extractor mined no dark matter locally after %.1fs" % elapsed)
print("extractor mined %d dark matter locally, bank still %d" % [
mined, game_state.banked_resource(GameState.RESOURCE_DARK_MATTER)
])
# Force a full load directly (bypasses waiting out the real
# mining rate for the rest) so the transporter pickup dispatch
# fires without a long extra wait.
game_state.extractors[extractor_index].inventory.set_amount(GameState.RESOURCE_DARK_MATTER, load_limit)
elapsed = 0.0
stage = 3
return false
3:
# --- a transporter collects the full load and banks it -----------
if game_state.banked_resource(GameState.RESOURCE_DARK_MATTER) >= load_limit:
print("transporter banked %d dark matter" % game_state.banked_resource(GameState.RESOURCE_DARK_MATTER))
print("TEST_BLACK_HOLE: PASS")
quit(0)
return true
if elapsed > 10.0:
return _fail("dark matter was never collected to the bank (extractor=%d bank=%d)" % [
game_state.extractor_resource(extractor_index, GameState.RESOURCE_DARK_MATTER),
game_state.banked_resource(GameState.RESOURCE_DARK_MATTER),
])
return false
return false
func _fail(message: String) -> bool:
print("TEST_BLACK_HOLE: FAIL (%s)" % message)
quit(1)
return true