#84: move 215 root files into a folder hierarchy

Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-21 19:44:14 +02:00
commit 2f161621c3
495 changed files with 571 additions and 567 deletions

94
world/Asteroid.gd Normal file
View file

@ -0,0 +1,94 @@
extends Area2D
# A drifting rock that can be shot apart for the ore inside it.
#
# Asteroids used to be pure hazard: they could only ever cost you a ship. Now
# they are also the one resource you can reach without conquering anything,
# which is what the fleet's guns are for out in open space.
#
# Two ways to destroy one, and they are deliberately NOT the same:
# - shot apart, which yields the ore
# - flown into, which does not. The rock shatters against a hull instead of
# being mined, and you have just lost a ship (or a shield charge) for it.
# Without that split, a shield vessel could farm asteroids by ramming.
const ROCK_SCENE: PackedScene = preload("res://surface/Rock.tscn")
# Shots needed to crack one open. Low enough that a single vessel clears one
# in a couple of bursts, high enough that a stray shot in passing does not.
const HIT_POINTS: int = 2
# How long the dropped ore survives if nobody comes for it. Surface rocks live
# forever because a planet surface is bounded and hand-authored; open space is
# not, and an asteroid field shot up and left behind would otherwise leak
# nodes for the rest of the session.
const DROP_LIFETIME: float = 60.0
var velocity: Vector2 = Vector2.ZERO
var rotation_speed: float = 0.0
var hp: int = HIT_POINTS
var shape_points: PackedVector2Array = PackedVector2Array()
func _ready() -> void:
randomize()
add_to_group("asteroids")
area_entered.connect(_on_area_entered)
var point_count: int = 7
for i in range(point_count):
var angle: float = (float(i) / point_count) * TAU
var radius: float = randf_range(14.0, 22.0)
shape_points.append(Vector2(cos(angle), sin(angle)) * radius)
queue_redraw()
func _process(delta: float) -> void:
# Deliberately no queue_redraw() here. position and rotation are node
# TRANSFORM properties - Godot applies them when rendering, and the
# polygon built in _ready() is in local space, so it never changes.
# Redrawing each frame re-tessellated four antialiased polylines per
# asteroid for an identical result; with ~150 asteroids on screen that
# was the single largest source of stutter.
position += velocity * delta
rotation += rotation_speed * delta
# Same contract as Enemy.take_damage, so every weapon that already knows how
# to hurt an enemy can hurt an asteroid without learning a second protocol.
func take_damage(amount: int) -> bool:
hp -= maxi(amount, 1)
if hp > 0:
return false
shatter(true)
return true
func shatter(drop_ore: bool) -> void:
AsteroidExplosionVFX.spawn(get_tree().root, position)
if drop_ore:
_drop_ore()
queue_free()
func _drop_ore() -> void:
var rock: Node2D = ROCK_SCENE.instantiate()
rock.build_shape(randi())
rock.position = position
rock.lifetime = DROP_LIFETIME
# Inherits the asteroid's drift, so the ore carries on along the path the
# rock it came from was already travelling instead of stopping dead in
# space - which reads as debris rather than as a spawned pickup.
rock.velocity = velocity
# Deferred: this runs from inside an area_entered signal, where the
# physics server is mid-flush and refuses a direct add_child.
get_parent().add_child.call_deferred(rock)
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("player_ships"):
# No ore: see the note at the top of this file.
shatter(false)
func _draw() -> void:
NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.65, 0.5, 0.35), 1.0)

1
world/Asteroid.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cfakkuki24iks

