// What the opening shows at each global frame. import { ACCENT, model } from "../../src/model.ts"; import { bezier } from "../scenes/time.ts"; import { type Box, type Shown, type View, ground, project } from "./draw.ts"; export const FPS = 30; export const WIDTH = 1920; export const HEIGHT = 1080; const BEAT = 60 / 112; const LAND = 0.2 * FPS; /** Bars 3 to 8: beats 8 to 32. */ export const FIRST = frameAt(8); export const END = frameAt(32); const settle = bezier(0.2, 0.8, 0.3, 1); const glide = bezier(0.65, 0, 0.35, 1); /** The frame nearest a global beat. */ export function frameAt(beat: number): number { return Math.round(beat * BEAT * FPS); } /** Progress of a move of `span` frames ending on `beat`. */ function toward(f: number, beat: number, span = LAND, ease = settle): number { const end = frameAt(beat); return ease(Math.min(1, Math.max(0, (f - (end - span)) / span))); } const desk = model[0].blocks[0]; const TOP = desk.h; const CX = desk.w / 2; const CY = desk.d / 2; const SEED: [number, number] = [8, 5]; /** Chebyshev ring of a cell around the seed. */ const ring = (i: number, j: number) => Math.max(Math.abs(i - SEED[0]), Math.abs(j - SEED[1])); /** Tiles landing per sixteenth, from beat 8; the rest land on 16. */ const RAIN: Record = { 2: 1, 4: 1, 6: 2, 8: 2, 10: 2, 12: 3, 13: 1, 14: 3, 15: 2, 16: 3, 17: 3, 18: 4, 19: 4, 20: 5, 21: 5, 22: 6, 23: 6, 24: 7, 25: 8, 26: 9, 27: 10, 28: 11, 29: 12, 30: 13, 31: 14, }; interface Tile { box: Box; lands: number; falls: number; } const tiles: Tile[] = (() => { const cells = Array.from({ length: desk.w * desk.d }, (_, n) => [n % desk.w, Math.floor(n / desk.w)]); const angle = ([i, j]: number[]) => Math.atan2(j - SEED[1], i - SEED[0]); cells.sort((a, b) => ring(a[0], a[1]) - ring(b[0], b[1]) || angle(a) - angle(b)); const slots = Object.entries(RAIN).flatMap(([k, n]) => Array(n).fill(Number(k))); return cells.map(([i, j], n) => ({ box: { x: i, y: j, z: 0, w: 1, d: 1, h: TOP, tone: desk.tone }, lands: frameAt(8 + (slots[n] ?? 32) / 4), falls: Math.round(20 - (11 * n) / cells.length), })); })(); /** Rise order follows the roads: Doc, Mark, Tree, Table, Show, PDF. */ const RISE: [string, number][] = [ ["ElyDoc", 16.25], ["ElyMark", 17.5], ["ElyTree", 18.5], ["ElyTable", 19.5], ["ElyShow", 20.75], ["ElyPDF", 22], ]; const parts = RISE.map(([name, beat]) => { const part = model.find((p) => p.name === name); if (!part) throw new Error(`no part ${name}`); return part.blocks.map((box, i) => ({ box, beat: beat + i / 4 })); }).flat(); const roads = model.find((p) => p.name === "Roads")?.blocks; if (!roads) throw new Error("model has no roads"); /** A road cut into cells, laid from one end on sixteenths. */ function lay(road: Box, n: number, beat: number, reverse = false): { box: Box; beat: number }[] { const long = road.w >= road.d; return Array.from({ length: n }, (_, i) => { const k = reverse ? n - 1 - i : i; const box = long ? { ...road, x: road.x + (k * road.w) / n, w: road.w / n } : { ...road, y: road.y + (k * road.d) / n, d: road.d / n }; return { box, beat: beat + i / 4 }; }); } const main = lay(roads[3], 18, 19); const paving = [ ...lay(roads[0], 1, 16.75), { ...main[0], beat: 17 }, ...lay(roads[4], 3, 17.25), ...lay(roads[5], 4, 18), ...main.slice(1).map((c, i) => ({ ...c, beat: 19 + i / 4 })), ...lay(roads[1], 1, 20.75), ...lay(roads[2], 3, 22.5, true), ]; /** Files hop one cell a beat toward their app. */ const STEP = 0.6; const FILE = 0.36; const streams: { path: [number, number][]; spawns: number[] }[] = [ { path: [[4.3, 7.9], [6.2, 7.9]], spawns: [20, 23, 26, 29] }, { path: [[2.3, 4.3], [2.3, 4.9], [12.7, 4.9]], spawns: [22, 25, 28] }, { path: [[6.3, 4.2], [6.3, 4.9], [12.7, 4.9]], spawns: [24, 27, 30] }, { path: [[11, 3.15], [11, 4.9], [12.7, 4.9]], spawns: [25, 28, 31] }, ]; function along(path: [number, number][], s: number): [number, number] { for (let i = 1; i < path.length; i++) { const [[ax, ay], [bx, by]] = [path[i - 1], path[i]]; const len = Math.hypot(bx - ax, by - ay); if (s <= len || i === path.length - 1) { const u = Math.min(s / len, 1); return [ax + (bx - ax) * u, ay + (by - ay) * u]; } s -= len; } throw new Error("a path needs two points"); } const length = (path: [number, number][]) => path.slice(1).reduce((sum, [x, y], i) => sum + Math.hypot(x - path[i][0], y - path[i][1]), 0); function files(f: number): Shown[] { const out: Shown[] = []; for (const { path, spawns } of streams) { const total = length(path); const hops = Math.ceil(total / STEP); for (const spawn of spawns) { if (f <= frameAt(spawn) - LAND) continue; let [s, lift] = [0, 0]; for (let k = 1; k <= hops; k++) { const u = toward(f, spawn + k); s += STEP * u; if (u > 0 && u < 1) lift = 0.35 * Math.sin(Math.PI * toward(f, spawn + k, LAND, (x) => x)); } const sink = 1 - toward(f, spawn + hops + 1); if (sink <= 0) continue; const [x, y] = along(path, Math.min(s, total)); const drop = 1 - toward(f, spawn); const box = { x: x - FILE / 2, y: y - FILE / 2, z: TOP + 0.08 + lift + drop, w: FILE, d: FILE, h: FILE * sink, tone: ACCENT }; out.push({ box, alpha: 1 - drop }); } } return out; } /** World turn: a quarter on bar 6, back on bar 7. */ function turn(f: number): number { const out = toward(f, 22, 2 * BEAT * FPS, glide); const back = toward(f, 28, 4 * BEAT * FPS, glide); return (Math.PI / 2) * (out - back); } const all = model.flatMap((p) => p.blocks); const built = all.filter((b) => b !== desk); const flat = (t: number): View => ({ turn: t, cx: CX, cy: CY, s: 1, ox: 0, oy: 0 }); const TURNS = Array.from({ length: 19 }, (_, i) => (i * Math.PI) / 36); function extent(list: Box[], turns: number[]): [number, number, number, number] { let [x0, x1, y0, y1] = [Infinity, -Infinity, Infinity, -Infinity]; for (const t of turns) for (const b of list) for (const [i, j, k] of [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => [i, j, k])))) { const [x, y] = project(flat(t), b.x + i * b.w, b.y + j * b.d, b.z + k * b.h); [x0, x1, y0, y1] = [Math.min(x0, x), Math.max(x1, x), Math.min(y0, y), Math.max(y1, y)]; } return [x0, x1, y0, y1]; } interface Frame { s: number; ox: number; oy: number; } /** Bars 5 and 6: the desk fills 70% of the width. */ const wide: Frame = (() => { const [x0, x1] = extent(all, [0]); const [u0, u1, v0, v1] = extent(all, TURNS); const s = Math.min((0.7 * WIDTH) / (x1 - x0), (0.92 * HEIGHT) / (v1 - v0)); return { s, ox: WIDTH / 2 - (s * (u0 + u1)) / 2, oy: HEIGHT / 2 - (s * (v0 + v1)) / 2 }; })(); /** Bars 7 and 8: buildings above the lower 35%. */ const titled: Frame = (() => { const [, , top] = extent(all, TURNS); const [u0, u1, , bottom] = extent(built, TURNS); const [high, low] = [70, 0.63 * HEIGHT]; const s = Math.min(wide.s, (low - high) / (bottom - top)); return { s, ox: WIDTH / 2 - (s * (u0 + u1)) / 2, oy: low - s * bottom }; })(); /** Bar 3 opens close on the seed, low in frame. */ const close: Frame = (() => { const s = wide.s * 2.6; const [x, y] = project(flat(0), SEED[0] + 0.5, SEED[1] + 0.5, TOP); return { s, ox: WIDTH / 2 - s * x, oy: 0.66 * HEIGHT - s * y }; })(); function blend(a: Frame, b: Frame, u: number): Frame { const s = a.s * (b.s / a.s) ** u; const at = (o: number, fs: number) => (WIDTH / 2 - o) / fs; const py = (o: number, fs: number) => (HEIGHT / 2 - o) / fs; const [wx, wy] = [at(a.ox, a.s) + (at(b.ox, b.s) - at(a.ox, a.s)) * u, py(a.oy, a.s) + (py(b.oy, b.s) - py(a.oy, a.s)) * u]; return { s, ox: WIDTH / 2 - s * wx, oy: HEIGHT / 2 - s * wy }; } /** The camera and turn for frame `f`. */ export function view(f: number): View { const pull = toward(f, 16, 8 * BEAT * FPS, glide); const rest = toward(f, 24, 2 * BEAT * FPS, glide); const cam = blend(blend(close, wide, pull), titled, rest); return { turn: turn(f), cx: CX, cy: CY, ...cam }; } /** Frame when field ring `r` finishes flipping light. */ export const flips: number[] = (() => { const fronts = Array.from({ length: 33 }, (_, k) => { const v = view(frameAt(8 + k / 4)); const corners = [[0, 0], [WIDTH, 0], [0, HEIGHT], [WIDTH, HEIGHT]].map(([x, y]) => ground(v, x, y)); const reach = Math.max(...corners.map(([i, j]) => ring(Math.floor(i), Math.floor(j)))) + 1; return k < 4 ? -1 : k === 32 ? Infinity : reach * ((k - 3) / 29) ** 2.4; }); const out: number[] = []; for (let r = 0; out.length === r; r++) { const k = fronts.findIndex((front) => front >= r); out.push(frameAt(8 + k / 4)); if (k === 32) break; } return out; })(); /** Progress of field cell (i, j) toward light. */ export function flip(f: number, i: number, j: number): number { const r = ring(i, j); const end = flips[Math.min(r, flips.length - 1)]; return settle(Math.min(1, Math.max(0, (f - (end - LAND)) / LAND))); } /** Every box on the desk at frame `f`. */ export function pieces(f: number, v: View): Shown[] { const out: Shown[] = []; if (f >= frameAt(16)) out.push({ box: desk, alpha: 1 }); else for (const t of tiles) { if (f <= t.lands - t.falls) continue; const u = Math.min(1, (f - (t.lands - t.falls)) / t.falls); const [, y] = project(v, t.box.x + 0.5, t.box.y + 0.5, TOP); const off = ((y + 80) * (1 - u * u)) / v.s; out.push({ box: { ...t.box, z: off }, alpha: 1, seam: true }); } for (const { box, beat } of parts) { const u = toward(f, beat); if (u <= 0) continue; if (box.z <= TOP + 1e-6) out.push({ box: { ...box, h: box.h * u }, alpha: 1 }); else out.push({ box: { ...box, z: box.z + 1 - u }, alpha: u }); } for (const { box, beat } of paving) { const u = toward(f, beat); if (u > 0) out.push({ box: { ...box, z: box.z + 0.6 * (1 - u) }, alpha: u }); } return [...out, ...files(f)]; }