Add planet scanner and octopus biome
This commit is contained in:
parent
2ecd61de98
commit
8fbcc340ce
21 changed files with 784 additions and 30 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
class_name BuildingDefinitions
|
class_name BuildingDefinitions
|
||||||
extends RefCounted
|
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 = {
|
const DEFINITIONS: Dictionary = {
|
||||||
"mothership": preload("res://buildings/MothershipBuildingDefinition.tres"),
|
"mothership": preload("res://buildings/MothershipBuildingDefinition.tres"),
|
||||||
"ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"),
|
"ship_crafter": preload("res://buildings/CrafterBuildingDefinition.tres"),
|
||||||
|
|
@ -9,6 +9,7 @@ const DEFINITIONS: Dictionary = {
|
||||||
"outpost": preload("res://buildings/OutpostBuildingDefinition.tres"),
|
"outpost": preload("res://buildings/OutpostBuildingDefinition.tres"),
|
||||||
"planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"),
|
"planning_center": preload("res://buildings/PlanningBuildingDefinition.tres"),
|
||||||
"spacial_workshop": preload("res://buildings/WorkshopBuildingDefinition.tres"),
|
"spacial_workshop": preload("res://buildings/WorkshopBuildingDefinition.tres"),
|
||||||
|
"planet_scanner": preload("res://buildings/ScannerBuildingDefinition.tres"),
|
||||||
"extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"),
|
"extractor": preload("res://buildings/ExtractorBuildingDefinition.tres"),
|
||||||
"transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"),
|
"transport_hub": preload("res://buildings/TransportHubBuildingDefinition.tres"),
|
||||||
"ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"),
|
"ship_lab": preload("res://buildings/ShipLabBuildingDefinition.tres"),
|
||||||
|
|
|
||||||
25
buildings/ScannerBuildingDefinition.tres
Normal file
25
buildings/ScannerBuildingDefinition.tres
Normal 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
|
||||||
26
buildings/ScannerBuildingVisual.gd
Normal file
26
buildings/ScannerBuildingVisual.gd
Normal 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)
|
||||||
11
buildings/ScannerBuildingVisual.tscn
Normal file
11
buildings/ScannerBuildingVisual.tscn
Normal 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")
|
||||||
|
|
@ -32,6 +32,7 @@ const HUD_ROW_GAP := 16.0
|
||||||
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
|
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
|
||||||
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
|
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
|
||||||
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
|
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
|
||||||
|
@onready var scanner_menu: Control = get_node("../UI/ScannerMenu")
|
||||||
|
|
||||||
var pending_build_type: String = ""
|
var pending_build_type: String = ""
|
||||||
var craft_building_id: int = 0
|
var craft_building_id: int = 0
|
||||||
|
|
@ -42,6 +43,7 @@ var lab_can_buy: bool = false
|
||||||
var transfer_building_id: int = 0
|
var transfer_building_id: int = 0
|
||||||
var transfer_extractor_index: int = -1
|
var transfer_extractor_index: int = -1
|
||||||
var garrison_building_id: int = 0
|
var garrison_building_id: int = 0
|
||||||
|
var scanner_building_id: int = 0
|
||||||
|
|
||||||
var current_building: Node2D = null
|
var current_building: Node2D = null
|
||||||
var pending_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.formation_changed.connect(_on_formation_changed)
|
||||||
formation_menu.reset_requested.connect(_on_reset_formation)
|
formation_menu.reset_requested.connect(_on_reset_formation)
|
||||||
skill_menu.research_requested.connect(_on_unlock_requested)
|
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,
|
for control in [action_button, building_action_button, landing_button,
|
||||||
transfer_button, hangar_button]:
|
transfer_button, hangar_button]:
|
||||||
|
|
@ -107,7 +111,7 @@ func _seat_hud_columns() -> void:
|
||||||
|
|
||||||
func _menu_is_open() -> bool:
|
func _menu_is_open() -> bool:
|
||||||
for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu,
|
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:
|
if menu.visible:
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
@ -307,6 +311,24 @@ func _on_action_pressed() -> void:
|
||||||
elif building_action == "garrison":
|
elif building_action == "garrison":
|
||||||
garrison_building_id = building_id
|
garrison_building_id = building_id
|
||||||
garrison_menu.open_for(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:
|
func _on_build_pressed() -> void:
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,13 @@ var dev_mode: bool = false:
|
||||||
# player lands on and leaves a planet.
|
# player lands on and leaves a planet.
|
||||||
var explored_chunks: Dictionary = {}
|
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 -------------------------------------------------------------
|
# --- Resources -------------------------------------------------------------
|
||||||
|
|
||||||
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
|
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
|
# Converts gas into energy, both stored/moved through the building's own
|
||||||
# inventory exactly like every other building (#54).
|
# inventory exactly like every other building (#54).
|
||||||
const BUILDING_GAS_BURNER: String = "gas_burner"
|
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 = []
|
var buildings: Array = []
|
||||||
|
|
@ -532,6 +542,13 @@ const SKILL_TREE: Array = [
|
||||||
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
|
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
|
||||||
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
|
"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",
|
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
|
||||||
"costs": {"ore": 80, "gas": 30},
|
"costs": {"ore": 80, "gas": 30},
|
||||||
|
|
@ -791,6 +808,70 @@ func reset_new_game() -> void:
|
||||||
reset_formation()
|
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
|
# 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.
|
# the game immediately, without being so large that costs stop registering.
|
||||||
const DEV_RESOURCE_AMOUNT: int = 2000
|
const DEV_RESOURCE_AMOUNT: int = 2000
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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.",
|
"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",
|
"id": "worlds",
|
||||||
"title": "THE SURVEY",
|
"title": "THE SURVEY",
|
||||||
"objective": "Land on three different kinds of world",
|
"objective": "Land on four different kinds of world",
|
||||||
"goal": {"kind": GOAL_PLANET_KINDS, "count": 3},
|
"goal": {"kind": GOAL_PLANET_KINDS, "count": 4},
|
||||||
"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.",
|
"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": "Three kinds of world surveyed. Not one of them holds any sign of the convoy.",
|
"done": "Four kinds of world surveyed. No convoy—but their signatures resolve a fifth signal, hidden inside something alive.",
|
||||||
"important": true,
|
"important": true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
22
tests/_scanner_menu_preview.gd
Normal file
22
tests/_scanner_menu_preview.gd
Normal 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()
|
||||||
6
tests/_scanner_menu_preview.tscn
Normal file
6
tests/_scanner_menu_preview.tscn
Normal 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")
|
||||||
|
|
@ -4,8 +4,9 @@ extends SceneTree
|
||||||
# point loop. This test drives the real landed surface and encounter nodes.
|
# point loop. This test drives the real landed surface and encounter nodes.
|
||||||
#
|
#
|
||||||
# Covers:
|
# Covers:
|
||||||
# A. the planet/profile are registered, dev mode plants it nearby, and its
|
# A. the planet/profile are registered, dev mode plants it nearby, normal
|
||||||
# scripted profile is excluded from the map editor
|
# 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
|
# B. landing composes BossEncounter instead of a PlanetSetup and does not
|
||||||
# mark the world conquered for free
|
# mark the world conquered for free
|
||||||
# C. the off-screen approach remains controllable and reveals with its own cue
|
# 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:
|
!= PlanetDefinitions.BOSS_ID:
|
||||||
return _fail("dev mode does not plant the boss planet")
|
return _fail("dev mode does not plant the boss planet")
|
||||||
state.dev_mode = false
|
state.dev_mode = false
|
||||||
var normal_boss_found: bool = false
|
var normal_boss_count: int = 0
|
||||||
for x in range(-24, 25):
|
for x in range(-24, 25):
|
||||||
for y in range(-24, 25):
|
for y in range(-24, 25):
|
||||||
var chunk := Vector2i(x, y)
|
var chunk := Vector2i(x, y)
|
||||||
if WorldGen.has_planet_at_chunk(chunk) \
|
if WorldGen.has_planet_at_chunk(chunk) \
|
||||||
and String(WorldGen.planet_data_at_chunk(chunk).get(
|
and String(WorldGen.planet_data_at_chunk(chunk).get(
|
||||||
"definition_id", "")) == PlanetDefinitions.BOSS_ID:
|
"definition_id", "")) == PlanetDefinitions.BOSS_ID:
|
||||||
normal_boss_found = true
|
normal_boss_count += 1
|
||||||
break
|
if normal_boss_count != 1 \
|
||||||
if normal_boss_found:
|
or WorldGen.biome_at_chunk(WorldGen.OCTOPUS_BIOME_CENTER) \
|
||||||
break
|
!= WorldGen.Biome.OCTOPUS:
|
||||||
if not normal_boss_found:
|
return _fail("normal world generation has %d bosses, or its only boss is outside the octopus biome" \
|
||||||
return _fail("normal world generation never produces the boss planet")
|
% normal_boss_count)
|
||||||
print("boss planet registered, editor-hidden, and generated normally plus in dev mode")
|
print("boss planet registered, editor-hidden, unique in its normal octopus biome, plus nearby in dev mode")
|
||||||
|
|
||||||
# --- B. generic surface composition ---------------------------
|
# --- B. generic surface composition ---------------------------
|
||||||
encounter = surface.get_node_or_null("SurfaceFeatures/BossEncounter")
|
encounter = surface.get_node_or_null("SurfaceFeatures/BossEncounter")
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,16 @@ func _initialize() -> void:
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
var provider: Node = building.visual.get_node_or_null("ActionProvider")
|
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():
|
if provider != null or not building.available_actions().is_empty():
|
||||||
_fail("transport hub composed a manual action")
|
_fail("transport hub composed a manual action")
|
||||||
return
|
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"):
|
elif provider == null or not provider.actions.has("transfer"):
|
||||||
_fail("%s has no composed transfer action" % definition.definition_id)
|
_fail("%s has no composed transfer action" % definition.definition_id)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
132
tests/test_planet_scanner.gd
Normal file
132
tests/test_planet_scanner.gd
Normal 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)
|
||||||
|
|
@ -66,6 +66,9 @@ enum Icon {
|
||||||
# Development tools: three sliders, used only by the editor-only sandbox
|
# Development tools: three sliders, used only by the editor-only sandbox
|
||||||
# button. Appended to preserve every serialized icon number above.
|
# button. Appended to preserve every serialized icon number above.
|
||||||
DEV_TOOLS,
|
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
|
# Size the glyphs were originally drawn against. Every glyph dimension is
|
||||||
|
|
@ -328,6 +331,27 @@ func _draw() -> void:
|
||||||
_draw_dash_icon(center, pulse, s)
|
_draw_dash_icon(center, pulse, s)
|
||||||
Icon.DEV_TOOLS:
|
Icon.DEV_TOOLS:
|
||||||
_draw_dev_tools_icon(center, pulse, s)
|
_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:
|
func _draw_dev_tools_icon(center: Vector2, pulse: float, s: float) -> void:
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,46 @@ offset_top = 1554
|
||||||
offset_bottom = 1606
|
offset_bottom = 1606
|
||||||
script = ExtResource("price_tag")
|
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="."]
|
[node name="CloseButton" type="Control" parent="."]
|
||||||
anchor_left = 1.0
|
anchor_left = 1.0
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
|
|
|
||||||
56
ui/menus/ScannerMenu.gd
Normal file
56
ui/menus/ScannerMenu.gd
Normal 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
184
ui/menus/ScannerMenu.tscn
Normal 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)
|
||||||
|
|
@ -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/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/HangarMenu.tscn" id="menu_hangar"]
|
||||||
[ext_resource type="PackedScene" path="res://ui/menus/LabMenu.tscn" id="menu_lab"]
|
[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/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/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/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"]
|
[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://ui/SpeedFader.gd" id="31"]
|
||||||
[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"]
|
[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://ui/QuestPanel.gd" id="quest_panel"]
|
||||||
|
[ext_resource type="Script" path="res://world/OctopusField.gd" id="octopus_field"]
|
||||||
|
|
||||||
[sub_resource type="ShaderMaterial" id="1"]
|
[sub_resource type="ShaderMaterial" id="1"]
|
||||||
shader = ExtResource("5")
|
shader = ExtResource("5")
|
||||||
|
|
@ -104,6 +106,9 @@ script = ExtResource("6")
|
||||||
[node name="AsteroidField" type="Node2D" parent="."]
|
[node name="AsteroidField" type="Node2D" parent="."]
|
||||||
script = ExtResource("7")
|
script = ExtResource("7")
|
||||||
|
|
||||||
|
[node name="OctopusField" type="Node2D" parent="."]
|
||||||
|
script = ExtResource("octopus_field")
|
||||||
|
|
||||||
[node name="PlanetField" type="Node2D" parent="."]
|
[node name="PlanetField" type="Node2D" parent="."]
|
||||||
script = ExtResource("11")
|
script = ExtResource("11")
|
||||||
|
|
||||||
|
|
@ -513,6 +518,7 @@ icon = 1
|
||||||
[node name="GarrisonMenu" parent="UI" instance=ExtResource("menu_garrison")]
|
[node name="GarrisonMenu" parent="UI" instance=ExtResource("menu_garrison")]
|
||||||
[node name="SkillMenu" parent="UI" instance=ExtResource("menu_skill")]
|
[node name="SkillMenu" parent="UI" instance=ExtResource("menu_skill")]
|
||||||
[node name="DevMenu" parent="UI" instance=ExtResource("menu_dev")]
|
[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"]]
|
[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]]
|
||||||
visible = false
|
visible = false
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
|
|
||||||
62
world/OctopusField.gd
Normal file
62
world/OctopusField.gd
Normal 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()
|
||||||
|
|
@ -15,6 +15,8 @@ extends ColorRect
|
||||||
|
|
||||||
@onready var camera: Camera2D = get_node("../../Camera2D")
|
@onready var camera: Camera2D = get_node("../../Camera2D")
|
||||||
|
|
||||||
|
var current_chunk: Vector2i = Vector2i(2147483647, 2147483647)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
var viewport_size: Vector2 = get_viewport_rect().size
|
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_frac", p - cell)
|
||||||
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
|
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
|
||||||
material.set_shader_parameter("viewport_size", viewport_size)
|
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)))
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
|
||||||
# revisiting the same chunk always yields the same biome, and biomes form
|
# revisiting the same chunk always yields the same biome, and biomes form
|
||||||
# smooth multi-chunk regions instead of a per-chunk random checkerboard.
|
# 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 CHUNK_SIZE: float = 1200.0
|
||||||
const NOISE_SEED: int = 1337
|
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
|
# from 0.08 after playtesting found that rate too scarce for how central
|
||||||
# research points had become to progression.
|
# research points had become to progression.
|
||||||
const RESEARCH_LAB_CHANCE: float = 0.16
|
const RESEARCH_LAB_CHANCE: float = 0.16
|
||||||
# A boss world is rarer than every ordinary discovery. Its roll uses a
|
# The hostile region is authored into procedural space as one finite,
|
||||||
# separately seeded generator below, so adding it cannot rename or re-roll
|
# deterministic island. A probability per chunk can only say "rare"; over an
|
||||||
# any planet in an existing world.
|
# infinite world it inevitably creates infinitely many boss worlds. One
|
||||||
const BOSS_PLANET_CHANCE: float = 0.025
|
# 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
|
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
|
||||||
# around the spawn so all five can be reached in seconds.
|
# 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:
|
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))
|
var n: float = _get_noise().get_noise_2d(float(chunk.x), float(chunk.y))
|
||||||
if n > ASTEROID_FIELD_THRESHOLD:
|
if n > ASTEROID_FIELD_THRESHOLD:
|
||||||
return Biome.ASTEROID_FIELD
|
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:
|
static func has_planet_at_chunk(chunk: Vector2i) -> bool:
|
||||||
if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk):
|
if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk):
|
||||||
return true
|
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))
|
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)
|
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()
|
var black_hole_roll: float = rng.randf()
|
||||||
# Same principle, its own independent roll (#63).
|
# Same principle, its own independent roll (#63).
|
||||||
var research_lab_roll: float = rng.randf()
|
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
|
# A stable per-chunk roll makes gas giants persistent landmarks rather
|
||||||
# than a different resource whenever the scene reloads.
|
# than a different resource whenever the scene reloads.
|
||||||
var gas_giant: bool = biome_at_chunk(chunk) == Biome.SPACE
|
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
|
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.
|
# 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
|
definition_id = PLANET_DEFINITIONS.BOSS_ID
|
||||||
elif black_hole_roll < BLACK_HOLE_CHANCE:
|
elif black_hole_roll < BLACK_HOLE_CHANCE:
|
||||||
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
|
||||||
|
|
@ -186,5 +195,26 @@ static func biome_color(biome: int) -> Color:
|
||||||
match biome:
|
match biome:
|
||||||
Biome.ASTEROID_FIELD:
|
Biome.ASTEROID_FIELD:
|
||||||
return Color(0.55, 0.36, 0.18, 1.0)
|
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,11 @@ func close_map() -> void:
|
||||||
|
|
||||||
|
|
||||||
func recenter_view() -> 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
|
var viewport_center: Vector2 = get_viewport_rect().size / 2.0
|
||||||
content.position = viewport_center - fleet_local * content.scale.x
|
content.position = viewport_center - fleet_local * content.scale.x
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue