#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
72
shaders/breach.gdshader
Normal file
72
shaders/breach.gdshader
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,11 @@ extends SceneTree
|
||||||
# I. the handover: the flag clears, the buttons come back, the spawner and
|
# 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
|
# 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
|
# 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 game_state: Node
|
||||||
var scene: Node
|
var scene: Node
|
||||||
|
|
@ -85,6 +90,18 @@ func _process(delta: float) -> bool:
|
||||||
if not director.running:
|
if not director.running:
|
||||||
return _fail("the director did not start on a new game")
|
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 --------------------------------------
|
# --- C. the convoy --------------------------------------
|
||||||
convoy_size = fleet.ships.size()
|
convoy_size = fleet.ships.size()
|
||||||
if convoy_size <= GameState.BASE_FLEET_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")
|
return _fail("the convoy has no escorts at all")
|
||||||
print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts])
|
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 -----------------------
|
# --- B. movement and nothing else -----------------------
|
||||||
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
|
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
|
||||||
"UI/BuildingActionButton", "UI/EnterAtmosphereButton",
|
"UI/BuildingActionButton", "UI/EnterAtmosphereButton",
|
||||||
|
|
@ -138,7 +184,10 @@ func _process(delta: float) -> bool:
|
||||||
stage = 2
|
stage = 2
|
||||||
return false
|
return false
|
||||||
2:
|
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
|
return false
|
||||||
# The director advances itself, so the evidence that clearing the
|
# The director advances itself, so the evidence that clearing the
|
||||||
# ambush ended the beat is that it has MOVED ON - asking
|
# 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")
|
return _fail("player steering still moved the fleet during the breach")
|
||||||
print("the breach has the wheel: steering input moves nothing")
|
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 --------------------------------------
|
# --- H. the vortex --------------------------------------
|
||||||
director._begin(director.Beat.VORTEX)
|
director._begin(director.Beat.VORTEX)
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
|
|
@ -226,6 +297,10 @@ func _process(delta: float) -> bool:
|
||||||
return _fail("the build button never came back")
|
return _fail("the build button never came back")
|
||||||
if narration.is_speaking():
|
if narration.is_speaking():
|
||||||
return _fail("the guide is still talking after the handover")
|
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")
|
print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back")
|
||||||
_drop_scene()
|
_drop_scene()
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
|
|
|
||||||
43
ui/BreachOverlay.gd
Normal file
43
ui/BreachOverlay.gd
Normal file
|
|
@ -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))
|
||||||
|
|
@ -10,10 +10,22 @@ extends Control
|
||||||
# Lines queue rather than replacing each other, so a beat that fires while
|
# Lines queue rather than replacing each other, so a beat that fires while
|
||||||
# the previous line is still up does not silently eat it.
|
# 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 FADE_SECONDS: float = 0.5
|
||||||
const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0)
|
const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0)
|
||||||
const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95)
|
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 label: Label
|
||||||
var queued: Array = []
|
var queued: Array = []
|
||||||
|
|
@ -97,14 +109,54 @@ func _process(delta: float) -> void:
|
||||||
_advance()
|
_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:
|
func _draw() -> void:
|
||||||
if current == "":
|
if current == "":
|
||||||
return
|
return
|
||||||
# Fades out over its last moments so lines hand over rather than cutting.
|
# 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)
|
var alpha: float = clampf(time_left / FADE_SECONDS, 0.0, 1.0)
|
||||||
label.modulate.a = alpha
|
label.modulate.a = alpha
|
||||||
draw_rect(Rect2(Vector2.ZERO, size), Color(0.02, 0.05, 0.12, 0.72 * alpha))
|
var frame: PackedVector2Array = _frame()
|
||||||
NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y),
|
|
||||||
Color(FRAME_COLOR, alpha), 0.7, 0.55)
|
draw_colored_polygon(frame, Color(0.02, 0.05, 0.12, 0.78 * alpha))
|
||||||
NeonDraw.draw_glowing_line(self, Vector2(0.0, 0.0), Vector2(size.x, 0.0),
|
NeonDraw.draw_glowing_polygon(self, frame, Color(FRAME_COLOR, alpha), 0.8)
|
||||||
Color(FRAME_COLOR, alpha * 0.6), 0.5, 0.4)
|
|
||||||
|
# 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)
|
||||||
|
|
|
||||||
119
vfx/DimensionalBreach.gd
Normal file
119
vfx/DimensionalBreach.gd
Normal file
|
|
@ -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)
|
||||||
6
vfx/DimensionalBreach.tscn
Normal file
6
vfx/DimensionalBreach.tscn
Normal file
|
|
@ -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")
|
||||||
|
|
@ -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
|
# 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
|
# not fleet-eligible at all, so a convoy of them would be silently filtered
|
||||||
# down to its escorts.
|
# down to its escorts.
|
||||||
const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier",
|
#
|
||||||
"carrier", "interceptor", "carrier", "carrier", "interceptor",
|
# #125: the order here pairs index-for-index with CONVOY_FORMATION below.
|
||||||
"carrier", "interceptor"]
|
# 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_COUNT: int = 3
|
||||||
const AMBUSH_AHEAD: float = 1100.0
|
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_AHEAD: float = 2200.0
|
||||||
const BELT_ROCKS: int = 90
|
const BELT_ROCKS: int = 90
|
||||||
const BELT_HALF_WIDTH: float = 2600.0
|
const BELT_HALF_WIDTH: float = 2600.0
|
||||||
const BELT_DEPTH: float = 1400.0
|
const BELT_DEPTH: float = 1400.0
|
||||||
const BREACH_PULL: float = 620.0
|
const BREACH_PULL: float = 620.0
|
||||||
const SHAKE_MAX: float = 34.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 = [
|
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(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),
|
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 shake: float = 0.0
|
||||||
var vortex_time: float = 0.0
|
var vortex_time: float = 0.0
|
||||||
var survivor: Node2D = null
|
var survivor: Node2D = null
|
||||||
|
var breach: Node2D = null
|
||||||
var _done: bool = false
|
var _done: bool = false
|
||||||
|
|
||||||
|
@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay")
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# A NEW-game thing. A player returning from a planet surface, or
|
# A NEW-game thing. A player returning from a planet surface, or
|
||||||
|
|
@ -91,11 +117,28 @@ func _process(delta: float) -> void:
|
||||||
beat_time += delta
|
beat_time += delta
|
||||||
_tick(delta)
|
_tick(delta)
|
||||||
_apply_shake(delta)
|
_apply_shake(delta)
|
||||||
if beat_time >= _deadline() or _beat_complete():
|
if beat_time < _deadline() and not _ready_to_advance():
|
||||||
if beat >= Beat.AFTERMATH:
|
return
|
||||||
finish()
|
if beat >= Beat.AFTERMATH:
|
||||||
return
|
finish()
|
||||||
_begin(beat + 1)
|
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
|
# Ends the prologue and hands over. The same call ends it whether it was
|
||||||
|
|
@ -125,7 +168,42 @@ func skip() -> void:
|
||||||
finish()
|
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:
|
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:
|
for prop in ambush + rocks:
|
||||||
if is_instance_valid(prop):
|
if is_instance_valid(prop):
|
||||||
prop.queue_free()
|
prop.queue_free()
|
||||||
|
|
@ -143,6 +221,11 @@ func _begin(next: int) -> void:
|
||||||
Beat.BRIEF:
|
Beat.BRIEF:
|
||||||
start_at = fleet.get_centroid()
|
start_at = fleet.get_centroid()
|
||||||
GameState.intro_fleet_size = CONVOY.size()
|
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)
|
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("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)
|
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:
|
Beat.BREACH:
|
||||||
GameState.intro_locks_movement = true
|
GameState.intro_locks_movement = true
|
||||||
breach_at = fleet.get_centroid() + Vector2(0.0, -900.0)
|
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)
|
narration.say("Kestrel, your heading is - something is opening ahead of you -", 4.0)
|
||||||
Beat.VORTEX:
|
Beat.VORTEX:
|
||||||
vortex_time = 0.0
|
vortex_time = 0.0
|
||||||
|
|
@ -172,17 +256,44 @@ func _begin(next: int) -> void:
|
||||||
|
|
||||||
func _deadline() -> float:
|
func _deadline() -> float:
|
||||||
match beat:
|
match beat:
|
||||||
Beat.BRIEF: return 10.0
|
Beat.BRIEF: return 16.0
|
||||||
Beat.RUN: return 45.0
|
Beat.RUN: return 60.0
|
||||||
Beat.AMBUSH: return 75.0
|
Beat.AMBUSH: return 90.0
|
||||||
Beat.CLEAR: return 4.0
|
Beat.CLEAR: return 8.0
|
||||||
Beat.GAUNTLET: return 60.0
|
Beat.GAUNTLET: return 75.0
|
||||||
Beat.BREACH: return 4.2
|
Beat.BREACH: return 9.0
|
||||||
Beat.VORTEX: return 3.6
|
Beat.VORTEX: return 7.0
|
||||||
Beat.AFTERMATH: return 10.5
|
Beat.AFTERMATH: return 15.0
|
||||||
return 1.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:
|
func _beat_complete() -> bool:
|
||||||
match beat:
|
match beat:
|
||||||
Beat.RUN:
|
Beat.RUN:
|
||||||
|
|
@ -199,6 +310,9 @@ func _tick(delta: float) -> void:
|
||||||
match beat:
|
match beat:
|
||||||
Beat.BREACH:
|
Beat.BREACH:
|
||||||
shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX)
|
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.
|
# Dragged in, not flown in: the player has already lost the wheel.
|
||||||
var to_breach: Vector2 = breach_at - fleet.get_centroid()
|
var to_breach: Vector2 = breach_at - fleet.get_centroid()
|
||||||
if to_breach.length() > 1.0:
|
if to_breach.length() > 1.0:
|
||||||
|
|
@ -207,10 +321,15 @@ func _tick(delta: float) -> void:
|
||||||
Beat.VORTEX:
|
Beat.VORTEX:
|
||||||
vortex_time += delta
|
vortex_time += delta
|
||||||
shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35)
|
shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35)
|
||||||
|
_drive_breach(1.0)
|
||||||
_whirl(delta)
|
_whirl(delta)
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
Beat.AFTERMATH:
|
Beat.AFTERMATH:
|
||||||
shake = maxf(shake - delta * SHAKE_MAX, 0.0)
|
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()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -309,42 +428,17 @@ func _living(nodes: Array) -> int:
|
||||||
# --- what the prologue draws ----------------------------------------------
|
# --- what the prologue draws ----------------------------------------------
|
||||||
|
|
||||||
func _draw() -> void:
|
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
|
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)
|
var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0)
|
||||||
for i in range(VORTEX_COLORS.size() * 2):
|
for i in range(VORTEX_COLORS.size() * 2):
|
||||||
var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()]
|
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 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 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
|
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.35 + 0.5 * pull), 1.0, 0.8)
|
NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.3 + 0.45 * 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
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
[ext_resource type="Script" path="res://ui/DevWaveButton.gd" id="38"]
|
[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://ui/HangarMenuPanel.gd" id="39"]
|
||||||
[ext_resource type="Script" path="res://logistics/RepairDroneField.gd" id="40"]
|
[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://logistics/TransporterField.gd" id="23"]
|
||||||
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
|
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
|
||||||
[ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"]
|
[ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"]
|
||||||
|
|
@ -243,6 +244,14 @@ script = ExtResource("10")
|
||||||
icon = 5
|
icon = 5
|
||||||
glow_color = Color(1, 0.45, 0.2, 1)
|
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"]
|
[node name="NarrationBanner" type="Control" parent="UI"]
|
||||||
visible = false
|
visible = false
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue