diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index a632490..412ad12 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -313,28 +313,31 @@ aimed = true; } } + // How good is the view down heading `a` from (px,py)? Corridor depth, + // and at the reel's end, a bonus for visible subjects. + const viewScoreAt = (px: number, py: number, a: number): number => { + let score = Math.min(castRay(v, px, py, a).dist, 8); + if (!atEnd) return score; + const bonus = (cell: { x: number; y: number }, worth: number) => { + const bx = cell.x + 0.5, by = cell.y + 0.5; + const bearing = Math.atan2(by - py, bx - px); + if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0; + const d = Math.hypot(bx - px, by - py); + if (d < 0.1) return 0; + const sight = castRay(v, px, py, bearing); + return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0; + }; + for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6); + for (const c of v.creatures) score += bonus(c.position, 5); + for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3); + return score; + }; if (!aimed) { // Nothing aims the eyes. If they'd idle nose-to-brick (the opening // cut, a dead-end beat — or the reel's CLOSING shot, which gets a // higher standard), find a better view: the deepest corridor, and // on the final frame, preferably one with something in it. - const viewScore = (a: number): number => { - let score = Math.min(castRay(v, tx, ty, a).dist, 8); - if (!atEnd) return score; - const bonus = (cell: { x: number; y: number }, worth: number) => { - const bx = cell.x + 0.5, by = cell.y + 0.5; - const bearing = Math.atan2(by - ty, bx - tx); - if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0; - const d = Math.hypot(bx - tx, by - ty); - if (d < 0.1) return 0; - const sight = castRay(v, tx, ty, bearing); - return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0; - }; - for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6); - for (const c of v.creatures) score += bonus(c.position, 5); - for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3); - return score; - }; + const viewScore = (a: number): number => viewScoreAt(tx, ty, a); // A cut lands with whatever stale facing it carried, so a cut with // nothing aimed always re-picks its view from the new position. const staring = willCut || @@ -351,15 +354,32 @@ targetFacing = bestA; } } + // The reel's LAST step may be a stride into a corner: the walk keeps + // its own facing, then the camera turns once more to a view worth + // ending on. + let closingArc = 0; + if (atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) { + const current = viewScoreAt(tx, ty, targetFacing); + let bestA = targetFacing, bestScore = current; + for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { + const sc = viewScoreAt(tx, ty, a); + if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; } + } + closingArc = shortestArc(targetFacing, bestA); + } const stepIdx = Math.min(idx, steps.length - 1); const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null; /** Hold the OLD world on screen this long, so the outcome lands only * once the eyes face its recipient: a cutaway gets a beat of the * "before"; a turn holds until the turn is done. */ const setSlate = (holdMs: number) => { - heldView = holdMs > 60 && prevView ? prevView : null; - camPlan = { idx: stepIdx, holdMs: heldView ? holdMs : 0 }; - if (heldView) { + // Locals only: reading heldView back here would register it as a + // dependency of this very effect, and the timer clearing it would + // re-trigger us into a ping-pong. + const hold = holdMs > 60 && prevView ? prevView : null; + heldView = hold; + camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 }; + if (hold) { const t = setTimeout(() => (heldView = null), holdMs); return () => clearTimeout(t); } @@ -379,6 +399,7 @@ const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed; const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed; const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0); + const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed; const t0 = performance.now(); let raf = 0; const tick = (now: number) => { @@ -394,8 +415,12 @@ const ease = w * w * (3 - 2 * w); cam.x = fromX + (tx - fromX) * ease; cam.y = fromY + (ty - fromY) * ease; + } else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) { + cam.x = tx; cam.y = ty; + const w = (t - turnMs - walkMs) / closingMs; + cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w); } else { - cam.facing = fromF + arc; + cam.facing = fromF + arc + closingArc; cam.x = tx; cam.y = ty; return; } diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 9c6d2c8..d3350b4 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -5,7 +5,7 @@ // by the same depth buffer the walls wrote. import { castRay, billboards } from "./raycast"; import { materialTextures } from "./textures"; - import { doorOpenness, fxFallback, type FpFx } from "./fx3d"; + import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { tokenArt } from "../art"; import { PLAYER_COLORS } from "../colors"; @@ -150,12 +150,21 @@ ey += f.mag * (1 - p) * Math.cos(time * 0.087); } - // Doors mid-swing this frame, from the animated fx. + // Doors mid-swing and conjurations mid-growth this frame. let doors: Record | undefined; + let growing: Record | undefined; + const spawn: Record = {}; for (const f of fx) { - if (f.kind !== "door") continue; - const a = doorOpenness((time - f.t0) / f.dur); - if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); + if (f.kind === "door") { + const a = doorOpenness((time - f.t0) / f.dur); + if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); + } else if (f.kind === "grow") { + const p = (time - f.t0) / f.dur; + if (p < 0 || p >= 1) continue; + const g = growProgress(p); + if (f.slot === "solid") (growing ??= {})[f.key] = g; + else spawn[f.key] = g; + } } // The ground and the vault overhead, cast per pixel: each screen row @@ -248,11 +257,13 @@ // stands at the crossing — drawn after the sprites so bodies show // through it, shimmering so nobody mistakes it for stone. const ghosts: { col: number; depth: number; u: number; worldU: number }[] = []; - // Open doorways collect their lintels, hung after the sprites. + // Open doorways collect their lintels, hung after the sprites; and + // conjurations still rising collect their growing columns. const lintels: { col: number; depth: number; u: number }[] = []; + const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = []; for (let col = 0; col < W; col++) { const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2)); - const hit = castRay(view, ex, ey, rayAngle, doors); + const hit = castRay(view, ex, ey, rayAngle, doors, growing); const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye zbuf[col] = depth; if (hit.warpId !== undefined) { @@ -268,6 +279,12 @@ if (hit.doorway) { lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u }); } + if (hit.rising) { + risings.push({ + col, depth: hit.rising.dist * Math.cos(rayAngle - facing), + u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g, + }); + } const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); const top = half - wallH / 2; const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!; @@ -331,7 +348,10 @@ // Sprites, far to near, sliced against the depth buffer. const sprites = billboards(view, povId, tokenArt, posOverride) - .map((b) => project(b, ex, ey)) + .map((b) => { + const g = b.key !== undefined ? spawn[b.key] : undefined; + return project(g !== undefined ? { ...b, scale: b.scale * g } : b, ex, ey); + }) .filter((s): s is Projected => s !== null); for (const spot of rubble) { const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey); @@ -392,6 +412,22 @@ if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1; } + // Conjurations rising: the wall (or stone) grows bottom-up where it + // will stand, glowing swirls running over the young face. + for (const ri of risings) { + const full = Math.min(H * 2.5, H / Math.max(ri.depth, 0.05)); + const top0 = half + full / 2 - full * ri.g; + const tex = textures[ri.kind] ?? textures.wall!; + ctx.globalAlpha = 0.55 + 0.45 * ri.g; + ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), 0, + Math.max(1, tex.width / 96), tex.height, ri.col, top0, 1, full * ri.g); + ctx.globalAlpha = 1; + const swirl = (0.35 * (1 - ri.g) + 0.08) * + (0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21)); + ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; + ctx.fillRect(ri.col, top0, 1, full * ri.g); + } + // Doorway lintels: the frame's top rows hung across each opening. for (const li of lintels) { const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05)); diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index f36c139..e1de799 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -20,7 +20,15 @@ export type FpFx = rise?: number } | { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number } | { id: number; kind: "shake"; mag: number; t0: number; dur: number } - | { id: number; kind: "door"; edge: string; t0: number; dur: number }; + | { id: number; kind: "door"; edge: string; t0: number; dur: number } + | { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number }; + +/** Eased growth 0..1 for a conjuration's rise. */ +export function growProgress(p: number): number { + if (p <= 0) return 0.01; + if (p >= 1) return 1; + return Math.max(0.01, p * p * (3 - 2 * p)); +} /** How far open an animated door stands at progress p: swings open, * holds for the crossing, swings shut. */ @@ -144,11 +152,34 @@ export function fpFxForEvents( } } } - if (e.type === "wallCreated" && "edge" in e) { - // Stone conjured from nothing: dust where it rises. - const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell, - (e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side); - out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 }); + if ((e.type === "wallCreated" || e.type === "firewallCreated" || e.type === "illusionWallCreated") && "edge" in e) { + // Conjured from nothing: the edge RISES from the floor, wrapped in + // dust and shimmer. (An illusion rises too — for the deceived, it + // is a wall in every way.) + const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge; + const key = edgeKey(edge.cell, edge.side); + out.push({ fx: { id: nextId++, kind: "grow", slot: "solid", key, t0: 0, dur: 750 }, delay: 0 }); + const at = edgeMid(edge.cell, edge.side); + if (e.type === "wallCreated") { + out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 }); + } + out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at, t0: 0, dur: 750 }, delay: 0 }); + } + if (e.type === "creatureCreated" && "at" in e) { + const at = (e as { at: { x: number; y: number } }).at; + const cid = (e as { creatureId: string }).creatureId; + out.push({ fx: { id: nextId++, kind: "grow", slot: "sprite", key: cid, t0: 0, dur: 750 }, delay: 0 }); + out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 }); + } + if (e.type === "squareFilled" && "cell" in e) { + const cell = (e as { cell: { x: number; y: number } }).cell; + const kind = (e as { kind: string }).kind; + const cellK = `${cell.x},${cell.y}`; + out.push({ + fx: { id: nextId++, kind: "grow", slot: kind === "stone" ? "solid" : "sprite", key: cellK, t0: 0, dur: 750 }, + delay: 0, + }); + out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: cell.x + 0.5, y: cell.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 }); } if (e.type === "sectorRotated" || e.type === "sectorRelocated") { // The maze ITSELF moves: the whole world flashes violet and heaves. diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 84d74db..08bd281 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -37,6 +37,9 @@ export interface Hit { /** The first OPEN doorway the ray passed through: the renderer hangs * the lintel of the frame across it. */ doorway?: { dist: number; u: number }; + /** Something conjured is still RISING here: the ray passes over it, + * and the renderer draws it growing bottom-up where it stood. */ + rising?: { dist: number; u: number; worldU: number; kind: Hit["kind"]; g: number }; /** The struck slab is a doorway's jamb post, dressed in frame stone. */ frame?: boolean; } @@ -108,6 +111,9 @@ export function castRay( * view holds open stand at 1 without an entry. Wolf3D-style: an open * door has slid along its own edge, the gap growing from u=0. */ doors?: Record, + /** Conjurations mid-growth (0..1), keyed by edge key or stone cell + * key: while below 1 the ray passes, and the hit records a rising. */ + growing?: Record, ): Hit { // Sight runs THROUGH warps as the rules say it does: a ray that reaches // a mouth re-enters at the paired mouth and marches on, its hits hazed. @@ -117,6 +123,7 @@ export function castRay( let warpDist: number | undefined; let ghost: Hit["ghost"]; let doorway: Hit["doorway"]; + let rising: Hit["rising"]; for (let traversal = 0; traversal < 3; traversal++) { const dx = Math.cos(angle); const dy = Math.sin(angle); @@ -140,6 +147,15 @@ export function castRay( if (!kind) return; const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY); if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return; + const g = growing?.[key]; + if (g !== undefined && g < 1) { + // Still being conjured: the eye passes; the growth is drawn. + if (!rising) { + const along0 = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx; + rising = { dist: baseDist + h.t, u: along0 - Math.floor(along0), worldU: along0, kind, g }; + } + return; + } let slide = 0; let frame = false; if (kind === "door") { @@ -178,7 +194,7 @@ export function castRay( // A sliding door carries its texture with it: sample past the gap. return { dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide), - axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, ghost, doorway, + axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, ghost, doorway, rising, ...(b.frame ? { frame: true } : {}), }; } @@ -205,7 +221,7 @@ export function castRay( (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side, ); const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined; - if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway }; + if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising }; if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; } // Step through: re-enter at the paired mouth, heading inward, the // lane carried by the same proper rotation the heading turns by — @@ -224,14 +240,19 @@ export function castRay( break; } if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { - return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway }; + const sg = growing?.[cellKey({ x: nx, y: ny })]; + if (sg !== undefined && sg < 1) { + if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg }; + } else { + return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising }; + } } cx = nx; cy = ny; } if (!warped || traversal === 2) break; } - return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway }; + return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway, rising }; } /** Can a wizard's body (not just their eye) cross this edge? Workshop @@ -286,6 +307,8 @@ export interface Billboard { bias?: number; /** Procedural stand-in name while the painted file loads. */ fallback?: string; + /** Identity for growth animation: a creature's id, or a cell key. */ + key?: string; /** A reflection seen through a warp mouth, not the thing itself — * visible only through THAT warp's columns, beyond its mouth. */ warped?: boolean; @@ -356,7 +379,7 @@ export function billboards( const o = posOverride?.[c.id]; out.push({ 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, + src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id, }); } for (const t of view.treasures) { @@ -378,17 +401,17 @@ export function billboards( if (content.kind === "thornbush" || content.kind === "rosebush") { out.push({ ...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind, - scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, + scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k, }); } else if (content.kind === "ooze") { out.push({ ...at, src: "/terrain3d/jello.png", fallback: "jello", - scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", + scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k, }); } else if (content.kind === "dust") { out.push({ ...at, src: "/terrain3d/dust.png", fallback: "dust", - scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", + scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k, }); } else if (content.kind === "safe") { out.push({