Add planet scanner and octopus biome

This commit is contained in:
Loïc Wisniewski 2026-09-04 15:56:07 +02:00
commit 8fbcc340ce
21 changed files with 784 additions and 30 deletions

View file

@ -1,7 +1,7 @@
class_name BuildingDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "extractor", "transport_hub", "ship_lab", "gas_burner"]
const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "outpost", "planning_center", "spacial_workshop", "planet_scanner", "extractor", "transport_hub", "ship_lab", "gas_burner"]
const DEFINITIONS: Dictionary = {
"mothership": preload("res://buildings/MothershipBuildingDefinition.tres"),
"ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"),
@ -9,6 +9,7 @@ const DEFINITIONS: Dictionary = {
"outpost": preload("res://buildings/OutpostBuildingDefinition.tres"),
"planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"),
"spacial_workshop": preload("res://buildings/WorkshopBuildingDefinition.tres"),
"planet_scanner": preload("res://buildings/ScannerBuildingDefinition.tres"),
"extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"),
"transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"),
"ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"),

View file

@ -0,0 +1,25 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://buildings/ScannerBuildingVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "planet_scanner"
display_name = "Planet Scanner"
description = "Searches deep space for the nearest planet of a chosen type and reveals it on the world map. Survey all four ordinary planet types to identify the tentacled world."
cost = 45
build_resource_costs = {"ore": 45, "gas": 15}
action_icon = 47
menu_icon = 47
visual_scene = ExtResource("2")
capabilities = PackedStringArray("planet_scanner")
map_color = Color(0.35, 0.88, 1, 1)
map_sides = 6
map_radius = 8.0
map_phase = 0.523599
menu_button_name = "ScannerButton"
menu_label_name = "ScannerLabel"
menu_cost_name = "ScannerCost"
unique = true
max_hp = 75

View file

@ -0,0 +1,26 @@
extends Node2D
const COLOR: Color = Color(0.35, 0.88, 1.0)
func _ready() -> void:
set_process(false)
queue_redraw()
func _draw() -> void:
# A broad dish on an open orbital truss. Static by design: scanning is a
# deliberate menu action, so a permanent sweep animation would promise
# background work that the building does not perform.
NeonDraw.draw_glowing_path(self, PackedVector2Array([
Vector2(-58, -34), Vector2(-34, 12), Vector2(12, 30),
]), COLOR, 0.9, 0.7)
NeonDraw.draw_glowing_line(self, Vector2(-31, 12), Vector2(-12, 62),
COLOR, 0.9, 0.7)
NeonDraw.draw_glowing_line(self, Vector2(-52, 62), Vector2(30, 62),
COLOR, 0.9, 0.7)
NeonDraw.draw_glowing_circle(self, Vector2(-25, 4), 7.0,
Color(0.95, 0.3, 0.72), 1.0, Color(0.06, 0.02, 0.12))
for radius in [34.0, 52.0, 70.0]:
draw_arc(Vector2(-30, 3), radius, -1.15, -0.18, 18,
COLOR, 2.2, true)

View file

@ -0,0 +1,11 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/ScannerBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("scanner")

View file

@ -32,6 +32,7 @@ const HUD_ROW_GAP := 16.0
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
@onready var scanner_menu: Control = get_node("../UI/ScannerMenu")
var pending_build_type: String = ""
var craft_building_id: int = 0
@ -42,6 +43,7 @@ var lab_can_buy: bool = false
var transfer_building_id: int = 0
var transfer_extractor_index: int = -1
var garrison_building_id: int = 0
var scanner_building_id: int = 0
var current_building: Node2D = null
var pending_building: Node2D = null
@ -76,6 +78,8 @@ func _ready() -> void:
formation_menu.formation_changed.connect(_on_formation_changed)
formation_menu.reset_requested.connect(_on_reset_formation)
skill_menu.research_requested.connect(_on_unlock_requested)
scanner_menu.scan_requested.connect(_on_scan_requested)
scanner_menu.closed.connect(func(): scanner_building_id = 0)
for control in [action_button, building_action_button, landing_button,
transfer_button, hangar_button]:
@ -107,7 +111,7 @@ func _seat_hud_columns() -> void:
func _menu_is_open() -> bool:
for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu,
formation_menu, skill_menu, planning_menu, garrison_menu]:
formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu]:
if menu.visible:
return true
return false
@ -307,6 +311,24 @@ func _on_action_pressed() -> void:
elif building_action == "garrison":
garrison_building_id = building_id
garrison_menu.open_for(building_id)
elif building_action == "scanner":
scanner_building_id = building_id
scanner_menu.open_menu()
func _on_scan_requested(definition_id: String) -> void:
var scanner: Dictionary = GameState.find_building(scanner_building_id)
if scanner.is_empty():
scanner_menu.show_failure(definition_id)
return
var result: Dictionary = GameState.scan_for_planet(definition_id,
Vector2(scanner.position))
if result.is_empty():
scanner_menu.show_failure(definition_id)
return
scanner_menu.close_menu()
world_map.open_map()
world_map.focus_world_position(Vector2(result.world_position))
func _on_build_pressed() -> void:

View file

@ -203,6 +203,13 @@ var dev_mode: bool = false:
# player lands on and leaves a planet.
var explored_chunks: Dictionary = {}
# The four ordinary destinations teach the scanner enough signatures to
# identify the hostile boss world. Kept explicit so adding a sixth authored
# planet later cannot silently move the progression gate.
const SCANNER_SURVEY_TYPES: PackedStringArray = [PLANET_DEFINITIONS.ROCKY_ID,
PLANET_DEFINITIONS.GAS_GIANT_ID, PLANET_DEFINITIONS.BLACK_HOLE_ID,
PLANET_DEFINITIONS.RESEARCH_LAB_ID]
# --- Resources -------------------------------------------------------------
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
@ -430,6 +437,9 @@ const BUILDING_SHIP_LAB: String = "ship_lab"
# Converts gas into energy, both stored/moved through the building's own
# inventory exactly like every other building (#54).
const BUILDING_GAS_BURNER: String = "gas_burner"
# A map-revealing structure, not a passive sensor bonus. Its menu searches
# the same deterministic WorldGen catalogue that PlanetField spawns from.
const BUILDING_PLANET_SCANNER: String = "planet_scanner"
var buildings: Array = []
@ -532,6 +542,13 @@ const SKILL_TREE: Array = [
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{
"id": "deep_space_scanning", "name": "Deep-Space Scanning", "cost": 50,
"costs": {"ore": 50, "gas": 15},
"requires": "coordination", "x": 0.34,
"unlocks": BUILDING_PLANET_SCANNER, "research_points": 1,
"desc": "Build a planet scanner and reveal the nearest world of a chosen type.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"costs": {"ore": 80, "gas": 30},
@ -791,6 +808,70 @@ func reset_new_game() -> void:
reset_formation()
func scanner_can_find_boss() -> bool:
for definition_id in SCANNER_SURVEY_TYPES:
if not visited_planet_kinds.has(definition_id):
return false
return true
func scanner_available_planet_types() -> PackedStringArray:
var available: PackedStringArray = SCANNER_SURVEY_TYPES.duplicate()
if scanner_can_find_boss():
available.append(PLANET_DEFINITIONS.BOSS_ID)
return available
# Finds the nearest generated planet of this type, reveals its neighbourhood
# on the persistent map, and returns the same identity PlanetField will spawn.
# The search is ring-shaped, so the first hit is nearest in chunk distance and
# results do not depend on dictionary iteration order.
func scan_for_planet(definition_id: String, origin_world_position: Vector2) -> Dictionary:
if not structure_exists(BUILDING_PLANET_SCANNER) \
or definition_id not in scanner_available_planet_types():
return {}
var origin: Vector2i = WORLD_GEN.chunk_coords(origin_world_position)
for radius in range(0, 257):
if radius == 0:
var center_match: Dictionary = _scanner_match_at(origin,
definition_id, radius)
if not center_match.is_empty():
return center_match
continue
# Only the perimeter is new at each radius. The old square-with-a-
# condition loop revisited every inner chunk for every radius, turning
# a wide scan cubic precisely when the phone had the most work to do.
for x in range(-radius, radius + 1):
for y in [-radius, radius]:
var horizontal: Dictionary = _scanner_match_at(
origin + Vector2i(x, y), definition_id, radius)
if not horizontal.is_empty():
return horizontal
for y in range(-radius + 1, radius):
for x in [-radius, radius]:
var vertical: Dictionary = _scanner_match_at(
origin + Vector2i(x, y), definition_id, radius)
if not vertical.is_empty():
return vertical
return {}
func _scanner_match_at(chunk: Vector2i, definition_id: String,
distance_chunks: int) -> Dictionary:
if not WORLD_GEN.has_planet_at_chunk(chunk):
return {}
var data: Dictionary = WORLD_GEN.planet_data_at_chunk(chunk)
if String(data.get("definition_id", "")) != definition_id:
return {}
for reveal_y in range(-1, 2):
for reveal_x in range(-1, 2):
explored_chunks[chunk + Vector2i(reveal_x, reveal_y)] = true
data["chunk"] = chunk
data["world_position"] = WORLD_GEN.chunk_origin(chunk) + Vector2(data.local_pos)
data["distance_chunks"] = distance_chunks
return data
# Amounts a dev game opens with: enough of everything to reach any part of
# the game immediately, without being so large that costs stop registering.
const DEV_RESOURCE_AMOUNT: int = 2000

View file

@ -233,13 +233,21 @@ const CHAIN: Array = [
"brief": "An empty outpost is only a building. Open its hangar and station a ship there. It will hold that ground whether we are there or not.",
"done": "The outpost has a crew. It watches that approach now, so we do not have to.",
},
{
"id": "planet_scanner",
"title": "LISTENING POST",
"objective": "Build a planet scanner",
"goal": {"kind": GOAL_BUILDING, "building": "planet_scanner"},
"brief": "Blind searching is wasting our reach. Research Deep-Space Scanning at the mothership, then build a planet scanner. Give it a world signature and it will put the nearest match on our map.",
"done": "The scanner is listening. Now we can choose where the search goes next.",
},
{
"id": "worlds",
"title": "THE SURVEY",
"objective": "Land on three different kinds of world",
"goal": {"kind": GOAL_PLANET_KINDS, "count": 3},
"brief": "Rock, gas, and whatever else is out here. Land on three different kinds of world. If the World Ship came this way, it passed something.",
"done": "Three kinds of world surveyed. Not one of them holds any sign of the convoy.",
"objective": "Land on four different kinds of world",
"goal": {"kind": GOAL_PLANET_KINDS, "count": 4},
"brief": "Rock, gas, the abandoned labs, and the lightless wells. Use the scanner and land on all four kinds of world. If the World Ship came this way, it passed something.",
"done": "Four kinds of world surveyed. No convoy—but their signatures resolve a fifth signal, hidden inside something alive.",
"important": true,
},
{

View file

@ -0,0 +1,22 @@
extends Node2D
# Phone-sized visual fixture for the scene-owned scanner panel.
# bash tests/screenshot.sh res://tests/_scanner_menu_preview.tscn /tmp/scanner.png 20 720x1280
func _ready() -> void:
GameState.reset_new_game()
GameState.intro_active = false
GameState.reset_quests(false)
GameState.unlocked.append("deep_space_scanning")
GameState.add_building(GameState.BUILDING_PLANET_SCANNER, Vector2.ZERO)
for definition_id in GameState.SCANNER_SURVEY_TYPES:
GameState.note_planet_visited("preview:%s" % definition_id, definition_id)
# The panel owns a full-screen backdrop, so loading the entire live world
# only adds software-renderer work and no pixels that can be seen.
var menu: Control = load("res://ui/menus/ScannerMenu.tscn").instantiate()
add_child(menu)
menu.set_anchors_preset(Control.PRESET_TOP_LEFT)
menu.position = Vector2.ZERO
menu.size = get_viewport_rect().size
menu.open_menu()

View file

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

View file

@ -4,8 +4,9 @@ extends SceneTree
# point loop. This test drives the real landed surface and encounter nodes.
#
# Covers:
# A. the planet/profile are registered, dev mode plants it nearby, and its
# scripted profile is excluded from the map editor
# A. the planet/profile are registered, dev mode plants it nearby, normal
# generation owns exactly one inside its octopus biome, and its scripted
# profile is excluded from the map editor
# B. landing composes BossEncounter instead of a PlanetSetup and does not
# mark the world conquered for free
# C. the off-screen approach remains controllable and reveals with its own cue
@ -86,20 +87,20 @@ func _process(delta: float) -> bool:
!= PlanetDefinitions.BOSS_ID:
return _fail("dev mode does not plant the boss planet")
state.dev_mode = false
var normal_boss_found: bool = false
var normal_boss_count: int = 0
for x in range(-24, 25):
for y in range(-24, 25):
var chunk := Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk) \
and String(WorldGen.planet_data_at_chunk(chunk).get(
"definition_id", "")) == PlanetDefinitions.BOSS_ID:
normal_boss_found = true
break
if normal_boss_found:
break
if not normal_boss_found:
return _fail("normal world generation never produces the boss planet")
print("boss planet registered, editor-hidden, and generated normally plus in dev mode")
normal_boss_count += 1
if normal_boss_count != 1 \
or WorldGen.biome_at_chunk(WorldGen.OCTOPUS_BIOME_CENTER) \
!= WorldGen.Biome.OCTOPUS:
return _fail("normal world generation has %d bosses, or its only boss is outside the octopus biome" \
% normal_boss_count)
print("boss planet registered, editor-hidden, unique in its normal octopus biome, plus nearby in dev mode")
# --- B. generic surface composition ---------------------------
encounter = surface.get_node_or_null("SurfaceFeatures/BossEncounter")

View file

@ -21,10 +21,16 @@ func _initialize() -> void:
])
return
var provider: Node = building.visual.get_node_or_null("ActionProvider")
if definition.definition_id == GameState.BUILDING_TRANSPORT_HUB:
if "transport_hub" in definition.capabilities:
if provider != null or not building.available_actions().is_empty():
_fail("transport hub composed a manual action")
return
elif "planet_scanner" in definition.capabilities:
if provider == null or not provider.actions.has("scanner") \
or provider.actions.has("transfer"):
_fail("%s did not compose its scanner-only action" \
% definition.definition_id)
return
elif provider == null or not provider.actions.has("transfer"):
_fail("%s has no composed transfer action" % definition.definition_id)
return

