Guide the fleet to the nearest unconquered scan target

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-04 21:46:24 +02:00
commit 10d19a369b
9 changed files with 311 additions and 38 deletions

View file

@ -202,6 +202,9 @@ var dev_mode: bool = false:
# every scene swap) so exploration progress isn't lost every time the
# player lands on and leaves a planet.
var explored_chunks: Dictionary = {}
# The scanner's latest unconquered destination. Unlike explored_chunks this
# is one intentional navigation target, shared by the map and flight HUD.
var scanner_target: Dictionary = {}
# The four ordinary destinations teach the scanner enough signatures to
# identify the hostile boss world. Kept explicit so adding a sixth authored
@ -791,6 +794,7 @@ func reset_new_game() -> void:
dev_unlimited_resources = false
dev_mode = false
explored_chunks.clear()
scanner_target.clear()
bank_inventory = INVENTORY.new()
fleet_inventory = INVENTORY.new()
buildings.clear()
@ -822,56 +826,95 @@ func scanner_available_planet_types() -> PackedStringArray:
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.
# Finds the nearest unconquered generated planet of this type, reveals its
# neighbourhood on the persistent map, and retains it as the flight target.
# Rings bound the work, but candidates are compared by their actual generated
# world position: the first planet in the first matching chunk ring is not
# necessarily the physically closest one when local positions differ.
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)
var best: Dictionary = {}
var best_distance: float = INF
for radius in range(0, 257):
if radius == 0:
var center_match: Dictionary = _scanner_match_at(origin,
var center: Dictionary = _scanner_candidate_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 {}
if not center.is_empty():
best = center
best_distance = origin_world_position.distance_to(
Vector2(center.world_position))
else:
# Only the perimeter is new at each radius. Every matching candidate
# on it is considered before deciding whether the search can stop.
for x in range(-radius, radius + 1):
for y in [-radius, radius]:
var horizontal: Dictionary = _scanner_candidate_at(
origin + Vector2i(x, y), definition_id, radius)
var horizontal_distance: float = INF if horizontal.is_empty() else \
origin_world_position.distance_to(Vector2(horizontal.world_position))
if horizontal_distance < best_distance:
best = horizontal
best_distance = horizontal_distance
for y in range(-radius + 1, radius):
for x in [-radius, radius]:
var vertical: Dictionary = _scanner_candidate_at(
origin + Vector2i(x, y), definition_id, radius)
var vertical_distance: float = INF if vertical.is_empty() else \
origin_world_position.distance_to(Vector2(vertical.world_position))
if vertical_distance < best_distance:
best = vertical
best_distance = vertical_distance
if not best.is_empty() and best_distance <= _scanner_unsearched_distance(
origin, origin_world_position, radius):
return _accept_scanner_target(best)
return _accept_scanner_target(best) if not best.is_empty() else {}
func _scanner_match_at(chunk: Vector2i, definition_id: String,
func _scanner_candidate_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:
if String(data.get("definition_id", "")) != definition_id \
or is_planet_conquered(String(data.get("planet_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
func _scanner_unsearched_distance(origin_chunk: Vector2i,
origin_world_position: Vector2, radius: int) -> float:
var top_left: Vector2 = WORLD_GEN.chunk_origin(
origin_chunk - Vector2i(radius, radius))
var bottom_right: Vector2 = WORLD_GEN.chunk_origin(
origin_chunk + Vector2i(radius + 1, radius + 1))
return minf(minf(origin_world_position.x - top_left.x,
bottom_right.x - origin_world_position.x), minf(
origin_world_position.y - top_left.y,
bottom_right.y - origin_world_position.y))
func _accept_scanner_target(data: Dictionary) -> Dictionary:
var chunk := Vector2i(data.chunk)
for reveal_y in range(-1, 2):
for reveal_x in range(-1, 2):
explored_chunks[chunk + Vector2i(reveal_x, reveal_y)] = true
scanner_target = data.duplicate(true)
return data
func active_scanner_target() -> Dictionary:
if scanner_target.is_empty() \
or is_planet_conquered(String(scanner_target.get("planet_id", ""))):
return {}
return scanner_target
# 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