From bfece01468e261726186fa3d7fc21007496d2c4b Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 15:28:34 -0400 Subject: [PATCH] The workshop walks through warps; conjurations wear their own inks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stride into a warp mouth now carries the walker out of the paired mouth mid-glide, body turned exactly as the rays turn — canWalk admits a rim edge only where a mouth waits, and the crawler splits the stride at the crossing. And with painted conjuration sprites arriving, the fx pass composites them normally instead of additively: painted art is ink, not light, and additive blending would bloom its outlines white. Co-Authored-By: Claude Fable 5 --- packages/web/src/fpv/FirstPerson.svelte | 12 +++----- packages/web/src/fpv/FpvWorkshop.svelte | 38 ++++++++++++++++++++++++- packages/web/src/fpv/raycast.ts | 21 +++++++++++--- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 4b495dc..933ef36 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -220,10 +220,9 @@ const img = imageFor(s.src) ?? (s.fallback ? fxFallback(s.fallback) : null); const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0); const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0); - if (s.glow) { - ctx.globalCompositeOperation = "lighter"; - ctx.globalAlpha = s.alpha ?? 1; - } + // Conjurations are painted art, not light sources: normal + // compositing keeps their inks true; only the fade is borrowed. + if (s.glow) ctx.globalAlpha = s.alpha ?? 1; for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { if (s.depth >= zbuf[col]!) continue; const texX = ((col - s.left) / (s.right - s.left)); @@ -243,10 +242,7 @@ ctx.fillRect(col, s.top, 1, s.bottom - s.top); } } - if (s.glow) { - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = 1; - } + if (s.glow) ctx.globalAlpha = 1; } // A whisper of vignette holds the torchlit mood together. diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index 944d1ec..b515947 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -7,7 +7,7 @@ import type { GameEvent, GameState, GameView } from "@wizwar/engine"; import FirstPerson from "./FirstPerson.svelte"; import Replay from "../Replay.svelte"; - import { canWalk } from "./raycast"; + import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast"; const q = new URLSearchParams(location.search); const seed = Number(q.get("seed") ?? 42); @@ -44,6 +44,9 @@ type Anim = | { kind: "glide"; fx: number; fy: number; tx: number; ty: number; t0: number; dur: number } | { kind: "turn"; from: number; to: number; t0: number; dur: number } + | { kind: "warpglide"; fx: number; fy: number; mx: number; my: number; + nx: number; ny: number; tx: number; ty: number; dturn: number; + turned: boolean; t0: number; dur: number } | null; let anim: Anim = null; const ease = (w: number) => w * w * (3 - 2 * w); @@ -63,6 +66,27 @@ const side = SIDES4[idx]!; const cell = { x: Math.floor(x), y: Math.floor(y) }; if (!canWalk(view, cell, side)) return; + const to = { + x: cell.x + (side === "E" ? 1 : side === "W" ? -1 : 0), + y: cell.y + (side === "S" ? 1 : side === "N" ? -1 : 0), + }; + // An open rim edge canWalk allowed is a warp mouth: stride into it, + // and the stride carries on out of the paired mouth, the body turned + // exactly as the rays turn. + const warp = !view.board.cells[cellKey(to)] + ? view.board.warps.find((w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side) + : undefined; + if (warp) { + const mouth = edgeMid(cell, side); + const far = edgeMid(warp.to.cell, warp.to.side); + anim = { + kind: "warpglide", fx: x, fy: y, mx: mouth.x, my: mouth.y, + nx: far.x, ny: far.y, tx: warp.to.cell.x + 0.5, ty: warp.to.cell.y + 0.5, + dturn: SIDE_ANGLE[OPPOSITE[warp.to.side]] - SIDE_ANGLE[warp.from.side], + turned: false, t0: now, dur: 320, + }; + return; + } const tx = x + (side === "E" ? 1 : side === "W" ? -1 : 0); const ty = y + (side === "S" ? 1 : side === "N" ? -1 : 0); anim = { kind: "glide", fx: x, fy: y, tx, ty, t0: now, dur: 280 }; @@ -76,6 +100,18 @@ if (anim.kind === "glide") { x = anim.fx + (anim.tx - anim.fx) * ease(w); y = anim.fy + (anim.ty - anim.fy) * ease(w); + } else if (anim.kind === "warpglide") { + // Half the stride reaches the mouth; the moment it crosses, the + // eye stands at the far mouth, turned, finishing the stride. + const e = ease(w); + if (e < 0.5) { + x = anim.fx + (anim.mx - anim.fx) * (e * 2); + y = anim.fy + (anim.my - anim.fy) * (e * 2); + } else { + if (!anim.turned) { facing += anim.dturn; anim.turned = true; } + x = anim.nx + (anim.tx - anim.nx) * ((e - 0.5) * 2); + y = anim.ny + (anim.ty - anim.ny) * ((e - 0.5) * 2); + } } else { facing = anim.from + (anim.to - anim.from) * ease(w); } diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index cba71c3..6164371 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -25,8 +25,8 @@ export interface Hit { warped?: boolean; } -const SIDE_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; -const OPPOSITE: Record = { E: "W", W: "E", N: "S", S: "N" }; +export const SIDE_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; +export const OPPOSITE: Record = { E: "W", W: "E", N: "S", S: "N" }; /** Is this edge passable to the EYE (rays), and if not, what is it? */ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null { @@ -155,7 +155,8 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): } /** Can a wizard's body (not just their eye) cross this edge? Workshop - * collision: walls and closed doors stop you, fire and warps do not. */ + * collision: walls and closed doors stop you; fire does not, and an open + * rim edge carries you only where a warp mouth waits. */ export function canWalk(view: GameView, from: Cell, side: Side): boolean { const key = edgeKey(from, side); const e = view.board.edges[key] ?? "open"; @@ -166,11 +167,23 @@ export function canWalk(view: GameView, from: Cell, side: Side): boolean { x: from.x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: from.y + (side === "S" ? 1 : side === "N" ? -1 : 0), }; - if (!view.board.cells[cellKey(to)]) return false; // the rim (warp-walk later) + if (!view.board.cells[cellKey(to)]) { + return view.board.warps.some( + (w) => cellKey(w.from.cell) === cellKey(from) && w.from.side === side, + ); + } if (view.squareContents[cellKey(to)]?.kind === "stone") return false; return true; } +/** The midpoint of one cell edge, in world units. */ +export function edgeMid(cell: Cell, side: Side): { x: number; y: number } { + if (side === "E") return { x: cell.x + 1, y: cell.y + 0.5 }; + if (side === "W") return { x: cell.x, y: cell.y + 0.5 }; + if (side === "S") return { x: cell.x + 0.5, y: cell.y + 1 }; + return { x: cell.x + 0.5, y: cell.y }; +} + export interface Billboard { /** World position (cell-centered). */ x: number;