View file

@ -0,0 +1,132 @@
extends SceneTree
# Focused coverage for #192-#196: the scanner progression, its scene-owned
# menu, the one hostile region and the ambient enemies that inhabit it.
const QUESTS = preload("res://core/Quests.gd")
class FakeFleet:
extends Node2D
var ships: Array = [Node2D.new()]
func get_centroid() -> Vector2:
return position
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var state: Node = root.get_node("GameState")
state.reset_new_game()
state.intro_active = false
state.reset_quests(false)
# #194: exact, finite hostile geography instead of an endless rare roll.
WorldGen.dev_planets_enabled = false
var boss_count: int = 0
for x in range(-40, 41):
for y in range(-40, 41):
var chunk := Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk) \
and String(WorldGen.planet_data_at_chunk(chunk).definition_id) \
== PlanetDefinitions.BOSS_ID:
boss_count += 1
if boss_count != 1:
return _fail("normal generation contains %d boss planets, expected exactly one" % boss_count)
if WorldGen.biome_at_chunk(WorldGen.OCTOPUS_BIOME_CENTER) \
!= WorldGen.Biome.OCTOPUS:
return _fail("the unique boss is not inside the octopus biome")
if WorldGen.octopus_biome_chunks().size() < 5:
return _fail("the octopus biome is not a multi-chunk region")
for chunk in WorldGen.octopus_biome_chunks():
if WorldGen.biome_at_chunk(chunk) != WorldGen.Biome.OCTOPUS:
return _fail("declared octopus chunk %s has another biome" % chunk)
if chunk != WorldGen.OCTOPUS_BIOME_CENTER \
and WorldGen.has_planet_at_chunk(chunk):
return _fail("the hostile region contains an extra planet at %s" % chunk)
print("one finite nine-chunk octopus biome contains the one normal boss planet")
# #193/#195: research gate, real generated scan results and boss gating.
if state.is_building_unlocked(GameState.BUILDING_PLANET_SCANNER):
return _fail("planet scanner is available without research")
state.unlocked.append("coordination")
state.bank_inventory.deposit(GameState.RESOURCE_ORE, 50)
state.bank_inventory.deposit(GameState.RESOURCE_GAS, 15)
state.research_points = 1
if not state.unlock("deep_space_scanning") \
or not state.is_building_unlocked(GameState.BUILDING_PLANET_SCANNER):
return _fail("Deep-Space Scanning did not unlock its building")
state.add_building(GameState.BUILDING_PLANET_SCANNER, Vector2.ZERO)
var rocky: Dictionary = state.scan_for_planet(PlanetDefinitions.ROCKY_ID,
Vector2.ZERO)
if rocky.is_empty() or String(rocky.definition_id) != PlanetDefinitions.ROCKY_ID:
return _fail("the scanner did not find a real rocky planet")
if not state.explored_chunks.has(Vector2i(rocky.chunk)):
return _fail("a scan result was not revealed on the persistent map")
if PlanetDefinitions.BOSS_ID in state.scanner_available_planet_types():
return _fail("boss signature is available before the four-world survey")
var menu: Control = load("res://ui/menus/ScannerMenu.tscn").instantiate()
root.add_child(menu)
menu.open_menu()
if menu.get_node("BossButton").visible:
return _fail("scanner menu shows the boss before the survey")
for definition_id in GameState.SCANNER_SURVEY_TYPES:
state.note_planet_visited("scanner:test:%s" % definition_id, definition_id)
menu.refresh()
if not menu.get_node("BossButton").visible \
or not menu.get_node("BossLabel").visible:
return _fail("the fifth scanner option did not appear after four planet types")
var boss: Dictionary = state.scan_for_planet(PlanetDefinitions.BOSS_ID,
Vector2.ZERO)
if boss.is_empty() or Vector2i(boss.chunk) != WorldGen.OCTOPUS_BIOME_CENTER:
return _fail("boss scan did not resolve the unique hostile region")
print("researched scanner reveals generated destinations; the fifth option waits for 4/4")
# #192: the scanner is taught immediately before the widened survey.
var scanner_quest_at: int = -1
var worlds_at: int = -1
for i in range(QUESTS.count()):
if String(QUESTS.at(i).id) == "planet_scanner":
scanner_quest_at = i
if String(QUESTS.at(i).id) == "worlds":
worlds_at = i
if scanner_quest_at < 0 or worlds_at != scanner_quest_at + 1:
return _fail("scanner quest is not directly before the world survey")
if int(QUESTS.at(worlds_at).goal.count) != 4:
return _fail("world survey does not require four planet types")
print("the quest chain teaches the scanner immediately before the four-world survey")
# #196: entering/leaving the biome owns a bounded roaming population.
var host := Node2D.new()
root.add_child(host)
var fake_fleet := FakeFleet.new()
fake_fleet.name = "Fleet"
fake_fleet.position = WorldGen.chunk_origin(WorldGen.OCTOPUS_BIOME_CENTER) \
+ Vector2(WorldGen.CHUNK_SIZE * 0.5, WorldGen.CHUNK_SIZE * 0.5)
fake_fleet.add_to_group("player_fleet")
host.add_child(fake_fleet)
var field: Node2D = load("res://world/OctopusField.gd").new()
field.name = "OctopusField"
host.add_child(field)
field._process(0.1)
if field.active.size() != 1:
return _fail("entering the octopus biome spawned %d roamers" % field.active.size())
var roamer: Node = field.active[0]
if roamer.max_hp < 7 or not roamer.is_in_group("roaming_octopuses"):
return _fail("ambient octopus lacks its durable roaming composition")
fake_fleet.position = Vector2.ZERO
field._process(0.1)
if not field.active.is_empty():
return _fail("roaming octopuses followed the fleet outside their biome")
print("octopus biome spawns bounded durable roamers and retires them at its edge")
print("TEST_PLANET_SCANNER: PASS")
quit(0)
func _fail(message: String) -> void:
print("FAIL: %s" % message)
quit(1)

