The eyes look down, the die stops the reel, and a punch is a fist
A day of Eric watching the clips as a newcomer would. The camera can pitch: FirstPerson takes a `pitch` that shears the horizon up the pane (walls center on it, the eye stays half a wall high), so the ground at the pane's foot comes within half a cell. The director uses it for deeds at your own feet — a conjuration on your square, a treasure taken up, the pit you fell down — instead of the cutaway that stepped outside your body. Everything shows through a warp. The floor pass now runs between the cast and the wall draw, so a column that bent through a mouth maps its ground through the warp's rigid motion and wears the far room's decals — the pit past the wormhole included — under the same violet haze as its walls. A wizard down a pit is public (inPit on the player view), drawn sunk to the floor line in first person and dimmed on the board. A body sharing your square, which had no depth to draw at, stands right in front of you. A pit leap glides like a shove instead of cutting. And the director's blocked-sight test no longer counts a warp BEYOND the target as a block, nor stands a cutaway camera inside another wizard's square — the pair that put Wanderer's face across the whole pane. The reel stops for a die: a card over the held world names who rolls and for what, tumbles the faces on the reel's own clock, lands the roll, and only then plays the outcome; the beat, the fx, the glides and the camera all wait it out. A punch by you drives a fist into the scene; one at you comes at the camera; both land with a comic POW. film-clips rolls until the reel's last move rather than a fixed count, and the gate settles longer on a step whose caption rolls a die. All thirty goldens re-blessed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
@@ -21,7 +21,6 @@ const [outDir, ...only] = process.argv.slice(2);
|
|||||||
if (!outDir) { console.error("usage: film-clips.mjs <out-dir> [scene ...]"); process.exit(1); }
|
if (!outDir) { console.error("usage: film-clips.mjs <out-dir> [scene ...]"); process.exit(1); }
|
||||||
const PORT = 8809;
|
const PORT = 8809;
|
||||||
const FPS = 25;
|
const FPS = 25;
|
||||||
const STEP_MS = 1500; // the reel's own pace at 1x
|
|
||||||
const TAIL_MS = 2500; // hold on the last move
|
const TAIL_MS = 2500; // hold on the last move
|
||||||
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
||||||
|
|
||||||
@@ -82,15 +81,25 @@ try {
|
|||||||
const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) };
|
const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) };
|
||||||
// Roll: the reel's play button is the ▶ before the step button.
|
// Roll: the reel's play button is the ▶ before the step button.
|
||||||
await page.locator("button", { hasText: "▶" }).first().click();
|
await page.locator("button", { hasText: "▶" }).first().click();
|
||||||
const total = (count - 1) * STEP_MS + TAIL_MS;
|
// Roll until the reel reaches its last move (steps a die decides
|
||||||
const nFrames = Math.ceil(total / (1000 / FPS));
|
// run longer than the beat), then hold the tail. The poster is
|
||||||
const posterAt = ((sp.beats?.[0] ?? 1) * STEP_MS + 1200) / (1000 / FPS);
|
// the first pinned beat, settled.
|
||||||
for (let f = 0; f < nFrames; f++) {
|
const posterBeat = sp.beats?.[0] ?? 1;
|
||||||
|
let f = 0, tail = -1, posterIn = -1, total = 0;
|
||||||
|
const MAX_FRAMES = FPS * 240; // a reel that never ends is a bug, not a feature
|
||||||
|
while (tail !== 0 && f < MAX_FRAMES) {
|
||||||
const png = await page.screenshot({ clip });
|
const png = await page.screenshot({ clip });
|
||||||
writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png);
|
writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png);
|
||||||
if (view === "fpv" && f === Math.round(posterAt)) writeFileSync(join(frames, "poster.png"), png);
|
const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]);
|
||||||
|
if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(1200 / (1000 / FPS));
|
||||||
|
if (posterIn === 0) writeFileSync(join(frames, "poster.png"), png);
|
||||||
|
if (posterIn >= 0) posterIn--;
|
||||||
|
if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS));
|
||||||
|
if (tail > 0) tail--;
|
||||||
await page.clock.runFor(1000 / FPS);
|
await page.clock.runFor(1000 / FPS);
|
||||||
|
f++; total += 1000 / FPS;
|
||||||
}
|
}
|
||||||
|
const nFrames = f;
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-framerate", String(FPS),
|
execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-framerate", String(FPS),
|
||||||
"-i", join(frames, "%05d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides
|
"-i", join(frames, "%05d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
@@ -121,7 +121,10 @@ try {
|
|||||||
for (const beat of beats) {
|
for (const beat of beats) {
|
||||||
for (; at < beat; at++) {
|
for (; at < beat; at++) {
|
||||||
await page.locator("button", { hasText: "▶" }).last().click();
|
await page.locator("button", { hasText: "▶" }).last().click();
|
||||||
await page.clock.runFor(at === beat - 1 ? 2600 : 600);
|
// A step the die decides holds the world for the roll's
|
||||||
|
// interlude before anything moves: wait it out on top.
|
||||||
|
const die = (await page.locator(".replay-caption").textContent()).includes("\u{1F3B2}");
|
||||||
|
await page.clock.runFor((at === beat - 1 ? 2600 : 600) + (die ? 2400 : 0));
|
||||||
}
|
}
|
||||||
const box = await page.locator(".replay").boundingBox();
|
const box = await page.locator(".replay").boundingBox();
|
||||||
const name = `${sp.name}-s${beat}-${view}.png`;
|
const name = `${sp.name}-s${beat}-${view}.png`;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface PlayerPublicView {
|
|||||||
extraTurns: number;
|
extraTurns: number;
|
||||||
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
||||||
passWallCharges: number;
|
passWallCharges: number;
|
||||||
|
/** Down a pit, until a roll climbs them out — everyone saw the fall. */
|
||||||
|
inPit: boolean;
|
||||||
displayed: CardInstance[];
|
displayed: CardInstance[];
|
||||||
/** Which of the six physical wizard colors this player plays. */
|
/** Which of the six physical wizard colors this player plays. */
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
@@ -132,6 +134,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
position: p.position,
|
position: p.position,
|
||||||
home: p.home,
|
home: p.home,
|
||||||
life: p.life,
|
life: p.life,
|
||||||
|
inPit: p.inPit,
|
||||||
alive: p.alive,
|
alive: p.alive,
|
||||||
handCount: p.hand.length,
|
handCount: p.hand.length,
|
||||||
carriedTreasureId: p.carriedTreasureId,
|
carriedTreasureId: p.carriedTreasureId,
|
||||||
|
|||||||
@@ -570,7 +570,7 @@
|
|||||||
href={wizardArt(p.id)}
|
href={wizardArt(p.id)}
|
||||||
x={-half} y={-half}
|
x={-half} y={-half}
|
||||||
width={half * 2} height={half * 2}
|
width={half * 2} height={half * 2}
|
||||||
cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}`}
|
cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}${p.inPit ? " in-pit" : ""}`}
|
||||||
/>
|
/>
|
||||||
<rect
|
<rect
|
||||||
x={-half} y={-half}
|
x={-half} y={-half}
|
||||||
@@ -754,6 +754,8 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
:global(.token-art.hit) { pointer-events: auto; cursor: pointer; }
|
:global(.token-art.hit) { pointer-events: auto; cursor: pointer; }
|
||||||
|
/* Down a pit: the standee sits in shadow, smaller, as if seen from above. */
|
||||||
|
:global(.token-art.in-pit) { filter: brightness(0.5) saturate(0.7); transform: scale(0.8); transform-box: fill-box; transform-origin: center; }
|
||||||
.creature-ring, .wizard-ring {
|
.creature-ring, .wizard-ring {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
|
|||||||
@@ -82,6 +82,43 @@
|
|||||||
return pov ?? steps[0]!.view.you;
|
return pov ?? steps[0]!.view.you;
|
||||||
});
|
});
|
||||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||||
|
|
||||||
|
/** A step the die decided stops the reel for the roll: the stakes, the
|
||||||
|
* tumble, the face, and only then the outcome — a leap over a pit is
|
||||||
|
* not a teleport, and a viewer new to the game should see why. */
|
||||||
|
const DIE_MS = 2400;
|
||||||
|
const dieOf = (st: { events: GameEvent[] } | undefined) =>
|
||||||
|
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
|
||||||
|
/** How long this step's interlude holds the world before it moves. */
|
||||||
|
const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0);
|
||||||
|
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
const i = Math.min(idx, steps.length - 1);
|
||||||
|
const d = dieOf(steps[i]);
|
||||||
|
if (!d || !prefs.flourishes) { die = null; return; }
|
||||||
|
const t0 = performance.now();
|
||||||
|
const dur = DIE_MS / speed;
|
||||||
|
const who = d.player ?? "The maze";
|
||||||
|
die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: 1, landed: false };
|
||||||
|
let raf = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const p = Math.max(0, now - t0) / dur;
|
||||||
|
if (p >= 1) { die = null; return; }
|
||||||
|
if (p < 0.48) {
|
||||||
|
// The tumble: faces flick past, slowing as the die settles. A
|
||||||
|
// fixed sequence, so the harness films the same roll every time.
|
||||||
|
const n = Math.floor(p * 26 - p * p * 18);
|
||||||
|
die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: ((n * 3 + 1) % 4) + 1, landed: false };
|
||||||
|
} else if (!die?.landed) {
|
||||||
|
die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: d.roll, landed: true };
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => { cancelAnimationFrame(raf); die = null; };
|
||||||
|
});
|
||||||
|
/** While the die is in the air the board shows the world as it was. */
|
||||||
|
const dieHeld = $derived(die && die.idx > 0 ? steps[die.idx - 1]!.view : null);
|
||||||
/** What you drew belongs in the chronicle, not on a reel that may be
|
/** What you drew belongs in the chronicle, not on a reel that may be
|
||||||
* recorded and passed around — captions keep the count, not the cards. */
|
* recorded and passed around — captions keep the count, not the cards. */
|
||||||
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
|
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
|
||||||
@@ -92,6 +129,8 @@
|
|||||||
.filter((l): l is string => l !== null),
|
.filter((l): l is string => l !== null),
|
||||||
);
|
);
|
||||||
const atEnd = $derived(idx >= steps.length - 1);
|
const atEnd = $derived(idx >= steps.length - 1);
|
||||||
|
/** What the roll brought about, in the caption's own words. */
|
||||||
|
const dieVerdict = $derived(lines.filter((l) => !l.startsWith("\u{1F3B2}")).slice(0, 2).join(" "));
|
||||||
|
|
||||||
/** The acting wizard's standee, shown beside their words. */
|
/** The acting wizard's standee, shown beside their words. */
|
||||||
const actorArt = $derived.by(() => {
|
const actorArt = $derived.by(() => {
|
||||||
@@ -171,12 +210,15 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const step = steps[Math.min(idx, steps.length - 1)];
|
const step = steps[Math.min(idx, steps.length - 1)];
|
||||||
if (!step || !prefs.flourishes) return;
|
if (!step || !prefs.flourishes) return;
|
||||||
const cancel = scheduleFx(
|
let cancel = () => {};
|
||||||
step.events, step.view,
|
const wait = setTimeout(() => {
|
||||||
(fx) => (boardFx = [...boardFx, fx]),
|
cancel = scheduleFx(
|
||||||
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
step.events, step.view,
|
||||||
);
|
(fx) => (boardFx = [...boardFx, fx]),
|
||||||
return () => { cancel(); boardFx = []; };
|
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||||||
|
);
|
||||||
|
}, dieMs);
|
||||||
|
return () => { clearTimeout(wait); cancel(); boardFx = []; };
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The director's slate: the camera effect writes how long this step's
|
/** The director's slate: the camera effect writes how long this step's
|
||||||
@@ -214,11 +256,16 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
const t = setInterval(() => {
|
// Each step gets the reel's beat, plus the die's interlude when
|
||||||
|
// one decided it. Read the step here so every advance re-arms the
|
||||||
|
// timer — a derived that stays 0 across steps would not.
|
||||||
|
const from = idx;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (idx !== from) return;
|
||||||
if (idx < steps.length - 1) idx += 1;
|
if (idx < steps.length - 1) idx += 1;
|
||||||
else playing = false;
|
else playing = false;
|
||||||
}, 1500 / speed);
|
}, 1500 / speed + dieMs);
|
||||||
return () => clearInterval(t);
|
return () => clearTimeout(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Other bodies glide between steps instead of blinking cell to cell:
|
/** Other bodies glide between steps instead of blinking cell to cell:
|
||||||
@@ -235,7 +282,7 @@
|
|||||||
if (!before || before === v) return;
|
if (!before || before === v) return;
|
||||||
const moves = gatherGlides(before, v, povId);
|
const moves = gatherGlides(before, v, povId);
|
||||||
if (moves.length === 0) { actorPos = {}; return; }
|
if (moves.length === 0) { actorPos = {}; return; }
|
||||||
const t0 = performance.now();
|
const t0 = performance.now() + dieMs;
|
||||||
const dur = 400 / speed;
|
const dur = 400 / speed;
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
@@ -257,7 +304,10 @@
|
|||||||
// Each step the camera settles on your position. A step away tweens —
|
// Each step the camera settles on your position. A step away tweens —
|
||||||
// turn first, then stride; a leap (teleport, warp) cuts. When you stood
|
// turn first, then stride; a leap (teleport, warp) cuts. When you stood
|
||||||
// still, the eye turns toward whoever acted.
|
// still, the eye turns toward whoever acted.
|
||||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
const cam = $state({ x: 0, y: 0, facing: 0, pitch: 0 });
|
||||||
|
/** How far the eyes drop to watch their own feet: the horizon rises
|
||||||
|
* to the pane's top third and the ground underfoot fills the rest. */
|
||||||
|
const LOOK_DOWN = 0.36;
|
||||||
let camReady = false;
|
let camReady = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fp) { camReady = false; return; }
|
if (!fp) { camReady = false; return; }
|
||||||
@@ -273,6 +323,7 @@
|
|||||||
const camX = untrack(() => cam.x);
|
const camX = untrack(() => cam.x);
|
||||||
const camY = untrack(() => cam.y);
|
const camY = untrack(() => cam.y);
|
||||||
const camF = untrack(() => cam.facing);
|
const camF = untrack(() => cam.facing);
|
||||||
|
const camP = untrack(() => cam.pitch);
|
||||||
const dx = tx - camX;
|
const dx = tx - camX;
|
||||||
const dy = ty - camY;
|
const dy = ty - camY;
|
||||||
const dist = Math.hypot(dx, dy);
|
const dist = Math.hypot(dx, dy);
|
||||||
@@ -304,26 +355,44 @@
|
|||||||
const i2 = Math.min(idx, steps.length - 1);
|
const i2 = Math.min(idx, steps.length - 1);
|
||||||
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
||||||
heldView = pv;
|
heldView = pv;
|
||||||
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
|
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
|
||||||
if (pv) {
|
if (pv) {
|
||||||
const t = setTimeout(() => (heldView = null), 420 / speed);
|
const t = setTimeout(() => (heldView = null), 420 / speed + dieMs);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let targetFacing = camF;
|
let targetFacing = camF;
|
||||||
let aimed = false;
|
let aimed = false;
|
||||||
|
// Something at your own feet — a conjuration on your square, a
|
||||||
|
// treasure taken up, the pit you just fell down — is watched by
|
||||||
|
// looking down; the eyes come back level with the next step.
|
||||||
|
const underfoot = step.events.some((e) =>
|
||||||
|
(e.type === "fellInPit" || e.type === "climbedFromPit") && "player" in e && (e as { player: string }).player === povId);
|
||||||
|
let targetPitch = 0;
|
||||||
|
// Only GROUND deeds earn the look-down: a conjuration on this
|
||||||
|
// square, a treasure taken up or set down. A punch traded with
|
||||||
|
// someone sharing the square aims at nobody's feet — they stand
|
||||||
|
// right in front of the eyes, wherever those point.
|
||||||
|
const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated",
|
||||||
|
"treasurePickedUp", "treasureDropped", "objectDropped"]);
|
||||||
|
const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type));
|
||||||
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
||||||
|
else if (groundwork || underfoot) {
|
||||||
|
targetPitch = LOOK_DOWN;
|
||||||
|
aimed = true;
|
||||||
|
}
|
||||||
else if (aim?.self) {
|
else if (aim?.self) {
|
||||||
// Summoned at your own feet: no turn can show it — step outside
|
// Nothing to turn toward: keep the view, unless it is a wall.
|
||||||
// yourself and watch it grow beside you.
|
|
||||||
return takeCutaway(me.position.x + 0.5, me.position.y + 0.5);
|
|
||||||
}
|
}
|
||||||
else if (aim) {
|
else if (aim) {
|
||||||
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
const ax = aim.x + 0.5, ay = aim.y + 0.5;
|
||||||
const toAim = Math.hypot(ax - tx, ay - ty);
|
const toAim = Math.hypot(ax - tx, ay - ty);
|
||||||
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||||
if (sight.warped || sight.dist < toAim - 0.4) {
|
// Blocked: a wall before the target, or a warp mouth before it —
|
||||||
|
// a warp BEYOND the target bends nothing the eyes need.
|
||||||
|
const warpBefore = sight.warped && (sight.warpDist ?? Infinity) < toAim - 0.4;
|
||||||
|
if (warpBefore || sight.dist < toAim - 0.4) {
|
||||||
return takeCutaway(ax, ay);
|
return takeCutaway(ax, ay);
|
||||||
}
|
}
|
||||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||||
@@ -335,7 +404,8 @@
|
|||||||
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
|
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
|
||||||
const toActor = Math.hypot(ax - tx, ay - ty);
|
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||||
if (!ray.warped && ray.dist > toActor - 0.2) {
|
const warpBefore = ray.warped && (ray.warpDist ?? Infinity) < toActor - 0.2;
|
||||||
|
if (!warpBefore && ray.dist > toActor - 0.2) {
|
||||||
targetFacing = Math.atan2(ay - ty, ax - tx);
|
targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||||
aimed = true;
|
aimed = true;
|
||||||
}
|
}
|
||||||
@@ -403,6 +473,7 @@
|
|||||||
// Locals only: reading heldView back here would register it as a
|
// Locals only: reading heldView back here would register it as a
|
||||||
// dependency of this very effect, and the timer clearing it would
|
// dependency of this very effect, and the timer clearing it would
|
||||||
// re-trigger us into a ping-pong.
|
// re-trigger us into a ping-pong.
|
||||||
|
holdMs += dieMs;
|
||||||
const hold = holdMs > 60 && prevView ? prevView : null;
|
const hold = holdMs > 60 && prevView ? prevView : null;
|
||||||
heldView = hold;
|
heldView = hold;
|
||||||
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
|
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
|
||||||
@@ -417,23 +488,35 @@
|
|||||||
// cut still holds a beat of the before-world once the reel is
|
// cut still holds a beat of the before-world once the reel is
|
||||||
// rolling; the opening frame reveals at once.
|
// rolling; the opening frame reveals at once.
|
||||||
const wasReady = camReady;
|
const wasReady = camReady;
|
||||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
const cut = () => { cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.pitch = targetPitch; };
|
||||||
camReady = true;
|
camReady = true;
|
||||||
|
if (dieMs > 0 && wasReady) {
|
||||||
|
// The die decides where the eyes land: cut once it has.
|
||||||
|
const t = setTimeout(cut, dieMs);
|
||||||
|
const clear = setSlate(aim ? 380 / speed : 0);
|
||||||
|
return () => { clearTimeout(t); clear(); };
|
||||||
|
}
|
||||||
|
cut();
|
||||||
return setSlate(wasReady && aim ? 380 / speed : 0);
|
return setSlate(wasReady && aim ? 380 / speed : 0);
|
||||||
}
|
}
|
||||||
const fromX = camX, fromY = camY, fromF = camF;
|
const fromX = camX, fromY = camY, fromF = camF, fromP = camP;
|
||||||
const arc = shortestArc(fromF, targetFacing);
|
const arc = shortestArc(fromF, targetFacing);
|
||||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||||
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
|
const tiltMs = (Math.abs(targetPitch - fromP) > 0.01 ? 340 : 0) / speed;
|
||||||
|
const clearSlate = setSlate(aimed && (aim || underfoot) ? Math.max(turnMs, tiltMs) + 120 / speed : 0);
|
||||||
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
|
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
|
||||||
const t0 = performance.now();
|
const t0 = performance.now() + dieMs;
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
// rAF hands frame-start time, which can precede t0; and a
|
// rAF hands frame-start time, which can precede t0; and a
|
||||||
// zero-length phase must never divide. Either poisons the facing
|
// zero-length phase must never divide. Either poisons the facing
|
||||||
// with NaN, which no later frame can wash out.
|
// with NaN, which no later frame can wash out.
|
||||||
const t = Math.max(0, now - t0);
|
const t = Math.max(0, now - t0);
|
||||||
|
if (tiltMs > 0) {
|
||||||
|
const w = Math.min(1, t / tiltMs);
|
||||||
|
cam.pitch = fromP + (targetPitch - fromP) * (w * w * (3 - 2 * w));
|
||||||
|
}
|
||||||
if (turnMs > 0 && t < turnMs) {
|
if (turnMs > 0 && t < turnMs) {
|
||||||
cam.facing = fromF + arc * (t / turnMs);
|
cam.facing = fromF + arc * (t / turnMs);
|
||||||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||||
@@ -446,9 +529,12 @@
|
|||||||
cam.x = tx; cam.y = ty;
|
cam.x = tx; cam.y = ty;
|
||||||
const w = (t - turnMs - walkMs) / closingMs;
|
const w = (t - turnMs - walkMs) / closingMs;
|
||||||
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
|
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
|
||||||
} else {
|
} else if (tiltMs > 0 && t < tiltMs) {
|
||||||
cam.facing = fromF + arc + closingArc;
|
cam.facing = fromF + arc + closingArc;
|
||||||
cam.x = tx; cam.y = ty;
|
cam.x = tx; cam.y = ty;
|
||||||
|
} else {
|
||||||
|
cam.facing = fromF + arc + closingArc;
|
||||||
|
cam.x = tx; cam.y = ty; cam.pitch = targetPitch;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
@@ -596,10 +682,22 @@
|
|||||||
<div class="replay-board" bind:this={stageEl}>
|
<div class="replay-board" bind:this={stageEl}>
|
||||||
{#if fp}
|
{#if fp}
|
||||||
<FirstPerson view={heldView ?? step.view} povId={cutawayShot ? "" : povId}
|
<FirstPerson view={heldView ?? step.view} povId={cutawayShot ? "" : povId}
|
||||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
x={cam.x} y={cam.y} facing={cam.facing} pitch={cam.pitch} width={640} height={360}
|
||||||
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
|
||||||
{:else}
|
{:else}
|
||||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
<Board view={dieHeld ?? step.view} effects={boardFx} {sightTrace} />
|
||||||
|
{/if}
|
||||||
|
{#if die}
|
||||||
|
<div class="die-slate" class:landed={die.landed}>
|
||||||
|
<div class="die-card">
|
||||||
|
<div class="die-who">{die.player} rolls the die</div>
|
||||||
|
<div class="die-why">{die.purpose}</div>
|
||||||
|
<div class="die-cube" style:transform={`rotate(${die.landed ? 0 : [-14, 9, -5, 12][die.face - 1]}deg) scale(${die.landed ? 1.12 : 1})`}>
|
||||||
|
<span>{die.face}</span>
|
||||||
|
</div>
|
||||||
|
<div class="die-verdict">{die.landed ? dieVerdict || "…" : "\u00a0"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="replay-caption">
|
<div class="replay-caption">
|
||||||
@@ -695,7 +793,52 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
/* The die's interlude: a card over the held world. */
|
||||||
|
.die-slate {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(10, 12, 16, 0.55);
|
||||||
|
}
|
||||||
|
.die-card {
|
||||||
|
background: #efe8d4;
|
||||||
|
color: #3a2f1f;
|
||||||
|
border: 2px solid #43331f;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.7rem 1.4rem 0.8rem;
|
||||||
|
min-width: 17rem;
|
||||||
|
max-width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Courier Prime", monospace;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.die-who {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b5a3e;
|
||||||
|
}
|
||||||
|
.die-why { font-size: 0.82rem; margin: 0.15rem 0 0.5rem; }
|
||||||
|
.die-cube {
|
||||||
|
width: 4.2rem;
|
||||||
|
height: 4.2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e0b34a;
|
||||||
|
border: 2px solid #43331f;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
color: #2b2218;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.die-slate.landed .die-cube { background: #f2d27a; box-shadow: 0 0 0 4px rgba(224, 179, 74, 0.35); }
|
||||||
|
.die-verdict { margin-top: 0.55rem; font-size: 0.82rem; min-height: 1.3em; }
|
||||||
.replay-board :global(svg.board) {
|
.replay-board :global(svg.board) {
|
||||||
max-height: calc(100dvh - 15rem);
|
max-height: calc(100dvh - 15rem);
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
facing,
|
facing,
|
||||||
|
pitch = 0,
|
||||||
width = 720,
|
width = 720,
|
||||||
height = 440,
|
height = 440,
|
||||||
fx = [],
|
fx = [],
|
||||||
@@ -35,6 +36,11 @@
|
|||||||
y: number;
|
y: number;
|
||||||
/** Radians; 0 faces east, matching the board's +x. */
|
/** Radians; 0 faces east, matching the board's +x. */
|
||||||
facing: number;
|
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;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
/** Live spell moments: projectiles, impacts, flashes, shakes. */
|
/** Live spell moments: projectiles, impacts, flashes, shakes. */
|
||||||
@@ -63,7 +69,8 @@
|
|||||||
* the pane is an instrument only because the renderer remembers what
|
* the pane is an instrument only because the renderer remembers what
|
||||||
* stood under every pixel. */
|
* stood under every pixel. */
|
||||||
let hitFrame: {
|
let hitFrame: {
|
||||||
W: number; H: number; ex: number; ey: number; facing: number;
|
W: number;
|
||||||
|
horizon: number; H: number; ex: number; ey: number; facing: number;
|
||||||
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
|
||||||
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
|
||||||
/** Depth-sorted nearest-first, ready for hover hit-testing. */
|
/** Depth-sorted nearest-first, ready for hover hit-testing. */
|
||||||
@@ -189,7 +196,10 @@
|
|||||||
const ctx = canvas?.getContext("2d");
|
const ctx = canvas?.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
||||||
const W = width, H = height, half = H / 2;
|
// `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.
|
// Active shakes wobble the eye a hair off true, fading as they run.
|
||||||
let ex = x, ey = y;
|
let ex = x, ey = y;
|
||||||
@@ -223,12 +233,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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
|
// 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
|
||||||
// or ceiling texture — every maze cell wearing one full tile of it.
|
// or ceiling texture — every maze cell wearing one full tile of it.
|
||||||
const flen = (W / 2) / Math.tan(FOV / 2);
|
const flen = (W / 2) / Math.tan(FOV / 2);
|
||||||
const cosF = Math.cos(facing), sinF = Math.sin(facing);
|
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);
|
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
|
||||||
const buf = frame.data;
|
const buf = frame.data;
|
||||||
const fl = pixelsOf(textures.floor!);
|
const fl = pixelsOf(textures.floor!);
|
||||||
@@ -278,25 +348,35 @@
|
|||||||
if (shade <= 0.02) {
|
if (shade <= 0.02) {
|
||||||
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
|
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
|
||||||
} else {
|
} else {
|
||||||
let u = wx % 1; if (u < 0) u += 1;
|
// Ground seen through a warp mouth belongs to the far room:
|
||||||
let v = wy % 1; if (v < 0) v += 1;
|
// 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;
|
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
||||||
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
||||||
let sh = shade;
|
let sh = shade;
|
||||||
if (litGrid && below) {
|
if (litGrid && below) {
|
||||||
const gx = (wx - u) | 0, gy = (wy - v) | 0;
|
const cx = (gx - u) | 0, cy = (gy - v) | 0;
|
||||||
if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3;
|
if (cx < 0 || cy < 0 || cx >= litBw || cy >= litBh || !litGrid[cy * litBw + cx]) sh *= 0.3;
|
||||||
}
|
}
|
||||||
if (dreadGrid && below) {
|
if (dreadGrid && below) {
|
||||||
const gx = (wx - u) | 0, gy = (wy - v) | 0;
|
const cx = (gx - u) | 0, cy = (gy - v) | 0;
|
||||||
if (gx >= 0 && gy >= 0 && gx < litBw && gy < litBh && dreadGrid[gy * litBw + gx]) {
|
if (cx >= 0 && cy >= 0 && cx < litBw && cy < litBh && dreadGrid[cy * litBw + cx]) {
|
||||||
r = r * (1 - dreadBlend) + 122 * dreadBlend;
|
r = r * (1 - dreadBlend) + 122 * dreadBlend;
|
||||||
g = g * (1 - dreadBlend) + 74 * dreadBlend;
|
g = g * (1 - dreadBlend) + 74 * dreadBlend;
|
||||||
b = b * (1 - dreadBlend) + 138 * dreadBlend;
|
b = b * (1 - dreadBlend) + 138 * dreadBlend;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dec) {
|
if (dec) {
|
||||||
const hx = (wx - u) | 0, hy = (wy - v) | 0;
|
const hx = (gx - u) | 0, hy = (gy - v) | 0;
|
||||||
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
||||||
const di = dec.grid[hy * dec.bw + hx]!;
|
const di = dec.grid[hy * dec.bw + hx]!;
|
||||||
if (di >= 0) {
|
if (di >= 0) {
|
||||||
@@ -315,6 +395,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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] = r * sh;
|
||||||
buf[o + 1] = g * sh;
|
buf[o + 1] = g * sh;
|
||||||
buf[o + 2] = b * sh;
|
buf[o + 2] = b * sh;
|
||||||
@@ -326,57 +410,9 @@
|
|||||||
}
|
}
|
||||||
ctx.putImageData(frame, 0, 0);
|
ctx.putImageData(frame, 0, 0);
|
||||||
|
|
||||||
// Walls, one ray per column; remember each column's depth for
|
// Walls, drawn from the cast: one column each.
|
||||||
// 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 }[] = [];
|
|
||||||
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 { hit, depth, wallH, top } = rays[col]!;
|
||||||
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 };
|
|
||||||
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
|
||||||
// Sample by the texture's own size: painted files may be any scale.
|
// 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
|
// Fire shifts its slice per world cell, so a blaze spanning edges
|
||||||
@@ -466,7 +502,7 @@
|
|||||||
}
|
}
|
||||||
sprites.sort((a, b) => b.sort - a.sort);
|
sprites.sort((a, b) => b.sort - a.sort);
|
||||||
hitFrame = {
|
hitFrame = {
|
||||||
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
W, H, horizon: half, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
|
||||||
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
|
||||||
};
|
};
|
||||||
// The nearest sprite each column carries — depth AND vertical span —
|
// The nearest sprite each column carries — depth AND vertical span —
|
||||||
@@ -542,18 +578,22 @@
|
|||||||
if (s.depth < spriteZ[col]!) {
|
if (s.depth < spriteZ[col]!) {
|
||||||
spriteZ[col] = s.depth;
|
spriteZ[col] = s.depth;
|
||||||
sprTop[col] = s.top;
|
sprTop[col] = s.top;
|
||||||
sprBot[col] = s.bottom;
|
sprBot[col] = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) : s.bottom;
|
||||||
}
|
}
|
||||||
const texX = ((col - s.left) / (s.right - s.left));
|
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) {
|
if (img) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
img,
|
img,
|
||||||
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih,
|
texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih * (shown / (s.bottom - s.top)),
|
||||||
col, s.top, 1, s.bottom - s.top,
|
col, s.top, 1, shown,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = "rgba(200,190,160,0.6)";
|
ctx.fillStyle = "rgba(200,190,160,0.6)";
|
||||||
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
|
ctx.fillRect(col, s.top, 1, shown);
|
||||||
}
|
}
|
||||||
if (s.warped) {
|
if (s.warped) {
|
||||||
// A body seen through a warp swims in the same violet haze.
|
// A body seen through a warp swims in the same violet haze.
|
||||||
@@ -709,6 +749,71 @@
|
|||||||
ctx.globalAlpha = 1;
|
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 fx) {
|
||||||
|
if (f.kind !== "fist") continue;
|
||||||
|
const p = (time - f.t0) / f.dur;
|
||||||
|
if (p < 0 || p >= 1) continue;
|
||||||
|
const ease = (w: number) => w * w * (3 - 2 * w);
|
||||||
|
const out = f.swing === "out";
|
||||||
|
// Travel: out 0→0.42 lunge, 0.42→1 retract; in 0→0.45 approach, then hold and fade.
|
||||||
|
const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : Math.min(1, ease(p / 0.45));
|
||||||
|
const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
|
||||||
|
const tx = W * 0.56, ty = H * 0.5;
|
||||||
|
const fx0 = sx + (tx - sx) * a, fy0 = sy + (ty - sy) * a;
|
||||||
|
const size = out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a);
|
||||||
|
const glyph = out ? "\u{1F91C}" : "\u{1F44A}";
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1;
|
||||||
|
ctx.translate(fx0, fy0);
|
||||||
|
ctx.rotate(out ? -0.6 : 0.15);
|
||||||
|
ctx.font = `${Math.round(size)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.shadowColor = "rgba(0,0,0,0.55)";
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.shadowOffsetY = 6;
|
||||||
|
ctx.fillText(glyph, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
// POW: pops at the landing, holds, and shrinks away.
|
||||||
|
const land = out ? 0.36 : 0.4;
|
||||||
|
if (p >= land) {
|
||||||
|
const q = (p - land) / (1 - land);
|
||||||
|
const pop = q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
|
||||||
|
if (pop > 0.01) {
|
||||||
|
const R = H * 0.19 * pop;
|
||||||
|
const cx = out ? tx - W * 0.02 : W * 0.5, cy = out ? ty - H * 0.16 : H * 0.34;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(-0.14);
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const r = i % 2 === 0 ? R : R * 0.68;
|
||||||
|
const t = (i / 24) * Math.PI * 2;
|
||||||
|
ctx.lineTo(Math.cos(t) * r * 1.35, Math.sin(t) * r);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = "#f2d24a";
|
||||||
|
ctx.strokeStyle = "#2b2218";
|
||||||
|
ctx.lineWidth = Math.max(2, R * 0.08);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = "#c03020";
|
||||||
|
ctx.strokeStyle = "#2b2218";
|
||||||
|
ctx.lineWidth = Math.max(1.5, R * 0.06);
|
||||||
|
ctx.font = `600 ${Math.round(R * 0.95)}px Oswald, "Archivo Narrow", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.strokeText("POW!", 0, R * 0.04);
|
||||||
|
ctx.fillText("POW!", 0, R * 0.04);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A change of eyes: the slate wipes across, names the wizard whose
|
// A change of eyes: the slate wipes across, names the wizard whose
|
||||||
// sight this now is, and wipes away to reveal it.
|
// sight this now is, and wipes away to reveal it.
|
||||||
for (const f of fx) {
|
for (const f of fx) {
|
||||||
@@ -722,27 +827,28 @@
|
|||||||
ctx.clip();
|
ctx.clip();
|
||||||
ctx.fillStyle = "#171a20";
|
ctx.fillStyle = "#171a20";
|
||||||
ctx.fillRect(x0, 0, x1 - x0, H);
|
ctx.fillRect(x0, 0, x1 - x0, H);
|
||||||
|
const mid = H / 2;
|
||||||
ctx.fillStyle = "#e0b34a";
|
ctx.fillStyle = "#e0b34a";
|
||||||
ctx.fillRect(x0, half - 1, x1 - x0, 2);
|
ctx.fillRect(x0, mid - 1, x1 - x0, 2);
|
||||||
// The card's face: standee, then the name — composed on the full
|
// 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.
|
// pane so it lands where it will sit once the band is complete.
|
||||||
const face = f.art ? imageFor(f.art) : null;
|
const face = f.art ? imageFor(f.art) : null;
|
||||||
const size = Math.round(H * 0.24);
|
const size = Math.round(H * 0.24);
|
||||||
const cx = W / 2;
|
const cx = W / 2;
|
||||||
if (face) {
|
if (face) {
|
||||||
ctx.drawImage(face, cx - size / 2, half - size - 18, size, size);
|
ctx.drawImage(face, cx - size / 2, mid - size - 18, size, size);
|
||||||
ctx.strokeStyle = "#43331f";
|
ctx.strokeStyle = "#43331f";
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(cx - size / 2, half - size - 18, size, size);
|
ctx.strokeRect(cx - size / 2, mid - size - 18, size, size);
|
||||||
}
|
}
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "alphabetic";
|
ctx.textBaseline = "alphabetic";
|
||||||
ctx.fillStyle = "#a49c86";
|
ctx.fillStyle = "#a49c86";
|
||||||
ctx.font = `600 ${Math.round(H * 0.045)}px Oswald, "Archivo Narrow", sans-serif`;
|
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, half + Math.round(H * 0.09));
|
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.fillStyle = "#e9e1cb";
|
||||||
ctx.font = `600 ${Math.round(H * 0.13)}px Oswald, "Archivo Narrow", sans-serif`;
|
ctx.font = `600 ${Math.round(H * 0.13)}px Oswald, "Archivo Narrow", sans-serif`;
|
||||||
ctx.fillText(f.name.toUpperCase(), cx, half + Math.round(H * 0.23));
|
ctx.fillText(f.name.toUpperCase(), cx, mid + Math.round(H * 0.23));
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,6 +941,8 @@
|
|||||||
glow?: boolean;
|
glow?: boolean;
|
||||||
alpha?: number;
|
alpha?: number;
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
|
/** Rows at or below this are underground: not painted. */
|
||||||
|
clipY?: number;
|
||||||
clampL?: number;
|
clampL?: number;
|
||||||
clampR?: number;
|
clampR?: number;
|
||||||
hit?: { kind: "player" | "creature"; id: string };
|
hit?: { kind: "player" | "creature"; id: string };
|
||||||
@@ -854,14 +962,17 @@
|
|||||||
clip?: { x: number; y: number };
|
clip?: { x: number; y: number };
|
||||||
hit?: { kind: "player" | "creature"; id: string };
|
hit?: { kind: "player" | "creature"; id: string };
|
||||||
cell?: { x: number; y: number };
|
cell?: { x: number; y: number };
|
||||||
webbed?: boolean; gaze?: boolean; dread?: boolean },
|
webbed?: boolean; gaze?: boolean; dread?: boolean; sink?: boolean },
|
||||||
ex: number, ey: number,
|
ex: number, ey: number,
|
||||||
): Projected | null {
|
): Projected | null {
|
||||||
const relX = b.x - ex, relY = b.y - ey;
|
const relX = b.x - ex, relY = b.y - ey;
|
||||||
const depth = relX * Math.cos(facing) + relY * Math.sin(facing);
|
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;
|
if (depth < 0.15) return null;
|
||||||
const side = -relX * Math.sin(facing) + relY * Math.cos(facing);
|
const W = width, H = height, half = H / 2 - pitch * H;
|
||||||
const W = width, H = height, half = H / 2;
|
|
||||||
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
||||||
const wallH = H / depth;
|
const wallH = H / depth;
|
||||||
const size = wallH * b.scale;
|
const size = wallH * b.scale;
|
||||||
@@ -873,6 +984,8 @@
|
|||||||
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
||||||
: size;
|
: size;
|
||||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
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
|
// A cell-bound volume is windowed to its own square: the billboard's
|
||||||
// camera-facing plane is intersected with the cell, and only that
|
// camera-facing plane is intersected with the cell, and only that
|
||||||
// lateral segment may paint, so an obliquely-viewed hedge cannot
|
// lateral segment may paint, so an obliquely-viewed hedge cannot
|
||||||
@@ -908,7 +1021,7 @@
|
|||||||
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
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,
|
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
|
||||||
webbed: b.webbed, gaze: b.gaze, dread: b.dread,
|
webbed: b.webbed, gaze: b.gaze, dread: b.dread,
|
||||||
hit: b.hit, cell: b.cell,
|
hit: b.hit, cell: b.cell, clipY,
|
||||||
left: screenX - wide / 2, right: screenX + wide / 2,
|
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||||
top: bottom - size, bottom,
|
top: bottom - size, bottom,
|
||||||
};
|
};
|
||||||
@@ -937,7 +1050,7 @@
|
|||||||
const f = hitFrame;
|
const f = hitFrame;
|
||||||
if (!f) return null;
|
if (!f) return null;
|
||||||
const col = Math.max(0, Math.min(f.W - 1, px | 0));
|
const col = Math.max(0, Math.min(f.W - 1, px | 0));
|
||||||
const half = f.H / 2;
|
const half = f.horizon;
|
||||||
|
|
||||||
// Sprites first, nearest first, honoring the draw pass's own
|
// Sprites first, nearest first, honoring the draw pass's own
|
||||||
// visibility rules for this column.
|
// visibility rules for this column.
|
||||||
|
|||||||
@@ -98,9 +98,11 @@ export function aimOfEvents(
|
|||||||
/** Does this batch hurl the pov wizard bodily — a blow to glide with,
|
/** Does this batch hurl the pov wizard bodily — a blow to glide with,
|
||||||
* eyes held steady, rather than a stride or a cut? */
|
* eyes held steady, rather than a stride or a cut? */
|
||||||
export function hurledIn(events: GameEvent[], povId: string): boolean {
|
export function hurledIn(events: GameEvent[], povId: string): boolean {
|
||||||
|
// A leap over a pit is a glide too: two squares in one bound, fast
|
||||||
|
// and straight, eyes ahead — never a cut that reads as a teleport.
|
||||||
return events.some((e) =>
|
return events.some((e) =>
|
||||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||||
e.type === "retreatedInHorror") && e.player === povId);
|
e.type === "retreatedInHorror" || e.type === "jumpedPit") && e.player === povId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Glide {
|
export interface Glide {
|
||||||
@@ -156,7 +158,17 @@ export function cutawayStand(view: GameView, x: number, y: number): { facing: nu
|
|||||||
let deepest = -1, facing = 0;
|
let deepest = -1, facing = 0;
|
||||||
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
|
||||||
const h = castRay(view, x, y, a);
|
const h = castRay(view, x, y, a);
|
||||||
const d = h.warped && h.warpDist !== undefined ? Math.min(h.dist, h.warpDist) : h.dist;
|
let d = h.warped && h.warpDist !== undefined ? Math.min(h.dist, h.warpDist) : h.dist;
|
||||||
|
// A body standing down this corridor is where the eyes must stop
|
||||||
|
// short of — the camera stands before it, or picks another way,
|
||||||
|
// never inside a wizard's own square with their face filling it.
|
||||||
|
for (const p of view.players) {
|
||||||
|
if (!p.alive) continue;
|
||||||
|
const px = p.position.x + 0.5 - x, py = p.position.y + 0.5 - y;
|
||||||
|
const along = px * Math.cos(a) + py * Math.sin(a);
|
||||||
|
const across = -px * Math.sin(a) + py * Math.cos(a);
|
||||||
|
if (along > 0.1 && along < d && Math.abs(across) < 0.5) d = Math.min(d, along - 0.55);
|
||||||
|
}
|
||||||
if (d > deepest) { deepest = d; facing = a; }
|
if (d > deepest) { deepest = d; facing = a; }
|
||||||
}
|
}
|
||||||
return { facing, depth: deepest };
|
return { facing, depth: deepest };
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export type FpFx =
|
|||||||
* whose turn — and whose sight — this now is, so a cut between
|
* whose turn — and whose sight — this now is, so a cut between
|
||||||
* wizards never reads as one wizard teleporting. */
|
* wizards never reads as one wizard teleporting. */
|
||||||
| { id: number; kind: "slate"; name: string; art: string | null; t0: number; dur: number }
|
| { id: number; kind: "slate"; name: string; art: string | null; t0: number; dur: number }
|
||||||
|
/** A punch, felt: your own fist drives into the scene ("out"), or
|
||||||
|
* someone's comes at the camera ("in") — with the comic POW at the
|
||||||
|
* moment it lands, so a blow never reads as just another flash. */
|
||||||
|
| { id: number; kind: "fist"; swing: "out" | "in"; t0: number; dur: number }
|
||||||
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
|
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
|
||||||
|
|
||||||
/** How hard a surging veil glows at progress p: a snap to full as the
|
/** How hard a surging veil glows at progress p: a snap to full as the
|
||||||
@@ -157,6 +161,11 @@ export function fpFxForEvents(
|
|||||||
const warpIndex = (m: { cell: { x: number; y: number }; side: string }) =>
|
const warpIndex = (m: { cell: { x: number; y: number }; side: string }) =>
|
||||||
view.board.warps.findIndex((w) => w.from.cell.x === m.cell.x && w.from.cell.y === m.cell.y && w.from.side === m.side);
|
view.board.warps.findIndex((w) => w.from.cell.x === m.cell.x && w.from.cell.y === m.cell.y && w.from.side === m.side);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
if (e.type === "punched" && "attacker" in e) {
|
||||||
|
const pe = e as { attacker: string; target: string };
|
||||||
|
if (pe.attacker === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "out", t0: 0, dur: 620 }, delay: 0 });
|
||||||
|
else if (pe.target === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "in", t0: 0, dur: 620 }, delay: 0 });
|
||||||
|
}
|
||||||
if (e.type === "warpOpened") {
|
if (e.type === "warpOpened") {
|
||||||
for (const [m, delay] of [[e.a, 0], [e.b, 200]] as const) {
|
for (const [m, delay] of [[e.a, 0], [e.b, 200]] as const) {
|
||||||
const warp = warpIndex(m);
|
const warp = warpIndex(m);
|
||||||
|
|||||||
@@ -308,8 +308,11 @@ export interface Billboard {
|
|||||||
src: string;
|
src: string;
|
||||||
/** Fraction of wall height (a wizard stands taller than a chest). */
|
/** Fraction of wall height (a wizard stands taller than a chest). */
|
||||||
scale: number;
|
scale: number;
|
||||||
/** Lifted off the floor (0 = feet on the ground). */
|
/** Lifted off the floor (0 = feet on the ground); negative sinks. */
|
||||||
rise: number;
|
rise: number;
|
||||||
|
/** Below the floor line the body is hidden — a wizard down a pit shows
|
||||||
|
* only what stands proud of the ground. */
|
||||||
|
sink?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
/** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */
|
/** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */
|
||||||
aspect?: number;
|
aspect?: number;
|
||||||
@@ -413,7 +416,8 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
|
||||||
src: art(`wizard-${p.colorIndex}`, "players"),
|
src: art(`wizard-${p.colorIndex}`, "players"),
|
||||||
scale: big ? 1.2 : small ? 0.53 : 0.85, rise: 0, label: p.id,
|
scale: big ? 1.2 : small ? 0.53 : 0.85, label: p.id,
|
||||||
|
rise: p.inPit ? -0.42 : 0, sink: p.inPit || undefined,
|
||||||
alpha: ghost ? 0.35 : undefined,
|
alpha: ghost ? 0.35 : undefined,
|
||||||
webbed: wears(p.id, "sticky-web") || undefined,
|
webbed: wears(p.id, "sticky-web") || undefined,
|
||||||
gaze: wears(p.id, "medusa") || undefined,
|
gaze: wears(p.id, "medusa") || undefined,
|
||||||
|
|||||||