12
world/Asteroid.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://world/Asteroid.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 18.0
[node name="Asteroid" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

107
world/AsteroidField.gd Normal file
View file

@ -0,0 +1,107 @@
extends Node2D
# Spawns slow-drifting asteroids inside ASTEROID_FIELD biome chunks near
# the fleet, and despawns them once the fleet has moved far enough away -
# keeps the scene from accumulating unbounded nodes over a long session
# while still populating newly-entered asteroid regions on the fly.
@export var check_interval: float = 1.0
@export var spawn_radius_chunks: int = 2
# No asteroid may be created within this distance of the fleet. At cruise
# speed this is well over a second of warning, so anything that reaches the
# fleet was visible on its way in.
@export var safe_spawn_radius: float = 420.0
@export var despawn_radius_chunks: int = 4
@export var asteroids_per_chunk: int = 6
@export var min_speed: float = 12.0
@export var max_speed: float = 35.0
@onready var fleet: Node2D = get_node("../Fleet")
var asteroid_scene: PackedScene = preload("res://world/Asteroid.tscn")
var populated_chunks: Dictionary = {}
var timer: float = 0.0
func _ready() -> void:
randomize()
func _process(delta: float) -> void:
timer += delta
if timer < check_interval:
return
timer = 0.0
update_chunks()
func update_chunks() -> void:
# Public (not just called from _process's own timer): also invoked
# once synchronously right after the fleet is positioned on a fresh
# Main.tscn load, so nearby asteroids exist immediately instead of
# only appearing ~check_interval seconds later - most noticeable as a
# jarring pop-in right after returning from a planet's surface.
var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid())
for dx in range(-spawn_radius_chunks, spawn_radius_chunks + 1):
for dy in range(-spawn_radius_chunks, spawn_radius_chunks + 1):
var chunk: Vector2i = center_chunk + Vector2i(dx, dy)
if populated_chunks.has(chunk):
continue
if WorldGen.biome_at_chunk(chunk) != WorldGen.Biome.ASTEROID_FIELD:
continue
_spawn_chunk_asteroids(chunk)
var to_remove: Array = []
for chunk in populated_chunks.keys():
if _chebyshev_chunk_distance(chunk, center_chunk) > despawn_radius_chunks:
for asteroid in populated_chunks[chunk]:
if is_instance_valid(asteroid):
asteroid.queue_free()
to_remove.append(chunk)
for chunk in to_remove:
populated_chunks.erase(chunk)
func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int:
return max(abs(a.x - b.x), abs(a.y - b.y))
func _safe_position(candidate: Vector2, centre: Vector2) -> Vector2:
# Nothing may materialise on top of the fleet. Chunks are populated around
# wherever the fleet happens to be - including the chunk it is standing in
# - so without this an asteroid could appear directly on a ship and
# destroy it with no chance to react. That is worst on the frame a scene
# loads: arriving back from a planet could cost a ship instantly.
if candidate.distance_to(centre) >= safe_spawn_radius:
return candidate
# Push it out to the edge of the bubble rather than dropping it, so the
# field keeps its density near the fleet instead of thinning out.
var away: Vector2 = candidate - centre
if away.length() < 0.001:
away = Vector2.RIGHT.rotated(randf() * TAU)
return centre + away.normalized() * safe_spawn_radius
func _spawn_chunk_asteroids(chunk: Vector2i) -> void:
var origin: Vector2 = WorldGen.chunk_origin(chunk)
var spawned: Array = []
var fleet_centre: Vector2 = fleet.get_centroid()
for i in range(asteroids_per_chunk):
var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE)
var asteroid: Node2D = asteroid_scene.instantiate()
asteroid.position = _safe_position(origin + local_pos, fleet_centre)
var angle: float = randf() * TAU
var speed: float = randf_range(min_speed, max_speed)
asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed
asteroid.rotation_speed = randf_range(-0.4, 0.4)
# Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet
# is positioned, to avoid a pop-in on scene load) - Godot refuses
# a direct add_child() while the tree is still "busy setting up
# children" in that situation, so this must be deferred either way.
get_parent().add_child.call_deferred(asteroid)
spawned.append(asteroid)
populated_chunks[chunk] = spawned

View file

@ -0,0 +1 @@
uid://bmwogtrwsr7kh

View file

@ -0,0 +1,23 @@
extends Node2D
# Tracks which world chunks have been explored (fleet has come within
# sensor range), so the world map can reveal only chunks actually visited
# instead of the whole procedurally generated world at once. Writes
# directly into GameState.explored_chunks (not a local dict) so this
# history survives the Main.tscn <-> PlanetSurface.tscn scene swap, which
# tears down and recreates this node from scratch each time.
@export var sensor_radius_chunks: int = 1
@onready var fleet: Node2D = get_node("../Fleet")
func _process(_delta: float) -> void:
var center_chunk: Vector2i = WorldGen.chunk_coords(fleet.get_centroid())
for dx in range(-sensor_radius_chunks, sensor_radius_chunks + 1):
for dy in range(-sensor_radius_chunks, sensor_radius_chunks + 1):
GameState.explored_chunks[center_chunk + Vector2i(dx, dy)] = true
func get_explored_chunks() -> Array:
return GameState.explored_chunks.keys()

