From 10d19a369b9f74cb6b5196c653a51c973d55149a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 4 Sep 2026 21:46:24 +0200 Subject: [PATCH] Guide the fleet to the nearest unconquered scan target Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- core/GameState.gd | 101 +++++++++++++++++++-------- tests/_marker_preview.gd | 17 +++-- tests/_scanner_guidance_preview.gd | 16 +++++ tests/_scanner_guidance_preview.tscn | 6 ++ tests/test_planet_scanner.gd | 66 ++++++++++++++++- ui/ScannerGuidance.gd | 78 +++++++++++++++++++++ ui/ScannerGuidance.tscn | 32 +++++++++ world/Main.tscn | 5 +- world/WorldMapContent.gd | 24 +++++++ 9 files changed, 309 insertions(+), 36 deletions(-) create mode 100644 tests/_scanner_guidance_preview.gd create mode 100644 tests/_scanner_guidance_preview.tscn create mode 100644 ui/ScannerGuidance.gd create mode 100644 ui/ScannerGuidance.tscn diff --git a/core/GameState.gd b/core/GameState.gd index a4f8827..d95961b 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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 diff --git a/tests/_marker_preview.gd b/tests/_marker_preview.gd index 315e92d..65ee2c6 100644 --- a/tests/_marker_preview.gd +++ b/tests/_marker_preview.gd @@ -2,14 +2,17 @@ extends Node2D # Preview, not a test (underscore prefix keeps it out of run_tests.sh). # Renders the world map with buildings placed and an extractor built, so the -# issue #11 building markers and the issue #9 extractor badge can actually be -# LOOKED at. --headless draws no pixels, so no headless test can tell whether -# a marker is visible - see tests/screenshot.sh. +# issue #11 building markers, issue #9 extractor badge, and issue #200 scan +# reticle can actually be LOOKED at. --headless draws no pixels, so no +# headless test can tell whether a marker is visible - see tests/screenshot.sh. # # bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280 func _ready() -> void: + GameState.reset_new_game() + GameState.intro_active = false + GameState.reset_quests(false) GameState.buildings.clear() GameState.extractors.clear() GameState.explored_chunks.clear() @@ -18,6 +21,8 @@ func _ready() -> void: GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0)) GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0)) GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0)) + GameState.add_building(GameState.BUILDING_PLANET_SCANNER, Vector2(-350.0, -500.0)) + GameState.scan_for_planet(PlanetDefinitions.GAS_GIANT_ID, Vector2.ZERO) # Reveal a patch of map, and put an extractor on whichever planet is # nearest the origin so its badge is in frame too. @@ -42,4 +47,8 @@ func _ready() -> void: # open_map(), not `visible = true` - opening also recentres the view on # the fleet, and without that the map is drawn from its own origin in the # corner of the screen with everything near the fleet cropped off. - main.get_node("UI/WorldMap").open_map() + var world_map: Control = main.get_node("UI/WorldMap") + world_map.open_map() + var target: Dictionary = GameState.active_scanner_target() + if not target.is_empty(): + world_map.focus_world_position(Vector2(target.world_position)) diff --git a/tests/_scanner_guidance_preview.gd b/tests/_scanner_guidance_preview.gd new file mode 100644 index 0000000..e57812f --- /dev/null +++ b/tests/_scanner_guidance_preview.gd @@ -0,0 +1,16 @@ +extends Node2D + +# Phone-sized visual fixture for the scanner's persistent flight direction. +# bash tests/screenshot.sh res://tests/_scanner_guidance_preview.tscn /tmp/scanner_guidance.png 45 720x1280 + + +func _ready() -> void: + GameState.reset_new_game() + GameState.intro_active = false + GameState.reset_quests(false) + GameState.add_building(GameState.BUILDING_PLANET_SCANNER, Vector2.ZERO) + GameState.scan_for_planet(PlanetDefinitions.GAS_GIANT_ID, Vector2.ZERO) + var main: Node = load("res://world/Main.tscn").instantiate() + add_child(main) + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) diff --git a/tests/_scanner_guidance_preview.tscn b/tests/_scanner_guidance_preview.tscn new file mode 100644 index 0000000..ba0a32f --- /dev/null +++ b/tests/_scanner_guidance_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_scanner_guidance_preview.gd" id="1"] + +[node name="ScannerGuidancePreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_planet_scanner.gd b/tests/test_planet_scanner.gd index ab2990c..2ec08ba 100644 --- a/tests/test_planet_scanner.gd +++ b/tests/test_planet_scanner.gd @@ -59,12 +59,29 @@ func _run() -> void: 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 explored_before_scan: Dictionary = state.explored_chunks.duplicate() 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 explored_before_scan.has(Vector2i(rocky.chunk)): + return _fail("the scanner test's first target was already explored") if not state.explored_chunks.has(Vector2i(rocky.chunk)): return _fail("a scan result was not revealed on the persistent map") + if String(state.active_scanner_target().planet_id) != String(rocky.planet_id): + return _fail("the revealed planet was not retained as navigation target") + if not _is_nearest_unconquered(state, rocky, PlanetDefinitions.ROCKY_ID): + return _fail("the scanner did not select the physically closest rocky planet") + # A conquered nearest match must be skipped on the next scan. + state.planet_capture[String(rocky.planet_id)] = { + "captured": [], "conquered": true, + } + var next_rocky: Dictionary = state.scan_for_planet(PlanetDefinitions.ROCKY_ID, + Vector2.ZERO) + if next_rocky.is_empty() or String(next_rocky.planet_id) == String(rocky.planet_id) \ + or state.is_planet_conquered(String(next_rocky.planet_id)): + return _fail("scanner reused the nearest conquered rocky planet") + print("scanner found the nearest unvisited world and skipped it after conquest") if PlanetDefinitions.BOSS_ID in state.scanner_available_planet_types(): return _fail("boss signature is available before the four-world survey") @@ -83,6 +100,13 @@ func _run() -> void: 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") + var map_content: Node2D = load("res://world/WorldMapContent.gd").new() + var marker: Dictionary = map_content.scanner_target_marker() + if marker.is_empty() or String(marker.planet_id) != String(boss.planet_id) \ + or Vector2(marker.position).distance_to( + Vector2(boss.world_position) * map_content.WORLD_TO_LOCAL) > 0.01: + return _fail("the active scan has no matching destination point on the map") + map_content.free() print("researched scanner reveals generated destinations; the fifth option waits for 4/4") # #192: the scanner is taught immediately before the widened survey. @@ -104,10 +128,27 @@ func _run() -> void: 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.position = Vector2.ZERO fake_fleet.add_to_group("player_fleet") host.add_child(fake_fleet) + menu.close_menu() + var guidance: Control = load("res://ui/ScannerGuidance.tscn").instantiate() + root.add_child(guidance) + guidance._process(0.0) + if not guidance.visible or not guidance.get_node("TargetLabel").text.contains( + String(boss.name).to_upper()): + return _fail("the flight HUD did not point toward the scanned destination") + var indicator: Control = guidance.get_node("Indicator") + var indicator_center: Vector2 = indicator.position + indicator.size * 0.5 + var viewport_size: Vector2 = guidance.get_viewport_rect().size + if indicator_center.x < 20.0 or indicator_center.y < 20.0 \ + or indicator_center.x > viewport_size.x - 20.0 \ + or indicator_center.y > viewport_size.y - 20.0: + return _fail("scanner direction indicator was placed outside the viewport") + guidance.queue_free() + + fake_fleet.position = WorldGen.chunk_origin(WorldGen.OCTOPUS_BIOME_CENTER) \ + + Vector2(WorldGen.CHUNK_SIZE * 0.5, WorldGen.CHUNK_SIZE * 0.5) var field: Node2D = load("res://world/OctopusField.gd").new() field.name = "OctopusField" host.add_child(field) @@ -130,3 +171,24 @@ func _run() -> void: func _fail(message: String) -> void: print("FAIL: %s" % message) quit(1) + + +func _is_nearest_unconquered(state: Node, result: Dictionary, + definition_id: String) -> bool: + var origin := Vector2.ZERO + var result_distance: float = origin.distance_to(Vector2(result.world_position)) + var radius: int = int(result.distance_chunks) + 2 + for x in range(-radius, radius + 1): + for y in range(-radius, radius + 1): + var chunk := Vector2i(x, y) + if not WorldGen.has_planet_at_chunk(chunk): + continue + var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) + if String(data.definition_id) != definition_id \ + or state.is_planet_conquered(String(data.planet_id)): + continue + var world_position: Vector2 = WorldGen.chunk_origin(chunk) \ + + Vector2(data.local_pos) + if origin.distance_to(world_position) < result_distance - 0.01: + return false + return true diff --git a/ui/ScannerGuidance.gd b/ui/ScannerGuidance.gd new file mode 100644 index 0000000..32e256e --- /dev/null +++ b/ui/ScannerGuidance.gd @@ -0,0 +1,78 @@ +class_name ScannerGuidance +extends Control + +const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd") +const EDGE_MARGIN: float = 34.0 + +@onready var indicator: Control = $Indicator +@onready var target_label: Label = $TargetLabel + +var fleet: Node2D + + +func _ready() -> void: + fleet = get_tree().get_first_node_in_group("player_fleet") as Node2D + visible = false + + +func _process(_delta: float) -> void: + var target: Dictionary = GameState.active_scanner_target() + if target.is_empty() or GameState.intro_active or _modal_is_open(): + visible = false + return + if fleet == null or not is_instance_valid(fleet): + fleet = get_tree().get_first_node_in_group("player_fleet") as Node2D + if fleet == null: + visible = false + return + + visible = true + var target_world := Vector2(target.world_position) + var viewport_size: Vector2 = get_viewport_rect().size + var screen_position: Vector2 = get_viewport().get_canvas_transform() * target_world + var safe_margin: Vector2 = Vector2.ONE * EDGE_MARGIN + indicator.size * 0.5 + var safe_rect := Rect2(safe_margin, viewport_size - safe_margin * 2.0) + var at: Vector2 = screen_position if safe_rect.has_point(screen_position) \ + else edge_position(viewport_size, screen_position - viewport_size * 0.5) + indicator.position = at - indicator.size * 0.5 + var toward_target: Vector2 = target_world - fleet.get_centroid() + if toward_target.length_squared() > 0.01: + indicator.rotation = toward_target.angle() + PI / 2.0 + + var definition: Resource = PLANET_DEFINITIONS.get_definition( + String(target.definition_id)) + if definition != null: + indicator.glow_color = definition.map_color + indicator.queue_redraw() + var sectors: float = toward_target.length() / WorldGen.CHUNK_SIZE + target_label.text = "%s ยท %.1f SECTORS" % [ + String(target.get("name", "SCANNER TARGET")).to_upper(), sectors] + _place_label(at, viewport_size) + + +func edge_position(viewport_size: Vector2, direction: Vector2) -> Vector2: + var center: Vector2 = viewport_size * 0.5 + if direction.length_squared() <= 0.01: + return center + var half: Vector2 = center - Vector2.ONE * EDGE_MARGIN - indicator.size * 0.5 + var scale_to_edge: float = minf(half.x / maxf(absf(direction.x), 0.001), + half.y / maxf(absf(direction.y), 0.001)) + return center + direction * scale_to_edge + + +func _place_label(indicator_center: Vector2, viewport_size: Vector2) -> void: + var toward_center: Vector2 = viewport_size * 0.5 - indicator_center + if toward_center.length_squared() < 0.01: + toward_center = Vector2.DOWN + var wanted: Vector2 = indicator_center + toward_center.normalized() * 105.0 \ + - target_label.size * 0.5 + target_label.position = Vector2( + clampf(wanted.x, EDGE_MARGIN, viewport_size.x - target_label.size.x - EDGE_MARGIN), + clampf(wanted.y, EDGE_MARGIN, viewport_size.y - target_label.size.y - EDGE_MARGIN)) + + +func _modal_is_open() -> bool: + for menu in get_tree().get_nodes_in_group("modal_menus"): + if menu.is_visible_in_tree(): + return true + return false diff --git a/ui/ScannerGuidance.tscn b/ui/ScannerGuidance.tscn new file mode 100644 index 0000000..20aacbb --- /dev/null +++ b/ui/ScannerGuidance.tscn @@ -0,0 +1,32 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ui/ScannerGuidance.gd" id="1"] +[ext_resource type="Script" path="res://ui/IconButton.gd" id="2"] + +[node name="ScannerGuidance" type="Control"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") + +[node name="Indicator" type="Control" parent="."] +mouse_filter = 2 +offset_right = 92.0 +offset_bottom = 92.0 +script = ExtResource("2") +icon = 4 +glow_color = Color(0.3, 0.95, 1, 1) +round_frame = true + +[node name="TargetLabel" type="Label" parent="."] +mouse_filter = 2 +offset_right = 420.0 +offset_bottom = 76.0 +theme_override_colors/font_color = Color(0.55, 0.95, 1, 1) +theme_override_colors/font_outline_color = Color(0.02, 0.02, 0.08, 0.95) +theme_override_constants/outline_size = 4 +theme_override_font_sizes/font_size = 27 +text = "SCANNER TARGET" +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/world/Main.tscn b/world/Main.tscn index 1b4bd19..9f97912 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=48 format=3] +[gd_scene load_steps=49 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"] @@ -10,6 +10,7 @@ [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="PackedScene" path="res://ui/ScannerGuidance.tscn" id="scanner_guidance"] [ext_resource type="Script" path="res://world/Starfield.gd" id="1"] @@ -296,6 +297,8 @@ offset_top = 188.0 offset_bottom = 252.0 script = ExtResource("32") +[node name="ScannerGuidance" parent="UI" instance=ExtResource("scanner_guidance")] + [node name="QuestButton" type="Control" parent="UI"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/world/WorldMapContent.gd b/world/WorldMapContent.gd index a3020f2..9180197 100644 --- a/world/WorldMapContent.gd +++ b/world/WorldMapContent.gd @@ -12,6 +12,7 @@ const WORLD_TO_LOCAL: float = 0.04 const EXTRACTOR_BADGE_COLOR: Color = Color(1.0, 0.85, 0.3) const EXTRACTOR_BADGE_OFFSET: Vector2 = Vector2(8.0, -8.0) const EXTRACTOR_BADGE_RADIUS: float = 5.0 +const SCANNER_TARGET_COLOR: Color = Color(0.3, 0.95, 1.0) @onready var tracker: Node2D = get_node("../../../ExplorationTracker") @onready var fleet: Node2D = get_node("../../../Fleet") @@ -53,6 +54,17 @@ func chunk_has_extractor_badge(chunk: Vector2i) -> bool: return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).planet_id) +func scanner_target_marker() -> Dictionary: + var target: Dictionary = GameState.active_scanner_target() + if target.is_empty(): + return {} + return { + "position": Vector2(target.world_position) * WORLD_TO_LOCAL, + "planet_id": String(target.planet_id), + "definition_id": String(target.definition_id), + } + + func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: var pts: PackedVector2Array = PackedVector2Array() for i in range(sides): @@ -103,6 +115,18 @@ func _draw() -> void: for marker in building_markers(): _draw_marker_polygon(_regular_polygon(marker.position, marker.radius, marker.sides, marker.phase), marker.color) + # A scan is an intentional destination, distinct from passive exploration. + # Draw its reticle independently of the explored-chunk pass so the point is + # guaranteed to remain visible even when it lies beyond travelled space. + var scan_marker: Dictionary = scanner_target_marker() + if not scan_marker.is_empty(): + var target_position := Vector2(scan_marker.position) + draw_arc(target_position, 11.0, 0.0, TAU, 24, + SCANNER_TARGET_COLOR, 2.5, true) + for direction in [Vector2.LEFT, Vector2.RIGHT, Vector2.UP, Vector2.DOWN]: + draw_line(target_position + direction * 13.0, + target_position + direction * 19.0, SCANNER_TARGET_COLOR, 2.0, true) + var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ fleet_local + Vector2(0, -10),