From 19888f9e844b1c38c8f17d52e7b65144f9f3379c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 1 Sep 2026 12:06:00 -0400 Subject: [PATCH] FEAR stains the forbidden ground in the cockpit too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aura's warp-walking diamond — dreadDistance ≤ 3, extracted to one shared fearCells so the board's dashed ring, the cockpit's floor stain, and the engine's refusal can never disagree — now tints the flagstones violet in first person, breathing like the board's aura, beneath the dread ring already at the bearer's feet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/view.ts | 17 +++++++++++++ packages/web/src/Board.svelte | 17 +++---------- packages/web/src/fpv/FirstPerson.svelte | 32 ++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index a6a5f69..e68220b 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -16,6 +16,7 @@ import { type SustainedEffect, type TreasureState, type TurnState, + dreadDistance, } from "./game"; export interface PlayerPublicView { @@ -193,6 +194,22 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { * reflects what this player knows (illusion walls they believe in block it). * The basis for the client's "dim the ineligible squares" targeting aid. */ +/** Every square inside a living FEAR-bearer's aura — dreadDistance walks + * through warps, the engine's own yardstick, so the painted aura and the + * movement refusal can never disagree. */ +export function fearCells(view: GameView): Set { + const out = new Set(); + for (const fp of view.players) { + if (!fp.alive) continue; + if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue; + for (const k of Object.keys(view.board.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k); + } + } + return out; +} + export function sightedCellsFor(view: GameView): Set { const out = new Set(); const me = view.players.find((p) => p.id === view.you); diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 09419b3..bd253ef 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -2,7 +2,7 @@ import type { Component } from "svelte"; import type { GameView, SightTrace } from "@wizwar/engine"; import type { Side } from "@wizwar/engine"; - import { dreadDistance } from "@wizwar/engine"; + import { fearCells } from "@wizwar/engine"; import type { BoardFx } from "./fx"; import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; @@ -212,18 +212,7 @@ // ("even if walls separate you"), never diagonally — and the maze wraps, // so the diamond continues from the opposite rim. The engine's own // yardstick, so the aura and the refusal can never disagree. - const fearCells = $derived.by(() => { - const out = new Set(); - for (const fp of view.players) { - if (!fp.alive) continue; - if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue; - for (const k of Object.keys(view.board.cells)) { - const [x, y] = k.split(",").map(Number) as [number, number]; - if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k); - } - } - return out; - }); + const fearAura = $derived(fearCells(view)); // Warp pairs share a letter, like the printed openings on the real boards. const warpLetters = $derived.by(() => { @@ -692,7 +681,7 @@ {/each} {/if} - {#each fearCells as key (key)} + {#each fearAura as key (key)} {@const bx = Number(key.split(",")[0])} {@const by = Number(key.split(",")[1])} (); + 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 [fx, fy] = k.split(",").map(Number) as [number, number]; + if (fx >= 0 && fy >= 0 && fx < v.board.width && fy < v.board.height) grid[fy * v.board.width + fx] = 1; + } + } + dreadCache.set(v, grid); + return grid; + } + let frame: ImageData | null = null; // The floor wears decals: home emblems in their owners' colors, pits @@ -222,6 +240,10 @@ 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(time / 400) : 0; for (let row = 0; row < H; row++) { const below = row > half; const dz = below ? row - half : half - row; @@ -260,6 +282,14 @@ const gx = (wx - u) | 0, gy = (wy - v) | 0; if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3; } + if (dreadGrid && below) { + const gx = (wx - u) | 0, gy = (wy - v) | 0; + if (gx >= 0 && gy >= 0 && gx < litBw && gy < litBh && dreadGrid[gy * litBw + gx]) { + r = r * (1 - dreadBlend) + 122 * dreadBlend; + g = g * (1 - dreadBlend) + 74 * dreadBlend; + b = b * (1 - dreadBlend) + 138 * dreadBlend; + } + } if (dec) { const hx = (wx - u) | 0, hy = (wy - v) | 0; if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {