The maze performs: doors swing, walls die loudly, homes wear their colors
Entertainment polish for the instant replay, all through one wizard's eyes. Doors now slide open along their own edge — Wolf3D style — when anyone steps through in a reel, hold for the crossing, and swing shut; the raycaster takes a per-edge openness map and doors carry their texture with them, leaving a sliver of jamb at the far post. A wall that dies bursts into stone and shakes the camera, then leaves a mound of rubble at the fallen edge for the rest of the reel (a tenth paintable sprite, drawn opaque — debris, not light). Jammed locks seethe: a rusty pulse across the wood, keyed off the view's own door states. Walking THROUGH a wall shimmers on both faces and washes the walker's own screen stone-gray. And every home base wears its owner's emblem on the flagstones — a near-white paintable overlay tinted by wizard color per cell in the floor cast. Testing surfaced a real ghost: a body standing near a far warp mouth also rendered at its virtual position, which can overlap real corridors — the depth buffer alone cannot clip it. Every column now remembers whether its ray bent, and a sprite draws only where its warp side matches the column's; projectile legs carry the same flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8a27c06b55
commit
cdfae39398
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -5,7 +5,7 @@
|
|||||||
import { humanize } from "./net.svelte";
|
import { humanize } from "./net.svelte";
|
||||||
import { scheduleFx, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay } from "./fpv/raycast";
|
import { castRay, edgeMid } from "./fpv/raycast";
|
||||||
import { prefs } from "./prefs.svelte";
|
import { prefs } from "./prefs.svelte";
|
||||||
import { stackSightTrace } from "@wizwar/engine";
|
import { stackSightTrace } from "@wizwar/engine";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
@@ -51,6 +51,24 @@
|
|||||||
// attack in progress, so a replay-watcher can see how a spell reached them.
|
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||||||
const sightTrace = $derived(stackSightTrace(step.view));
|
const sightTrace = $derived(stackSightTrace(step.view));
|
||||||
|
|
||||||
|
/** Every wall destroyed so far in the reel leaves a mound of rubble on
|
||||||
|
* the first-person floor for the rest of it. */
|
||||||
|
const rubbleSpots = $derived.by(() => {
|
||||||
|
const spots: { x: number; y: number }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) {
|
||||||
|
for (const e of steps[i]!.events) {
|
||||||
|
if (e.type !== "wallDestroyed" || !("edge" in e)) continue;
|
||||||
|
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
|
||||||
|
const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
spots.push(edgeMid(edge.cell, edge.side));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spots;
|
||||||
|
});
|
||||||
|
|
||||||
/** Each step's spells flare on the reel exactly as they did at the table. */
|
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||||||
let boardFx = $state<BoardFx[]>([]);
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -320,7 +338,7 @@
|
|||||||
{#if fp}
|
{#if fp}
|
||||||
<FirstPerson view={step.view} {povId}
|
<FirstPerson view={step.view} {povId}
|
||||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||||||
fx={fpFx} posOverride={actorPos} />
|
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||||||
{:else}
|
{:else}
|
||||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
wherever a file is missing.
|
wherever a file is missing.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
|
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as name (name)}
|
||||||
<figure>
|
<figure>
|
||||||
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||||
<figcaption>{name}</figcaption>
|
<figcaption>{name}</figcaption>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
// by the same depth buffer the walls wrote.
|
// by the same depth buffer the walls wrote.
|
||||||
import { castRay, billboards } from "./raycast";
|
import { castRay, billboards } from "./raycast";
|
||||||
import { materialTextures } from "./textures";
|
import { materialTextures } from "./textures";
|
||||||
import { fxFallback, type FpFx } from "./fx3d";
|
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
|
||||||
import { tokenArt } from "../art";
|
import { tokenArt } from "../art";
|
||||||
|
import { PLAYER_COLORS } from "../colors";
|
||||||
import type { GameView } from "@wizwar/engine";
|
import type { GameView } from "@wizwar/engine";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
height = 440,
|
height = 440,
|
||||||
fx = [],
|
fx = [],
|
||||||
posOverride,
|
posOverride,
|
||||||
|
rubble = [],
|
||||||
}: {
|
}: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
povId: string;
|
povId: string;
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
fx?: FpFx[];
|
fx?: FpFx[];
|
||||||
/** Bodies mid-glide: world positions that override the view's cells. */
|
/** Bodies mid-glide: world positions that override the view's cells. */
|
||||||
posOverride?: Record<string, { x: number; y: number }>;
|
posOverride?: Record<string, { x: number; y: number }>;
|
||||||
|
/** Where walls have died: a mound of stone marks each fallen edge. */
|
||||||
|
rubble?: { x: number; y: number }[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const FOV = Math.PI / 2.9;
|
const FOV = Math.PI / 2.9;
|
||||||
@@ -84,6 +88,33 @@
|
|||||||
}
|
}
|
||||||
let frame: ImageData | null = null;
|
let frame: ImageData | null = null;
|
||||||
|
|
||||||
|
// Home bases wear their owner's emblem on the floor: a per-view grid of
|
||||||
|
// owner tints, indexed by integer cell for the per-pixel pass.
|
||||||
|
const homeCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; tints: [number, number, number][] }>();
|
||||||
|
function homesOf(v: GameView) {
|
||||||
|
let h = homeCache.get(v);
|
||||||
|
if (!h) {
|
||||||
|
const bw = v.board.width, bh = v.board.height;
|
||||||
|
const grid = new Int16Array(bw * bh).fill(-1);
|
||||||
|
const tints: [number, number, number][] = [];
|
||||||
|
for (const p of v.players) {
|
||||||
|
if (!p.home) continue;
|
||||||
|
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
|
||||||
|
tints.push([
|
||||||
|
parseInt(hex.slice(1, 3), 16) / 255,
|
||||||
|
parseInt(hex.slice(3, 5), 16) / 255,
|
||||||
|
parseInt(hex.slice(5, 7), 16) / 255,
|
||||||
|
]);
|
||||||
|
if (p.home.x >= 0 && p.home.y >= 0 && p.home.x < bw && p.home.y < bh) {
|
||||||
|
grid[p.home.y * bw + p.home.x] = tints.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h = { grid, bw, bh, tints };
|
||||||
|
homeCache.set(v, h);
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
function draw(time: number) {
|
function draw(time: number) {
|
||||||
const ctx = canvas?.getContext("2d");
|
const ctx = canvas?.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -100,6 +131,14 @@
|
|||||||
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Doors mid-swing this frame, from the animated fx.
|
||||||
|
let doors: Record<string, number> | undefined;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// The ground and the vault overhead, cast per pixel: each screen row
|
// 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
|
// 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
|
// walked with a constant world-space step and sampled from the floor
|
||||||
@@ -123,6 +162,8 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const d = (H / 2) / dz;
|
const d = (H / 2) / dz;
|
||||||
|
const homes = below ? homesOf(view) : null;
|
||||||
|
const hp = below ? pixelsOf(textures.home!) : null;
|
||||||
const tex = below ? fl : ce;
|
const tex = below ? fl : ce;
|
||||||
const tw = tex.width, th = tex.height;
|
const tw = tex.width, th = tex.height;
|
||||||
const tp = tex.data;
|
const tp = tex.data;
|
||||||
@@ -142,9 +183,28 @@
|
|||||||
let u = wx % 1; if (u < 0) u += 1;
|
let u = wx % 1; if (u < 0) u += 1;
|
||||||
let v = wy % 1; if (v < 0) v += 1;
|
let v = wy % 1; if (v < 0) v += 1;
|
||||||
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
||||||
buf[o] = tp[ti]! * shade;
|
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
||||||
buf[o + 1] = tp[ti + 1]! * shade;
|
if (homes) {
|
||||||
buf[o + 2] = tp[ti + 2]! * shade;
|
const hx = (wx - u) | 0, hy = (wy - v) | 0;
|
||||||
|
if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) {
|
||||||
|
const owner = homes.grid[hy * homes.bw + hx]!;
|
||||||
|
if (owner >= 0 && hp) {
|
||||||
|
// The emblem sits over the flagstones, wearing its
|
||||||
|
// owner's color.
|
||||||
|
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] = homes.tints[owner]!;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf[o] = r * shade;
|
||||||
|
buf[o + 1] = g * shade;
|
||||||
|
buf[o + 2] = b * shade;
|
||||||
}
|
}
|
||||||
buf[o + 3] = 255;
|
buf[o + 3] = 255;
|
||||||
wx += stepX;
|
wx += stepX;
|
||||||
@@ -153,13 +213,18 @@
|
|||||||
}
|
}
|
||||||
ctx.putImageData(frame, 0, 0);
|
ctx.putImageData(frame, 0, 0);
|
||||||
|
|
||||||
// Walls, one ray per column; remember each column's depth for sprites.
|
// 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 zbuf = new Float64Array(W);
|
||||||
|
const warpedCol = new Uint8Array(W);
|
||||||
for (let col = 0; col < W; col++) {
|
for (let col = 0; col < W; col++) {
|
||||||
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
|
||||||
const hit = castRay(view, ex, ey, rayAngle);
|
const hit = castRay(view, ex, ey, rayAngle, doors);
|
||||||
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
|
||||||
zbuf[col] = depth;
|
zbuf[col] = depth;
|
||||||
|
warpedCol[col] = hit.warped ? 1 : 0;
|
||||||
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
|
||||||
const top = half - wallH / 2;
|
const top = half - wallH / 2;
|
||||||
const tex = textures[hit.kind] ?? textures.wall!;
|
const tex = textures[hit.kind] ?? textures.wall!;
|
||||||
@@ -185,6 +250,12 @@
|
|||||||
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||||
ctx.fillRect(col, top, 1, wallH);
|
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(time / 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) {
|
if (dark > 0.02) {
|
||||||
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
|
||||||
ctx.fillRect(col, top, 1, wallH);
|
ctx.fillRect(col, top, 1, wallH);
|
||||||
@@ -201,6 +272,10 @@
|
|||||||
const sprites = billboards(view, povId, tokenArt, posOverride)
|
const sprites = billboards(view, povId, tokenArt, posOverride)
|
||||||
.map((b) => project(b, ex, ey))
|
.map((b) => project(b, ex, ey))
|
||||||
.filter((s): s is Projected => s !== null);
|
.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.4, rise: 0 }, ex, ey);
|
||||||
|
if (s) sprites.push({ ...s, fallback: "rubble" });
|
||||||
|
}
|
||||||
for (const f of fx) {
|
for (const f of fx) {
|
||||||
if (f.kind !== "projectile" && f.kind !== "impact") continue;
|
if (f.kind !== "projectile" && f.kind !== "impact") continue;
|
||||||
const p = (time - f.t0) / f.dur;
|
const p = (time - f.t0) / f.dur;
|
||||||
@@ -212,6 +287,7 @@
|
|||||||
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
|
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
|
||||||
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
|
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
|
||||||
rise: 0.3,
|
rise: 0.3,
|
||||||
|
warped: f.kind === "projectile" ? f.warped : undefined,
|
||||||
}, ex, ey);
|
}, ex, ey);
|
||||||
if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
||||||
}
|
}
|
||||||
@@ -225,6 +301,7 @@
|
|||||||
if (s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
if (s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
||||||
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
||||||
if (s.depth >= zbuf[col]!) continue;
|
if (s.depth >= zbuf[col]!) continue;
|
||||||
|
if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue;
|
||||||
const texX = ((col - s.left) / (s.right - s.left));
|
const texX = ((col - s.left) / (s.right - s.left));
|
||||||
if (img) {
|
if (img) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
|
|||||||
@@ -6,21 +6,35 @@
|
|||||||
|
|
||||||
import { fxForEvents } from "../fx";
|
import { fxForEvents } from "../fx";
|
||||||
import { CELL } from "../fx-sprites/geom";
|
import { CELL } from "../fx-sprites/geom";
|
||||||
import { castRay, warpMotion } from "./raycast";
|
import { castRay, edgeMid, warpMotion } from "./raycast";
|
||||||
|
import { edgeKey } from "@wizwar/engine";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
|
||||||
export type FpFx =
|
export type FpFx =
|
||||||
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number }
|
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
|
||||||
|
/** This leg flies in a warp mouth's virtual space: visible only
|
||||||
|
* through the opening, on columns whose rays bent. */
|
||||||
|
warped?: boolean }
|
||||||
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number }
|
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number }
|
||||||
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: 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: "shake"; mag: number; t0: number; dur: number }
|
||||||
|
| { id: number; kind: "door"; edge: string; t0: number; dur: number };
|
||||||
|
|
||||||
|
/** How far open an animated door stands at progress p: swings open,
|
||||||
|
* holds for the crossing, swings shut. */
|
||||||
|
export function doorOpenness(p: number): number {
|
||||||
|
if (p < 0 || p >= 1) return 0;
|
||||||
|
if (p < 0.3) return p / 0.3;
|
||||||
|
if (p < 0.7) return 1;
|
||||||
|
return (1 - p) / 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
/** The paintable conjuration sprites: public/fx3d/<name>.png, square with
|
/** The paintable conjuration sprites: public/fx3d/<name>.png, square with
|
||||||
* transparent ground, drawn as a billboard mid-air. A procedural glow
|
* transparent ground, drawn as a billboard mid-air. A procedural glow
|
||||||
* stands in wherever a file is missing. */
|
* stands in wherever a file is missing. */
|
||||||
export const FX_ART = [
|
export const FX_ART = [
|
||||||
"fireball", "bolt", "waterbolt", "spark",
|
"fireball", "bolt", "waterbolt", "spark",
|
||||||
"burst", "splash", "hit", "shimmer", "shield",
|
"burst", "splash", "hit", "shimmer", "shield", "rubble",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
@@ -42,7 +56,7 @@ const IMPACT_ART: Record<string, string> = {
|
|||||||
* clipped by the depth buffer to the side the camera stands on. */
|
* clipped by the depth buffer to the side the camera stands on. */
|
||||||
function projectileLegs(
|
function projectileLegs(
|
||||||
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
|
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
|
||||||
): { from: { x: number; y: number }; to: { x: number; y: number } }[] {
|
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] {
|
||||||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
if (len < 0.05) return [{ from: a, to: b }];
|
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));
|
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
|
||||||
@@ -54,7 +68,7 @@ function projectileLegs(
|
|||||||
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
|
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) {
|
if (hit.warped && hit.dist >= vlen - 0.4) {
|
||||||
return [
|
return [
|
||||||
{ from: a, to: vt },
|
{ from: a, to: vt, warped: true },
|
||||||
{ from: motion.toReal(a), to: b },
|
{ from: motion.toReal(a), to: b },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -76,7 +90,7 @@ export function fpFxForEvents(
|
|||||||
out.push({
|
out.push({
|
||||||
fx: {
|
fx: {
|
||||||
id: nextId++, kind: "projectile", art,
|
id: nextId++, kind: "projectile", art,
|
||||||
from: leg.from, to: leg.to,
|
from: leg.from, to: leg.to, warped: leg.warped,
|
||||||
t0: 0, dur: PROJECTILE_DUR[art]!,
|
t0: 0, dur: PROJECTILE_DUR[art]!,
|
||||||
},
|
},
|
||||||
delay,
|
delay,
|
||||||
@@ -94,6 +108,30 @@ export function fpFxForEvents(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The maze itself performs: doors swing for whoever steps through,
|
||||||
|
// walls die into rubble, and walking THROUGH stone shimmers.
|
||||||
|
for (const e of events) {
|
||||||
|
if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) {
|
||||||
|
const via = e.type === "moved" ? e.via : "step";
|
||||||
|
const key = edgeKey(e.from, e.direction);
|
||||||
|
if (via === "step" && view.board.edges[key] === "door") {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 });
|
||||||
|
}
|
||||||
|
if (e.type === "moved" && e.via === "passWall") {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 });
|
||||||
|
if (e.player === povId) {
|
||||||
|
out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.type === "wallDestroyed" && "edge" in e) {
|
||||||
|
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: 700 }, delay: 0 });
|
||||||
|
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Blows that land on the point of view: give impacts a beat to arrive
|
// Blows that land on the point of view: give impacts a beat to arrive
|
||||||
// when something visibly flew first.
|
// when something visibly flew first.
|
||||||
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
|
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
|
||||||
@@ -129,6 +167,7 @@ const FX_COLORS: Record<string, [string, string]> = {
|
|||||||
hit: ["#ffd0c0", "#c02020"],
|
hit: ["#ffd0c0", "#c02020"],
|
||||||
shimmer: ["#f0e0ff", "#7040c0"],
|
shimmer: ["#f0e0ff", "#7040c0"],
|
||||||
shield: ["#ffffff", "#4060d0"],
|
shield: ["#ffffff", "#4060d0"],
|
||||||
|
rubble: ["#b0a898", "#5c544a"],
|
||||||
};
|
};
|
||||||
const baked = new Map<string, HTMLCanvasElement>();
|
const baked = new Map<string, HTMLCanvasElement>();
|
||||||
export function fxFallback(name: string): HTMLCanvasElement {
|
export function fxFallback(name: string): HTMLCanvasElement {
|
||||||
@@ -139,12 +178,25 @@ export function fxFallback(name: string): HTMLCanvasElement {
|
|||||||
t.height = 64;
|
t.height = 64;
|
||||||
const c = t.getContext("2d")!;
|
const c = t.getContext("2d")!;
|
||||||
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
|
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
|
||||||
|
if (name === "rubble") {
|
||||||
|
// A mound of broken stone, opaque — this one is debris, not light.
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const jx = 12 + ((i * 37) % 40);
|
||||||
|
const jy = 40 + ((i * 23) % 18);
|
||||||
|
const r = 6 + ((i * 13) % 8);
|
||||||
|
c.fillStyle = i % 2 ? core : rim;
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(jx, jy, r, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
|
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||||
g.addColorStop(0, core);
|
g.addColorStop(0, core);
|
||||||
g.addColorStop(0.55, rim);
|
g.addColorStop(0.55, rim);
|
||||||
g.addColorStop(1, "rgba(0,0,0,0)");
|
g.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
c.fillStyle = g;
|
c.fillStyle = g;
|
||||||
c.fillRect(0, 0, 64, 64);
|
c.fillRect(0, 0, 64, 64);
|
||||||
|
}
|
||||||
baked.set(name, t);
|
baked.set(name, t);
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface Hit {
|
|||||||
/** The un-wrapped along-face coordinate: u plus the world cell it lies
|
/** The un-wrapped along-face coordinate: u plus the world cell it lies
|
||||||
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
|
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
|
||||||
worldU: number;
|
worldU: number;
|
||||||
|
/** The edge struck, for state the renderer dresses (jammed locks). */
|
||||||
|
edge?: string;
|
||||||
/** The eye reached this through a warp: haze it other-worldly. */
|
/** The eye reached this through a warp: haze it other-worldly. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
}
|
}
|
||||||
@@ -28,15 +30,13 @@ export interface Hit {
|
|||||||
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
||||||
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
||||||
|
|
||||||
/** Is this edge passable to the EYE (rays), and if not, what is it? */
|
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
||||||
|
* Doors always report as doors; how far each stands open — fully for an
|
||||||
|
* open or held door, mid-swing during an animation — is castRay's call. */
|
||||||
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
||||||
const e = view.board.edges[key] ?? "open";
|
const e = view.board.edges[key] ?? "open";
|
||||||
if (e === "open") return null;
|
if (e === "open") return null;
|
||||||
if (e === "door") {
|
if (e === "door") return "door";
|
||||||
// An open or held door is a doorway; the eye passes through the gap.
|
|
||||||
if (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key)) return null;
|
|
||||||
return "door";
|
|
||||||
}
|
|
||||||
if (e === "firewall") return "firewall";
|
if (e === "firewall") return "firewall";
|
||||||
return "wall";
|
return "wall";
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,13 @@ function slabHit(
|
|||||||
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
|
export function castRay(
|
||||||
|
view: GameView, ox: number, oy: number, angle: number,
|
||||||
|
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
||||||
|
* 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<string, number>,
|
||||||
|
): Hit {
|
||||||
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
// 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.
|
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
||||||
let baseDist = 0;
|
let baseDist = 0;
|
||||||
@@ -90,12 +96,25 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
|
|||||||
// Every blocked edge of the current cell stands as a slab; the
|
// Every blocked edge of the current cell stands as a slab; the
|
||||||
// nearest strike inside this cell's span wins.
|
// nearest strike inside this cell's span wins.
|
||||||
const exit = Math.min(sideDistX, sideDistY);
|
const exit = Math.min(sideDistX, sideDistY);
|
||||||
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"] } | null = null;
|
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null;
|
||||||
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
|
||||||
const kind = edgeObstacle(view, edgeKey({ x: cx, y: cy }, side));
|
const key = edgeKey({ x: cx, y: cy }, side);
|
||||||
|
const kind = edgeObstacle(view, key);
|
||||||
if (!kind) return;
|
if (!kind) return;
|
||||||
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
|
||||||
if (h && h.t <= exit + 0.15 && (!best || h.t < best.t)) best = { ...h, kind };
|
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
|
||||||
|
let slide = 0;
|
||||||
|
if (kind === "door") {
|
||||||
|
const open = doors?.[key] ??
|
||||||
|
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
|
||||||
|
if (open > 0) {
|
||||||
|
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
|
||||||
|
const u = along - Math.floor(along);
|
||||||
|
if (u < open - 0.02) return; // the ray sails through the opening
|
||||||
|
slide = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best = { ...h, kind, slide, edge: key };
|
||||||
};
|
};
|
||||||
const hw = (side: Side) =>
|
const hw = (side: Side) =>
|
||||||
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
|
||||||
@@ -104,10 +123,14 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
|
|||||||
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
|
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
|
||||||
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
|
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
|
||||||
if (best !== null) {
|
if (best !== null) {
|
||||||
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"] };
|
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string };
|
||||||
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
|
||||||
const u = along - Math.floor(along);
|
const u = along - Math.floor(along);
|
||||||
return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, worldU: along, warped };
|
// 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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No slab in this cell: advance through the open boundary.
|
// No slab in this cell: advance through the open boundary.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
|
|||||||
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as const;
|
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as const;
|
||||||
|
|
||||||
/** The built-in bake: what ships when no painted file overrides it. */
|
/** The built-in bake: what ships when no painted file overrides it. */
|
||||||
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||||
@@ -107,6 +107,26 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
home: bake((c) => {
|
||||||
|
// A home base's floor emblem, laid over the flagstones and tinted by
|
||||||
|
// its owner's color at draw time — so paint it near-white, with
|
||||||
|
// transparency wherever the plain floor should show through.
|
||||||
|
c.strokeStyle = "rgba(240,235,225,0.85)";
|
||||||
|
c.lineWidth = 3;
|
||||||
|
c.strokeRect(4.5, 4.5, TEX - 9, TEX - 9);
|
||||||
|
c.lineWidth = 2;
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(TEX / 2, 12);
|
||||||
|
c.lineTo(TEX - 12, TEX / 2);
|
||||||
|
c.lineTo(TEX / 2, TEX - 12);
|
||||||
|
c.lineTo(12, TEX / 2);
|
||||||
|
c.closePath();
|
||||||
|
c.stroke();
|
||||||
|
c.fillStyle = "rgba(240,235,225,0.35)";
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
}),
|
||||||
warp: bake((c) => {
|
warp: bake((c) => {
|
||||||
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
||||||
grad.addColorStop(0, "#2c1a4e");
|
grad.addColorStop(0, "#2c1a4e");
|
||||||
|
|||||||
@@ -39,11 +39,22 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
|
|||||||
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
||||||
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
||||||
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
||||||
|
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
|
||||||
|
|
||||||
Projectiles hold their size in flight (about a third of a wizard's
|
Projectiles hold their size in flight (about a third of a wizard's
|
||||||
height). Impacts start small and swell to roughly double while fading —
|
height). Impacts start small and swell to roughly double while fading —
|
||||||
so an impact painted as a ring or burst reads especially well.
|
so an impact painted as a ring or burst reads especially well.
|
||||||
|
|
||||||
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
||||||
hit (red), healed (green), or teleported (violet), and the camera shake —
|
hit (red), healed (green), or teleported (violet), the camera shake, the
|
||||||
those are renderer effects, not images.
|
door slide (doors animate by sliding `door.png` along its own edge — no
|
||||||
|
separate art), and the jammed-lock seethe (a pulsing rust glow the
|
||||||
|
renderer lays over the door) — those are renderer effects, not images.
|
||||||
|
|
||||||
|
## One more paintable surface: the home tile
|
||||||
|
|
||||||
|
`public/textures/home.png` is a floor overlay for home-base cells,
|
||||||
|
drawn over the flagstones and TINTED by the owning wizard's color at
|
||||||
|
draw time. Paint it near-white/neutral with transparency wherever the
|
||||||
|
plain floor should show through; the tint does the rest. Square, any
|
||||||
|
size, same as the other textures.
|
||||||
|
|||||||
Reference in New Issue
Block a user