From d455361328e8a16e10fe59397f656a575fc35e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Mon, 24 Aug 2026 13:25:10 +0200 Subject: [PATCH] #125: slow the prologue down, and give the breach a real black hole Five things, all of them "this happens too fast or looks too thin". PACING. Every beat had a completion test that could be satisfied while the guide was still mid-sentence, which is why everything landed seconds after the thing before it. Beats now have a FLOOR as well as a deadline - a minimum length - and a beat whose point is the guide talking will not end while a line is still on screen. One rule in one place rather than tuning each condition. The run is 3400px instead of 1500, which at cruise was about four seconds, and a held line is 6.5s instead of 4.5. THE CONVOY. Escorts at the four corners of a box, holds packed in the middle of it, roughly three times the spread of the fleet's own wedge - this is freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION pair index-for-index, which is all it takes for spawn_ships to seat the guns on the outside, and the test pins that every hold sits further in than the furthest-out escort. THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip along the bottom and a caret in the corner - geometry rather than art, so it scales with the banner and costs nothing. THE BREACH. It was a few lines turning around a point. It is now two halves driven by one number: DimensionalBreach, a growing black hole out in the world built on BlackHoleVisual's idiom - empty core, counter-rotating arc segments hotter toward the centre - with infalling streamers it does not have, because a planet's disc is steady and this one has to say "now"; and breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships, rocks and the starfield all get dragged in, which is most of what makes a hole in space read as a hole rather than as a decal drawn on one. Inside the horizon the shader samples nothing at all - the same decision BlackHoleVisual makes, for the same reason: a black hole with something visible in the middle stops being one. Co-Authored-By: Claude Opus 5 --- shaders/breach.gdshader | 72 ++++++++++++++ tests/test_intro_scene.gd | 77 ++++++++++++++- ui/BreachOverlay.gd | 43 ++++++++ ui/NarrationBanner.gd | 64 ++++++++++-- vfx/DimensionalBreach.gd | 119 +++++++++++++++++++++++ vfx/DimensionalBreach.tscn | 6 ++ world/IntroDirector.gd | 194 +++++++++++++++++++++++++++---------- world/Main.tscn | 9 ++ 8 files changed, 527 insertions(+), 57 deletions(-) create mode 100644 shaders/breach.gdshader create mode 100644 ui/BreachOverlay.gd create mode 100644 vfx/DimensionalBreach.gd create mode 100644 vfx/DimensionalBreach.tscn diff --git a/shaders/breach.gdshader b/shaders/breach.gdshader new file mode 100644 index 0000000..792ad09 --- /dev/null +++ b/shaders/breach.gdshader @@ -0,0 +1,72 @@ +shader_type canvas_item; + +// #125: the dimensional breach, as a screen-space lens. +// +// The prologue's warp used to be a handful of lines turning around a point. +// This bends the ACTUAL SCENE around the breach instead - the ships, the +// rocks and the starfield all get dragged into it - which is most of what +// makes a hole in space read as a hole rather than as a decal drawn on top +// of one. +// +// Two effects, both radial and both fading to nothing at the edge of their +// reach, so the shader is invisible outside its own radius and costs a +// clamped texture fetch everywhere else: +// +// swirl angle offset that grows toward the centre, so the scene winds +// around the hole rather than merely sliding toward it +// pull inward displacement, strongest just outside the horizon, which +// is the lensing itself +// +// Inside the horizon nothing is sampled at all: the core is drawn as empty, +// the same decision BlackHoleVisual makes for the black-hole planet, and +// for the same reason - a "black hole" with something visible in the middle +// stops reading as one. + +uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear; + +// Where the breach is, in SCREEN pixels, and how big it has grown. +uniform vec2 breach_center = vec2(360.0, 640.0); +uniform vec2 viewport_size = vec2(720.0, 1280.0); +// The event horizon: everything inside is void. +uniform float horizon_radius = 0.0; +// How far the lens reaches past the horizon. Beyond this the scene is +// untouched. +uniform float lens_radius = 1.0; +// 0 while the breach is opening, 1 at full strength. Scales both effects +// together so one knob fades the whole thing in. +uniform float strength = 0.0; +uniform float swirl_turns = 2.4; +uniform vec4 rim_color : source_color = vec4(0.72, 0.45, 1.0, 1.0); + +void fragment() { + vec2 pixel = UV * viewport_size; + vec2 offset = pixel - breach_center; + float distance_to_centre = length(offset); + + if (distance_to_centre >= lens_radius || strength <= 0.001) { + COLOR = texture(screen_texture, SCREEN_UV); + } else if (distance_to_centre <= horizon_radius) { + // The core. Not black-with-alpha: genuinely nothing, so whatever is + // behind the breach is gone rather than dimmed. + COLOR = vec4(0.0, 0.0, 0.0, 1.0); + } else { + // 1 at the horizon, 0 at the edge of the lens. Squared so the bend + // is concentrated near the hole instead of smeared evenly outward. + float falloff = 1.0 - clamp( + (distance_to_centre - horizon_radius) / max(lens_radius - horizon_radius, 1.0), + 0.0, 1.0); + float bend = falloff * falloff * strength; + + float angle = atan(offset.y, offset.x) + bend * swirl_turns; + float pulled = distance_to_centre - bend * (lens_radius - horizon_radius) * 0.55; + pulled = max(pulled, horizon_radius); + vec2 sampled = breach_center + vec2(cos(angle), sin(angle)) * pulled; + + COLOR = texture(screen_texture, sampled / viewport_size); + + // A hot rim where the lensing is fiercest, which is what gives the + // horizon an edge rather than a fade. + float rim = smoothstep(0.72, 1.0, falloff) * strength; + COLOR.rgb = mix(COLOR.rgb, rim_color.rgb, rim * 0.65); + } +} diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index fda172a..ac8bfe8 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -21,6 +21,11 @@ extends SceneTree # I. the handover: the flag clears, the buttons come back, the spawner and # the asteroid field are running again, and the fleet is a new game's # J. skipping lands in the same state as playing it through +# K. #125 - no beat ends before its own floor, and a beat whose point is +# the guide talking does not end mid-sentence +# L. #125 - the convoy is spread out, with its holds surrounded by escorts +# M. #125 - the breach is a growing black hole with a lens over it, not a +# few lines turning around a point var game_state: Node var scene: Node @@ -85,6 +90,18 @@ func _process(delta: float) -> bool: if not director.running: return _fail("the director did not start on a new game") + # --- K. nothing ends before its floor -------------------- + # The floors are what #125 bought: every beat had a condition + # that could be satisfied while the guide was still talking. + for b in range(director.Beat.AFTERMATH + 1): + if director._floor_time() <= 0.0 and b != director.Beat.RUN: + return _fail("beat %d has no minimum length" % b) + if director.beat != director.Beat.BRIEF: + return _fail("the opening beat was over before the first line finished") + if not narration.is_speaking(): + return _fail("the opening beat is silent, so waiting for it proves nothing") + print("opening beat still running at %.1fs, guide still talking" % director.beat_time) + # --- C. the convoy -------------------------------------- convoy_size = fleet.ships.size() if convoy_size <= GameState.BASE_FLEET_SIZE: @@ -104,6 +121,35 @@ func _process(delta: float) -> bool: return _fail("the convoy has no escorts at all") print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts]) + # --- L. spread out, and the holds are on the inside ------- + var spread: float = 0.0 + var closest: float = 1.0e9 + for a in fleet.ships: + for b in fleet.ships: + if a == b: + continue + spread = maxf(spread, a.position.distance_to(b.position)) + closest = minf(closest, a.position.distance_to(b.position)) + if spread < 400.0: + return _fail("the whole convoy fits in %.0fpx - that is a wedge, not a convoy" % spread) + if closest < 80.0: + return _fail("two convoy vessels are %.0fpx apart" % closest) + # Every hold must be further in than the furthest-out escort is, + # which is what "surrounded by the default vessels" means. + var escort_reach: float = 0.0 + var hold_reach: float = 0.0 + for ship in fleet.ships: + var from_centre: float = ship.position.length() + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + escort_reach = maxf(escort_reach, from_centre) + else: + hold_reach = maxf(hold_reach, from_centre) + if hold_reach >= escort_reach: + return _fail("a hold sits %.0fpx out, past the escorts at %.0f - they are not screened" \ + % [hold_reach, escort_reach]) + print("convoy %.0fpx across, holds within %.0f and escorts out at %.0f" \ + % [spread, hold_reach, escort_reach]) + # --- B. movement and nothing else ----------------------- for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton", "UI/BuildingActionButton", "UI/EnterAtmosphereButton", @@ -138,7 +184,10 @@ func _process(delta: float) -> bool: stage = 2 return false 2: - if elapsed < 0.3: + # Past the ambush beat's own floor (#125): clearing it is what + # ENDS it, but no beat may end before its floor, so watching for + # the advance means waiting that long. + if elapsed < director._floor_time() + 0.5: return false # The director advances itself, so the evidence that clearing the # ambush ended the beat is that it has MOVED ON - asking @@ -177,6 +226,28 @@ func _process(delta: float) -> bool: return _fail("player steering still moved the fleet during the breach") print("the breach has the wheel: steering input moves nothing") + # --- M. the breach is a black hole ---------------------- + var hole: Node2D = director.breach + if hole == null or not is_instance_valid(hole): + return _fail("the breach beat opened no black hole") + if hole.horizon_radius() > 1.0: + return _fail("the hole was already open the moment the beat began") + director._drive_breach(0.5) + var half_open: float = hole.horizon_radius() + director._drive_breach(1.0) + if hole.horizon_radius() <= half_open: + return _fail("the hole does not grow - %.1f at half, %.1f at full" \ + % [half_open, hole.horizon_radius()]) + if hole.disc_radius() <= hole.horizon_radius(): + return _fail("the accretion disc is inside the horizon") + var lens: ColorRect = scene.get_node("UI/BreachOverlay") + if not lens.visible: + return _fail("the screen-space lens never came up") + if lens.material == null or lens.material.shader == null: + return _fail("the lens has no shader on it") + print("breach: horizon %.0f -> %.0f, disc %.0f, lens up" \ + % [half_open, hole.horizon_radius(), hole.disc_radius()]) + # --- H. the vortex -------------------------------------- director._begin(director.Beat.VORTEX) elapsed = 0.0 @@ -226,6 +297,10 @@ func _process(delta: float) -> bool: return _fail("the build button never came back") if narration.is_speaking(): return _fail("the guide is still talking after the handover") + if scene.get_node("UI/BreachOverlay").visible: + return _fail("the breach lens is still over the screen after the handover") + if is_instance_valid(director) and director.breach != null: + return _fail("the black hole outlived the prologue") print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back") _drop_scene() elapsed = 0.0 diff --git a/ui/BreachOverlay.gd b/ui/BreachOverlay.gd new file mode 100644 index 0000000..8f94b30 --- /dev/null +++ b/ui/BreachOverlay.gd @@ -0,0 +1,43 @@ +extends ColorRect + +# #125: drives breach.gdshader. +# +# Lives in the UI layer because it works in SCREEN space - it bends the +# rendered frame, so it has to be the last thing drawn and it has to cover +# the whole viewport. The world-space half of the breach (the horizon, the +# accretion disc, the infalling matter) is DimensionalBreach, a Node2D out +# in the world; this is only the lens over the top of it. +# +# Inert unless something calls set_breach(): the shader is a no-op at zero +# strength, but a full-screen quad that is never needed is still a full +# screen of fragment work, so it hides itself instead. + +const SHADER_PATH: String = "res://shaders/breach.gdshader" + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + if material == null: + var shader_material := ShaderMaterial.new() + shader_material.shader = load(SHADER_PATH) + material = shader_material + + +func clear_breach() -> void: + visible = false + if material != null: + material.set_shader_parameter("strength", 0.0) + + +# `at` is in SCREEN pixels; the director projects the world point through the +# camera before calling, because only it knows where the camera is. +func set_breach(at: Vector2, horizon: float, lens: float, breach_strength: float) -> void: + if material == null: + return + visible = breach_strength > 0.001 + material.set_shader_parameter("breach_center", at) + material.set_shader_parameter("viewport_size", size) + material.set_shader_parameter("horizon_radius", horizon) + material.set_shader_parameter("lens_radius", maxf(lens, horizon + 1.0)) + material.set_shader_parameter("strength", clampf(breach_strength, 0.0, 1.0)) diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index f67d5d8..2c17bc4 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -10,10 +10,22 @@ extends Control # Lines queue rather than replacing each other, so a beat that fires while # the previous line is still up does not silently eat it. -const HOLD_SECONDS: float = 4.5 +# #125: lines were gone before they had been read. A held line is the +# cheapest possible pacing control and the prologue leans on it heavily. +const HOLD_SECONDS: float = 6.5 const FADE_SECONDS: float = 0.5 const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0) const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95) +# #125: the panel was a plain rectangle with a line under it. These are the +# pieces that make it read as a transmission window instead - the corners +# are cut, the short edges are brackets rather than sides, and there is a +# tick strip along the bottom. All of it is geometry rather than art, so it +# scales with the banner and costs nothing. +const CUT: float = 26.0 +const BRACKET: float = 34.0 +const INSET: float = 7.0 +const TICK_COUNT: int = 24 +const TICK_HEIGHT: float = 5.0 var label: Label var queued: Array = [] @@ -97,14 +109,54 @@ func _process(delta: float) -> void: _advance() +# The panel's outline: a rectangle with its corners cut off. Returned rather +# than drawn so the fill and the stroke are guaranteed to be the same shape. +func _frame() -> PackedVector2Array: + var w: float = size.x + var h: float = size.y + return PackedVector2Array([ + Vector2(CUT, 0.0), Vector2(w - CUT, 0.0), Vector2(w, CUT), + Vector2(w, h - CUT), Vector2(w - CUT, h), Vector2(CUT, h), + Vector2(0.0, h - CUT), Vector2(0.0, CUT), + ]) + + func _draw() -> void: if current == "": return # Fades out over its last moments so lines hand over rather than cutting. var alpha: float = clampf(time_left / FADE_SECONDS, 0.0, 1.0) label.modulate.a = alpha - draw_rect(Rect2(Vector2.ZERO, size), Color(0.02, 0.05, 0.12, 0.72 * alpha)) - NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y), - Color(FRAME_COLOR, alpha), 0.7, 0.55) - NeonDraw.draw_glowing_line(self, Vector2(0.0, 0.0), Vector2(size.x, 0.0), - Color(FRAME_COLOR, alpha * 0.6), 0.5, 0.4) + var frame: PackedVector2Array = _frame() + + draw_colored_polygon(frame, Color(0.02, 0.05, 0.12, 0.78 * alpha)) + NeonDraw.draw_glowing_polygon(self, frame, Color(FRAME_COLOR, alpha), 0.8) + + # Brackets at the short edges: a doubled inner line that stops well short + # of the corners, so the panel reads as held open rather than closed. + for side in [0.0, 1.0]: + var x: float = lerpf(INSET, size.x - INSET, side) + NeonDraw.draw_glowing_line(self, Vector2(x, CUT + INSET), + Vector2(x, size.y - CUT - INSET), Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3) + var arm: float = BRACKET * (1.0 if side < 0.5 else -1.0) + for y in [CUT + INSET, size.y - CUT - INSET]: + NeonDraw.draw_glowing_line(self, Vector2(x, y), Vector2(x + arm, y), + Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3) + + # A tick strip along the bottom, dimming toward the ends. It is the one + # piece that is pure texture, and it is what stops the lower edge from + # reading as a plain underline. + var span: float = size.x - CUT * 2.0 + for i in range(TICK_COUNT): + var t: float = float(i) / float(TICK_COUNT - 1) + var tx: float = CUT + span * t + var lit: float = 0.25 + 0.55 * sin(t * PI) + draw_line(Vector2(tx, size.y - INSET), Vector2(tx, size.y - INSET - TICK_HEIGHT), + Color(FRAME_COLOR, alpha * lit), 2.0, true) + + # And a caret in the top-left corner, so a line reads as being SAID by + # someone rather than printed on the glass. + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + Vector2(CUT + 4.0, INSET + 8.0), Vector2(CUT + 16.0, INSET + 15.0), + Vector2(CUT + 4.0, INSET + 22.0), + ]), Color(0.72, 0.92, 1.0, alpha * 0.9), 0.9, 0.4) diff --git a/vfx/DimensionalBreach.gd b/vfx/DimensionalBreach.gd new file mode 100644 index 0000000..922c29e --- /dev/null +++ b/vfx/DimensionalBreach.gd @@ -0,0 +1,119 @@ +class_name DimensionalBreach +extends Node2D + +# #125: the hole itself, in world space. +# +# The prologue's warp was a few lines turning around a point. This is built +# on the same idiom as BlackHoleVisual - an empty core with an accretion +# disc of counter-rotating ARC SEGMENTS around it, hotter and thicker toward +# the centre - because that is the part of the black-hole planet that reads +# well, and a breach and a black hole should look like the same kind of +# thing. +# +# What it adds over that planet is growth and infall. A planet's disc is +# steady; this one opens from nothing, drags streamers in from well outside +# its own disc, and keeps accelerating, so the picture says "this is +# happening now" rather than "this has always been here". +# +# grow() drives everything from one 0..1 number, so the director never has +# to know about rings or streamers - it says how far along the breach is and +# this decides what that looks like. + +const CORE_RADIUS: float = 54.0 +const RING_COUNT: int = 6 +const RING_STEP: float = 19.0 +const DISC_COLOR: Color = Color(0.55, 0.15, 0.85) +const DISC_HOT_COLOR: Color = Color(0.92, 0.62, 1.0) +const RIM_COLOR: Color = Color(0.98, 0.88, 1.0) +# Streamers of matter falling in from outside the disc. They are what makes +# the hole look like it is PULLING rather than merely spinning. +const STREAMER_COUNT: int = 14 +const STREAMER_REACH: float = 5.2 +const STREAMER_STEPS: int = 14 + +# 0 = closed, 1 = fully open. Everything below is scaled from it. +var progress: float = 0.0 +var disc_phase: float = 0.0 + + +func grow(value: float) -> void: + progress = clampf(value, 0.0, 1.0) + queue_redraw() + + +func horizon_radius() -> float: + return CORE_RADIUS * progress + + +func disc_radius() -> float: + return (CORE_RADIUS + float(RING_COUNT - 1) * RING_STEP) * progress + + +func _process(delta: float) -> void: + # The spin accelerates as it opens - a hole that turned at a constant + # rate while growing read as a picture being scaled up. + disc_phase += delta * (0.6 + progress * 2.4) + queue_redraw() + + +func _draw() -> void: + if progress <= 0.001: + return + var horizon: float = horizon_radius() + + # The core is drawn as genuinely empty - an opaque disc of nothing, not a + # dark tint. Same decision as BlackHoleVisual, and for the same reason: a + # black hole with something visible in the middle stops reading as one. + draw_circle(Vector2.ZERO, horizon, Color(0.0, 0.0, 0.0, 1.0)) + + _draw_streamers(horizon) + + for i in range(RING_COUNT): + var radius: float = (CORE_RADIUS + float(i) * RING_STEP) * progress + # 1.0 at the innermost ring, fading outward - drives colour, weight + # and spin, exactly as the planet's disc does. + var t: float = 1.0 - float(i) / float(maxi(RING_COUNT - 1, 1)) + var ring_color: Color = DISC_COLOR.lerp(DISC_HOT_COLOR, t * 0.7) + # Alternating direction per ring reads as turbulence rather than as + # one disc rotating rigidly. + var spin: float = disc_phase * (0.3 + t * 0.9) * (1.0 if i % 2 == 0 else -1.0) + for arc_i in range(2): + var start: float = spin + float(arc_i) * PI + 0.15 + var end: float = start + PI - 0.3 + var points := PackedVector2Array() + for j in range(25): + var angle: float = lerpf(start, end, float(j) / 24.0) + points.append(Vector2(cos(angle), sin(angle)) * radius) + NeonDraw.draw_glowing_path(self, points, ring_color, + (0.5 + t * 0.5) * progress, 0.45 + t * 0.4) + + # The rim: a bright, thin circle right on the horizon. It is what gives + # the void an edge instead of a fade, and it is drawn last so nothing + # else washes over it. + if horizon > 2.0: + draw_arc(Vector2.ZERO, horizon, 0.0, TAU, 48, + Color(RIM_COLOR, 0.85 * progress), 3.0, true) + draw_arc(Vector2.ZERO, horizon * 1.06, 0.0, TAU, 48, + Color(DISC_HOT_COLOR, 0.4 * progress), 7.0, true) + + +func _draw_streamers(horizon: float) -> void: + # Each streamer is a spiral falling from outside the disc down onto the + # horizon. They wind tighter as the breach opens, so the pull visibly + # strengthens rather than the picture merely getting bigger. + var outer: float = disc_radius() * STREAMER_REACH + if outer <= horizon + 1.0: + return + for i in range(STREAMER_COUNT): + var lead: float = disc_phase * 1.6 + TAU * float(i) / float(STREAMER_COUNT) + var points := PackedVector2Array() + for j in range(STREAMER_STEPS + 1): + var t: float = float(j) / float(STREAMER_STEPS) + # Cubic, so a streamer spends most of its length far out and + # whips through the last stretch - which is what infall does. + var radius: float = lerpf(outer, horizon, t * t * t) + var angle: float = lead + t * TAU * (0.55 + progress * 0.85) + points.append(Vector2(cos(angle), sin(angle)) * radius) + var fade: float = (0.16 + 0.5 * progress) * (0.5 + 0.5 * sin(lead * 3.0)) + NeonDraw.draw_glowing_path(self, points, + DISC_COLOR.lerp(DISC_HOT_COLOR, 0.35), maxf(fade, 0.08), 0.3) diff --git a/vfx/DimensionalBreach.tscn b/vfx/DimensionalBreach.tscn new file mode 100644 index 0000000..3cbe4d6 --- /dev/null +++ b/vfx/DimensionalBreach.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://vfx/DimensionalBreach.gd" id="1"] + +[node name="DimensionalBreach" type="Node2D"] +script = ExtResource("1") diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index b5eaed4..12d931d 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -28,18 +28,41 @@ const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0) # Carriers are the game's cargo hull; transporters belong to hubs and are # not fleet-eligible at all, so a convoy of them would be silently filtered # down to its escorts. -const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier", - "carrier", "interceptor", "carrier", "carrier", "interceptor", - "carrier", "interceptor"] +# +# #125: the order here pairs index-for-index with CONVOY_FORMATION below. +# The holds are the middle six entries and the escorts are the four outer +# ones, so the layout below can put the guns on the outside without either +# array having to know which slot is which - they simply agree. +const CONVOY: PackedStringArray = ["interceptor", "interceptor", + "interceptor", "interceptor", + "carrier", "carrier", "carrier", "carrier", "carrier", "carrier"] + +# #125: "space them a bit more and surround transporters by the default +# vessels". Escorts first, at the four corners of the box; then the holds +# packed in the middle of it. Roughly three times the spread of the fleet's +# own wedge - this is a freight convoy under guard, not a fighting +# formation, and it should not look like one. +const CONVOY_FORMATION: Array[Vector2] = [ + Vector2(-300.0, -260.0), Vector2(300.0, -260.0), + Vector2(-300.0, 260.0), Vector2(300.0, 260.0), + Vector2(-105.0, -120.0), Vector2(105.0, -120.0), + Vector2(-105.0, 0.0), Vector2(105.0, 0.0), + Vector2(-105.0, 120.0), Vector2(105.0, 120.0), +] const AMBUSH_COUNT: int = 3 const AMBUSH_AHEAD: float = 1100.0 -const RUN_DISTANCE: float = 1500.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 const BELT_ROCKS: int = 90 const BELT_HALF_WIDTH: float = 2600.0 const BELT_DEPTH: float = 1400.0 const BREACH_PULL: float = 620.0 const SHAKE_MAX: float = 34.0 +# How far past the horizon the lens reaches. Generous: the bend is what +# sells the hole, and it falls off to nothing on its own well before this. +const BREACH_LENS_SCALE: float = 7.0 const VORTEX_COLORS: Array = [ Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8), Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6), @@ -62,8 +85,11 @@ var rocks: Array = [] var shake: float = 0.0 var vortex_time: float = 0.0 var survivor: Node2D = null +var breach: Node2D = null var _done: bool = false +@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay") + func _ready() -> void: # A NEW-game thing. A player returning from a planet surface, or @@ -91,11 +117,28 @@ func _process(delta: float) -> void: beat_time += delta _tick(delta) _apply_shake(delta) - if beat_time >= _deadline() or _beat_complete(): - if beat >= Beat.AFTERMATH: - finish() - return - _begin(beat + 1) + if beat_time < _deadline() and not _ready_to_advance(): + return + if beat >= Beat.AFTERMATH: + finish() + return + _begin(beat + 1) + + +func _ready_to_advance() -> bool: + if beat_time < _floor_time(): + return false + if _waits_for_narration() and narration.is_speaking(): + return false + return _beat_complete() or beat_time >= _floor_time() and _floor_only() + + +# Beats with no condition of their own end when their floor is up. Kept +# explicit so a beat that DOES have a condition cannot be advanced early by +# the floor alone. +func _floor_only() -> bool: + return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.BREACH \ + or beat == Beat.VORTEX or beat == Beat.AFTERMATH # Ends the prologue and hands over. The same call ends it whether it was @@ -125,7 +168,42 @@ func skip() -> void: finish() +func _apply_convoy_formation() -> void: + for i in range(mini(CONVOY_FORMATION.size(), GameState.formation.size())): + GameState.formation[i] = CONVOY_FORMATION[i] + + +# The world-space half of the breach, plus the screen-space lens over it. +func _open_breach() -> void: + if is_instance_valid(breach): + return + breach = load("res://vfx/DimensionalBreach.tscn").instantiate() + breach.position = breach_at + get_parent().add_child(breach) + + +# Drives both halves from one number. The overlay works in SCREEN pixels, so +# the world point is projected through the canvas transform here - only the +# director knows where the camera is. +func _drive_breach(open_amount: float) -> void: + if is_instance_valid(breach): + breach.grow(open_amount) + if overlay == null: + return + var canvas: Transform2D = get_viewport().get_canvas_transform() + var on_screen: Vector2 = canvas * breach_at + var horizon: float = (breach.horizon_radius() if is_instance_valid(breach) else 0.0) \ + * canvas.get_scale().x + overlay.set_breach(on_screen, horizon, + maxf(horizon * BREACH_LENS_SCALE, 1.0), open_amount) + + func _clear_props() -> void: + if is_instance_valid(breach): + breach.queue_free() + breach = null + if overlay != null: + overlay.clear_breach() for prop in ambush + rocks: if is_instance_valid(prop): prop.queue_free() @@ -143,6 +221,11 @@ func _begin(next: int) -> void: Beat.BRIEF: start_at = fleet.get_centroid() GameState.intro_fleet_size = CONVOY.size() + # #125: the convoy flies in its own spread-out box, escorts on + # the corners. spawn_ships seats vessel i in formation slot i, + # so writing the layout here - before the spawn - is all it + # takes for CONVOY and CONVOY_FORMATION to line up. + _apply_convoy_formation() fleet.spawn_ships(CONVOY.size(), start_at, CONVOY) narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.", 5.0) narration.say("Hold your heading and keep the holds intact. We will be watching.", 4.5) @@ -159,6 +242,7 @@ func _begin(next: int) -> void: Beat.BREACH: GameState.intro_locks_movement = true breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) + _open_breach() narration.say("Kestrel, your heading is - something is opening ahead of you -", 4.0) Beat.VORTEX: vortex_time = 0.0 @@ -172,17 +256,44 @@ func _begin(next: int) -> void: func _deadline() -> float: match beat: - Beat.BRIEF: return 10.0 - Beat.RUN: return 45.0 - Beat.AMBUSH: return 75.0 - Beat.CLEAR: return 4.0 - Beat.GAUNTLET: return 60.0 - Beat.BREACH: return 4.2 - Beat.VORTEX: return 3.6 - Beat.AFTERMATH: return 10.5 + Beat.BRIEF: return 16.0 + Beat.RUN: return 60.0 + Beat.AMBUSH: return 90.0 + Beat.CLEAR: return 8.0 + Beat.GAUNTLET: return 75.0 + Beat.BREACH: return 9.0 + Beat.VORTEX: return 7.0 + Beat.AFTERMATH: return 15.0 return 1.0 +# #125: "everything happens seconds after the previous thing started." +# +# The deadline above is how long a beat may take; this is how long it must +# take. Two beats ran short for the same reason - their completion test was +# satisfied while the guide was still mid-sentence - so no beat may end +# before its own floor, and a beat that is still TALKING holds regardless. +# One rule, applied in one place, rather than tuning each condition. +func _floor_time() -> float: + match beat: + Beat.BRIEF: return 11.0 + Beat.RUN: return 6.0 + Beat.AMBUSH: return 3.0 + Beat.CLEAR: return 5.0 + Beat.GAUNTLET: return 6.0 + Beat.BREACH: return 8.0 + Beat.VORTEX: return 6.5 + Beat.AFTERMATH: return 12.0 + return 0.0 + + +# Beats where the guide is the point, and cutting the line off would be +# cutting the beat off. The action beats are deliberately not here: a wave +# arriving must not wait for a sentence to finish. +func _waits_for_narration() -> bool: + return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.AFTERMATH + + func _beat_complete() -> bool: match beat: Beat.RUN: @@ -199,6 +310,9 @@ func _tick(delta: float) -> void: match beat: Beat.BREACH: shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX) + # Opens over the whole beat rather than snapping to full size, + # which is the "growing" the ticket asks for. + _drive_breach(clampf(beat_time / maxf(_deadline(), 0.001), 0.0, 1.0)) # Dragged in, not flown in: the player has already lost the wheel. var to_breach: Vector2 = breach_at - fleet.get_centroid() if to_breach.length() > 1.0: @@ -207,10 +321,15 @@ func _tick(delta: float) -> void: Beat.VORTEX: vortex_time += delta shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35) + _drive_breach(1.0) _whirl(delta) queue_redraw() Beat.AFTERMATH: shake = maxf(shake - delta * SHAKE_MAX, 0.0) + # It closes behind them over the first stretch of the aftermath, + # so the last thing the player sees is the hole sealing rather + # than a cut. + _drive_breach(clampf(1.0 - beat_time / 3.0, 0.0, 1.0)) queue_redraw() @@ -309,42 +428,17 @@ func _living(nodes: Array) -> int: # --- what the prologue draws ---------------------------------------------- func _draw() -> void: - if not running: + # #125: the breach draws itself now - a growing black hole out in the + # world (DimensionalBreach) with a screen-space lens over it + # (BreachOverlay). What is left here is the ribbon of matter being torn + # off the convoy, which belongs to the fleet rather than to the hole. + if not running or beat != Beat.VORTEX: return - if beat == Beat.BREACH: - _draw_breach(clampf(beat_time / _deadline(), 0.0, 1.0)) - elif beat == Beat.VORTEX: - _draw_breach(1.0) - _draw_vortex() - - -func _draw_breach(progress: float) -> void: - var radius: float = lerpf(20.0, 340.0, progress) - draw_circle(breach_at, radius, Color(0.06, 0.0, 0.12, 0.75 * progress)) - for i in range(VORTEX_COLORS.size()): - var colour: Color = VORTEX_COLORS[i] - var r: float = radius * (0.45 + 0.14 * float(i)) - var spin: float = beat_time * (1.4 + 0.5 * float(i)) - NeonDraw.draw_glowing_path(self, _spiral(breach_at, r, spin, i), - Color(colour, 0.55 + 0.35 * progress), 0.9, 0.5) - - -func _draw_vortex() -> void: - # Ribbons dragged inward, brightening as the fleet is pulled apart. var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0) for i in range(VORTEX_COLORS.size() * 2): var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()] var angle: float = vortex_time * 3.2 + TAU * float(i) / float(VORTEX_COLORS.size() * 2) - var outer: float = lerpf(900.0, 120.0, pull) + var outer: float = lerpf(900.0, 150.0, pull) var from: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * outer - var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 40.0 - NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.35 + 0.5 * pull), 1.0, 0.8) - - -func _spiral(centre: Vector2, radius: float, phase: float, arm: int) -> PackedVector2Array: - var points: PackedVector2Array = PackedVector2Array() - for i in range(18): - var t: float = float(i) / 17.0 - var angle: float = phase + t * TAU * 0.8 + float(arm) - points.append(centre + Vector2(cos(angle), sin(angle)) * radius * (0.35 + 0.65 * t)) - return points + var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 60.0 + NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.3 + 0.45 * pull), 1.0, 0.8) diff --git a/world/Main.tscn b/world/Main.tscn index 707df7a..7fec674 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -29,6 +29,7 @@ [ext_resource type="Script" path="res://ui/DevWaveButton.gd" id="38"] [ext_resource type="Script" path="res://ui/HangarMenuPanel.gd" id="39"] [ext_resource type="Script" path="res://logistics/RepairDroneField.gd" id="40"] +[ext_resource type="Script" path="res://ui/BreachOverlay.gd" id="41"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] @@ -243,6 +244,14 @@ script = ExtResource("10") icon = 5 glow_color = Color(1, 0.45, 0.2, 1) +[node name="BreachOverlay" type="ColorRect" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(1, 1, 1, 1) +script = ExtResource("41") + [node name="NarrationBanner" type="Control" parent="UI"] visible = false mouse_filter = 2