View file

@ -66,6 +66,9 @@ enum Icon {
# Development tools: three sliders, used only by the editor-only sandbox
# button. Appended to preserve every serialized icon number above.
DEV_TOOLS,
# #193: a directional sensor dish. Appended: scene files store these as
# integers, so insertion would change every icon after it.
SCANNER,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
@ -328,6 +331,27 @@ func _draw() -> void:
_draw_dash_icon(center, pulse, s)
Icon.DEV_TOOLS:
_draw_dev_tools_icon(center, pulse, s)
Icon.SCANNER:
_draw_scanner_icon(center, pulse, s)
func _draw_scanner_icon(center: Vector2, pulse: float, s: float) -> void:
# Dish, mast and three outward wavefronts: distinct from MAP (a folded
# sheet) and RESEARCH (the mothership's archive).
NeonDraw.draw_glowing_path(self, PackedVector2Array([
center + Vector2(-12, -3) * s,
center + Vector2(-5, 7) * s,
center + Vector2(5, 4) * s,
]), _active_color(), pulse, 0.6 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-4, 7) * s,
center + Vector2(-4, 14) * s, _active_color(), pulse, 0.6 * s,
core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-11, 14) * s,
center + Vector2(3, 14) * s, _active_color(), pulse, 0.6 * s,
core_color)
for radius in [7.0, 13.0, 19.0]:
draw_arc(center + Vector2(-7, 1) * s, radius * s, -1.1, -0.15, 10,
_active_color(), maxf(1.0, 1.3 * s), true)
func _draw_dev_tools_icon(center: Vector2, pulse: float, s: float) -> void:

