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 # #126: no asteroid may be created anywhere the player can SEE. It used to be # a fixed bubble around the fleet, which was smaller than the screen - so a # rock could and did materialise in plain view, several hundred pixels from # the fleet but well inside the window. The real rule is the visible rect, # and this radius is now only the floor under it: what to use when the # viewport cannot be measured (no camera yet on the very first frame of a # scene, or a headless run with no window at all). @export var safe_spawn_radius: float = 420.0 # Pushed this far past the edge of the visible rect, so a rock placed just # outside does not drift into view on the same frame it was created. @export var offscreen_margin: float = 140.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)) # The area a new asteroid must stay out of: whatever is on screen, or the # fallback bubble around the fleet when there is no screen to measure. # # Public since #139: the prologue lays its own debris belt (IntroDirector) # with its own arithmetic and had the same bug this fixed - rocks appearing # inside the view. One rule, in one place, borrowed rather than restated. # # Chunks are populated around wherever the fleet happens to be, including the # chunk it is standing in, so without this a rock could appear directly on a # ship - worst on the frame a scene loads, where arriving back from a planet # could cost a vessel instantly. #126 widens that from "not on the fleet" to # "not in view", which is the same rule the player actually notices. func keep_out_rect(centre: Vector2) -> Rect2: var fallback := Rect2(centre - Vector2(safe_spawn_radius, safe_spawn_radius), Vector2(safe_spawn_radius, safe_spawn_radius) * 2.0) if get_viewport() == null: return fallback var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self) if visible_rect.size.x < 1.0 or visible_rect.size.y < 1.0: return fallback visible_rect = visible_rect.grow(offscreen_margin) # Never smaller than the old bubble: on a heavily zoomed-in view the # screen can be narrower than the distance a rock needs to be safe. return visible_rect.merge(fallback) func safe_position(candidate: Vector2, keep_out: Rect2) -> Vector2: if not keep_out.has_point(candidate): return candidate # Pushed out through the nearest edge rather than dropped, so the field # keeps its density around the fleet instead of thinning out where the # player is looking. var centre: Vector2 = keep_out.get_center() var away: Vector2 = candidate - centre if absf(away.x) < 0.001 and absf(away.y) < 0.001: away = Vector2.RIGHT.rotated(randf() * TAU) var half: Vector2 = keep_out.size * 0.5 # The smallest scaling that puts the point on the boundary along `away`: # whichever axis it would leave through first decides. var scale_x: float = half.x / absf(away.x) if absf(away.x) > 0.001 else INF var scale_y: float = half.y / absf(away.y) if absf(away.y) > 0.001 else INF var factor: float = minf(scale_x, scale_y) if is_inf(factor): return centre + Vector2(half.x + 1.0, 0.0) return centre + away * factor * 1.02 func _spawn_chunk_asteroids(chunk: Vector2i) -> void: var origin: Vector2 = WorldGen.chunk_origin(chunk) var spawned: Array = [] # Measured once per chunk rather than per asteroid: it cannot change # between two rocks placed in the same frame, and it is a viewport # transform rather than a field read. var keep_out: Rect2 = keep_out_rect(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, keep_out) 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