mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(dashboard): show polish — readable lines, gliding arcs, a real hall
- Lines get a layered soft glow plus a radial pool of darkness under the text zone, so they hold up over the burning border map without a hard box. - Arc heads now interpolate fractionally along the curve with ease-out growth (the whole-segment quantization read as stutter), in the show and the beacon teaser alike. - The score gains a convolution reverb with a procedurally generated impulse response; the fear act trades raw saws for an organic bed (sine sub, drifting fifth, breathing noise rumble, a semitone grind in the dread act), the shimmer runs through a feedback echo, impact tails bloom in the hall, the heartbeat stays close and dry.
This commit is contained in:
@@ -73,14 +73,21 @@ function TeaserCanvas() {
|
||||
const my = (y1 + y2) / 2 - Math.min(Math.abs(x2 - x1) * 0.35, h * 0.34)
|
||||
c.beginPath()
|
||||
const steps = 22
|
||||
const upto = Math.max(2, Math.round(steps * grow))
|
||||
const eased = 1 - (1 - grow) ** 3
|
||||
const upto = Math.floor(steps * eased)
|
||||
const pt = (q: number): [number, number] => [
|
||||
(1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2,
|
||||
(1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2,
|
||||
]
|
||||
for (let s = 0; s <= upto; s++) {
|
||||
const q = s / steps
|
||||
const ix = (1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2
|
||||
const iy = (1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2
|
||||
const [ix, iy] = pt(s / steps)
|
||||
if (s === 0) c.moveTo(ix, iy)
|
||||
else c.lineTo(ix, iy)
|
||||
}
|
||||
if (eased < 1) {
|
||||
const [tx, ty] = pt(eased)
|
||||
c.lineTo(tx, ty)
|
||||
}
|
||||
c.strokeStyle = `rgba(255, 180, 95, ${0.12 * fade})`
|
||||
c.lineWidth = 3.4
|
||||
c.stroke()
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
justify-content: center;
|
||||
padding: 0 12vw;
|
||||
pointer-events: none;
|
||||
/* A soft radial pool of darkness under the words, so they never sink into
|
||||
the burning map — depth, not a hard box. */
|
||||
background: radial-gradient(52% 34% at 50% 50%, rgba(5, 6, 10, 0.55), rgba(5, 6, 10, 0) 72%);
|
||||
}
|
||||
|
||||
.fz-line {
|
||||
@@ -50,7 +53,10 @@
|
||||
font-weight: 300;
|
||||
line-height: 1.45;
|
||||
letter-spacing: 0.015em;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85);
|
||||
text-shadow:
|
||||
0 1px 2px rgba(5, 6, 10, 0.9),
|
||||
0 4px 18px rgba(5, 6, 10, 0.8),
|
||||
0 10px 46px rgba(5, 6, 10, 0.65);
|
||||
animation: fzLineIn 1.6s cubic-bezier(0.19, 1, 0.22, 1) both;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Procedural soundtrack for the 4.0.0 "KEINE ANGST" show. Everything is
|
||||
// synthesized with the Web Audio API at runtime — heartbeat with a real click
|
||||
// transient, wind-noise textures, a trailer-style noise riser, cue-driven
|
||||
// sub-bass impacts and a moving chord progression with glided voice-leading —
|
||||
// so the show ships zero audio assets and touches no copyrighted material.
|
||||
// synthesized with the Web Audio API at runtime — no audio assets, no
|
||||
// copyrighted material. The cinema comes from three things: a convolution
|
||||
// reverb fed by a procedurally generated impulse response (dry synthesis
|
||||
// always sounds like a buzzer; a hall makes it a room), an organic low bed of
|
||||
// sub sine + filtered rumble instead of raw sawtooths, and cue-driven sub
|
||||
// impacts whose tails bloom in that same hall.
|
||||
|
||||
export type NoFearAct = 'fear' | 'dread' | 'silence' | 'hope' | 'anthem' | 'end'
|
||||
|
||||
@@ -24,6 +26,7 @@ const BASS_ROOTS = [36.71, 41.2, 46.25, 41.2] // D1 E1 F#1 E1
|
||||
export class NoFearAudio {
|
||||
private ctx: AudioContext | null = null
|
||||
private master: GainNode | null = null
|
||||
private reverbIn: GainNode | null = null
|
||||
private noiseBuf: AudioBuffer | null = null
|
||||
private muted = false
|
||||
private act: NoFearAct | null = null
|
||||
@@ -46,15 +49,26 @@ export class NoFearAudio {
|
||||
// A lazily-loaded chunk can construct us a beat after the click gesture —
|
||||
// resume() re-arms the context if the browser started it suspended.
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {})
|
||||
this.master = this.ctx.createGain()
|
||||
this.master.gain.value = this.muted ? 0 : 0.9
|
||||
const limiter = this.ctx.createDynamicsCompressor()
|
||||
limiter.threshold.value = -12
|
||||
limiter.knee.value = 22
|
||||
limiter.ratio.value = 12
|
||||
this.master.connect(limiter)
|
||||
limiter.connect(this.ctx.destination)
|
||||
// 2s of looped white noise feeds every texture (wind, risers, ticks).
|
||||
this.master = this.ctx.createGain()
|
||||
this.master.gain.value = this.muted ? 0 : 0.9
|
||||
this.master.connect(limiter)
|
||||
// The hall: a convolver fed by 3.4s of exponentially decaying stereo noise.
|
||||
// Everything sends into it in different amounts; the wet return rides the
|
||||
// same master gain so mute/fade cover it too.
|
||||
const convolver = this.ctx.createConvolver()
|
||||
convolver.buffer = this.makeImpulseResponse(3.4, 2.7)
|
||||
const wet = this.ctx.createGain()
|
||||
wet.gain.value = 0.5
|
||||
this.reverbIn = this.ctx.createGain()
|
||||
this.reverbIn.connect(convolver)
|
||||
convolver.connect(wet)
|
||||
wet.connect(this.master)
|
||||
// 2s of looped white noise feeds every texture (wind, rumble, risers, ticks).
|
||||
const len = this.ctx.sampleRate * 2
|
||||
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
|
||||
const data = this.noiseBuf.getChannelData(0)
|
||||
@@ -63,6 +77,32 @@ export class NoFearAudio {
|
||||
this.heartbeatTimer = window.setInterval(() => this.scheduleBeats(), 100)
|
||||
}
|
||||
|
||||
private makeImpulseResponse(seconds: number, decay: number): AudioBuffer {
|
||||
const ctx = this.ctx as AudioContext
|
||||
const rate = ctx.sampleRate
|
||||
const len = Math.floor(rate * seconds)
|
||||
const ir = ctx.createBuffer(2, len, rate)
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const d = ir.getChannelData(ch)
|
||||
for (let i = 0; i < len; i++) {
|
||||
d[i] = (Math.random() * 2 - 1) * (1 - i / len) ** decay
|
||||
}
|
||||
}
|
||||
return ir
|
||||
}
|
||||
|
||||
/** Routes a node to the dry bus plus a measured send into the hall. */
|
||||
private out(node: AudioNode, send: number): void {
|
||||
if (!this.master || !this.reverbIn) return
|
||||
node.connect(this.master)
|
||||
if (send > 0) {
|
||||
const g = (this.ctx as AudioContext).createGain()
|
||||
g.gain.value = send
|
||||
node.connect(g)
|
||||
g.connect(this.reverbIn)
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
this.muted = muted
|
||||
if (this.master && this.ctx) {
|
||||
@@ -85,7 +125,7 @@ export class NoFearAudio {
|
||||
else void this.ctx.resume().catch(() => {})
|
||||
}
|
||||
|
||||
/** A cue-driven cinema hit: sub boom + filtered noise burst. strength 0..1. */
|
||||
/** A cue-driven cinema hit: sub boom + filtered noise burst, tail blooming in the hall. */
|
||||
impact(strength = 1): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const t = this.ctx.currentTime + 0.02
|
||||
@@ -97,7 +137,8 @@ export class NoFearAudio {
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(0.9 * strength, t + 0.018)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6)
|
||||
o.connect(g); g.connect(this.master)
|
||||
o.connect(g)
|
||||
this.out(g, 0.5)
|
||||
o.start(t); o.stop(t + 1.8)
|
||||
if (this.noiseBuf) {
|
||||
const n = this.ctx.createBufferSource()
|
||||
@@ -108,9 +149,10 @@ export class NoFearAudio {
|
||||
lp.frequency.exponentialRampToValueAtTime(120, t + 0.5)
|
||||
const ng = this.ctx.createGain()
|
||||
ng.gain.setValueAtTime(0.0001, t)
|
||||
ng.gain.exponentialRampToValueAtTime(0.32 * strength, t + 0.012)
|
||||
ng.gain.exponentialRampToValueAtTime(0.3 * strength, t + 0.012)
|
||||
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.6)
|
||||
n.connect(lp); lp.connect(ng); ng.connect(this.master)
|
||||
n.connect(lp); lp.connect(ng)
|
||||
this.out(ng, 0.6)
|
||||
n.start(t); n.stop(t + 0.8)
|
||||
}
|
||||
}
|
||||
@@ -125,14 +167,14 @@ export class NoFearAudio {
|
||||
case 'fear':
|
||||
this.bpm = 74
|
||||
this.beatGain = 0.55
|
||||
this.drone(55, 82.4, 0.15, 230, 3)
|
||||
this.darkBed(0.16, false)
|
||||
this.wind(0.05, 420)
|
||||
break
|
||||
case 'dread':
|
||||
this.bpm = 116
|
||||
this.beatGain = 0.7
|
||||
this.drone(55, 77.8, 0.19, 300, 1) // flattened fifth — it beats uneasily
|
||||
this.wind(0.085, 700)
|
||||
this.darkBed(0.21, true) // + a semitone rub that grinds
|
||||
this.wind(0.09, 700)
|
||||
this.riser(t)
|
||||
break
|
||||
case 'silence':
|
||||
@@ -163,6 +205,7 @@ export class NoFearAudio {
|
||||
const ctx = this.ctx
|
||||
this.ctx = null
|
||||
this.master = null
|
||||
this.reverbIn = null
|
||||
if (ctx) window.setTimeout(() => { void ctx.close().catch(() => {}) }, 300)
|
||||
}
|
||||
|
||||
@@ -185,41 +228,70 @@ export class NoFearAudio {
|
||||
})
|
||||
}
|
||||
|
||||
/** Two slightly detuned low saws through a lowpass — the fear act's room tone. */
|
||||
private drone(f1: number, f2: number, level: number, cutoff: number, attack: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
/**
|
||||
* The fear act's floor: a sine sub, a quiet detune-drifting fifth and a
|
||||
* low rumble of filtered noise that breathes — a room, not a synthesizer.
|
||||
* `grind` adds a close semitone against the sub that beats uneasily.
|
||||
*/
|
||||
private darkBed(level: number, grind: boolean): void {
|
||||
if (!this.ctx || !this.noiseBuf) return
|
||||
const t = this.ctx.currentTime
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(level, t + attack)
|
||||
const lp = this.ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.value = cutoff
|
||||
// A slow LFO breathes the filter so the drone never sits still.
|
||||
const lfo = this.ctx.createOscillator()
|
||||
lfo.frequency.value = 0.09
|
||||
const lfoGain = this.ctx.createGain()
|
||||
lfoGain.gain.value = cutoff * 0.35
|
||||
lfo.connect(lfoGain); lfoGain.connect(lp.frequency)
|
||||
lfo.start(t)
|
||||
const oscs: (OscillatorNode | AudioBufferSourceNode)[] = [f1, f2].map(f => {
|
||||
const o = this.ctx!.createOscillator()
|
||||
o.type = 'sawtooth'
|
||||
o.frequency.value = f
|
||||
o.detune.value = Math.random() * 7 - 3.5
|
||||
o.connect(lp)
|
||||
o.start(t)
|
||||
return o
|
||||
})
|
||||
oscs.push(lfo)
|
||||
lp.connect(g)
|
||||
g.connect(this.master)
|
||||
g.gain.exponentialRampToValueAtTime(level, t + 3)
|
||||
const sub = this.ctx.createOscillator()
|
||||
sub.type = 'sine'
|
||||
sub.frequency.value = 55
|
||||
const subG = this.ctx.createGain()
|
||||
subG.gain.value = 0.85
|
||||
sub.connect(subG); subG.connect(g)
|
||||
const fifth = this.ctx.createOscillator()
|
||||
fifth.type = 'triangle'
|
||||
fifth.frequency.value = 82.4
|
||||
const fifthG = this.ctx.createGain()
|
||||
fifthG.gain.value = 0.3
|
||||
fifth.connect(fifthG); fifthG.connect(g)
|
||||
// Slow detune drift keeps the interval from ever sitting still.
|
||||
const drift = this.ctx.createOscillator()
|
||||
drift.frequency.value = 0.07
|
||||
const driftG = this.ctx.createGain()
|
||||
driftG.gain.value = 6
|
||||
drift.connect(driftG); driftG.connect(fifth.detune)
|
||||
const oscs: (OscillatorNode | AudioBufferSourceNode)[] = [sub, fifth, drift]
|
||||
if (grind) {
|
||||
const rub = this.ctx.createOscillator()
|
||||
rub.type = 'sine'
|
||||
rub.frequency.value = 58.27 // a semitone against the sub — it grinds
|
||||
const rubG = this.ctx.createGain()
|
||||
rubG.gain.value = 0.4
|
||||
rub.connect(rubG); rubG.connect(g)
|
||||
rub.start(t)
|
||||
oscs.push(rub)
|
||||
}
|
||||
// The rumble: looped noise through a low lowpass, its gain breathing slowly.
|
||||
const rumble = this.ctx.createBufferSource()
|
||||
rumble.buffer = this.noiseBuf
|
||||
rumble.loop = true
|
||||
const rlp = this.ctx.createBiquadFilter()
|
||||
rlp.type = 'lowpass'
|
||||
rlp.frequency.value = 120
|
||||
const rg = this.ctx.createGain()
|
||||
rg.gain.value = 0.5
|
||||
const breathe = this.ctx.createOscillator()
|
||||
breathe.frequency.value = 0.06
|
||||
const breatheG = this.ctx.createGain()
|
||||
breatheG.gain.value = 0.25
|
||||
breathe.connect(breatheG); breatheG.connect(rg.gain)
|
||||
rumble.connect(rlp); rlp.connect(rg); rg.connect(g)
|
||||
sub.start(t); fifth.start(t); drift.start(t); rumble.start(t); breathe.start(t)
|
||||
oscs.push(rumble, breathe)
|
||||
this.out(g, 0.3)
|
||||
this.hold(g, oscs)
|
||||
}
|
||||
|
||||
/** Bandpassed noise that slowly swells and recedes — wind through the scene. */
|
||||
private wind(level: number, freq: number): void {
|
||||
if (!this.ctx || !this.master || !this.noiseBuf) return
|
||||
if (!this.ctx || !this.noiseBuf) return
|
||||
const t = this.ctx.currentTime
|
||||
const src = this.ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuf
|
||||
@@ -237,14 +309,15 @@ export class NoFearAudio {
|
||||
lfoGain.gain.value = level * 0.55
|
||||
lfo.connect(lfoGain); lfoGain.connect(g.gain)
|
||||
lfo.start(t)
|
||||
src.connect(bp); bp.connect(g); g.connect(this.master)
|
||||
src.connect(bp); bp.connect(g)
|
||||
this.out(g, 0.55)
|
||||
src.start(t)
|
||||
this.hold(g, [src, lfo])
|
||||
}
|
||||
|
||||
/** Trailer riser: a saw sweep doubled by a noise sweep, 14s of climb. */
|
||||
private riser(t: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
if (!this.ctx) return
|
||||
const o = this.ctx.createOscillator()
|
||||
o.type = 'sawtooth'
|
||||
o.frequency.setValueAtTime(180, t)
|
||||
@@ -256,8 +329,9 @@ export class NoFearAudio {
|
||||
bp.Q.value = 8
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(0.055, t + 7)
|
||||
o.connect(bp); bp.connect(g); g.connect(this.master)
|
||||
g.gain.exponentialRampToValueAtTime(0.05, t + 7)
|
||||
o.connect(bp); bp.connect(g)
|
||||
this.out(g, 0.35)
|
||||
o.start(t)
|
||||
const nodes: (OscillatorNode | AudioBufferSourceNode)[] = [o]
|
||||
if (this.noiseBuf) {
|
||||
@@ -271,8 +345,9 @@ export class NoFearAudio {
|
||||
nbp.Q.value = 1.4
|
||||
const ng = this.ctx.createGain()
|
||||
ng.gain.setValueAtTime(0.0001, t)
|
||||
ng.gain.exponentialRampToValueAtTime(0.09, t + 12)
|
||||
n.connect(nbp); nbp.connect(ng); ng.connect(this.master)
|
||||
ng.gain.exponentialRampToValueAtTime(0.08, t + 12)
|
||||
n.connect(nbp); nbp.connect(ng)
|
||||
this.out(ng, 0.35)
|
||||
n.start(t)
|
||||
nodes.push(n)
|
||||
this.hold(ng, [n])
|
||||
@@ -284,7 +359,7 @@ export class NoFearAudio {
|
||||
|
||||
/** Five gliding triangle voices + a sub root, stepping through D–A–f#m–E. */
|
||||
private startChords(cutoff: number, level: number, withSub: boolean): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
if (!this.ctx) return
|
||||
const t = this.ctx.currentTime
|
||||
const lp = this.ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
@@ -309,7 +384,7 @@ export class NoFearAudio {
|
||||
}
|
||||
}
|
||||
lp.connect(g)
|
||||
g.connect(this.master)
|
||||
this.out(g, 0.55) // the pad lives IN the hall — that's what makes it big
|
||||
this.hold(g, oscs)
|
||||
if (withSub) {
|
||||
const sub = this.ctx.createOscillator()
|
||||
@@ -318,7 +393,8 @@ export class NoFearAudio {
|
||||
const sg = this.ctx.createGain()
|
||||
sg.gain.setValueAtTime(0.0001, t)
|
||||
sg.gain.exponentialRampToValueAtTime(0.16, t + 2.5)
|
||||
sub.connect(sg); sg.connect(this.master)
|
||||
sub.connect(sg)
|
||||
this.out(sg, 0) // sub stays dry — low end in the hall turns to mud
|
||||
sub.start(t)
|
||||
this.bassOsc = sub
|
||||
this.hold(sg, [sub])
|
||||
@@ -332,7 +408,6 @@ export class NoFearAudio {
|
||||
this.chordIdx = (this.chordIdx + 1) % PROGRESSION.length
|
||||
const chord = PROGRESSION[this.chordIdx]
|
||||
const t = this.ctx.currentTime
|
||||
// Each pair of detuned oscillators shares a chord voice; glide legato.
|
||||
for (let i = 0; i < this.padVoices.length; i++) {
|
||||
const target = chord[Math.floor(i / 2) % chord.length]
|
||||
this.padVoices[i].frequency.setTargetAtTime(target, t, 0.55)
|
||||
@@ -347,13 +422,21 @@ export class NoFearAudio {
|
||||
this.bassOsc = null
|
||||
}
|
||||
|
||||
/** Sparse high pings on the A-major pentatonic — tiny lights going on. */
|
||||
/** Sparse pentatonic pings through a feedback echo, drowned in the hall. */
|
||||
private shimmer(t: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
if (!this.ctx) return
|
||||
const notes = [880, 1108.7, 1318.5, 1479.98, 1760]
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.value = 0.055
|
||||
g.connect(this.master)
|
||||
g.gain.value = 0.045
|
||||
// A 0.38s feedback delay turns each ping into a trailing echo.
|
||||
const delay = this.ctx.createDelay(1)
|
||||
delay.delayTime.value = 0.38
|
||||
const fb = this.ctx.createGain()
|
||||
fb.gain.value = 0.35
|
||||
delay.connect(fb); fb.connect(delay)
|
||||
g.connect(delay)
|
||||
this.out(g, 0.8)
|
||||
this.out(delay, 0.8)
|
||||
const oscs: OscillatorNode[] = []
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const o = this.ctx.createOscillator()
|
||||
@@ -387,7 +470,8 @@ export class NoFearAudio {
|
||||
}
|
||||
}
|
||||
|
||||
/** A heartbeat with body AND transient: low sine thump + tiny filtered click. */
|
||||
/** A heartbeat with body AND transient: low sine thump + tiny filtered click.
|
||||
Stays nearly dry — a heart is close, not in a hall. */
|
||||
private thump(at: number, level: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const o = this.ctx.createOscillator()
|
||||
@@ -399,7 +483,7 @@ export class NoFearAudio {
|
||||
g.gain.exponentialRampToValueAtTime(level, at + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
|
||||
o.connect(g)
|
||||
g.connect(this.master)
|
||||
this.out(g, 0.1)
|
||||
o.start(at)
|
||||
o.stop(at + 0.32)
|
||||
if (this.noiseBuf) {
|
||||
|
||||
@@ -307,14 +307,20 @@ export class NoFearScene {
|
||||
}
|
||||
|
||||
private strokeArc(c: CanvasRenderingContext2D, arc: Arc, upTo: number, alpha: number): void {
|
||||
const steps = 26
|
||||
const n = Math.max(2, Math.round(steps * upTo))
|
||||
// The tip interpolates FRACTIONALLY along the curve — quantizing growth to
|
||||
// whole segments made the heads visibly step forward instead of glide.
|
||||
const steps = 48
|
||||
const n = Math.floor(steps * upTo)
|
||||
c.beginPath()
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const [ix, iy] = this.arcPoint(arc, (i / steps))
|
||||
if (i === 0) c.moveTo(ix, iy)
|
||||
else c.lineTo(ix, iy)
|
||||
}
|
||||
if (upTo < 1) {
|
||||
const [tx, ty] = this.arcPoint(arc, upTo)
|
||||
c.lineTo(tx, ty)
|
||||
}
|
||||
c.strokeStyle = `rgba(255, 176, 90, ${0.10 * alpha})`
|
||||
c.lineWidth = 4.2
|
||||
c.stroke()
|
||||
@@ -324,7 +330,10 @@ export class NoFearScene {
|
||||
}
|
||||
|
||||
private arcLocal(arc: Arc, web: number): number {
|
||||
return Math.min(Math.max((web - arc.delay * 0.72) / 0.28, 0), 1)
|
||||
// A tighter growth window + ease-out: each arc shoots off and settles,
|
||||
// rather than crawling for a third of the act.
|
||||
const q = Math.min(Math.max((web - arc.delay * 0.84) / 0.16, 0), 1)
|
||||
return 1 - (1 - q) ** 3
|
||||
}
|
||||
|
||||
draw(c: CanvasRenderingContext2D, s: SceneState, t: number): void {
|
||||
|
||||
Reference in New Issue
Block a user