View file

@ -448,6 +448,46 @@ offset_top = 1554
offset_bottom = 1606
script = ExtResource("price_tag")
[node name="ScannerButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -64
offset_right = 64
offset_top = 1610
offset_bottom = 1738
script = ExtResource("10")
icon = 47
glow_color = Color(0.35, 0.88, 1, 1)
[node name="ScannerLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -150
offset_right = 150
offset_top = 1744
offset_bottom = 1810
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
text = "Planet Scanner"
horizontal_alignment = 1
[node name="ScannerCost" type="Control" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -60
offset_right = 60
offset_top = 1814
offset_bottom = 1866
script = ExtResource("price_tag")
[node name="CloseButton" type="Control" parent="."]
anchor_left = 1.0
anchor_right = 1.0

56
ui/menus/ScannerMenu.gd Normal file
View file

@ -0,0 +1,56 @@
extends "res://ui/menus/ModalMenu.gd"
signal scan_requested(definition_id: String)
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
@onready var status_label: Label = $StatusLabel
@onready var progress_label: Label = $ProgressLabel
var entries: Dictionary = {}
func _ready() -> void:
super._ready()
entries = {
PLANET_DEFINITIONS.ROCKY_ID: [$RockyButton, $RockyLabel],
PLANET_DEFINITIONS.GAS_GIANT_ID: [$GasButton, $GasLabel],
PLANET_DEFINITIONS.BLACK_HOLE_ID: [$BlackHoleButton, $BlackHoleLabel],
PLANET_DEFINITIONS.RESEARCH_LAB_ID: [$ResearchButton, $ResearchLabel],
PLANET_DEFINITIONS.BOSS_ID: [$BossButton, $BossLabel],
}
for definition_id in entries:
var id: String = String(definition_id)
var button: Control = entries[definition_id][0]
button.pressed.connect(_request_scan.bind(id))
refresh()
func _request_scan(definition_id: String) -> void:
scan_requested.emit(definition_id)
func open_menu() -> void:
super.open_menu()
status_label.text = "Choose a signature to reveal its nearest world."
refresh()
func refresh() -> void:
var surveyed: int = 0
for definition_id in GameState.SCANNER_SURVEY_TYPES:
if GameState.visited_planet_kinds.has(definition_id):
surveyed += 1
var boss_visible: bool = GameState.scanner_can_find_boss()
for node in entries[PLANET_DEFINITIONS.BOSS_ID]:
node.visible = boss_visible
progress_label.text = "PLANETARY SIGNATURES %d / %d" % [surveyed,
GameState.SCANNER_SURVEY_TYPES.size()]
if not boss_visible:
progress_label.text += "\nSurvey every signature to isolate the hostile signal."
func show_failure(definition_id: String) -> void:
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
status_label.text = "No %s signature found within scanner range." \
% definition.display_name.to_lower()

184
ui/menus/ScannerMenu.tscn Normal file
View file

@ -0,0 +1,184 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/IconButton.gd" id="icon"]
[ext_resource type="Script" path="res://ui/menus/ScannerMenu.gd" id="menu"]
[node name="ScannerMenu" type="Control" groups=["modal_menus"]]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("menu")
[node name="Backdrop" type="ColorRect" parent="."]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.96)
[node name="TitleLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 24.0
offset_right = -180.0
offset_bottom = 100.0
theme_override_font_sizes/font_size = 42
text = "PLANET SCANNER"
horizontal_alignment = 1
[node name="ProgressLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 36.0
offset_top = 130.0
offset_right = -36.0
offset_bottom = 230.0
theme_override_font_sizes/font_size = 22
theme_override_colors/font_color = Color(0.55, 0.78, 0.86, 1)
text = "PLANETARY SIGNATURES 0 / 4"
horizontal_alignment = 1
autowrap_mode = 2
[node name="RockyButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -276.0
offset_top = 280.0
offset_right = -116.0
offset_bottom = 440.0
script = ExtResource("icon")
icon = 47
glow_color = Color(0.7, 0.65, 0.55, 1)
[node name="RockyLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -326.0
offset_top = 448.0
offset_right = -66.0
offset_bottom = 500.0
theme_override_font_sizes/font_size = 28
text = "Rocky planet"
horizontal_alignment = 1
[node name="GasButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 116.0
offset_top = 280.0
offset_right = 276.0
offset_bottom = 440.0
script = ExtResource("icon")
icon = 47
glow_color = Color(0.65, 0.42, 0.9, 1)
[node name="GasLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 66.0
offset_top = 448.0
offset_right = 326.0
offset_bottom = 500.0
theme_override_font_sizes/font_size = 28
text = "Gas giant"
horizontal_alignment = 1
[node name="BlackHoleButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -276.0
offset_top = 560.0
offset_right = -116.0
offset_bottom = 720.0
script = ExtResource("icon")
icon = 47
glow_color = Color(0.55, 0.15, 0.85, 1)
[node name="BlackHoleLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -326.0
offset_top = 728.0
offset_right = -66.0
offset_bottom = 780.0
theme_override_font_sizes/font_size = 28
text = "Black hole"
horizontal_alignment = 1
[node name="ResearchButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 116.0
offset_top = 560.0
offset_right = 276.0
offset_bottom = 720.0
script = ExtResource("icon")
icon = 47
glow_color = Color(0.7, 0.2, 0.15, 1)
[node name="ResearchLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 66.0
offset_top = 728.0
offset_right = 326.0
offset_bottom = 810.0
theme_override_font_sizes/font_size = 28
text = "Research Lab"
horizontal_alignment = 1
[node name="BossButton" type="Control" parent="."]
visible = false
anchor_left = 0.5
anchor_right = 0.5
offset_left = -80.0
offset_top = 835.0
offset_right = 80.0
offset_bottom = 995.0
script = ExtResource("icon")
icon = 47
glow_color = Color(0.95, 0.18, 0.58, 1)
[node name="BossLabel" type="Label" parent="."]
visible = false
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -180.0
offset_top = 1003.0
offset_right = 180.0
offset_bottom = 1055.0
theme_override_font_sizes/font_size = 28
text = "Tentacled world"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -170.0
offset_right = -32.0
offset_bottom = -30.0
theme_override_font_sizes/font_size = 26
text = "Choose a signature to reveal its nearest world."
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="CloseButton" type="Control" parent="."]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -164.0
offset_top = 24.0
offset_right = -24.0
offset_bottom = 164.0
script = ExtResource("icon")
icon = 1
glow_color = Color(0.35, 0.88, 1, 1)

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=46 format=3]
[gd_scene load_steps=48 format=3]
[ext_resource type="PackedScene" path="res://ui/menus/BuildMenu.tscn" id="menu_build"]
[ext_resource type="PackedScene" path="res://ui/menus/HangarMenu.tscn" id="menu_hangar"]
[ext_resource type="PackedScene" path="res://ui/menus/LabMenu.tscn" id="menu_lab"]
@ -9,6 +9,7 @@
[ext_resource type="PackedScene" path="res://ui/menus/GarrisonMenu.tscn" id="menu_garrison"]
[ext_resource type="PackedScene" path="res://ui/menus/SkillMenu.tscn" id="menu_skill"]
[ext_resource type="PackedScene" path="res://ui/menus/DevMenu.tscn" id="menu_dev"]
[ext_resource type="PackedScene" path="res://ui/menus/ScannerMenu.tscn" id="menu_scanner"]
[ext_resource type="Script" path="res://world/Starfield.gd" id="1"]
@ -45,6 +46,7 @@
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"]
[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"]
[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"]
[ext_resource type="Script" path="res://world/OctopusField.gd" id="octopus_field"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5")
@ -104,6 +106,9 @@ script = ExtResource("6")
[node name="AsteroidField" type="Node2D" parent="."]
script = ExtResource("7")
[node name="OctopusField" type="Node2D" parent="."]
script = ExtResource("octopus_field")
[node name="PlanetField" type="Node2D" parent="."]
script = ExtResource("11")
@ -513,6 +518,7 @@ icon = 1
[node name="GarrisonMenu" parent="UI" instance=ExtResource("menu_garrison")]
[node name="SkillMenu" parent="UI" instance=ExtResource("menu_skill")]
[node name="DevMenu" parent="UI" instance=ExtResource("menu_dev")]
[node name="ScannerMenu" parent="UI" instance=ExtResource("menu_scanner")]
[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
mouse_filter = 2

62
world/OctopusField.gd Normal file
View file

@ -0,0 +1,62 @@
extends Node2D
# A bounded ambient threat attached to the OCTOPUS biome. Like AsteroidField,
# this node owns only the nearby presentation: the biome itself and its one
# boss planet remain deterministic WorldGen data.
@export var spawn_interval: float = 2.4
@export var max_octopuses: int = 7
@export var spawn_margin: float = 180.0
@export var retire_distance: float = 2600.0
@onready var fleet: Node2D = get_node("../Fleet")
var octopus_scene: PackedScene = preload("res://boss/BossMinion.tscn")
var active: Array = []
var spawn_left: float = 0.0
var spawn_count: int = 0
func _process(delta: float) -> void:
active = active.filter(func(o): return is_instance_valid(o))
var in_biome: bool = WorldGen.biome_at_chunk(
WorldGen.chunk_coords(fleet.get_centroid())) == WorldGen.Biome.OCTOPUS
if not in_biome or GameState.intro_active:
retire_all()
spawn_left = 0.0
return
for octopus in active.duplicate():
if octopus.global_position.distance_to(fleet.get_centroid()) > retire_distance:
octopus.retire()
active.erase(octopus)
spawn_left -= delta
if spawn_left <= 0.0 and active.size() < max_octopuses:
spawn_one()
spawn_left = spawn_interval
func spawn_one() -> Node2D:
if fleet == null or fleet.ships.is_empty():
return null
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
var radius: float = maxf(visible_rect.size.length() * 0.5 + spawn_margin,
620.0)
var angle: float = randf() * TAU
var octopus: Node2D = octopus_scene.instantiate()
# Ambient hatchlings are sturdy enough to harass a travelling fleet, but
# never marked: only the boss encounter's golden hatchlings open eyes.
octopus.max_hp = 7
octopus.position = fleet.get_centroid() + Vector2.from_angle(angle) * radius
get_parent().add_child(octopus)
octopus.uses_dash_pattern = spawn_count % 3 == 2
octopus.add_to_group("roaming_octopuses")
active.append(octopus)
spawn_count += 1
return octopus
func retire_all() -> void:
for octopus in active:
if is_instance_valid(octopus):
octopus.retire()
active.clear()

View file

@ -15,6 +15,8 @@ extends ColorRect
@onready var camera: Camera2D = get_node("../../Camera2D")
var current_chunk: Vector2i = Vector2i(2147483647, 2147483647)
func _process(_delta: float) -> void:
var viewport_size: Vector2 = get_viewport_rect().size
@ -26,3 +28,8 @@ func _process(_delta: float) -> void:
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)
var chunk: Vector2i = WorldGen.chunk_coords(camera.global_position)
if chunk != current_chunk:
current_chunk = chunk
material.set_shader_parameter("bg_color",
WorldGen.biome_background_color(WorldGen.biome_at_chunk(chunk)))

View file

@ -9,7 +9,7 @@ const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
# revisiting the same chunk always yields the same biome, and biomes form
# smooth multi-chunk regions instead of a per-chunk random checkerboard.
enum Biome { SPACE, ASTEROID_FIELD }
enum Biome { SPACE, ASTEROID_FIELD, OCTOPUS }
const CHUNK_SIZE: float = 1200.0
const NOISE_SEED: int = 1337
@ -37,10 +37,18 @@ const BLACK_HOLE_CHANCE: float = 0.05
# from 0.08 after playtesting found that rate too scarce for how central
# research points had become to progression.
const RESEARCH_LAB_CHANCE: float = 0.16
# A boss world is rarer than every ordinary discovery. Its roll uses a
# separately seeded generator below, so adding it cannot rename or re-roll
# any planet in an existing world.
const BOSS_PLANET_CHANCE: float = 0.025
# The hostile region is authored into procedural space as one finite,
# deterministic island. A probability per chunk can only say "rare"; over an
# infinite world it inevitably creates infinitely many boss worlds. One
# anchor and one shape make the actual rule representable: one multi-chunk
# octopus biome, with its boss at the centre.
const OCTOPUS_BIOME_CENTER: Vector2i = Vector2i(12, -9)
const OCTOPUS_BIOME_OFFSETS: Array[Vector2i] = [
Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0),
Vector2i(0, -1), Vector2i(0, 1),
Vector2i(-1, -1), Vector2i(1, -1),
Vector2i(-1, 1), Vector2i(1, 1),
]
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
# around the spawn so all five can be reached in seconds.
@ -113,6 +121,8 @@ static func chunk_origin(chunk: Vector2i) -> Vector2:
static func biome_at_chunk(chunk: Vector2i) -> int:
if is_octopus_chunk(chunk):
return Biome.OCTOPUS
var n: float = _get_noise().get_noise_2d(float(chunk.x), float(chunk.y))
if n > ASTEROID_FIELD_THRESHOLD:
return Biome.ASTEROID_FIELD
@ -122,6 +132,10 @@ static func biome_at_chunk(chunk: Vector2i) -> int:
static func has_planet_at_chunk(chunk: Vector2i) -> bool:
if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk):
return true
if chunk == OCTOPUS_BIOME_CENTER:
return true
if is_octopus_chunk(chunk):
return false
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
return (biome_at_chunk(chunk) == Biome.ASTEROID_FIELD and n > PLANET_THRESHOLD) or (biome_at_chunk(chunk) == Biome.SPACE and n > GAS_GIANT_THRESHOLD)
@ -148,17 +162,12 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
var black_hole_roll: float = rng.randf()
# Same principle, its own independent roll (#63).
var research_lab_roll: float = rng.randf()
# Independent from the established generation stream: existing chunks keep
# their old names and shapes when this new rare outcome is introduced.
var boss_rng := RandomNumberGenerator.new()
boss_rng.seed = hash("boss:%d:%d" % [chunk.x, chunk.y])
var boss_roll: float = boss_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 = biome_at_chunk(chunk) == Biome.SPACE
var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID
# Rare authored outcomes, seeded deterministically like everything else.
if boss_roll < BOSS_PLANET_CHANCE:
if chunk == OCTOPUS_BIOME_CENTER:
definition_id = PLANET_DEFINITIONS.BOSS_ID
elif black_hole_roll < BLACK_HOLE_CHANCE:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
@ -186,5 +195,26 @@ static func biome_color(biome: int) -> Color:
match biome:
Biome.ASTEROID_FIELD:
return Color(0.55, 0.36, 0.18, 1.0)
Biome.OCTOPUS:
return Color(0.34, 0.07, 0.24, 1.0)
_:
return Color(0.10, 0.13, 0.28, 1.0)
static func biome_background_color(biome: int) -> Color:
match biome:
Biome.OCTOPUS:
return Color(0.075, 0.012, 0.065, 1.0)
_:
return Color(0.02, 0.02, 0.08, 1.0)
static func is_octopus_chunk(chunk: Vector2i) -> bool:
return chunk - OCTOPUS_BIOME_CENTER in OCTOPUS_BIOME_OFFSETS
static func octopus_biome_chunks() -> Array[Vector2i]:
var chunks: Array[Vector2i] = []
for offset in OCTOPUS_BIOME_OFFSETS:
chunks.append(OCTOPUS_BIOME_CENTER + offset)
return chunks

View file

@ -58,7 +58,11 @@ func close_map() -> void:
func recenter_view() -> void:
var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL
focus_world_position(fleet.get_centroid())
func focus_world_position(world_position: Vector2) -> void:
var fleet_local: Vector2 = world_position * content.WORLD_TO_LOCAL
var viewport_center: Vector2 = get_viewport_rect().size / 2.0
content.position = viewport_center - fleet_local * content.scale.x