Stage three: spells fly through your own eyes
The reel's first-person mode now shows the fight, not just the rooms. The board flourishes' event map is recast into world space (fx3d): fireballs, bolts, and thrown things streak caster to target as glowing billboards; landings swell and fade; blows that land on YOU wash the screen red and jolt the camera; warp-steps and teleports flash violet; a hurled body glides fast and straight with its eyes held steady. Nine paintable conjuration sprites ship at public/fx3d (spec for the artist in research/), shown in the token workshop beside the masonry. Three rough edges sharpened along the way: sprites now project through warp mouths under the same rigid motion the rays use, clipped by the depth buffer to the opening; fire and warp surfaces pull a different texture slice per world cell so a long blaze reads as one; and the replay camera only turns toward an actor its eyes could actually see. Two more gifts: the reel can record its first-person canvas straight to a downloadable WebM (⏺ save video), and the About tab now shows visitors the way into the three workshops. One buried mine defused: rAF hands frame-start times that can precede the tween's own clock, and a zero-length turn divided by it — one NaN facing poisoned every ray black and re-triggered the camera effect forever. The tween clamps and guards its phases now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fcafdb06de
commit
e8c6b93c71
@@ -3,6 +3,8 @@
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay } from "./fpv/raycast";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
@@ -43,6 +45,27 @@
|
||||
return () => { cancel(); boardFx = []; };
|
||||
});
|
||||
|
||||
/** In first person the same events become projectiles, impacts,
|
||||
* flashes, and shakes — scheduled on this step's beat. */
|
||||
let fpFx = $state<FpFx[]>([]);
|
||||
$effect(() => {
|
||||
const st = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, st.view.you)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
|
||||
}, delay / speed));
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!playing) return;
|
||||
const t = setInterval(() => {
|
||||
@@ -76,14 +99,24 @@
|
||||
const dist = Math.hypot(dx, dy);
|
||||
// Where should the eye end up pointing? Along its own stride; at the
|
||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
||||
// A blow that hurls the body glides fast and straight, however far,
|
||||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||
const hurled = step.events.some((e) =>
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||
e.type === "retreatedInHorror") && e.player === v.you);
|
||||
const actor = v.players.find((p) => p.id === step.actor);
|
||||
let targetFacing = cam.facing;
|
||||
if (dist > 0.05) targetFacing = Math.atan2(dy, dx);
|
||||
if (dist > 0.05 && !hurled) targetFacing = Math.atan2(dy, dx);
|
||||
else if (actor && actor.id !== v.you &&
|
||||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||
targetFacing = Math.atan2(actor.position.y + 0.5 - ty, actor.position.x + 0.5 - tx);
|
||||
// Turn toward the actor — but only if these eyes could actually see
|
||||
// them: an unbroken straight sight line, no warps bending it.
|
||||
const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5;
|
||||
const toActor = Math.hypot(ax - tx, ay - ty);
|
||||
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
|
||||
if (!ray.warped && ray.dist > toActor - 0.2) targetFacing = Math.atan2(ay - ty, ax - tx);
|
||||
}
|
||||
if (!camReady || dist > 1.6) {
|
||||
if (!camReady || (dist > 1.6 && !hurled)) {
|
||||
// First frame, or a leap the legs cannot explain: cut.
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
camReady = true;
|
||||
@@ -91,15 +124,18 @@
|
||||
}
|
||||
const fromX = cam.x, fromY = cam.y, fromF = cam.facing;
|
||||
const arc = shortestArc(fromF, targetFacing);
|
||||
const turnMs = Math.min(260, Math.abs(arc) * 180) / speed;
|
||||
const walkMs = (dist > 0.05 ? 420 : 0) / speed;
|
||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const t = now - t0;
|
||||
if (t < turnMs) {
|
||||
// rAF hands frame-start time, which can precede t0; and a
|
||||
// zero-length phase must never divide. Either poisons the facing
|
||||
// with NaN, which no later frame can wash out.
|
||||
const t = Math.max(0, now - t0);
|
||||
if (turnMs > 0 && t < turnMs) {
|
||||
cam.facing = fromF + arc * (t / turnMs);
|
||||
} else if (t < turnMs + walkMs) {
|
||||
} else if (walkMs > 0 && t < turnMs + walkMs) {
|
||||
cam.facing = fromF + arc;
|
||||
const w = (t - turnMs) / walkMs;
|
||||
const ease = w * w * (3 - 2 * w);
|
||||
@@ -116,6 +152,36 @@
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
// --- Save a video: record the first-person canvas as it plays. --------
|
||||
let stageEl: HTMLDivElement | undefined = $state();
|
||||
let recorder = $state<MediaRecorder | null>(null);
|
||||
function toggleRecord() {
|
||||
if (recorder) { recorder.stop(); return; }
|
||||
const cv = stageEl?.querySelector("canvas");
|
||||
if (!cv) return;
|
||||
const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
|
||||
? "video/webm;codecs=vp9" : "video/webm";
|
||||
const rec = new MediaRecorder(cv.captureStream(60), { mimeType: mime });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = () => {
|
||||
const url = URL.createObjectURL(new Blob(chunks, { type: "video/webm" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wizwar-moves-${steps[0]?.seq ?? 0}-${steps[steps.length - 1]?.seq ?? 0}.webm`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
recorder = null;
|
||||
};
|
||||
rec.start();
|
||||
recorder = rec;
|
||||
playing = true;
|
||||
}
|
||||
// The reel running out ends the recording and hands over the file.
|
||||
$effect(() => {
|
||||
if (recorder && !playing && atEnd) recorder.stop();
|
||||
});
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
if (e.key === "ArrowRight") { playing = false; idx = Math.min(idx + 1, steps.length - 1); }
|
||||
@@ -133,12 +199,16 @@
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||
{#if fp}
|
||||
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
|
||||
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
|
||||
{/if}
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<div class="replay-board" bind:this={stageEl}>
|
||||
{#if fp}
|
||||
<FirstPerson view={step.view} povId={step.view.you}
|
||||
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} />
|
||||
{:else}
|
||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user