diff --git a/buildings/BuildingDefinitions.gd b/buildings/BuildingDefinitions.gd index 4ebdb9a..5a29724 100644 --- a/buildings/BuildingDefinitions.gd +++ b/buildings/BuildingDefinitions.gd @@ -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"), diff --git a/buildings/ScannerBuildingDefinition.tres b/buildings/ScannerBuildingDefinition.tres new file mode 100644 index 0000000..406ba17 --- /dev/null +++ b/buildings/ScannerBuildingDefinition.tres @@ -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 diff --git a/buildings/ScannerBuildingVisual.gd b/buildings/ScannerBuildingVisual.gd new file mode 100644 index 0000000..14d5a74 --- /dev/null +++ b/buildings/ScannerBuildingVisual.gd @@ -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) diff --git a/buildings/ScannerBuildingVisual.tscn b/buildings/ScannerBuildingVisual.tscn new file mode 100644 index 0000000..edb0209 --- /dev/null +++ b/buildings/ScannerBuildingVisual.tscn @@ -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") diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 7fa56fd..8fe1b33 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -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: diff --git a/core/GameState.gd b/core/GameState.gd index 161083a..1c522d6 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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 diff --git a/core/Quests.gd b/core/Quests.gd index 78ba22d..0715f27 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -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, }, { diff --git a/tests/_scanner_menu_preview.gd b/tests/_scanner_menu_preview.gd new file mode 100644 index 0000000..6bad7b7 --- /dev/null +++ b/tests/_scanner_menu_preview.gd @@ -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() diff --git a/tests/_scanner_menu_preview.tscn b/tests/_scanner_menu_preview.tscn new file mode 100644 index 0000000..6fd9a35 --- /dev/null +++ b/tests/_scanner_menu_preview.tscn @@ -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") diff --git a/tests/test_boss_planet.gd b/tests/test_boss_planet.gd index caf8387..e68047e 100644 --- a/tests/test_boss_planet.gd +++ b/tests/test_boss_planet.gd @@ -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") diff --git a/tests/test_building_composition.gd b/tests/test_building_composition.gd index 1caacdf..5cc65b8 100644 --- a/tests/test_building_composition.gd +++ b/tests/test_building_composition.gd @@ -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 diff --git a/tests/test_planet_scanner.gd b/tests/test_planet_scanner.gd new file mode 100644 index 0000000..ab2990c --- /dev/null +++ b/tests/test_planet_scanner.gd @@ -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) diff --git a/ui/IconButton.gd b/ui/IconButton.gd index 5e9597a..7b9fdad 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -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: diff --git a/ui/menus/BuildMenu.tscn b/ui/menus/BuildMenu.tscn index 960e378..1a44c88 100644 --- a/ui/menus/BuildMenu.tscn +++ b/ui/menus/BuildMenu.tscn @@ -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 diff --git a/ui/menus/ScannerMenu.gd b/ui/menus/ScannerMenu.gd new file mode 100644 index 0000000..cb35a79 --- /dev/null +++ b/ui/menus/ScannerMenu.gd @@ -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() diff --git a/ui/menus/ScannerMenu.tscn b/ui/menus/ScannerMenu.tscn new file mode 100644 index 0000000..78d5e58 --- /dev/null +++ b/ui/menus/ScannerMenu.tscn @@ -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) diff --git a/world/Main.tscn b/world/Main.tscn index 63e8c1e..89cecce 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -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 diff --git a/world/OctopusField.gd b/world/OctopusField.gd new file mode 100644 index 0000000..4f30d82 --- /dev/null +++ b/world/OctopusField.gd @@ -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() diff --git a/world/Starfield.gd b/world/Starfield.gd index 78ad99d..b9c962e 100644 --- a/world/Starfield.gd +++ b/world/Starfield.gd @@ -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))) diff --git a/world/WorldGen.gd b/world/WorldGen.gd index 5825d71..02a4650 100644 --- a/world/WorldGen.gd +++ b/world/WorldGen.gd @@ -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 diff --git a/world/WorldMap.gd b/world/WorldMap.gd index d694157..48da341 100644 --- a/world/WorldMap.gd +++ b/world/WorldMap.gd @@ -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