Engine: pit-rim exits live in one helper (pitRimExits) shared by the resolver and the automaton; chargeReach names the six-stride cap; dust sight is inDust + dustAtEnd; PushPending is exported once; the force-field counter builds its events in one list; drawUnderSlowDeath says what it draws under. Test groups are named for the rule they pin, not the revision that introduced it. Client: one smoothstep; one edgeScar per battle-scarred edge; one board-zone rule (which also seats the caption strip on phones); the compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery replaces two hand-rolled matchMedia listeners; sprites carry their sizes as plain numbers and their CSS in one rule each; canvas helpers are formatted like the rest of the tree. Comments that told how a cel or a fallback used to look now say what it draws. The actions-over notice no longer blames a pickup when slime ended the turn. Server and ops: dataRoot() is the one data directory; headlineOf builds its deeds without a cast; the rollup and skills read the same way the code behaves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
1090 lines
48 KiB
Svelte
1090 lines
48 KiB
Svelte
<script lang="ts">
|
|
// The maze through one wizard's eyes: a canvas raycaster over the
|
|
// GameView. Columns of wall shaded by distance and facing; token art
|
|
// billboarded for whatever stands in the corridors, occluded per column
|
|
// by the same depth buffer the walls wrote.
|
|
import { motion } from "../motion";
|
|
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
|
|
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing as floorRing } from "./paintedFx";
|
|
import { materialTextures } from "./textures";
|
|
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
|
|
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
|
import { tokenArt } from "../art";
|
|
import { PLAYER_COLORS } from "../colors";
|
|
import { fearCells } from "@wizwar/engine";
|
|
import type { GameView } from "@wizwar/engine";
|
|
|
|
let {
|
|
view,
|
|
povId,
|
|
x,
|
|
y,
|
|
facing,
|
|
pitch = 0,
|
|
width = 720,
|
|
height = 440,
|
|
fx = [],
|
|
posOverride,
|
|
rubble = [],
|
|
ontarget,
|
|
hover = false,
|
|
litCells = null,
|
|
aimBeings = false,
|
|
edgeSelect = false,
|
|
}: {
|
|
view: GameView;
|
|
povId: string;
|
|
/** Eye position in world units (cell centers are n + 0.5). */
|
|
x: number;
|
|
y: number;
|
|
/** Radians; 0 faces east, matching the board's +x. */
|
|
facing: number;
|
|
/** Tilt, as the fraction of the pane's height the horizon rises:
|
|
* 0 looks level, ~0.36 looks down at your own feet. The raycaster
|
|
* shears rather than rotates — verticals stay vertical, and the
|
|
* ground at the pane's foot comes within half a cell of the eye. */
|
|
pitch?: number;
|
|
width?: number;
|
|
height?: number;
|
|
/** Live spell moments: projectiles, impacts, flashes, shakes. */
|
|
fx?: FpFx[];
|
|
/** Bodies mid-glide: world positions that override the view's cells. */
|
|
posOverride?: Record<string, { x: number; y: number }>;
|
|
/** Where walls have died: a mound of stone marks each fallen edge. */
|
|
rubble?: { x: number; y: number }[];
|
|
/** Present = the pane is an instrument: clicks resolve to targets. */
|
|
ontarget?: (t: FpvTarget) => void;
|
|
/** Show the crosshair's hover cue without a click handler (the workshop's poses). */
|
|
hover?: boolean;
|
|
/** Squares a selected cell-target card may aim at: the pane dims the
|
|
* ineligible ground exactly as the board dims its squares. */
|
|
litCells?: Set<string> | null;
|
|
/** The armed card could land on a wizard or creature: beings on
|
|
* castable ground wear the ember ring. Creations light floor only. */
|
|
aimBeings?: boolean;
|
|
/** An edge-target card is raised: every wall and door face glows
|
|
* faintly, the pane's answer to the board's edge handles. */
|
|
edgeSelect?: boolean;
|
|
} = $props();
|
|
|
|
const FOV = Math.PI / 2.9;
|
|
let canvas: HTMLCanvasElement;
|
|
|
|
/** Everything the LAST drawn frame knew, kept for click resolution:
|
|
* the pane is an instrument only because the renderer remembers what
|
|
* stood under every pixel. */
|
|
let hitFrame: {
|
|
W: number;
|
|
horizon: number; H: number; ex: number; ey: number; facing: number;
|
|
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
|
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
|
/** Depth-sorted nearest-first, ready for hover hit-testing. */
|
|
sprites: Projected[];
|
|
} | null = null;
|
|
|
|
/** The one visibility rule a sprite obeys in a column — shared by the
|
|
* draw pass and the hover hit test so they can never disagree. */
|
|
function spriteVisibleInCol(
|
|
sp: Projected, col: number,
|
|
zbuf: Float64Array, warpIdCol: Int32Array, warpDistCol: Float64Array,
|
|
): boolean {
|
|
if (sp.depth >= zbuf[col]!) return false;
|
|
if (sp.warped) {
|
|
if (warpIdCol[col] !== sp.warpId || sp.depth <= warpDistCol[col]!) return false;
|
|
} else if (sp.depth >= warpDistCol[col]!) return false;
|
|
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) return false;
|
|
return true;
|
|
}
|
|
/** Crosshair position in canvas pixels, while the pointer is over the
|
|
* pane and the pane is an instrument. */
|
|
let mouse: { x: number; y: number } | null = null;
|
|
|
|
// Token art loads lazily; a sprite draws once its image has arrived.
|
|
const images = new Map<string, HTMLImageElement>();
|
|
function imageFor(src: string): HTMLImageElement | null {
|
|
let img = images.get(src);
|
|
if (!img) {
|
|
img = new Image();
|
|
img.src = src;
|
|
images.set(src, img);
|
|
}
|
|
return img.complete && img.naturalWidth > 0 ? img : null;
|
|
}
|
|
|
|
// Materials come from /textures/*.png when those files exist — the
|
|
// independently paintable set — with the baked procedurals underneath
|
|
// so a missing or still-loading file never leaves a wall naked.
|
|
const textures = materialTextures();
|
|
|
|
// Floor-casting samples per PIXEL, so the two ground-plane textures are
|
|
// cached as raw pixel buffers — re-extracted whenever a painted file
|
|
// finishes loading and swaps the entry.
|
|
const pixelCache = new WeakMap<object, ImageData>();
|
|
function pixelsOf(src: CanvasImageSource & { width: number; height: number }): ImageData {
|
|
let px = pixelCache.get(src);
|
|
if (!px) {
|
|
const t = document.createElement("canvas");
|
|
t.width = src.width;
|
|
t.height = src.height;
|
|
const c = t.getContext("2d")!;
|
|
c.drawImage(src, 0, 0);
|
|
px = c.getImageData(0, 0, t.width, t.height);
|
|
pixelCache.set(src, px);
|
|
}
|
|
return px;
|
|
}
|
|
// The dread-stained squares, rasterized once per view (null = no fear).
|
|
const dreadCache = new WeakMap<object, Uint8Array | null>();
|
|
function dreadGridOf(v: GameView): Uint8Array | null {
|
|
if (dreadCache.has(v)) return dreadCache.get(v)!;
|
|
const cells = fearCells(v);
|
|
let grid: Uint8Array | null = null;
|
|
if (cells.size > 0) {
|
|
grid = new Uint8Array(v.board.width * v.board.height);
|
|
for (const k of cells) {
|
|
const [px, py] = k.split(",").map(Number) as [number, number];
|
|
if (px >= 0 && py >= 0 && px < v.board.width && py < v.board.height) grid[py * v.board.width + px] = 1;
|
|
}
|
|
}
|
|
dreadCache.set(v, grid);
|
|
return grid;
|
|
}
|
|
|
|
let frame: ImageData | null = null;
|
|
|
|
// The floor wears decals: home emblems in their owners' colors, pits
|
|
// opening downward, slime, tacks, dimensional warp rings — a per-view
|
|
// grid mapping each cell to one overlay, read by the per-pixel pass.
|
|
interface Decal { tex: string; tint: [number, number, number] | null }
|
|
const decalCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; decals: Decal[] }>();
|
|
function decalsOf(v: GameView) {
|
|
let h = decalCache.get(v);
|
|
if (!h) {
|
|
const bw = v.board.width, bh = v.board.height;
|
|
const grid = new Int16Array(bw * bh).fill(-1);
|
|
const decals: Decal[] = [];
|
|
const put = (x: number, y: number, tex: string, tint: Decal["tint"] = null) => {
|
|
if (x < 0 || y < 0 || x >= bw || y >= bh) return;
|
|
decals.push({ tex, tint });
|
|
grid[y * bw + x] = decals.length - 1;
|
|
};
|
|
for (const p of v.players) {
|
|
if (!p.home) continue;
|
|
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
|
|
put(p.home.x, p.home.y, "home", [
|
|
parseInt(hex.slice(1, 3), 16) / 255,
|
|
parseInt(hex.slice(3, 5), 16) / 255,
|
|
parseInt(hex.slice(5, 7), 16) / 255,
|
|
]);
|
|
}
|
|
const DECAL_KIND: Record<string, string> = {
|
|
pit: "pit", slime: "slime", tacks: "tacks",
|
|
thornbush: "underbrush", rosebush: "underbrush",
|
|
};
|
|
for (const [k, content] of Object.entries(v.squareContents)) {
|
|
const tex = DECAL_KIND[content.kind];
|
|
if (!tex) continue;
|
|
const [cx, cy] = k.split(",").map(Number) as [number, number];
|
|
put(cx, cy, tex);
|
|
}
|
|
for (const w of v.dimWarps) {
|
|
put(w.a.x, w.a.y, "dimwarp");
|
|
put(w.b.x, w.b.y, "dimwarp");
|
|
}
|
|
h = { grid, bw, bh, decals };
|
|
decalCache.set(v, h);
|
|
}
|
|
return h;
|
|
}
|
|
|
|
function draw(time: number) {
|
|
// With effects off the frame still draws — walls, bodies, hazards —
|
|
// but nothing moves: no fx, and the ambient pulses freeze at t=0.
|
|
const activeFx = motion.effects ? fx : [];
|
|
const ambientTime = motion.effects ? time : 0;
|
|
const ctx = canvas?.getContext("2d");
|
|
if (!ctx) return;
|
|
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
|
// `half` is the horizon row — mid-pane when level, higher when the
|
|
// eyes look down. Wall faces center on it; the eye's own height
|
|
// above the floor stays H/2 wherever the horizon sits.
|
|
const W = width, H = height, half = H / 2 - pitch * H;
|
|
|
|
// Active shakes wobble the eye a hair off true, fading as they run.
|
|
let ex = x, ey = y;
|
|
for (const f of activeFx) {
|
|
if (f.kind !== "shake") continue;
|
|
const p = (time - f.t0) / f.dur;
|
|
if (p < 0 || p >= 1) continue;
|
|
ex += f.mag * (1 - p) * Math.sin(time * 0.11);
|
|
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
|
}
|
|
|
|
// Doors mid-swing, conjurations mid-growth, and warp veils
|
|
// mid-surge this frame.
|
|
let doors: Record<string, number> | undefined;
|
|
let growing: Record<string, number> | undefined;
|
|
const spawn: Record<string, number> = {};
|
|
const surging: Record<number, number> = {};
|
|
for (const f of activeFx) {
|
|
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 === "surge") {
|
|
const s = surgeIntensity((time - f.t0) / f.dur);
|
|
if (s > 0) surging[f.warp] = Math.max(surging[f.warp] ?? 0, s);
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
// Walls, one ray per column; remember each column's depth for
|
|
// sprites, and whether its ray bent through a warp — a sprite's warp
|
|
// side must MATCH its column's, or bodies near a far mouth would
|
|
// ghost into real corridors the unrolled space happens to overlap.
|
|
const zbuf = new Float64Array(W);
|
|
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
|
|
// Per column: which warp (if any) the ray bent through, and how far
|
|
// away that mouth stood. A REAL body paints a column only if it is
|
|
// nearer than the mouth (it stands in front of the window); a
|
|
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
|
|
const warpIdCol = new Int32Array(W).fill(-1);
|
|
const warpDistCol = new Float64Array(W).fill(Infinity);
|
|
// Each mouth a ray passed through hangs a translucent veil of the
|
|
// warp texture at its own depth, drawn with the other overlays.
|
|
const veils: { col: number; depth: number; u: number; warp: number }[] = [];
|
|
// Known illusions: the ray passes, but a translucent ghost of a wall
|
|
// 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; 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 }[] = [];
|
|
// Cast first, draw after: the floor pass between them needs to know
|
|
// which columns bent through a warp, so the ground beyond a mouth can
|
|
// wear the far room's decals — the pit past the wormhole included.
|
|
const rays: { hit: ReturnType<typeof castRay>; depth: number; wallH: number; top: number }[] = new Array(W);
|
|
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, growing);
|
|
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
|
zbuf[col] = depth;
|
|
if (hit.warpId !== undefined) {
|
|
warpIdCol[col] = hit.warpId;
|
|
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
|
|
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0, warp: hit.warpId });
|
|
}
|
|
if (hit.ghost) {
|
|
ghosts.push({
|
|
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
|
|
u: hit.ghost.u, worldU: hit.ghost.worldU,
|
|
});
|
|
}
|
|
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;
|
|
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
|
|
rays[col] = { hit, depth, wallH, top };
|
|
}
|
|
|
|
// The ground and the vault overhead, cast per pixel: each screen row
|
|
// below (or above) the horizon lies at one fixed depth, so the row is
|
|
// walked with a constant world-space step and sampled from the floor
|
|
// or ceiling texture — every maze cell wearing one full tile of it.
|
|
const flen = (W / 2) / Math.tan(FOV / 2);
|
|
const cosF = Math.cos(facing), sinF = Math.sin(facing);
|
|
const motions = view.board.warps.map(warpMotion);
|
|
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
|
|
const buf = frame.data;
|
|
const fl = pixelsOf(textures.floor!);
|
|
const ce = pixelsOf(textures.ceiling!);
|
|
const FOG = 13;
|
|
// A selected cell-card's eligibility, rasterized once for the pixel
|
|
// loop: 1 = castable ground, 0 = dimmed. Absent card = null = all lit.
|
|
let litGrid: Uint8Array | null = null;
|
|
const litBw = view.board.width, litBh = view.board.height;
|
|
if (litCells) {
|
|
litGrid = new Uint8Array(litBw * litBh);
|
|
for (const k of litCells) {
|
|
const [lx, ly] = k.split(",").map(Number) as [number, number];
|
|
if (lx >= 0 && ly >= 0 && lx < litBw && ly < litBh) litGrid[ly * litBw + lx] = 1;
|
|
}
|
|
}
|
|
// FEAR's aura stains the forbidden ground itself, as the board draws
|
|
// its dashed diamond — the same warp-walking yardstick behind both.
|
|
const dreadGrid = dreadGridOf(view);
|
|
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(ambientTime / 400) : 0;
|
|
for (let row = 0; row < H; row++) {
|
|
const below = row > half;
|
|
const dz = below ? row - half : half - row;
|
|
if (dz < 1) {
|
|
// The horizon sliver: fog it flat.
|
|
for (let col = 0; col < W; col++) {
|
|
const o = (row * W + col) * 4;
|
|
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9; buf[o + 3] = 255;
|
|
}
|
|
continue;
|
|
}
|
|
const d = (H / 2) / dz;
|
|
const dec = below ? decalsOf(view) : null;
|
|
const tex = below ? fl : ce;
|
|
const tw = tex.width, th = tex.height;
|
|
const tp = tex.data;
|
|
const shade = Math.max(0, Math.min(1, 1.25 / (1 + d * 0.45)) * (1 - d / FOG));
|
|
// World position at column 0 and its per-column step, both at depth d.
|
|
const sideStep = d / flen;
|
|
const side0 = -(W / 2) * sideStep;
|
|
let wx = ex + d * cosF - side0 * sinF;
|
|
let wy = ey + d * sinF + side0 * cosF;
|
|
const stepX = -sideStep * sinF;
|
|
const stepY = sideStep * cosF;
|
|
for (let col = 0; col < W; col++) {
|
|
const o = (row * W + col) * 4;
|
|
if (shade <= 0.02) {
|
|
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
|
|
} else {
|
|
// Ground seen through a warp mouth belongs to the far room:
|
|
// carry the point through the warp's rigid motion before
|
|
// asking whose flagstone, whose decal, whose light it wears.
|
|
let gx = wx, gy = wy;
|
|
let hazed = false;
|
|
if (below && warpIdCol[col]! >= 0 && d > warpDistCol[col]!) {
|
|
const real = motions[warpIdCol[col]!]!.toReal({ x: wx, y: wy });
|
|
gx = real.x; gy = real.y;
|
|
hazed = true;
|
|
}
|
|
let u = gx % 1; if (u < 0) u += 1;
|
|
let v = gy % 1; if (v < 0) v += 1;
|
|
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
|
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
|
let sh = shade;
|
|
if (litGrid && below) {
|
|
const cx = (gx - u) | 0, cy = (gy - v) | 0;
|
|
if (cx < 0 || cy < 0 || cx >= litBw || cy >= litBh || !litGrid[cy * litBw + cx]) sh *= 0.3;
|
|
}
|
|
if (dreadGrid && below) {
|
|
const cx = (gx - u) | 0, cy = (gy - v) | 0;
|
|
if (cx >= 0 && cy >= 0 && cx < litBw && cy < litBh && dreadGrid[cy * litBw + cx]) {
|
|
r = r * (1 - dreadBlend) + 122 * dreadBlend;
|
|
g = g * (1 - dreadBlend) + 74 * dreadBlend;
|
|
b = b * (1 - dreadBlend) + 138 * dreadBlend;
|
|
}
|
|
}
|
|
if (dec) {
|
|
const hx = (gx - u) | 0, hy = (gy - v) | 0;
|
|
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
|
const di = dec.grid[hy * dec.bw + hx]!;
|
|
if (di >= 0) {
|
|
// A decal lies over the flagstones, alpha-blended texel
|
|
// by texel; home emblems wear their owner's color.
|
|
const decal = dec.decals[di]!;
|
|
const hp = pixelsOf(textures[decal.tex] ?? textures.home!);
|
|
const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4;
|
|
const ha = hp.data[hi + 3]! / 255;
|
|
if (ha > 0) {
|
|
const [tr, tg, tb] = decal.tint ?? [1, 1, 1];
|
|
r = r * (1 - ha) + hp.data[hi]! * tr * ha;
|
|
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
|
|
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (hazed) {
|
|
// The far room's ground swims in the same violet haze as its walls.
|
|
r = r * 0.82 + 120 * 0.18; g = g * 0.82 + 70 * 0.18; b = b * 0.82 + 220 * 0.18;
|
|
}
|
|
buf[o] = r * sh;
|
|
buf[o + 1] = g * sh;
|
|
buf[o + 2] = b * sh;
|
|
}
|
|
buf[o + 3] = 255;
|
|
wx += stepX;
|
|
wy += stepY;
|
|
}
|
|
}
|
|
ctx.putImageData(frame, 0, 0);
|
|
|
|
// Walls, drawn from the cast: one column each.
|
|
for (let col = 0; col < W; col++) {
|
|
const { hit, depth, wallH, top } = rays[col]!;
|
|
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
|
// Sample by the texture's own size: painted files may be any scale.
|
|
// Fire shifts its slice per world cell, so a blaze spanning edges
|
|
// reads as one long fire, not a repeated flame.
|
|
const texU = hit.kind === "firewall"
|
|
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
|
: hit.u;
|
|
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
|
|
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
|
|
if (!hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
|
// Harm shows: accumulated damage wears fractures into the face.
|
|
const dmg = view.wallDamage[hit.edge];
|
|
if (dmg) {
|
|
const ck = textures.cracks!;
|
|
ctx.globalAlpha = Math.min(0.9, 0.35 + dmg * 0.2);
|
|
ctx.drawImage(ck, Math.min(ck.width - 1, texU * ck.width), 0,
|
|
Math.max(1, ck.width / 96), ck.height, col, top, 1, wallH);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
// Distance and orientation carve the light; overlays animate it.
|
|
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
|
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
|
if (hit.kind === "firewall") {
|
|
const flicker = 0.15 + 0.15 * Math.sin(ambientTime / 90 + hit.worldU * 17 + col * 0.15);
|
|
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
|
|
ctx.fillRect(col, top, 1, wallH);
|
|
dark *= 0.5; // the fire lights itself
|
|
}
|
|
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
|
// The same tell the board gives: an untested illusion's face
|
|
// shimmers faintly — maybe stone, maybe not.
|
|
const tell = 0.06 + 0.05 * Math.sin(ambientTime / 260 + hit.worldU * 13);
|
|
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
|
|
ctx.fillRect(col, top, 1, wallH);
|
|
}
|
|
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
|
|
// A jammed lock seethes: a rusty seal pulsing across the wood.
|
|
const seethe = 0.10 + 0.08 * Math.sin(ambientTime / 160 + hit.worldU * 22);
|
|
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
|
|
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
|
|
}
|
|
if (dark > 0.02) {
|
|
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
|
ctx.fillRect(col, top, 1, wallH);
|
|
}
|
|
if (hit.warped) {
|
|
// Seen through a warp: the far side swims in violet haze.
|
|
const haze = 0.16 + 0.05 * Math.sin(ambientTime / 300 + col * 0.05);
|
|
ctx.fillStyle = `rgba(120,70,220,${haze})`;
|
|
ctx.fillRect(col, top, 1, wallH);
|
|
}
|
|
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
|
// The raised edge-card's invitation: faces breathe gold.
|
|
const offer = 0.10 + 0.05 * Math.sin(ambientTime / 420 + hit.worldU * 3);
|
|
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
|
|
ctx.fillRect(col, top, 1, wallH);
|
|
}
|
|
}
|
|
|
|
// Sprites, far to near, sliced against the depth buffer.
|
|
const sprites = billboards(view, povId, tokenArt, posOverride)
|
|
.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);
|
|
if (s) sprites.push({ ...s, fallback: "rubble" });
|
|
}
|
|
for (const f of activeFx) {
|
|
if (f.kind !== "projectile" && f.kind !== "impact") continue;
|
|
const p = (time - f.t0) / f.dur;
|
|
if (p < 0 || p >= 1) continue;
|
|
const at = f.kind === "projectile"
|
|
? { x: f.from.x + (f.to.x - f.from.x) * p, y: f.from.y + (f.to.y - f.from.y) * p }
|
|
: f.at;
|
|
const s = project({
|
|
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
|
|
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
|
|
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
|
|
warped: f.kind === "projectile" ? f.warped : undefined,
|
|
warpId: f.kind === "projectile" ? f.warpId : undefined,
|
|
}, ex, ey);
|
|
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
|
}
|
|
sprites.sort((a, b) => b.sort - a.sort);
|
|
hitFrame = {
|
|
W, H, horizon: half, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
|
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
|
};
|
|
// The nearest sprite each column carries — depth AND vertical span —
|
|
// so the overlay passes (veils, ghosts, lintels, risings) can paint
|
|
// around a body standing in front of them instead of over it.
|
|
const spriteZ = new Float64Array(W).fill(Infinity);
|
|
const sprTop = new Float64Array(W);
|
|
const sprBot = new Float64Array(W);
|
|
/** The parts of an overlay column NOT hidden behind the column's
|
|
* nearest sprite: whole, split around it, or nothing. */
|
|
const maskedSegs = (col: number, depth: number, top: number, h: number): [number, number][] => {
|
|
if (depth < spriteZ[col]!) return [[top, h]];
|
|
const segs: [number, number][] = [];
|
|
const bottom = top + h;
|
|
if (sprTop[col]! > top) segs.push([top, Math.min(h, sprTop[col]! - top)]);
|
|
if (sprBot[col]! < bottom) segs.push([sprBot[col]!, bottom - sprBot[col]!]);
|
|
return segs;
|
|
};
|
|
for (const s of sprites) {
|
|
const img = imageFor(s.src) ??
|
|
(s.fallback
|
|
? (TERRAIN3D.includes(s.fallback as (typeof TERRAIN3D)[number])
|
|
? terrainFallback(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);
|
|
// Rings live on the floor, behind the body, so the art stays clean:
|
|
// FEAR's dread ring smoulders at the feet, and a being standing on
|
|
// castable ground wears the ember ring while a targeted card is
|
|
// armed — the cockpit's answer to the board's lit squares. Only a
|
|
// sprite the walls actually show earns its marks.
|
|
let seen = false;
|
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
|
if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; }
|
|
}
|
|
const ringY = Math.min(H - 2, s.bottom);
|
|
const w = s.right - s.left;
|
|
if (seen && s.dread && s.hit) {
|
|
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155),
|
|
"160,30,30", 0.45 + 0.25 * Math.sin(ambientTime / 300), 0.12);
|
|
}
|
|
if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
|
|
litCells.has(`${s.cell.x},${s.cell.y}`) &&
|
|
!(s.hit.kind === "player" && s.hit.id === view.you)) {
|
|
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13),
|
|
"232,160,60", 0.55 + 0.25 * Math.sin(ambientTime / 220), 0.16);
|
|
}
|
|
// Painted art composites normally (inks stay true); light glows
|
|
// additively. Either way translucency applies.
|
|
if (s.glow) ctx.globalCompositeOperation = "lighter";
|
|
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
|
if (s.gaze) ctx.filter = "grayscale(0.9) brightness(0.8)";
|
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
|
if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue;
|
|
if (s.depth < spriteZ[col]!) {
|
|
spriteZ[col] = s.depth;
|
|
sprTop[col] = s.top;
|
|
sprBot[col] = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) : s.bottom;
|
|
}
|
|
const texX = ((col - s.left) / (s.right - s.left));
|
|
// Down a pit, the body ends at the ground: only the rows above
|
|
// the floor line paint, and only that much of the art.
|
|
const shown = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) - s.top : s.bottom - s.top;
|
|
if (shown <= 0) continue;
|
|
if (img) {
|
|
ctx.drawImage(
|
|
img,
|
|
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih * (shown / (s.bottom - s.top)),
|
|
col, s.top, 1, shown,
|
|
);
|
|
} else {
|
|
ctx.fillStyle = "rgba(200,190,160,0.6)";
|
|
ctx.fillRect(col, s.top, 1, shown);
|
|
}
|
|
if (s.warped) {
|
|
// A body seen through a warp swims in the same violet haze.
|
|
ctx.fillStyle = "rgba(120,70,220,0.2)";
|
|
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
|
|
}
|
|
}
|
|
if (s.gaze) ctx.filter = "none";
|
|
if (s.glow) ctx.globalCompositeOperation = "source-over";
|
|
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
|
// STICKY WAND's webs cling over the caught body: spokes from a hub
|
|
// and three sagging rings, each strand shadowed so the silk reads
|
|
// against busy token art. Only when the walls show the sprite.
|
|
if (s.webbed && s.hit && seen) {
|
|
paintWeb(ctx, s.left, s.top, s.right - s.left, s.bottom - s.top);
|
|
}
|
|
}
|
|
|
|
// Warp mouths wear their veil: the warp texture at the opening,
|
|
// translucent, swirling — the doorway between rooms that are not
|
|
// neighbors announces itself. A mouth something just passed through
|
|
// surges: the texture floods in, the swirl quickens and pales, and
|
|
// a bright ripple runs out from the crossing point to the jambs.
|
|
for (const vl of veils) {
|
|
const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
|
|
const vTop = half - vh / 2;
|
|
const wt = textures.warp!;
|
|
const s = surging[vl.warp] ?? 0;
|
|
const swirl = 0.1 + 0.1 * Math.sin(ambientTime / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
|
|
// The ripple: a ring of light widening from the veil's center over
|
|
// the surge, one full sweep to the edges; ~a fifth of the veil wide.
|
|
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
|
|
const glow = s * (0.45 * ring + 0.22);
|
|
for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) {
|
|
ctx.globalAlpha = 0.3 + 0.5 * s;
|
|
ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width),
|
|
((segTop - vTop) / vh) * wt.height,
|
|
Math.max(1, wt.width / 96), (segH / vh) * wt.height,
|
|
vl.col, segTop, 1, segH);
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
|
ctx.fillRect(vl.col, segTop, 1, segH);
|
|
if (glow > 0) {
|
|
ctx.fillStyle = `rgba(235,220,255,${Math.min(0.85, glow)})`;
|
|
ctx.fillRect(vl.col, segTop, 1, segH);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 grown = full * ri.g;
|
|
const top0 = half + full / 2 - grown;
|
|
const tex = textures[ri.kind] ?? textures.wall!;
|
|
const swirl = (0.35 * (1 - ri.g) + 0.08) *
|
|
(0.7 + 0.3 * Math.sin(ambientTime / 90 + ri.worldU * 21));
|
|
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
|
|
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
|
|
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
|
|
((segTop - top0) / grown) * tex.height,
|
|
Math.max(1, tex.width / 96), (segH / grown) * tex.height,
|
|
ri.col, segTop, 1, segH);
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
|
ctx.fillRect(ri.col, segTop, 1, segH);
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
const lTop = half - lh / 2;
|
|
const band = lh * 0.16;
|
|
const ft = textures.doorframe!;
|
|
const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45));
|
|
for (const [segTop, segH] of maskedSegs(li.col, li.depth, lTop, band)) {
|
|
ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width),
|
|
((segTop - lTop) / band) * ft.height * 0.16,
|
|
Math.max(1, ft.width / 96), (segH / band) * ft.height * 0.16,
|
|
li.col, segTop, 1, segH);
|
|
if (dark > 0.02) {
|
|
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
|
ctx.fillRect(li.col, segTop, 1, segH);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The ghosts of walls you know are lies: drawn over everything at
|
|
// their columns, thin as breath, rippling.
|
|
for (const gh of ghosts) {
|
|
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
|
|
const gTop = half - gHft / 2;
|
|
const tex = textures.wall!;
|
|
const ripple = 0.10 + 0.07 * Math.sin(ambientTime / 200 + gh.worldU * 11 + gHft * 0.01);
|
|
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
|
|
ctx.globalAlpha = 0.2;
|
|
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
|
|
((segTop - gTop) / gHft) * tex.height,
|
|
Math.max(1, tex.width / 96), (segH / gHft) * tex.height,
|
|
gh.col, segTop, 1, segH);
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`;
|
|
ctx.fillRect(gh.col, segTop, 1, segH);
|
|
}
|
|
}
|
|
|
|
// A whisper of vignette holds the torchlit mood together.
|
|
const vig = ctx.createRadialGradient(W / 2, half, H * 0.35, W / 2, half, H * 0.95);
|
|
vig.addColorStop(0, "rgba(0,0,0,0)");
|
|
vig.addColorStop(1, "rgba(0,0,0,0.45)");
|
|
ctx.fillStyle = vig;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Being hit is felt: a whole-screen wash that fades as it goes.
|
|
for (const f of activeFx) {
|
|
if (f.kind !== "flash") continue;
|
|
const p = (time - f.t0) / f.dur;
|
|
if (p < 0 || p >= 1) continue;
|
|
ctx.globalAlpha = f.peak * (1 - p);
|
|
ctx.fillStyle = f.color;
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// Fists. Yours lunges from the lower right into the scene, shrinking
|
|
// as it goes, and snaps back; one thrown at you swells from the
|
|
// middle of the pane until it fills it. Either way the blow lands
|
|
// with a comic starburst.
|
|
for (const f of activeFx) {
|
|
if (f.kind !== "fist") continue;
|
|
const p = (time - f.t0) / f.dur;
|
|
if (p < 0 || p >= 1) continue;
|
|
const pose = punchPose(p, f.swing === "out", W, H);
|
|
ctx.save();
|
|
ctx.globalAlpha = pose.alpha;
|
|
ctx.translate(pose.x, pose.y);
|
|
ctx.rotate(pose.rotation);
|
|
paintFist(ctx, pose.size, f.swing);
|
|
ctx.restore();
|
|
if (pose.radius > 0.5) {
|
|
ctx.save();
|
|
ctx.translate(pose.impactX, pose.impactY);
|
|
ctx.rotate(-0.14);
|
|
paintPow(ctx, pose.radius);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// A change of eyes: the slate wipes across, names the wizard whose
|
|
// sight this now is, and wipes away to reveal it.
|
|
for (const f of activeFx) {
|
|
if (f.kind !== "slate") continue;
|
|
const [l, r] = slateBand((time - f.t0) / f.dur);
|
|
if (r <= l) continue;
|
|
const x0 = Math.round(l * W), x1 = Math.round(r * W);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(x0, 0, x1 - x0, H);
|
|
ctx.clip();
|
|
ctx.fillStyle = "#171a20";
|
|
ctx.fillRect(x0, 0, x1 - x0, H);
|
|
const mid = H / 2;
|
|
ctx.fillStyle = "#e0b34a";
|
|
ctx.fillRect(x0, mid - 1, x1 - x0, 2);
|
|
// The card's face: standee, then the name — composed on the full
|
|
// pane so it lands where it will sit once the band is complete.
|
|
const face = f.art ? imageFor(f.art) : null;
|
|
const size = Math.round(H * 0.24);
|
|
const cx = W / 2;
|
|
if (face) {
|
|
ctx.drawImage(face, cx - size / 2, mid - size - 18, size, size);
|
|
ctx.strokeStyle = "#43331f";
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(cx - size / 2, mid - size - 18, size, size);
|
|
}
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "alphabetic";
|
|
ctx.fillStyle = "#a49c86";
|
|
ctx.font = `600 ${Math.round(H * 0.045)}px Oswald, "Archivo Narrow", sans-serif`;
|
|
ctx.fillText("T H R O U G H T H E E Y E S O F", cx, mid + Math.round(H * 0.09));
|
|
ctx.fillStyle = "#e9e1cb";
|
|
ctx.font = `600 ${Math.round(H * 0.13)}px Oswald, "Archivo Narrow", sans-serif`;
|
|
ctx.fillText(f.name.toUpperCase(), cx, mid + Math.round(H * 0.23));
|
|
ctx.restore();
|
|
}
|
|
|
|
// The crosshair's answer, before the click commits: what the pane
|
|
// would target here, outlined in the table's gold with its name.
|
|
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half);
|
|
}
|
|
|
|
|
|
/** Paint the hover cue for whatever stands under the crosshair. */
|
|
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
|
|
const f = hitFrame;
|
|
if (!f || !mouse) return;
|
|
const found = resolveHover(mouse.x, mouse.y);
|
|
if (!found) return;
|
|
// The cue carries a verdict when a targeted card is armed: gold for
|
|
// ground the cast can reach, cold slate for what stands beyond it.
|
|
const t = found.target;
|
|
const tCell = t.kind === "cell" || t.kind === "edge"
|
|
? t.cell
|
|
: t.kind === "player"
|
|
? view.players.find((p) => p.id === t.id)?.position
|
|
: view.creatures.find((c) => c.id === t.id)?.position;
|
|
const eligible = litCells == null ||
|
|
(t.kind === "edge" && edgeSelect) ||
|
|
(tCell != null && litCells.has(`${tCell.x},${tCell.y}`));
|
|
ctx.save();
|
|
ctx.strokeStyle = eligible ? "#c9a72a" : "rgba(150,150,155,0.55)";
|
|
ctx.fillStyle = eligible ? "rgba(201,167,42,0.14)" : "rgba(120,120,125,0.10)";
|
|
ctx.lineWidth = 1.5;
|
|
let label = "";
|
|
if (found.shape.kind === "rect") {
|
|
const r = found.shape;
|
|
ctx.strokeRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
|
|
ctx.fillRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
|
|
label = found.label;
|
|
} else if (found.shape.kind === "face") {
|
|
// Tint every column that shows THIS edge: the whole face answers.
|
|
for (let col = 0; col < f.W; col++) {
|
|
const c = f.cols[col];
|
|
if (!c || c.edge !== found.shape.edge) continue;
|
|
ctx.fillRect(col, c.top, 1, c.h);
|
|
}
|
|
label = found.label;
|
|
} else if (found.shape.kind === "none") {
|
|
label = found.label;
|
|
} else {
|
|
// A ground square: its four corners projected onto the floor plane.
|
|
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
|
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
|
const { x: cx0, y: cy0 } = found.shape.cell;
|
|
const pts: [number, number][] = [];
|
|
for (const [ox, oy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) {
|
|
const rx = cx0 + ox - f.ex, ry = cy0 + oy - f.ey;
|
|
const depth = rx * cosF + ry * sinF;
|
|
if (depth < 0.12) { pts.length = 0; break; }
|
|
const side = -rx * sinF + ry * cosF;
|
|
pts.push([f.W / 2 + (side / depth) * flen, half + (f.H / 2) / depth]);
|
|
}
|
|
if (pts.length === 4) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0]![0], pts[0]![1]);
|
|
for (const [px, py] of pts.slice(1)) ctx.lineTo(px, py);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
if (label) {
|
|
ctx.font = "12px 'Courier Prime', monospace";
|
|
const wTxt = ctx.measureText(label).width;
|
|
const lx = Math.min(f.W - wTxt - 10, Math.max(4, mouse.x + 10));
|
|
const ly = Math.max(16, mouse.y - 8);
|
|
ctx.fillStyle = "rgba(13,12,18,0.85)";
|
|
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
|
|
ctx.fillStyle = eligible ? "#e9e1cb" : "#8a8a90";
|
|
ctx.fillText(label, lx, ly);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
interface Projected {
|
|
src: string;
|
|
depth: number;
|
|
left: number;
|
|
right: number;
|
|
top: number;
|
|
bottom: number;
|
|
warped?: boolean;
|
|
warpId?: number;
|
|
glow?: boolean;
|
|
alpha?: number;
|
|
fallback?: string;
|
|
/** Rows at or below this are underground: not painted. */
|
|
clipY?: number;
|
|
clampL?: number;
|
|
clampR?: number;
|
|
hit?: { kind: "player" | "creature"; id: string };
|
|
cell?: { x: number; y: number };
|
|
webbed?: boolean;
|
|
gaze?: boolean;
|
|
dread?: boolean;
|
|
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
|
|
* hedge over the wizard standing in it) but must never let one
|
|
* cheat past a wall — occlusion always uses the true depth. */
|
|
sort: number;
|
|
}
|
|
function project(
|
|
b: Pick<Billboard, "x" | "y" | "src" | "scale" | "rise"> & Partial<Billboard>,
|
|
ex: number, ey: number,
|
|
): Projected | null {
|
|
const relX = b.x - ex, relY = b.y - ey;
|
|
let depth = relX * Math.cos(facing) + relY * Math.sin(facing);
|
|
let side = -relX * Math.sin(facing) + relY * Math.cos(facing);
|
|
// A being sharing the eyes' own square has no depth to draw at:
|
|
// it stands right in front of them, whichever way they look.
|
|
if (b.hit && Math.hypot(relX, relY) < 0.35) { depth = 1.0; side = 0; }
|
|
if (depth < 0.15) return null;
|
|
const W = width, H = height, half = H / 2 - pitch * H;
|
|
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
|
const wallH = H / depth;
|
|
const size = wallH * b.scale;
|
|
// Vertical pixels scale by H/depth, horizontal by focal/depth — and
|
|
// they differ. A sprite that declares an aspect means WORLD width
|
|
// (a hedge one cell wide must touch both posts), so its width uses
|
|
// the horizontal scale; plain square sprites keep their pixel shape.
|
|
const wide = b.aspect
|
|
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
|
: size;
|
|
const bottom = half + wallH / 2 - b.rise * wallH;
|
|
// A sunken body paints only above the floor line at its depth.
|
|
const clipY = b.sink ? half + wallH / 2 : undefined;
|
|
// A cell-bound volume is windowed to its own square: the billboard's
|
|
// camera-facing plane is intersected with the cell, and only that
|
|
// lateral segment may paint, so an obliquely-viewed hedge cannot
|
|
// poke its ends through the neighboring walls. (Virtual warp copies
|
|
// skip this — their clip cell lives in another frame.)
|
|
let clampL: number | undefined;
|
|
let clampR: number | undefined;
|
|
if (b.clip && !b.warped) {
|
|
const sinF = Math.sin(facing), cosF = Math.cos(facing);
|
|
let sMin = -Infinity, sMax = Infinity;
|
|
if (Math.abs(sinF) > 1e-6) {
|
|
const a1 = (b.x - b.clip.x) / sinF;
|
|
const a2 = (b.x - (b.clip.x + 1)) / sinF;
|
|
sMin = Math.max(sMin, Math.min(a1, a2));
|
|
sMax = Math.min(sMax, Math.max(a1, a2));
|
|
}
|
|
if (Math.abs(cosF) > 1e-6) {
|
|
const a1 = (b.clip.y - b.y) / cosF;
|
|
const a2 = (b.clip.y + 1 - b.y) / cosF;
|
|
sMin = Math.max(sMin, Math.min(a1, a2));
|
|
sMax = Math.min(sMax, Math.max(a1, a2));
|
|
}
|
|
if (sMin <= sMax && Number.isFinite(sMin) && Number.isFinite(sMax)) {
|
|
const sideC = -relX * sinF + relY * cosF;
|
|
const fl = (W / 2) / Math.tan(FOV / 2);
|
|
const e1 = W / 2 + ((sideC + sMin) / depth) * fl;
|
|
const e2 = W / 2 + ((sideC + sMax) / depth) * fl;
|
|
clampL = Math.min(e1, e2);
|
|
clampR = Math.max(e1, e2);
|
|
}
|
|
}
|
|
return {
|
|
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
|
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
|
|
webbed: b.webbed, gaze: b.gaze, dread: b.dread,
|
|
hit: b.hit, cell: b.cell, clipY,
|
|
left: screenX - wide / 2, right: screenX + wide / 2,
|
|
top: bottom - size, bottom,
|
|
};
|
|
}
|
|
|
|
/** Resolve a canvas-pixel click to what stood under it: the nearest
|
|
* visible sprite, else the struck wall or door face, else the floor
|
|
* (or vault) square the pixel lies on — warp-bent columns mapping
|
|
* their virtual ground back to real cells through the warp's own
|
|
* rigid motion. */
|
|
function hitTest(px: number, py: number): FpvTarget | null {
|
|
return resolveHover(px, py)?.target ?? null;
|
|
}
|
|
|
|
/** The full answer for a canvas pixel: the target, the screen shape to
|
|
* highlight, and the name to whisper beside the crosshair. */
|
|
function resolveHover(px: number, py: number): {
|
|
target: FpvTarget;
|
|
label: string;
|
|
shape:
|
|
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
|
|
| { kind: "face"; edge: string }
|
|
| { kind: "ground"; cell: { x: number; y: number } }
|
|
| { kind: "none" };
|
|
} | null {
|
|
const f = hitFrame;
|
|
if (!f) return null;
|
|
const col = Math.max(0, Math.min(f.W - 1, px | 0));
|
|
const half = f.horizon;
|
|
|
|
// Sprites first, nearest first, honoring the draw pass's own
|
|
// visibility rules for this column.
|
|
for (const sp of f.sprites) {
|
|
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
|
|
if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
|
|
if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue;
|
|
const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom };
|
|
const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp);
|
|
if (sp.hit) return { target: sp.hit, label, shape };
|
|
return { target: { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }, label, shape };
|
|
}
|
|
|
|
// The wall span: doors and walls answer as their EDGE.
|
|
const c = f.cols[col];
|
|
if (c && py >= c.top && py <= c.top + c.h) {
|
|
if ((c.kind === "wall" || c.kind === "door" || c.kind === "firewall") && c.edge) {
|
|
const [kind, coords] = c.edge.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
return {
|
|
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
|
|
label: c.kind === "firewall" ? "wall of fire" : c.kind,
|
|
shape: { kind: "face", edge: c.edge },
|
|
};
|
|
}
|
|
if (c.kind === "stone") {
|
|
// A stone fill is a square, not an edge: the cell just past the
|
|
// struck face along this column's ray.
|
|
const t = f.zbuf[col]! + 0.05;
|
|
const pt = groundPoint(f, col, t);
|
|
return pt ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null;
|
|
}
|
|
return null; // rims and frame posts are nobody's target
|
|
}
|
|
|
|
// Ground (or vault): each row below the horizon lies at one depth.
|
|
const dz = py > half ? py - half : half - py;
|
|
if (dz < 1) return null;
|
|
const d = (f.H / 2) / dz;
|
|
if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click
|
|
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
|
// Warp-bent ground still TARGETS truly, but the highlight quad
|
|
// cannot be drawn in this frame's geometry: label it instead.
|
|
const pt = groundPoint(f, col, d);
|
|
return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "none" } } : null;
|
|
}
|
|
const pt = groundPoint(f, col, d);
|
|
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
|
|
}
|
|
|
|
/** A sprite's spoken name: the creature or thing under the crosshair. */
|
|
function labelOf(sp: Projected): string {
|
|
if (sp.hit?.kind === "creature") {
|
|
const c = view.creatures.find((k) => k.id === sp.hit!.id);
|
|
return c ? c.kind.replace(/-/g, " ") : "creature";
|
|
}
|
|
if (sp.fallback) return sp.fallback.replace(/-/g, " ");
|
|
const m = sp.src.match(/\/([a-z0-9-]+)\.png/i);
|
|
return m ? m[1]!.replace(/-/g, " ") : "";
|
|
}
|
|
|
|
/** The real-world square at perpendicular depth d down column col —
|
|
* mapping through the column's warp when the ray bent. */
|
|
function groundPoint(
|
|
f: NonNullable<typeof hitFrame>, col: number, d: number,
|
|
): { x: number; y: number } | null {
|
|
const flen = (f.W / 2) / Math.tan(FOV / 2);
|
|
const side = (col - f.W / 2) * (d / flen);
|
|
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
|
|
let wx = f.ex + d * cosF - side * sinF;
|
|
let wy = f.ey + d * sinF + side * cosF;
|
|
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
|
|
const w = view.board.warps[f.warpIdCol[col]!];
|
|
if (!w) return null;
|
|
const real = warpMotion(w).toReal({ x: wx, y: wy });
|
|
wx = real.x; wy = real.y;
|
|
}
|
|
const cell = { x: Math.floor(wx), y: Math.floor(wy) };
|
|
return view.board.cells[`${cell.x},${cell.y}`] ? cell : null;
|
|
}
|
|
|
|
function onCanvasClick(e: MouseEvent) {
|
|
if (!ontarget || !canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const px = (e.clientX - rect.left) * (width / rect.width);
|
|
const py = (e.clientY - rect.top) * (height / rect.height);
|
|
const t = hitTest(px, py);
|
|
if (t) ontarget(t);
|
|
}
|
|
|
|
// Redraw every frame: the fire flickers and the warps swirl even when
|
|
// the camera holds still.
|
|
$effect(() => {
|
|
let raf = 0;
|
|
const loop = (t: number) => { draw(t); raf = requestAnimationFrame(loop); };
|
|
raf = requestAnimationFrame(loop);
|
|
return () => cancelAnimationFrame(raf);
|
|
});
|
|
</script>
|
|
|
|
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
|
|
onclick={onCanvasClick}
|
|
onpointermove={(e) => {
|
|
if (!ontarget || !canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
mouse = { x: (e.clientX - rect.left) * (width / rect.width), y: (e.clientY - rect.top) * (height / rect.height) };
|
|
}}
|
|
onpointerleave={() => (mouse = null)}></canvas>
|
|
|
|
<style>
|
|
.targeting { cursor: crosshair; }
|
|
.fpv-canvas {
|
|
display: block;
|
|
width: 100%;
|
|
max-width: 100%;
|
|
image-rendering: pixelated;
|
|
border: 1px solid #3a3428;
|
|
border-radius: 4px;
|
|
background: #0d0c12;
|
|
}
|
|
</style>
|