diff --git a/tests/test_asteroid_safe_spawn.gd b/tests/test_asteroid_safe_spawn.gd index bf3b5e7..9aa8b1b 100644 --- a/tests/test_asteroid_safe_spawn.gd +++ b/tests/test_asteroid_safe_spawn.gd @@ -10,16 +10,23 @@ extends SceneTree # test_return_position_matches_planet fail intermittently. # # Verifies: -# 1. _safe_position never returns a point inside the bubble, for candidates -# all around and including the exact centre. This is checked directly -# rather than by looking at where asteroids happened to land: with ~54 -# asteroids spread over 25 chunks, any single run only rarely produces a -# near miss, so an observational check passes just as happily with the -# guard removed and proves nothing. -# 2. candidates already outside the bubble are left exactly where they are, -# so the field is not distorted away from the fleet -# 3. the field still spawns a full complement, and a stationary fleet loses -# no ships +# #126 widens the rule from "not on the fleet" to "not IN VIEW": the old +# bubble was 420px, smaller than the screen, so a rock could and did +# materialise in plain sight several hundred pixels away. +# +# Verifies: +# 1. _safe_position never returns a point inside the keep-out area, for +# candidates all around it and including its exact centre. Checked +# directly rather than by looking at where asteroids happened to land: +# with ~54 asteroids over 25 chunks, any single run only rarely +# produces a near miss, so an observational check passes just as +# happily with the guard removed and proves nothing. +# 2. candidates already outside are left exactly where they are, so the +# field is not distorted away from the fleet +# 3. the keep-out area really is the visible rect and not the old bubble +# 4. nothing the field actually placed is on screen +# 5. the field still spawns a full complement, and a stationary fleet +# loses no ships var scene: Node var fleet: Node @@ -53,32 +60,50 @@ func _process(delta: float) -> bool: var centre: Vector2 = fleet.get_centroid() var safe: float = field.safe_spawn_radius + var keep_out: Rect2 = field._keep_out_rect(centre) - # --- the guard itself, deterministically ---------------------- - var worst_inside: float = 1e9 + # --- 3. the keep-out area is the screen, not the old bubble --- + var on_screen: Rect2 = ViewUtils.get_visible_world_rect(field) + if not keep_out.encloses(on_screen): + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area does not cover the visible rect)") + quit(1) + return true + if keep_out.size.x < safe * 2.0 or keep_out.size.y < safe * 2.0: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area shrank below the old bubble)") + quit(1) + return true + print("keep-out %.0fx%.0f covers a %.0fx%.0f screen (old bubble was %.0f across)" % [ + keep_out.size.x, keep_out.size.y, on_screen.size.x, on_screen.size.y, safe * 2.0 + ]) + + # --- 1/2. the guard itself, deterministically ----------------- + var half: Vector2 = keep_out.size * 0.5 + var reach: float = maxf(half.x, half.y) + var let_one_through: bool = false var moved_an_outside_one: bool = false for i in range(72): var a: float = (float(i) / 72.0) * TAU var dir: Vector2 = Vector2(cos(a), sin(a)) # Candidates well inside, just inside, and exactly on centre. - for d in [0.0, 1.0, safe * 0.25, safe * 0.99]: - var got: Vector2 = field._safe_position(centre + dir * d, centre) - worst_inside = minf(worst_inside, got.distance_to(centre)) + for d in [0.0, 1.0, reach * 0.25, reach * 0.7]: + var candidate: Vector2 = keep_out.get_center() + dir * d + if not keep_out.has_point(candidate): + continue + if keep_out.has_point(field._safe_position(candidate, keep_out)): + let_one_through = true # Candidates already outside must be returned untouched. - var outside: Vector2 = centre + dir * (safe * 1.5) - if field._safe_position(outside, centre).distance_to(outside) > 0.001: + var outside: Vector2 = keep_out.get_center() + dir * (reach * 3.0) + if field._safe_position(outside, keep_out).distance_to(outside) > 0.001: moved_an_outside_one = true - print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [ - worst_inside, safe - ]) - if worst_inside < safe - 0.001: - print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)") + if let_one_through: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the guard left a spawn inside the visible area)") quit(1) return true if moved_an_outside_one: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)") quit(1) return true + print("every one of 288 in-view candidates was pushed out; none outside was touched") var asteroids: Array = get_nodes_in_group("asteroids") var closest: float = 1e9 diff --git a/world/AsteroidField.gd b/world/AsteroidField.gd index e7a938a..63d1e2a 100644 --- a/world/AsteroidField.gd +++ b/world/AsteroidField.gd @@ -7,10 +7,17 @@ extends Node2D @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. +# #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 @@ -67,31 +74,61 @@ 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: +# 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. +# +# 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 - # 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. + # 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 away.length() < 0.001: + if absf(away.x) < 0.001 and absf(away.y) < 0.001: away = Vector2.RIGHT.rotated(randf() * TAU) - return centre + away.normalized() * safe_spawn_radius + 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 = [] - var fleet_centre: Vector2 = fleet.get_centroid() + # 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, fleet_centre) + 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