diff --git a/tests/test_asteroid_safe_spawn.gd b/tests/test_asteroid_safe_spawn.gd index 9aa8b1b..13fd96a 100644 --- a/tests/test_asteroid_safe_spawn.gd +++ b/tests/test_asteroid_safe_spawn.gd @@ -15,7 +15,7 @@ extends SceneTree # materialise in plain sight several hundred pixels away. # # Verifies: -# 1. _safe_position never returns a point inside the keep-out area, for +# 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 @@ -60,7 +60,7 @@ 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) + var keep_out: Rect2 = field.keep_out_rect(centre) # --- 3. the keep-out area is the screen, not the old bubble --- var on_screen: Rect2 = ViewUtils.get_visible_world_rect(field) @@ -89,11 +89,11 @@ func _process(delta: float) -> bool: 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)): + 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 = keep_out.get_center() + dir * (reach * 3.0) - if field._safe_position(outside, keep_out).distance_to(outside) > 0.001: + if field.safe_position(outside, keep_out).distance_to(outside) > 0.001: moved_an_outside_one = true if let_one_through: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the guard left a spawn inside the visible area)") diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index 62edd44..aae8f1a 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -32,6 +32,7 @@ extends SceneTree # P. #128 - the prologue opens STOPPED behind its first line, and # acknowledging it is what starts the world # Q. #133 - the handover runs straight into the first quest +# R. #139 - no belt rock is laid down inside the visible screen var game_state: Node var scene: Node @@ -276,6 +277,26 @@ func _process(delta: float) -> bool: % [ahead, standing.size()]) print("belt: %d rocks, %d of them across the lane ahead" % [standing.size(), ahead]) + # --- R. #139: and none of it appears on screen ------------ + # The belt used to be laid out with its own arithmetic, which put + # its near edge within a screen of the fleet - so rocks popped + # into existence in view. It goes through the asteroid field's + # own keep-out now, the same one #126 gave the rest of the game. + var field: Node2D = scene.get_node("AsteroidField") + var keep_out: Rect2 = field.keep_out_rect(fleet.get_centroid()) + var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(field) + if visible_rect.size.x > 1.0 and not keep_out.encloses(visible_rect): + return _fail("the belt's keep-out %s does not cover the screen %s" \ + % [keep_out, visible_rect]) + var nearest: float = 1.0e9 + for rock in standing: + if keep_out.has_point(rock.global_position): + return _fail("a belt rock was laid down at %s, inside the view %s" \ + % [rock.global_position, keep_out]) + nearest = minf(nearest, absf(rock.global_position.y - fleet.get_centroid().y)) + print("no belt rock inside the keep-out %.0fx%.0f; nearest is %.0f ahead" \ + % [keep_out.size.x, keep_out.size.y, nearest]) + # --- G. the breach takes the wheel ---------------------- director._begin(director.Beat.BREACH) if not game_state.intro_locks_movement: diff --git a/ui/QuestTracker.gd b/ui/QuestTracker.gd deleted file mode 100644 index a44c4a2..0000000 --- a/ui/QuestTracker.gd +++ /dev/null @@ -1,132 +0,0 @@ -extends Control - -# Forgejo #132: the standing objective, on the HUD. -# -# The banner (#128/#127) says a quest ONCE, stops the game while it is read, -# and then is gone. That is right for the sentence and wrong for the task - -# a player who put the game down mid-flight has nowhere to look up what they -# were doing. This is that place: small, always the current quest, and never -# in the way. -# -# Same idiom as the wave alert: a neon panel drawn out of geometry, no art, -# no textures. It sits under the alert on the right so the two stack rather -# than argue, and it stands down whenever a full-screen panel is open - the -# HUD's own rule for anything sitting behind a menu (see IconButton). - -const GLOW: Color = Color(0.42, 0.86, 0.95) -const TEXT: Color = Color(0.80, 0.94, 1.0) -const DONE: Color = Color(0.55, 1.0, 0.68) -const CUT: float = 14.0 -const PAD: float = 16.0 -const TITLE_SIZE: int = 20 -const OBJECTIVE_SIZE: int = 24 -const BAR_HEIGHT: float = 8.0 - -var title_label: Label -var objective_label: Label -var count_label: Label -var progress: Vector2i = Vector2i.ZERO -var shown_id: String = "" - - -func _ready() -> void: - mouse_filter = Control.MOUSE_FILTER_IGNORE - visible = false - title_label = _make_label(TITLE_SIZE, GLOW) - objective_label = _make_label(OBJECTIVE_SIZE, TEXT) - objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - count_label = _make_label(TITLE_SIZE, TEXT) - count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - resized.connect(_layout) - _layout() - - -func _make_label(font_size: int, colour: Color) -> Label: - var label: Label = Label.new() - label.mouse_filter = Control.MOUSE_FILTER_IGNORE - label.add_theme_font_size_override("font_size", font_size) - label.add_theme_color_override("font_color", colour) - label.add_theme_color_override("font_outline_color", Color(0.01, 0.03, 0.07, 0.95)) - label.add_theme_constant_override("outline_size", 3) - label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - add_child(label) - return label - - -func _layout() -> void: - if title_label == null: - return - var inner: float = maxf(size.x - PAD * 2.0, 0.0) - title_label.position = Vector2(PAD, PAD * 0.4) - title_label.size = Vector2(inner, float(TITLE_SIZE) * 1.3) - count_label.position = title_label.position - count_label.size = title_label.size - objective_label.position = Vector2(PAD, PAD * 0.4 + float(TITLE_SIZE) * 1.4) - objective_label.size = Vector2(inner, - maxf(size.y - objective_label.position.y - PAD - BAR_HEIGHT, 0.0)) - - -# Open panels take the whole screen and this would sit on top of one. Same -# question IconButton._blocked_by_modal asks, for the same reason. -func _behind_a_panel() -> bool: - for menu in get_tree().get_nodes_in_group("modal_menus"): - if menu.is_visible_in_tree(): - return true - return false - - -func _process(_delta: float) -> void: - var quest: Dictionary = GameState.active_quest() - var wanted: bool = not quest.is_empty() and not GameState.intro_active \ - and not _behind_a_panel() - if not wanted: - if visible: - visible = false - return - var id: String = String(quest.id) - var now: Vector2i = GameState.quest_progress(quest) - if id == shown_id and now == progress and visible: - return - shown_id = id - progress = now - title_label.text = String(quest.get("title", "")) - objective_label.text = String(quest.get("objective", "")) - # A one-of-one goal is a yes/no, and "0 / 1" reads as a broken counter - # rather than as a task not started. - count_label.text = "%d / %d" % [progress.x, progress.y] if progress.y > 1 else "" - visible = true - queue_redraw() - - -func _frame() -> PackedVector2Array: - return PackedVector2Array([ - Vector2(CUT, 0.0), Vector2(size.x, 0.0), - Vector2(size.x, size.y - CUT), Vector2(size.x - CUT, size.y), - Vector2(0.0, size.y), Vector2(0.0, CUT), - ]) - - -func _draw() -> void: - if not visible: - return - var frame: PackedVector2Array = _frame() - draw_colored_polygon(frame, Color(0.02, 0.07, 0.11, 0.66)) - NeonDraw.draw_glowing_polygon(self, frame, Color(GLOW, 0.85), 0.75) - - # A tick down the left edge, so the panel reads as clipped to the side of - # the screen rather than floating in the middle of it. - NeonDraw.draw_glowing_line(self, Vector2(PAD * 0.35, CUT + 4.0), - Vector2(PAD * 0.35, size.y - CUT - 4.0), Color(GLOW, 0.5), 0.6, 0.35) - - # The bar along the bottom edge. Drawn for every goal, including the - # yes/no ones - it is the one thing on the panel that says "this is a - # task with a state" without needing to be read. - var track: Rect2 = Rect2(Vector2(PAD, size.y - PAD * 0.6 - BAR_HEIGHT), - Vector2(maxf(size.x - PAD * 2.0, 0.0), BAR_HEIGHT)) - draw_rect(track, Color(GLOW, 0.16)) - var filled: float = 0.0 if progress.y <= 0 \ - else clampf(float(progress.x) / float(progress.y), 0.0, 1.0) - if filled <= 0.0: - return - var lit: Rect2 = Rect2(track.position, Vector2(track.size.x * filled, track.size.y)) - draw_rect(lit, Color(DONE if filled >= 1.0 else GLOW, 0.85)) diff --git a/ui/QuestTracker.gd.uid b/ui/QuestTracker.gd.uid deleted file mode 100644 index 6b911bc..0000000 --- a/ui/QuestTracker.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bd1ahcuybcou1 diff --git a/world/AsteroidField.gd b/world/AsteroidField.gd index 63d1e2a..6db56b7 100644 --- a/world/AsteroidField.gd +++ b/world/AsteroidField.gd @@ -77,12 +77,16 @@ func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int: # 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: +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: @@ -96,7 +100,7 @@ func _keep_out_rect(centre: Vector2) -> Rect2: return visible_rect.merge(fallback) -func _safe_position(candidate: Vector2, keep_out: Rect2) -> Vector2: +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 @@ -123,12 +127,12 @@ func _spawn_chunk_asteroids(chunk: Vector2i) -> void: # 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()) + 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) + 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 diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index 010b14f..c30f6b9 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -59,7 +59,12 @@ const AMBUSH_AHEAD: float = 1100.0 # #125: the run was over in about four seconds at cruise. Far enough now # that the convoy is genuinely under way before anything interrupts it. const RUN_DISTANCE: float = 3400.0 -const BELT_AHEAD: float = 2200.0 +# #139: far enough that the near edge of the belt is well past the screen +# even before the keep-out below trims it. The two together, rather than +# either alone: the distance keeps the belt looking laid out ahead rather +# than pinned to the screen edge, and the keep-out is what actually +# guarantees nothing appears in view at any aspect ratio or zoom. +const BELT_AHEAD: float = 2900.0 const BELT_ROCKS: int = 90 const BELT_HALF_WIDTH: float = 2600.0 const BELT_DEPTH: float = 1400.0 @@ -422,12 +427,22 @@ func _spawn_belt() -> void: rock.queue_free() rocks.clear() belt_at = fleet.get_centroid() + Vector2(0.0, -BELT_AHEAD) + # #139: rocks were popping into existence on screen. The belt is spread + # BELT_DEPTH either side of its centre, so its near edge reached back to + # within a screen of the fleet - and at a wider aspect than the one it + # was eyeballed on, into the view outright. + # + # The rule is the asteroid field's own (#126), borrowed rather than + # restated here: anything that lands in view is pushed out through the + # nearest edge, so the belt keeps its density instead of thinning where + # the player is looking. + var keep_out: Rect2 = asteroid_field.keep_out_rect(fleet.get_centroid()) var scene: PackedScene = load("res://world/Asteroid.tscn") for i in range(BELT_ROCKS): var rock: Node2D = scene.instantiate() - rock.position = belt_at + Vector2( + rock.position = asteroid_field.safe_position(belt_at + Vector2( randf_range(-BELT_HALF_WIDTH, BELT_HALF_WIDTH), - randf_range(-BELT_DEPTH, BELT_DEPTH)) + randf_range(-BELT_DEPTH, BELT_DEPTH)), keep_out) get_parent().add_child(rock) rocks.append(rock)