View file

@ -0,0 +1 @@
uid://gsuymyal5kyd

2143
world/Main.tscn Normal file

File diff suppressed because it is too large Load diff

28
world/Starfield.gd Normal file
View file

@ -0,0 +1,28 @@
extends ColorRect
# Feeds the starfield shader the camera's position split into a CELL INDEX
# plus a fraction within that cell, rather than a raw world position.
#
# Why: the fleet routinely reaches world coordinates in the tens of
# thousands. Handing those straight to the shader forces every downstream
# calculation (the division by cell_size, floor/fract, and especially the
# hash's internal multiplies) to work at large magnitudes, where a mobile
# GPU's float precision starts to break down - and no precision qualifier
# can fix that, since `highp` widens the type but the magnitudes stay huge.
# GDScript computes in double precision, so doing the split here and passing
# only small numbers (cell indices in the hundreds, fractions in [0,1])
# keeps the shader's arithmetic in a range it can represent exactly.
@onready var camera: Camera2D = get_node("../../Camera2D")
func _process(_delta: float) -> void:
var viewport_size: Vector2 = get_viewport_rect().size
var cell_size: float = material.get_shader_parameter("cell_size")
var p: Vector2 = camera.global_position / cell_size
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
material.set_shader_parameter("camera_cell", cell)
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)

1
world/Starfield.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://buhc1b6iqy30n

179
world/WorldGen.gd Normal file
View file

@ -0,0 +1,179 @@
class_name WorldGen
extends RefCounted
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
# Deterministic procedural world generation: the world is divided into
# square chunks, and each chunk's biome is derived from a seeded
# low-frequency noise field sampled at the chunk's own coordinates - so
# 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 }
const CHUNK_SIZE: float = 1200.0
const NOISE_SEED: int = 1337
const ASTEROID_FIELD_THRESHOLD: float = 0.15
# Planets used to sit one per ~29 chunks - 35,000 world units of empty space
# between them, which is a long flight to find anything at all. Lowered to
# about one per 12 chunks (measured over 14,641 chunks, not estimated).
#
# The two thresholds moved by DIFFERENT amounts on purpose. Every planet in a
# SPACE biome is a gas giant, so lowering both equally would have shifted the
# mix as well as the density - dropping these to 0.20/0.40 more than tripled
# the gas-giant share, from 10% of planets to 34%. 0.18/0.50 leaves it at 11%,
# so this is a change in how OFTEN you find a planet and not in what you find.
const PLANET_THRESHOLD: float = 0.18
const GAS_GIANT_THRESHOLD: float = 0.50
# A flat per-planet-chunk probability rather than another noise field: gas
# giants already cover every SPACE-biome planet chunk (see GAS_GIANT_THRESHOLD
# above), so making black holes noticeably rarer just needs a small, uniform
# chance independent of biome - not a second smooth region to tune against
# the first.
const BLACK_HOLE_CHANCE: float = 0.05
# #63/#74: findable without excessive exploration - research points gate
# mid/late skill-tree nodes and vessel research, so a player needs to run
# into one of these reasonably often, not just as a rare curiosity. Raised
# 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
# DEV MODE ONLY: one of every planet type, parked in the chunks immediately
# around the spawn so all four can be reached in seconds.
#
# This replaces the guaranteed gas giant that used to sit in the origin
# chunk for every game. That was a play-testing convenience which had become
# part of the real world's shape - every player got the same landmark in the
# same place, and the surrounding space was generated as if it were normal.
# Play-testing wants ALL the planet types to hand, not just gas, and a
# normal game wants none of them handed over; those are different needs and
# they now have different mechanisms.
#
# The local positions keep each planet off the spawn point itself: a fresh
# fleet starts at world origin, which is the top-left corner of chunk (0,0).
const DEV_PLANET_CHUNKS: Dictionary = {
Vector2i(0, 0): PLANET_DEFINITIONS.GAS_GIANT_ID,
Vector2i(1, 0): PLANET_DEFINITIONS.ROCKY_ID,
Vector2i(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_ID,
Vector2i(1, 1): PLANET_DEFINITIONS.BLACK_HOLE_ID,
}
const DEV_PLANET_LOCAL_POS: Vector2 = Vector2(600.0, 600.0)
# Dev mode plants a fixed set of planets; a normal game must not see them.
#
# PUSHED here by GameState.dev_mode's setter rather than read back out of the
# autoload. World generation is deliberately dependency-free - it is static,
# seeded purely by chunk coordinates, and callable from anywhere - and an
# autoload cannot be named from here anyway: `GameState` is not resolvable as
# a global identifier this early, the same load-order hazard PlanetSetups.gd
# and PlanetSurfaceController.gd already work around. GameState.dev_mode
# stays the single source of truth; this is its mirror.
static var dev_planets_enabled: bool = false
static func _dev_planets_active() -> bool:
return dev_planets_enabled
static var _noise: FastNoiseLite
static var _planet_noise: FastNoiseLite
static func _get_noise() -> FastNoiseLite:
if _noise == null:
_noise = FastNoiseLite.new()
_noise.seed = NOISE_SEED
_noise.frequency = 0.06
return _noise
static func _get_planet_noise() -> FastNoiseLite:
if _planet_noise == null:
_planet_noise = FastNoiseLite.new()
_planet_noise.seed = NOISE_SEED + 777
# Higher frequency than the biome noise so it spikes on individual
# chunks rather than forming its own smooth multi-chunk regions -
# "once or twice per asteroid biome [region]" needs a sparse, mostly
# independent chance per chunk, not another smooth blob.
_planet_noise.frequency = 0.35
return _planet_noise
static func chunk_coords(world_pos: Vector2) -> Vector2i:
return Vector2i(int(floor(world_pos.x / CHUNK_SIZE)), int(floor(world_pos.y / CHUNK_SIZE)))
static func chunk_origin(chunk: Vector2i) -> Vector2:
return Vector2(chunk.x * CHUNK_SIZE, chunk.y * CHUNK_SIZE)
static func biome_at_chunk(chunk: Vector2i) -> int:
var n: float = _get_noise().get_noise_2d(float(chunk.x), float(chunk.y))
if n > ASTEROID_FIELD_THRESHOLD:
return Biome.ASTEROID_FIELD
return Biome.SPACE
static func has_planet_at_chunk(chunk: Vector2i) -> bool:
if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk):
return true
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)
static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
# Single source of truth for a planet's identity within its chunk -
# seeded purely from the chunk's own coordinates (same pattern as the
# biome/planet noise fields above) so it's reproducible from anywhere,
# without needing an actual spawned Planet node. Both PlanetField (to
# actually spawn the planet) and the world map (to draw a marker for
# an explored-but-not-currently-loaded chunk) call this, so the two
# can never drift out of sync with each other.
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = hash(chunk)
var forced_definition_id: String = String(DEV_PLANET_CHUNKS.get(chunk, "")) \
if _dev_planets_active() else ""
var local_pos: Vector2 = DEV_PLANET_LOCAL_POS if not forced_definition_id.is_empty() \
else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
var shape_seed: int = rng.randi()
# Drawn unconditionally, in the same fixed position every call,
# regardless of biome or the gas-giant roll below - so adding this
# roll doesn't shift which random values any LATER draw (planet_name,
# below) consumes for chunks that would have existed before it.
var black_hole_roll: float = rng.randf()
# Same principle, its own independent roll (#63).
var research_lab_roll: float = 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
# A rare third outcome, seeded the same deterministic way as everything
# else here.
if black_hole_roll < BLACK_HOLE_CHANCE:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
elif research_lab_roll < RESEARCH_LAB_CHANCE:
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
# Dev mode's fixed planting wins over every roll above. Applied last, and
# AFTER all the rng draws, so switching it on cannot shift which random
# values any other chunk consumes - a dev world and a normal one differ
# only in these four chunks.
if not forced_definition_id.is_empty():
definition_id = forced_definition_id
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var names: PackedStringArray = definition.names
# Guarded for the same reason as the hangar grid: an empty names array
# would be a modulo by zero, which traps on wasm and takes the whole
# engine down rather than raising a script error.
var planet_name: String = "Unnamed" if names.is_empty() \
else names[rng.randi() % names.size()]
var planet_id: String = "chunk:%d:%d" % [chunk.x, chunk.y]
return {"planet_id": planet_id, "local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed,
"definition_id": definition_id}
static func biome_color(biome: int) -> Color:
match biome:
Biome.ASTEROID_FIELD:
return Color(0.55, 0.36, 0.18, 1.0)
_:
return Color(0.10, 0.13, 0.28, 1.0)

1
world/WorldGen.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://d1xpshlxsv0ap

129
world/WorldMap.gd Normal file
View file

@ -0,0 +1,129 @@
extends Control
@export var min_zoom: float = 0.3
@export var max_zoom: float = 3.0
@onready var content: Node2D = $MapContent
@onready var open_button: IconButton = get_node("../MapButton")
@onready var recenter_button: IconButton = $RecenterButton
@onready var close_button: IconButton = $CloseButton
@onready var fleet: Node2D = get_node("../../Fleet")
var touches: Dictionary = {}
var pinch_active: bool = false
var pinch_start_distance: float = 0.0
var pinch_start_zoom: float = 1.0
func _ready() -> void:
open_button.pressed.connect(open_map)
recenter_button.pressed.connect(recenter_view)
close_button.pressed.connect(close_map)
func open_map() -> void:
visible = true
open_button.visible = false
recenter_view()
func close_map() -> void:
visible = false
open_button.visible = true
touches.clear()
pinch_active = false
func recenter_view() -> void:
var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL
var viewport_center: Vector2 = get_viewport_rect().size / 2.0
content.position = viewport_center - fleet_local * content.scale.x
# The map takes over touch input while it is open, so a drag pans the map
# instead of ordering the fleet somewhere. What it must NOT take over is a
# touch aimed at its own buttons.
#
# _input() runs BEFORE the GUI system, so set_input_as_handled() here stopped
# the touch ever reaching CloseButton._gui_input: the map could be opened and
# then not closed. On Android and desktop that stayed hidden, because Godot
# also emits an emulated MOUSE event for each touch and this only ever
# swallowed touch events - the button still got the mouse one, a second
# chance a phone browser does not give. So it surfaced on the web build,
# while being a real routing bug on every platform.
#
# Letting those events through is safe: IconButton consumes them with
# accept_event(), which marks them handled before GameController's
# _unhandled_input runs - so tapping Close still cannot also move the fleet.
func _input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventScreenTouch:
# A press over one of the map's own buttons is never adopted, and
# because it is never adopted, neither is anything else that touch
# goes on to do - the index simply never enters `touches`.
if event.pressed and _over_own_button(event.position):
return
if not event.pressed and not touches.has(event.index):
return
_handle_touch(event)
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag:
if not touches.has(event.index):
return
_handle_drag(event)
get_viewport().set_input_as_handled()
elif event is InputEventMagnifyGesture:
_handle_magnify(event)
get_viewport().set_input_as_handled()
# Asked of the children rather than listed by name, so a button added to the
# map panel later is covered without anyone having to remember this exists.
# Keyed on having a `pressed` signal: the backdrop is a full-screen ColorRect
# and would otherwise match every point on the display.
func _over_own_button(at: Vector2) -> bool:
for child in get_children():
if child is Control and child.visible and child.has_signal("pressed") \
and (child as Control).get_global_rect().has_point(at):
return true
return false
func _handle_touch(event: InputEventScreenTouch) -> void:
if event.pressed:
touches[event.index] = event.position
if touches.size() == 2:
pinch_active = true
var positions: Array = touches.values()
pinch_start_distance = positions[0].distance_to(positions[1])
pinch_start_zoom = content.scale.x
else:
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
func _handle_drag(event: InputEventScreenDrag) -> void:
if not touches.has(event.index):
return
var previous_pos: Vector2 = touches[event.index]
touches[event.index] = event.position
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
var positions: Array = touches.values()
var current_distance: float = positions[0].distance_to(positions[1])
var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0))
_set_zoom(new_zoom)
elif touches.size() == 1:
content.position += event.position - previous_pos
func _handle_magnify(event: InputEventMagnifyGesture) -> void:
_set_zoom(content.scale.x * event.factor)
func _set_zoom(z: float) -> void:
var clamped: float = clamp(z, min_zoom, max_zoom)
content.scale = Vector2(clamped, clamped)

1
world/WorldMap.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://1ava7qm2fian

113
world/WorldMapContent.gd Normal file
View file

@ -0,0 +1,113 @@
extends Node2D
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd")
const WORLD_TO_LOCAL: float = 0.04
# A structure built on a planet's surface (see GameState.extractors) is not
# itself a map marker - it is a small badge on the existing planet marker,
# in a colour distinct from both the planets and the space buildings so it
# reads as "something is mined here" rather than another building type.
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
@onready var tracker: Node2D = get_node("../../../ExplorationTracker")
@onready var fleet: Node2D = get_node("../../../Fleet")
func _process(_delta: float) -> void:
if is_visible_in_tree():
queue_redraw()
# Local-space marker data for every player-placed building (GameState.
# buildings), one entry per building with its colour already resolved. Kept
# as a separate, callable step (rather than inlined in _draw()) so a
# headless test - which has no GPU and cannot check drawn pixels - can
# verify a built building actually produces a marker.
func building_markers() -> Array:
var markers: Array = []
for b in GameState.buildings:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(b.type))
if definition == null:
continue
markers.append({
"position": b.position * WORLD_TO_LOCAL,
"type": b.type,
"color": definition.map_color,
"sides": definition.map_sides,
"radius": definition.map_radius,
"phase": definition.map_phase,
})
return markers
# Whether the planet in this (explored) chunk has an extractor on it, per
# GameState.has_extractor_at(). A separate predicate for the same
# GPU-less-testability reason as building_markers().
func chunk_has_extractor_badge(chunk: Vector2i) -> bool:
if not WorldGen.has_planet_at_chunk(chunk):
return false
return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).planet_id)
func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(sides):
var a: float = (float(i) / float(sides)) * TAU + phase
pts.append(center + Vector2(cos(a), sin(a)) * radius)
return pts
# Filled shape plus a thin white outline - the same construction the fleet
# marker below already uses, just factored out since buildings now need it
# three times over (once per type) and the extractor badge needs it too.
func _draw_marker_polygon(pts: PackedVector2Array, color: Color) -> void:
draw_colored_polygon(pts, color)
var closed: PackedVector2Array = pts.duplicate()
closed.append(pts[0])
draw_polyline(closed, Color(1, 1, 1), 1.5, true)
func _draw() -> void:
var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL
for chunk in tracker.get_explored_chunks():
var origin_local: Vector2 = WorldGen.chunk_origin(chunk) * WORLD_TO_LOCAL
var biome: int = WorldGen.biome_at_chunk(chunk)
var color: Color = WorldGen.biome_color(biome)
var rect: Rect2 = Rect2(origin_local, Vector2(chunk_size_local, chunk_size_local))
draw_rect(rect, color, true)
draw_rect(rect, Color(0.0, 0.0, 0.0, 0.4), false, 1.5)
if WorldGen.has_planet_at_chunk(chunk):
# Same deterministic per-chunk data PlanetField spawns the
# actual Planet from - the marker stays put on the map even
# for a chunk whose Planet node isn't currently loaded (out of
# spawn_radius_chunks of the fleet).
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var planet_local: Vector2 = origin_local + data.local_pos * WORLD_TO_LOCAL
var definition: Resource = PLANET_DEFINITIONS.get_definition(String(data.definition_id))
draw_circle(planet_local, 5.0, definition.map_color)
draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true)
if chunk_has_extractor_badge(chunk):
var badge_pts: PackedVector2Array = _regular_polygon(
planet_local + EXTRACTOR_BADGE_OFFSET, EXTRACTOR_BADGE_RADIUS, 4, PI / 4.0)
_draw_marker_polygon(badge_pts, EXTRACTOR_BADGE_COLOR)
# Buildings are placed freely in space rather than tied to a chunk, so
# they get their own pass rather than living inside the chunk loop above.
for marker in building_markers():
_draw_marker_polygon(_regular_polygon(marker.position, marker.radius, marker.sides, marker.phase), marker.color)
var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL
var marker_points: PackedVector2Array = PackedVector2Array([
fleet_local + Vector2(0, -10),
fleet_local + Vector2(7, 8),
fleet_local + Vector2(-7, 8),
])
draw_colored_polygon(marker_points, Color(0.3, 0.9, 1.0))
draw_polyline(PackedVector2Array([marker_points[0], marker_points[1], marker_points[2], marker_points[0]]), Color(1, 1, 1), 1.5, true)

View file

@ -0,0 +1 @@
uid://xq4ay3r56nst