#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 <noreply@anthropic.com>
This commit is contained in:
parent
7d344b2a0a
commit
d455361328
8 changed files with 527 additions and 57 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue