From e8c6b93c71fa00e12321bfccf0964824b64be874 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 14:51:04 -0400 Subject: [PATCH] Stage three: spells fly through your own eyes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/public/fx3d/bolt.png | Bin 0 -> 6651 bytes packages/web/public/fx3d/burst.png | Bin 0 -> 6764 bytes packages/web/public/fx3d/fireball.png | Bin 0 -> 6703 bytes packages/web/public/fx3d/hit.png | Bin 0 -> 6258 bytes packages/web/public/fx3d/shield.png | Bin 0 -> 6909 bytes packages/web/public/fx3d/shimmer.png | Bin 0 -> 6752 bytes packages/web/public/fx3d/spark.png | Bin 0 -> 6806 bytes packages/web/public/fx3d/splash.png | Bin 0 -> 6791 bytes packages/web/public/fx3d/waterbolt.png | Bin 0 -> 6829 bytes packages/web/src/Help.svelte | 11 +++ packages/web/src/Replay.svelte | 90 ++++++++++++++++-- packages/web/src/TokenGallery.svelte | 20 ++++ packages/web/src/fpv/FirstPerson.svelte | 100 ++++++++++++++++---- packages/web/src/fpv/fx3d.ts | 120 ++++++++++++++++++++++++ packages/web/src/fpv/raycast.ts | 59 ++++++++++-- research/fx3d-sprite-spec.md | 49 ++++++++++ 16 files changed, 412 insertions(+), 37 deletions(-) create mode 100644 packages/web/public/fx3d/bolt.png create mode 100644 packages/web/public/fx3d/burst.png create mode 100644 packages/web/public/fx3d/fireball.png create mode 100644 packages/web/public/fx3d/hit.png create mode 100644 packages/web/public/fx3d/shield.png create mode 100644 packages/web/public/fx3d/shimmer.png create mode 100644 packages/web/public/fx3d/spark.png create mode 100644 packages/web/public/fx3d/splash.png create mode 100644 packages/web/public/fx3d/waterbolt.png create mode 100644 packages/web/src/fpv/fx3d.ts create mode 100644 research/fx3d-sprite-spec.md diff --git a/packages/web/public/fx3d/bolt.png b/packages/web/public/fx3d/bolt.png new file mode 100644 index 0000000000000000000000000000000000000000..3995c3f04d0fb2b599da20fae94e9d809bc7149f GIT binary patch literal 6651 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 literal 0 HcmV?d00001 diff --git a/packages/web/public/fx3d/shield.png b/packages/web/public/fx3d/shield.png new file mode 100644 index 0000000000000000000000000000000000000000..df6f7d97089f70c6cee49d9fcfac06ded02db770 GIT binary patch literal 6909 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

r6=k9ZvmUltqrcmnHi!2feC@hne7iqE+fB$r4W zldxe4a7|zaS3rXY z(ed?l7(9xGOjyV+na*qatn>qLfsH(nwN+kW1i!4hwI+2#0c2`0$32^ z<(tQ^n6P187skT;R!|emx~zV|3r?104cP6yUq&&a41fD%0u~{Pg@go_MT<2Ef%`zh zF|F`IL9ATA5Qyy>mao)+;V@PXsQ4zzvpQ1yNYV8VKCc70|H;EtUnJE=$h(=s&RGKT^#_FR3Ae_e?&U;+rb<_Oplo|UO?#*X>kZ~+e z?{PUka{*OHE4VR7OsqU!J@_vW1TCxy+NhFXOpKU;A9nyXP48zJT$pChVi!Lnv)-{? z0;pgB-}4gO1pgDplS4&ZB)Gg+e1_l&)gR{&tI49c#X0#M_u(RD000W6Nkl2Kbu+hom^fjAO)I7H|RBE@7Tm0M;$b?AG9&M++>@ z`w(V7``{oF>5JOq+fNdJA55G;0)%i*tjo+*7mJ%$#=1noE5y^{re&EY1R5ROIS@fO zn&0dMk`pHZl^my)xb+!aVXia_ZTp2b42}Ea9XL^x_Wx)d~$@ppI1F>MP{u4OIbD(+a zf)<_eN(S%a{loOuN$994Vxb9-C5PZXAYFbMyvIkFo2KB#$d!`oN~(7>6Zd!tWT2D)H^onhB7#P}gFr+GlJ z28hK@2cGB#WI%X7E$cMlxi-~$o}xy)y^fzJfavj<=6Rg_)KAVibig<+&+Lw?pNGW_ z!{U(uH}=7gUGPKaF&?An0KuphD4;G79*70^5ZJx|p)Js$(#>1t*}mqUiFoZp;@*4> z=%x`RVN8hS-OwX$2QJ+b`ri8i+(vZe2s@0 zQhvf${ZW!1R&MvjLG(PJG5RIUz|_HcxA>t8u7~Yf@8)SjWyT-f^PB`m2N69I(4h+$ zEo{rxMj}}F3w-T`9&38+&t)?ALYb7e5bpCJfA- zHAv+~)&$Yq0Wd!tpGpASen0rG8K$`%-8A>8a_>Xe2iNxwqCG^1x$C^^+Ti-e&0XW? zwi(eo11i-sqTpvGyO&Tr^&6lC*f!h&uQ9K|xJ73$y13uJcTn^RzY}8kxj{UR**DJj zEpq{n4m`UsjeX?m+unttpQdqSEnW9L{-;C!2kFnzrxL(d2Bnrkt^su#hc!$+AqrP~ z^#d~9cOi_Z0D}9zS$zNSu6r!5ea!Pb4X9lIhF3*@Y+HD3QQ;OMdE8;#aROj@KAz*+ z(?_5lm1m#qJ0M1kg_yzj4bU=|5WtDKEknm#S6q;O9Hbt4)&fzS0GOX1zmx!njP<^2 z`(;L@=b@Wj+buJK%w6l}z5$6Q+F1SW5$5(jPmjBsANOe*`e)0(bpXV)5ZEwK<%X-w z79G%H{Op7fKOOkI#kv}6lm)c5S$&CMN2xcj-|q&EvXkGX&wk}Egt9Q+996MDioB)`=KYlF%p=rMD9{$lSZP&VK zXy>tw)eoHgIf{Pxsl5kO-J$w^zTZIh>gLCF9`9E--LBL4xc9>}wGhu}VO7TBNz zn)mbYoI^+Ox_+Fdd2H_vKBBMr4u1`N-*0YeVoX1`=sNHKmnWybL8nHpV|#5;o33dZ zPQd$D-=C-8*Al>M2kMvBn;sP(nssTL;JXGNJB|0<1NZ)Y@sE2zRf6wsAawH%)kgyS z{J8kJIU^|-Ki$p{a-PQSF%IL<4cr+nw1Z#T-Y@;&LXWv|9EWM_AG|j1nQxacHSp20 zuPgfwb9)QEy*Y7=5lN&E5_ddR!-sYZbvEEO2UVrh^4Zd#ZfIINGI>ZW7!}0E6Ztl!wnZRjA^1v}H zw`LsM_Hwy!0-)iSkIzT|ep8}HV@%)Zb!;e14-b_~K;~U@UqgHIuDu1A<#NSSS1g85(%mcTN4r4d*+zkoAn)z|UwKMjd0}>g1P0b}t z%{9!AE5|@A#B6Wgb@wNi;ZC7@cyKg}YVeH?VD7&xp09jP0(hNkn&xr8-|oZXgv#IU zK{f%66Lsf38i%;k)##cYxufgM*|=nkyq^T*j?uV+85Ifwai@!SEfxrVv9t{gKoH}Coz zz6{-%Q?i6RQN!ZFl_~#Y{FVf;x0#yeKHjhQFf<6OaK-%&JF-1vjCmeUm$)XbeU5qWc^+>UB=+Lm^)kP_g)Wyz-~aXccznE$&Gp*#*UK>9uG925 z57YTFjhFL0+|MHtFin@!G~7;ecL8pvakxO}^`q<0C)Zz3sOU$Yi`Q|D*YY0M!^dyo7I!;^`Em089{0Gp zIfbb`voE*y@ZDV&aXWLYada0VbYA>(&Wp}(xBqMcIAWmFsp-1!FXQcTY0oEA=XRck z%h{uPWA`}E<2?$Ud)WSZ^5gBJ8_y@-0mCgOV8q;jYx8tI&1`XfoZ+LrL|;pDJGbK< zG5+hqv4~C$g8$+-JY4&%<4Yvq6$tm=1+CbfH=A&_^f=?0z&HtiJyE!wJu2@}Y24GX z(!sT-;I1d`2#;=%>;2?WbzVF9=7fj(X<6>k*EohVb>E*63^DLGE)2fJ@g)=R;^Xn# zV)uAFE`5JGjpMiH`@K0g=kuwZ9;Xf!p4$I9-2rs>$9wzT$Em%ay65?+z5JgOubu9H zJ&)IK&+LPJ5q-zs>HqHk^RJ-4*ZvU+_%RoB1%deOhWqt|3Z4i_8HV~qnE z{7A#)#A1oMQ(Q}gJ-qkJRhvHX`lA!@^N@Vc;&b{xM_d^2XWAYX_!}Ok#B;dz!yheh z?GHcx)d~3Gg8o^D{}%uN|NrXkInw|D00v1!K~w_(Ly9DxH9lPq00000NkvXXu0mjf DLw|mW literal 0 HcmV?d00001 diff --git a/packages/web/public/fx3d/shimmer.png b/packages/web/public/fx3d/shimmer.png new file mode 100644 index 0000000000000000000000000000000000000000..000698810ab6982e22cc4d7746f9279e465b9431 GIT binary patch literal 6752 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 z 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 Ef>_h?z5oCK literal 0 HcmV?d00001 diff --git a/packages/web/public/fx3d/splash.png b/packages/web/public/fx3d/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..3197127319e89e9239b9c23ec9e27395efcd92e6 GIT binary patch literal 6791 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 p009606#IwE00006NkleLN z7;XemRgaSXkH7w(fPZiSDA?0L3S>b31h;vN=Xec<|1r-WTLFLmbBr(w+HSX0RmYyfags@@%W&sSf4RbW5sfcRscKZ1Y=uEOzpPyhMf zM@n}G^3{Rpa9oo5NJ({ERerQ;1Jo~8F*adrwK?YNV@vwTd%nG^1LYVa=puqnV%<>x z`#rxW0Wt0OH8OZWW)FzjHBOLxbt+8$)SJEr-|QDujt%yvgz?v9dC?H*(G z2J<_tJsx80iFcc$PX76jyc`d-dv_>`KY>WtAc}u43gqvgNI^$MMX@KR_mpOP8*L9R zD-SXT^7`P?&C%-JQDhsSK8j@fB8n}tz2){RUPOvD8f)_ED6vPz*qZ>dJj79cSfHCy z*eL$A4>pO|Hk!Zw`Ar0T{q@%w&h{?d{_|d^@Ai52a>$b1US;(mE;a`Qe29|WG0OLc z2$@#t_Nem1F-9ds+4iVV2@K6`jHzsYR0YOCM3nCj5wEQd&~-Sd?08Vg%RVl*2c2&A zY_qD~?GSnf{OU*k%ALHg{@Z2Rk=X2PT<)AI_AV}V4jB`Avv&yy&_KF5P+A>TTpuaf z9tpuxx;;w0d7*T3Q~)KL7s+EdH(?D+T^&VI9fSsw0`60f+y^y+*c>D>Dv)`BY;&eX zf^&{)kl{{vv>#(Nvd;bb$a`dhy?T2f4PDSFlX5R$LnIY!<~tVy5vmWquuoJ~8zO>_ zc>m1jO9;?a^{!4f+fA$w8(HS4fNCpoy+^2msHk=-+wS6IgG$)$Rkk^xyn)?8X8S{w zab|Z1WD*(1Sw|H}oN<*u+7ZhPm608=etYCSD#v=(17QDNWxHTQrPWU4yIq`Zx4MK6 zWxZve61m-ggqrBzKbwTlB_JlM>Q<_a(@Cw8V&kJ4?P?>Ua!YZsl{k~g)XpcHy-e#p z-vc7q94M_3(B>eL?SYaFGQ2&C6ag~Oms|WmF zT`kjc6KO`ZQCYoJ$b?KcJC)Wum2Gx1srM@1AYKm_SxE0c07l6nUjoD{m=%Ve|1 z*g>Tn$o7DH-~ghIGTR=5MCN4x`UpOZWjn0d9t8IAUYXW=)`d27S3n8WThi55!Zs>l zgDR|@u4)P2JbZLXTCvYW`x5^D?DMk(e9fQp`LWb_UaBmsWL8#DQd9^YfI(etqO{&9 zUBMY-B&oM>c!z*@GTrb|I3U}5`3y>@Wu0VsmZgaw>znSiz3oxsj{k6F_4s84Ykr0-w8m4Y}8wkR*x#k zsx1Q7MXC@mBcd`>Kt#n;a=*%Wobi||(dRv7igiE=C5m-236Ls9_I1g&YL&otQXu

8WI# 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 bK~w_(2m;EFJU}*@00000NkvXXu0mjf_p$j* literal 0 HcmV?d00001 diff --git a/packages/web/src/Help.svelte b/packages/web/src/Help.svelte index 9f08319..f28bd0a 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -147,6 +147,17 @@ the edition currently in print, and deal seven cards to somebody at a real table.

+

Behind the curtain

+

+ The workshops where this table's pieces are made are open to + visitors: + the token workshop shows every token in both + arts beside the wall textures and spell sprites; + the flourish workshop plays each board effect on + demand; and + the first-person workshop walks the maze through + a wizard's own eyes (add &demo=1 to watch a reel). +

Send word

Feedback, bug reports, and faint praise all welcome — ravens fly to diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index bf197ef..19b7ea0 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -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([]); + $effect(() => { + const st = steps[Math.min(idx, steps.length - 1)]; + if (!fp || !st || !prefs.flourishes) return; + const timers: ReturnType[] = []; + 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(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 @@ move {idx + 1} of {steps.length} + {#if fp} + + {/if} -

+
{#if fp} + x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} fx={fpFx} /> {:else} {/if} diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte index e2d36c7..5d7f45c 100644 --- a/packages/web/src/TokenGallery.svelte +++ b/packages/web/src/TokenGallery.svelte @@ -7,6 +7,7 @@ import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte"; import FirewallEdge from "./FirewallEdge.svelte"; import IllusionShimmer from "./IllusionShimmer.svelte"; + import { FX_ART } from "./fpv/fx3d"; const GROUPS: { title: string; files: string[] }[] = [ { title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] }, @@ -102,6 +103,23 @@ {/each}
+

The conjurations — first-person spell sprites

+

+ The moments the raycaster throws through the air: projectiles in + flight, bursts on landing, shimmers and shields. Each lives at + public/fx3d/<name>.png — square, transparent ground, + drawn glowing mid-air at any size; a procedural glow stands in + wherever a file is missing. +

+
+ {#each FX_ART as name (name)} +
+ {`${name} +
{name}
+
+ {/each} +
+

Walls & doors — as the maze draws them

Wall-segment effects have never been tokens: locks picked, jammed, and @@ -195,4 +213,6 @@ border: 1px solid #3a3428; background: #101318; } + /* Sprites fly against darkness; show them on it. */ + .conjuration { background: #14101c; } diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 6f425d7..c24454e 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -3,8 +3,9 @@ // GameView. Columns of wall shaded by distance and facing; token art // billboarded for whatever stands in the corridors, occluded per column // by the same depth buffer the walls wrote. - import { castRay, billboards, type Billboard } from "./raycast"; + import { castRay, billboards } from "./raycast"; import { materialTextures } from "./textures"; + import { fxFallback, type FpFx } from "./fx3d"; import { tokenArt } from "../art"; import type { GameView } from "@wizwar/engine"; @@ -16,6 +17,7 @@ facing, width = 720, height = 440, + fx = [], }: { view: GameView; povId: string; @@ -26,6 +28,8 @@ facing: number; width?: number; height?: number; + /** Live spell moments: projectiles, impacts, flashes, shakes. */ + fx?: FpFx[]; } = $props(); const FOV = Math.PI / 2.9; @@ -83,11 +87,21 @@ ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew const W = width, H = height, half = H / 2; + // Active shakes wobble the eye a hair off true, fading as they run. + let ex = x, ey = y; + for (const f of fx) { + if (f.kind !== "shake") continue; + const p = (time - f.t0) / f.dur; + if (p < 0 || p >= 1) continue; + ex += f.mag * (1 - p) * Math.sin(time * 0.11); + ey += f.mag * (1 - p) * Math.cos(time * 0.087); + } + // The ground and the vault overhead, cast per pixel: each screen row // below (or above) the horizon lies at one fixed depth, so the row is // walked with a constant world-space step and sampled from the floor // or ceiling texture — every maze cell wearing one full tile of it. - const fx = (W / 2) / Math.tan(FOV / 2); + const flen = (W / 2) / Math.tan(FOV / 2); const cosF = Math.cos(facing), sinF = Math.sin(facing); if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H); const buf = frame.data; @@ -111,10 +125,10 @@ const tp = tex.data; const shade = Math.max(0, Math.min(1, 1.25 / (1 + d * 0.45)) * (1 - d / FOG)); // World position at column 0 and its per-column step, both at depth d. - const sideStep = d / fx; + const sideStep = d / flen; const side0 = -(W / 2) * sideStep; - let wx = x + d * cosF - side0 * sinF; - let wy = y + d * sinF + side0 * cosF; + let wx = ex + d * cosF - side0 * sinF; + let wy = ey + d * sinF + side0 * cosF; const stepX = -sideStep * sinF; const stepY = sideStep * cosF; for (let col = 0; col < W; col++) { @@ -140,26 +154,31 @@ const zbuf = new Float64Array(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, x, y, rayAngle); + const hit = castRay(view, ex, ey, rayAngle); const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye zbuf[col] = depth; const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); const top = half - wallH / 2; const tex = textures[hit.kind] ?? textures.wall!; // Sample by the texture's own size: painted files may be any scale. - ctx.drawImage(tex, Math.min(tex.width - 1, hit.u * tex.width), 0, + // Fire and warps shift their slice per world cell, so a blaze + // spanning edges reads as one long fire, not a repeated flame. + const texU = hit.kind === "firewall" || hit.kind === "warp" + ? (hit.u + Math.floor(hit.worldU) * 0.37) % 1 + : hit.u; + ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0, Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH); // Distance and orientation carve the light; overlays animate it. let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45)); if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8; if (hit.kind === "firewall") { - const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.u * 17 + col * 0.15); + const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15); ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`; ctx.fillRect(col, top, 1, wallH); dark *= 0.5; // the fire lights itself } if (hit.kind === "warp") { - const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.u * 9); + const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9); ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; ctx.fillRect(col, top, 1, wallH); } @@ -177,24 +196,53 @@ // Sprites, far to near, sliced against the depth buffer. const sprites = billboards(view, povId, tokenArt) - .map((b) => project(b)) - .filter((s): s is Projected => s !== null) - .sort((a, b) => b.depth - a.depth); + .map((b) => project(b, ex, ey)) + .filter((s): s is Projected => s !== null); + for (const f of fx) { + if (f.kind !== "projectile" && f.kind !== "impact") continue; + const p = (time - f.t0) / f.dur; + if (p < 0 || p >= 1) continue; + const at = f.kind === "projectile" + ? { x: f.from.x + (f.to.x - f.from.x) * p, y: f.from.y + (f.to.y - f.from.y) * p } + : f.at; + const s = project({ + x: at.x, y: at.y, src: `/fx3d/${f.art}.png`, + scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p, + rise: 0.3, + }, ex, ey); + if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); + } + sprites.sort((a, b) => b.depth - a.depth); for (const s of sprites) { - const img = imageFor(s.src); + const img = imageFor(s.src) ?? (s.fallback ? fxFallback(s.fallback) : null); + const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0); + const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0); + if (s.glow) { + ctx.globalCompositeOperation = "lighter"; + ctx.globalAlpha = s.alpha ?? 1; + } for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { if (s.depth >= zbuf[col]!) continue; const texX = ((col - s.left) / (s.right - s.left)); if (img) { ctx.drawImage( img, - texX * img.naturalWidth, 0, Math.max(1, img.naturalWidth / (s.right - s.left)), img.naturalHeight, + texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih, col, s.top, 1, s.bottom - s.top, ); } else { ctx.fillStyle = "rgba(200,190,160,0.6)"; ctx.fillRect(col, s.top, 1, s.bottom - s.top); } + if (s.warped) { + // A body seen through a warp swims in the same violet haze. + ctx.fillStyle = "rgba(120,70,220,0.2)"; + ctx.fillRect(col, s.top, 1, s.bottom - s.top); + } + } + if (s.glow) { + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; } } @@ -204,6 +252,17 @@ vig.addColorStop(1, "rgba(0,0,0,0.45)"); ctx.fillStyle = vig; ctx.fillRect(0, 0, W, H); + + // Being hit is felt: a whole-screen wash that fades as it goes. + for (const f of fx) { + if (f.kind !== "flash") continue; + const p = (time - f.t0) / f.dur; + if (p < 0 || p >= 1) continue; + ctx.globalAlpha = f.peak * (1 - p); + ctx.fillStyle = f.color; + ctx.fillRect(0, 0, W, H); + ctx.globalAlpha = 1; + } } interface Projected { @@ -213,9 +272,16 @@ right: number; top: number; bottom: number; + warped?: boolean; + glow?: boolean; + alpha?: number; + fallback?: string; } - function project(b: Billboard): Projected | null { - const relX = b.x - x, relY = b.y - y; + function project( + b: { x: number; y: number; src: string; scale: number; rise: number; warped?: boolean }, + ex: number, ey: number, + ): Projected | null { + const relX = b.x - ex, relY = b.y - ey; const depth = relX * Math.cos(facing) + relY * Math.sin(facing); if (depth < 0.15) return null; const side = -relX * Math.sin(facing) + relY * Math.cos(facing); @@ -225,7 +291,7 @@ const size = wallH * b.scale; const bottom = half + wallH / 2 - b.rise * wallH; return { - src: b.src, depth, + src: b.src, depth, warped: b.warped, left: screenX - size / 2, right: screenX + size / 2, top: bottom - size, bottom, }; diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts new file mode 100644 index 0000000..48a0dfe --- /dev/null +++ b/packages/web/src/fpv/fx3d.ts @@ -0,0 +1,120 @@ +// First-person spell moments. The board flourishes (fx.ts) already know +// which event hurls what across which cells; this recasts their output +// into world-space projectiles, impact bursts, whole-screen flashes, and +// camera shakes for the raycaster to draw. What happens ACROSS the room +// is watched; what happens to YOU is felt. + +import { fxForEvents } from "../fx"; +import { CELL } from "../fx-sprites/geom"; +import type { GameEvent, GameView } from "@wizwar/engine"; + +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: "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: "shake"; mag: number; t0: number; dur: number }; + +/** The paintable conjuration sprites: public/fx3d/.png, square with + * transparent ground, drawn as a billboard mid-air. A procedural glow + * stands in wherever a file is missing. */ +export const FX_ART = [ + "fireball", "bolt", "waterbolt", "spark", + "burst", "splash", "hit", "shimmer", "shield", +] as const; + +let nextId = 1; + +const PROJECTILE_DUR: Record = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 }; +/** Board-effect kinds that land here as an impact billboard. */ +const IMPACT_ART: Record = { + burst: "burst", splash: "splash", fireworks: "burst", + hit: "hit", pow: "hit", claw: "hit", + shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer", + soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer", + shield: "shield", absorb: "shield", +}; + +/** Translate one step's events into first-person moments with start delays. */ +export function fpFxForEvents( + events: GameEvent[], view: GameView, povId: string, +): { fx: FpFx; delay: number }[] { + const out: { fx: FpFx; delay: number }[] = []; + for (const { fx, delay } of fxForEvents(events, view)) { + if (fx.kind === "fireball" || fx.kind === "bolt" || fx.kind === "waterbolt" || fx.kind === "streak") { + const art = fx.kind === "streak" ? "spark" : fx.kind; + out.push({ + fx: { + id: nextId++, kind: "projectile", art, + from: { x: fx.a.x / CELL, y: fx.a.y / CELL }, + to: { x: fx.b.x / CELL, y: fx.b.y / CELL }, + t0: 0, dur: PROJECTILE_DUR[art]!, + }, + delay, + }); + } else if (fx.kind === "portal") { + out.push({ + fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 }, + delay, + }); + } else if ("at" in fx && IMPACT_ART[fx.kind]) { + out.push({ + fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 }, + delay, + }); + } + } + // Blows that land on the point of view: give impacts a beat to arrive + // when something visibly flew first. + const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0; + for (const e of events) { + if (e.type === "damaged" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#c03020", peak: 0.4, t0: 0, dur: 450 }, delay: povDelay }); + out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay }); + } else if (e.type === "lifeGained" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay }); + } else if (e.type === "teleported" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 }); + } else if (e.type === "moved" && e.via === "warp" && e.player === povId) { + out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.28, t0: 0, dur: 350 }, delay: 0 }); + } else if ( + (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || e.type === "retreatedInHorror") && + e.player === povId + ) { + out.push({ fx: { id: nextId++, kind: "shake", mag: 0.05, t0: 0, dur: 350 }, delay: povDelay }); + } + } + return out; +} + +/** Procedural stand-ins for the conjuration sprites: a soft glow in each + * art's palette, replaced on screen the moment a painted file loads. */ +const FX_COLORS: Record = { + fireball: ["#ffe27a", "#e0431a"], + bolt: ["#ffffff", "#7ab0ff"], + waterbolt: ["#cfefff", "#1c6ed0"], + spark: ["#fff6d8", "#c0a040"], + burst: ["#fff0a0", "#d05010"], + splash: ["#e8f8ff", "#2080c0"], + hit: ["#ffd0c0", "#c02020"], + shimmer: ["#f0e0ff", "#7040c0"], + shield: ["#ffffff", "#4060d0"], +}; +const baked = new Map(); +export function fxFallback(name: string): HTMLCanvasElement { + let t = baked.get(name); + if (!t) { + t = document.createElement("canvas"); + t.width = 64; + t.height = 64; + const c = t.getContext("2d")!; + const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!; + const g = c.createRadialGradient(32, 32, 2, 32, 32, 30); + g.addColorStop(0, core); + g.addColorStop(0.55, rim); + g.addColorStop(1, "rgba(0,0,0,0)"); + c.fillStyle = g; + c.fillRect(0, 0, 64, 64); + baked.set(name, t); + } + return t; +} diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 6db2a25..81b8701 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -18,6 +18,9 @@ export interface Hit { u: number; /** Vertical faces get a different shade than horizontal ones. */ axis: "x" | "y"; + /** 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. */ + worldU: number; /** The eye reached this through a warp: haze it other-worldly. */ warped?: boolean; } @@ -104,7 +107,7 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"] }; const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx; const u = along - Math.floor(along); - return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, warped }; + return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, worldU: along, warped }; } // No slab in this cell: advance through the open boundary. @@ -114,7 +117,8 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): if (crossingX) { nx = cx + stepX; sideDistX += dDistX; } else { ny = cy + stepY; sideDistY += dDistY; } const axis: Hit["axis"] = crossingX ? "x" : "y"; - const u0 = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1; + const along = crossingX ? oy + dist * dy : ox + dist * dx; + const u0 = along % 1; const texU = u0 < 0 ? u0 + 1 : u0; const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })]; @@ -123,31 +127,31 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number): const warp = view.board.warps.find( (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side, ); - if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, warped }; + if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped }; // Step through: re-enter at the paired mouth, heading inward, the // offset along the edge preserved (a wraparound keeps its lane). const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]]; angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]); const c = warp.to.cell; - const along = texU; - if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + along; } - else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + along; } - else if (warp.to.side === "S") { ox = c.x + along; oy = c.y + 1 - 1e-4; } - else { ox = c.x + along; oy = c.y + 1e-4; } + const lane = texU; + if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; } + else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; } + else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; } + else { ox = c.x + lane; oy = c.y + 1e-4; } baseDist += dist; warped = true; i = 64; // restart the DDA from the far mouth break; } if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { - return { dist: baseDist + dist, kind: "stone", u: texU, axis, warped }; + return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped }; } cx = nx; cy = ny; } if (!warped || traversal === 2) break; } - return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", warped }; + return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped }; } /** Can a wizard's body (not just their eye) cross this edge? Workshop @@ -178,6 +182,18 @@ export interface Billboard { /** Lifted off the floor (0 = feet on the ground). */ rise: number; label: string; + /** A reflection seen through a warp mouth, not the thing itself. */ + warped?: boolean; +} + +/** The along=0 corner of a warp mouth — the anchor castRay's lane + * preservation measures from. */ +function mouthAnchor(m: { cell: Cell; side: Side }): { x: number; y: number } { + const c = m.cell; + if (m.side === "E") return { x: c.x + 1, y: c.y }; + if (m.side === "W") return { x: c.x, y: c.y }; + if (m.side === "S") return { x: c.x, y: c.y + 1 }; + return { x: c.x, y: c.y }; } /** Everything standing in the maze that the reel should draw as a sprite. */ @@ -228,5 +244,28 @@ export function billboards( const [tx, ty] = k.split(",").map(Number) as [number, number]; out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId }); } + // Rays see through warp mouths; the standing world should follow. Each + // sprite near a far mouth also appears in the near mouth's frame, under + // the same rigid motion the rays use — the zbuffer clips it to the + // opening, since everything around the mouth is nearer wall. + const real = out.slice(); + for (const w of view.board.warps) { + const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side]; + const a1 = mouthAnchor(w.from); + const a2 = mouthAnchor(w.to); + const cosD = Math.cos(-delta), sinD = Math.sin(-delta); + const inward = SIDE_ANGLE[OPPOSITE[w.to.side]]; + const inX = Math.cos(inward), inY = Math.sin(inward); + for (const b of real) { + const rx = b.x - a2.x, ry = b.y - a2.y; + if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue; + out.push({ + ...b, + x: a1.x + rx * cosD - ry * sinD, + y: a1.y + rx * sinD + ry * cosD, + warped: true, + }); + } + } return out; } diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md new file mode 100644 index 0000000..89247f4 --- /dev/null +++ b/research/fx3d-sprite-spec.md @@ -0,0 +1,49 @@ +# The conjuration sprites — a spec for the artist + +The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws +spell moments through the air as **billboard sprites**: flat images that +always face the camera, drawn glowing in mid-air, occluded by walls, and +scaled by distance. Nine sprites cover every spell moment in the game. +Each lives at `packages/web/public/textures/../fx3d/.png` — replace +the file, refresh, done. The current files are placeholder radial glows; +all nine are shown side by side at `/?tokens` under "The conjurations." + +## Technical requirements + +- **Format:** PNG with a transparent background. The transparent area is + essential — whatever is opaque is what flies. +- **Size:** square, any resolution; 128×128 or 256×256 recommended. The + renderer scales freely and pixelates on magnification (the whole view + is deliberately chunky, like the wall textures). +- **Drawn additively:** sprites are composited in "lighter" (additive) + mode over a dark dungeon, so dark pixels vanish and bright pixels glow. + Paint light-on-transparent; pure black will be invisible. Midtones read + as translucent light. +- **No animation frames.** Each sprite is a single still. Motion comes + from the renderer: projectiles streak across the room, impacts swell + from small to large while fading out. Radially symmetric (or nearly so) + designs work best since the image does not rotate. +- **Fills most of the canvas.** Leave only a small transparent margin; + the renderer sizes the sprite by its canvas, not its painted extent. + +## The nine sprites and where they appear + +| file | flies as | moment | +| --- | --- | --- | +| `fireball.png` | projectile | Fireball, Sudden Death, Blaster Wand — a ball of fire streaking caster → target | +| `bolt.png` | projectile | Lightning Blast, Power Drain — a crackling bolt | +| `waterbolt.png` | projectile | Waterbolt — a hurled gout of water | +| `spark.png` | projectile | anything else that travels: thrown objects, bodies knocked back, dragged things — a neutral streak of force | +| `burst.png` | impact | a fireball landing; victory fireworks — swells and fades | +| `splash.png` | impact | waterbolt landing, stone turned to water | +| `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 | +| `shield.png` | impact | an attack fully stopped; absorbed spells | + +Projectiles hold their size in flight (about a third of a wizard's +height). Impacts start small and swell to roughly double while fading — +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 +hit (red), healed (green), or teleported (violet), and the camera shake — +those are renderer effects, not images.