diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 19b7ea0..2bc9783 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -75,6 +75,59 @@ return () => clearInterval(t); }); + /** Other bodies glide between steps instead of blinking cell to cell: + * each step, anyone whose position changed a walkable distance tweens + * from where the previous step's view had them. */ + let actorPos = $state>({}); + let prevView: GameView | null = null; + $effect(() => { + const st = steps[Math.min(idx, steps.length - 1)]; + if (!fp || !st) { prevView = null; actorPos = {}; return; } + const v = st.view; + const before = prevView; + prevView = v; + if (!before || before === v) return; + const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = []; + const gather = ( + id: string, + now: { x: number; y: number }, + was: { x: number; y: number } | undefined, + ) => { + if (!was) return; + const d = Math.hypot(now.x - was.x, now.y - was.y); + // A single stride or shove glides; a leap across the maze is a + // teleport and should simply be there. + if (d > 0.05 && d <= 3.5) { + moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 }); + } + }; + for (const p of v.players) { + if (!p.alive || p.id === v.you) continue; + const was = before.players.find((q) => q.id === p.id && q.alive); + gather(p.id, p.position, was?.position); + } + for (const c of v.creatures) { + gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position); + } + if (moves.length === 0) { actorPos = {}; return; } + const t0 = performance.now(); + const dur = 400 / speed; + let raf = 0; + const tick = (now: number) => { + const w = Math.min(1, Math.max(0, (now - t0) / dur)); + const ease = w * w * (3 - 2 * w); + const next: Record = {}; + for (const m of moves) { + next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease }; + } + actorPos = next; + if (w < 1) raf = requestAnimationFrame(tick); + else actorPos = {}; + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }); + // --- The first-person camera: your wizard's walk, relived. ------------- // Each step the camera settles on your position. A step away tweens — // turn first, then stride; a leap (teleport, warp) cuts. When you stood @@ -152,19 +205,51 @@ return () => cancelAnimationFrame(raf); }); - // --- Save a video: record the first-person canvas as it plays. -------- + // --- Save a video: the first-person view composited into a shareable + // 1280x720 frame — the step's caption burned in at the bottom, the + // game's name in the corner — so a downloaded clip explains itself. let stageEl: HTMLDivElement | undefined = $state(); let recorder = $state(null); function toggleRecord() { if (recorder) { recorder.stop(); return; } const cv = stageEl?.querySelector("canvas"); if (!cv) return; + const comp = document.createElement("canvas"); + comp.width = 1280; + comp.height = 720; + const g = comp.getContext("2d")!; + let compRaf = 0; + const drawComp = () => { + g.imageSmoothingEnabled = false; + g.drawImage(cv, 0, 0, 1280, 720); + g.imageSmoothingEnabled = true; + // The caption bar: who did what, in the reel's own words. + g.fillStyle = "rgba(12, 10, 8, 0.78)"; + g.fillRect(0, 720 - 96, 1280, 96); + g.fillStyle = "#e0b34a"; + g.font = "600 22px Oswald, sans-serif"; + g.fillText(step.actor.toUpperCase(), 28, 720 - 60, 400); + g.fillStyle = "#efe8d4"; + g.font = "21px 'Courier Prime', monospace"; + const said = lines.slice(0, 2); + if (said.length === 0) said.push("…considers the maze."); + said.forEach((line, i) => g.fillText(line, 28, 720 - 32 + i * 26, 1224)); + // The colophon corner. + g.fillStyle = "rgba(224, 179, 74, 0.6)"; + g.font = "600 20px Oswald, sans-serif"; + g.textAlign = "right"; + g.fillText("W I Z - W A R", 1280 - 24, 40); + g.textAlign = "left"; + compRaf = requestAnimationFrame(drawComp); + }; + compRaf = requestAnimationFrame(drawComp); const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9") ? "video/webm;codecs=vp9" : "video/webm"; - const rec = new MediaRecorder(cv.captureStream(60), { mimeType: mime }); + const rec = new MediaRecorder(comp.captureStream(60), { mimeType: mime }); const chunks: Blob[] = []; rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); }; rec.onstop = () => { + cancelAnimationFrame(compRaf); const url = URL.createObjectURL(new Blob(chunks, { type: "video/webm" })); const a = document.createElement("a"); a.href = url; @@ -208,7 +293,8 @@
{#if fp} + x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} + fx={fpFx} posOverride={actorPos} /> {:else} {/if} diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index c24454e..4b495dc 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -18,6 +18,7 @@ width = 720, height = 440, fx = [], + posOverride, }: { view: GameView; povId: string; @@ -30,6 +31,8 @@ height?: number; /** Live spell moments: projectiles, impacts, flashes, shakes. */ fx?: FpFx[]; + /** Bodies mid-glide: world positions that override the view's cells. */ + posOverride?: Record; } = $props(); const FOV = Math.PI / 2.9; @@ -195,7 +198,7 @@ } // Sprites, far to near, sliced against the depth buffer. - const sprites = billboards(view, povId, tokenArt) + const sprites = billboards(view, povId, tokenArt, posOverride) .map((b) => project(b, ex, ey)) .filter((s): s is Projected => s !== null); for (const f of fx) { diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 48a0dfe..04d7201 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -6,6 +6,7 @@ import { fxForEvents } from "../fx"; import { CELL } from "../fx-sprites/geom"; +import { castRay, warpMotion } from "./raycast"; import type { GameEvent, GameView } from "@wizwar/engine"; export type FpFx = @@ -34,6 +35,33 @@ const IMPACT_ART: Record = { shield: "shield", absorb: "shield", }; +/** One flight, possibly seen from two rooms: a projectile whose straight + * line is broken but whose sight line runs through a warp flies as TWO + * simultaneous legs — one in the near mouth's virtual space (visible + * through the opening), one in the far room's real space. Each leg is + * clipped by the depth buffer to the side the camera stands on. */ +function projectileLegs( + view: GameView, a: { x: number; y: number }, b: { x: number; y: number }, +): { from: { x: number; y: number }; to: { x: number; y: number } }[] { + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len < 0.05) return [{ from: a, to: b }]; + const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x)); + if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }]; + for (const w of view.board.warps) { + const motion = warpMotion(w); + const vt = motion.toVirtual(b); + const vlen = Math.hypot(vt.x - a.x, vt.y - a.y); + const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x)); + if (hit.warped && hit.dist >= vlen - 0.4) { + return [ + { from: a, to: vt }, + { from: motion.toReal(a), to: b }, + ]; + } + } + return [{ from: a, to: b }]; +} + /** Translate one step's events into first-person moments with start delays. */ export function fpFxForEvents( events: GameEvent[], view: GameView, povId: string, @@ -42,15 +70,18 @@ export function fpFxForEvents( for (const { fx, delay } of fxForEvents(events, view)) { if (fx.kind === "fireball" || fx.kind === "bolt" || fx.kind === "waterbolt" || fx.kind === "streak") { const art = fx.kind === "streak" ? "spark" : fx.kind; - out.push({ - fx: { - id: nextId++, kind: "projectile", art, - from: { x: fx.a.x / CELL, y: fx.a.y / CELL }, - to: { x: fx.b.x / CELL, y: fx.b.y / CELL }, - t0: 0, dur: PROJECTILE_DUR[art]!, - }, - delay, - }); + for (const leg of projectileLegs( + view, { x: fx.a.x / CELL, y: fx.a.y / CELL }, { x: fx.b.x / CELL, y: fx.b.y / CELL }, + )) { + out.push({ + fx: { + id: nextId++, kind: "projectile", art, + from: leg.from, to: leg.to, + t0: 0, dur: PROJECTILE_DUR[art]!, + }, + delay, + }); + } } else if (fx.kind === "portal") { out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 }, diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 81b8701..cba71c3 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -196,23 +196,52 @@ function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } { return { x: c.x, y: c.y }; } -/** Everything standing in the maze that the reel should draw as a sprite. */ +/** The rigid motion one warp applies to sight: far-region points map into + * the near mouth's virtual space (where a straight ray would put them), + * and back again. Anchored and rotated exactly as castRay re-enters. */ +export function warpMotion(w: GameView["board"]["warps"][number]): { + toVirtual(p: { x: number; y: number }): { x: number; y: number }; + toReal(p: { x: number; y: number }): { x: number; y: number }; +} { + const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side]; + const a1 = mouthAnchor(w.from); + const a2 = mouthAnchor(w.to); + const c = Math.cos(delta), s = Math.sin(delta); + return { + toVirtual(p) { + const rx = p.x - a2.x, ry = p.y - a2.y; + return { x: a1.x + rx * c + ry * s, y: a1.y - rx * s + ry * c }; + }, + toReal(p) { + const rx = p.x - a1.x, ry = p.y - a1.y; + return { x: a2.x + rx * c - ry * s, y: a2.y + rx * s + ry * c }; + }, + }; +} + +/** Everything standing in the maze that the reel should draw as a sprite. + * `posOverride` maps a player or creature id to an in-flight world + * position — the reel glides bodies between steps instead of blinking + * them from cell to cell. */ export function billboards( view: GameView, povId: string, art: (file: string, cat: "players" | "creatures" | "objects" | "terrain") => string, + posOverride?: Record, ): Billboard[] { const out: Billboard[] = []; for (const p of view.players) { if (!p.alive || p.id === povId) continue; + const o = posOverride?.[p.id]; out.push({ - x: p.position.x + 0.5, y: p.position.y + 0.5, + x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5, src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id, }); } for (const c of view.creatures) { + const o = posOverride?.[c.id]; out.push({ - x: c.position.x + 0.5, y: c.position.y + 0.5, + x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5, src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, }); } @@ -250,21 +279,14 @@ export function billboards( // opening, since everything around the mouth is nearer wall. const real = out.slice(); for (const w of view.board.warps) { - const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side]; - const a1 = mouthAnchor(w.from); + const motion = warpMotion(w); const a2 = mouthAnchor(w.to); - const cosD = Math.cos(-delta), sinD = Math.sin(-delta); const inward = SIDE_ANGLE[OPPOSITE[w.to.side]]; const inX = Math.cos(inward), inY = Math.sin(inward); for (const b of real) { const rx = b.x - a2.x, ry = b.y - a2.y; if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue; - out.push({ - ...b, - x: a1.x + rx * cosD - ry * sinD, - y: a1.y + rx * sinD + ry * cosD, - warped: true, - }); + out.push({ ...b, ...motion.toVirtual(b), warped: true }); } } return out;