From 5fc5805354d906d0bb6f2fb4d4117fddaab27448 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 19:32:42 -0400 Subject: [PATCH] Odds, ends, and every missing effect: doorways dressed, victory earns its fireworks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit Eric called for, delivered whole. Open doors are DOORWAYS now: stone jamb posts at both ends and a lintel hung across the top (paintable as textures/doorframe.png), the way through open to the eye. Battle damage shows — the engine's per-edge wallDamage wears painted cracks into any wall, heavier as harm mounts. The wall safe stands as a strongbox volume instead of a flat token. Hedges take root: an underbrush floor decal covers the whole square beneath them (Eric's report — a billboard at center depth can't reach the floor trapezoid's near edge), and the hedge itself widens past the cell. Rubble stretches across the fallen wall's full gap, corner piles landing at the posts, exactly as its art was painted. The first-person effect gaps close: victory now throws SIX fireworks climbing over the winner's home with gold washing the screen; sector rotations flash violet and heave the whole world; the Thumb of God lands with a burst and a shudder; walls conjured from nothing rise in stone dust; and every hazard pratfall plays — pit falls (with a dark drop and jolt for the faller's own eyes), ooze slips, tack yelps, thorn snaps, slime, dust. Dying in first person fades long and dark. And the whole tale travels: a finished game's full replay carries the share button too — turn -1 mints a link to every turn of the game, each through its wizard's own eyes, titled for the winner's triumph. Co-Authored-By: Claude Fable 5 --- packages/server/src/index.ts | 41 ++++++++++---- packages/web/public/terrain3d/safe.png | Bin 0 -> 1771 bytes packages/web/public/textures/cracks.png | Bin 0 -> 1648 bytes packages/web/public/textures/doorframe.png | Bin 0 -> 605 bytes packages/web/public/textures/underbrush.png | Bin 0 -> 5038 bytes packages/web/src/App.svelte | 4 +- packages/web/src/Replay.svelte | 2 +- packages/web/src/SharePage.svelte | 5 +- packages/web/src/TokenGallery.svelte | 2 +- packages/web/src/fpv/FirstPerson.svelte | 45 +++++++++++++-- packages/web/src/fpv/FpvWorkshop.svelte | 8 +++ packages/web/src/fpv/fx3d.ts | 58 +++++++++++++++++++- packages/web/src/fpv/raycast.ts | 44 +++++++++++---- packages/web/src/fpv/terrain3d.ts | 24 +++++++- packages/web/src/fpv/textures.ts | 50 ++++++++++++++++- packages/web/src/net.svelte.ts | 9 +-- research/fx3d-sprite-spec.md | 16 ++++++ 17 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 packages/web/public/terrain3d/safe.png create mode 100644 packages/web/public/textures/cracks.png create mode 100644 packages/web/public/textures/doorframe.png create mode 100644 packages/web/public/textures/underbrush.png diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8fe5b9b..4910b8f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -108,6 +108,8 @@ interface ShareData { steps: { actor: PlayerId; events: unknown[]; view: unknown }[]; actor: string; round: number; + /** A whole finished game rather than one turn. */ + whole?: boolean; } const shareCache = new Map(); function shareData(id: string): ShareData | null { @@ -117,9 +119,21 @@ function shareData(id: string): ShareData | null { const share = getShare(id); const room = share ? getRoom(share.roomId) : undefined; if (share && room?.state) { - const reel = momentSteps(room, SPECTATOR, share.turn); - if (!("error" in reel) && reel.steps.length > 0) { - data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round }; + if (share.turn < 0) { + // The whole tale: a finished game from the deal to the crown. + const steps = catchUpSteps(room, SPECTATOR, 0, true); + if (!("error" in steps) && steps.length > 0) { + let winner = ""; + for (const st of steps) { + for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player; + } + data = { steps: steps as unknown as ShareData["steps"], actor: winner, round: 0, whole: true }; + } + } else { + const reel = momentSteps(room, SPECTATOR, share.turn); + if (!("error" in reel) && reel.steps.length > 0) { + data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round }; + } } } if (shareCache.size > 50) shareCache.clear(); @@ -143,9 +157,13 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin .replace(/]*>\s*/g, "") .replace(/[^<]*<\/title>\s*/, ""); const base = `${proto}://${host}`; - const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; - const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` + - `Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`; + const title = data.whole + ? "The whole tale — a game of Wiz-War, replayed" + : `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; + const desc = data.whole + ? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.` + : `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` + + `Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`; const metas = [ `<title>${title}`, ``, @@ -193,7 +211,7 @@ const httpServer = createServer((req, res) => { "cache-control": "public, max-age=60", "access-control-allow-origin": "*", }); - res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round })); + res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true })); return; } const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/); @@ -607,14 +625,17 @@ wss.on("connection", (socket) => { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); const turn = Number(msg.turn); - if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" }); + if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" }); const now = Date.now(); if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) { return send(socket, { type: "error", message: "one moment" }); } session.lastCatchUpAt = now; - const reel = momentSteps(room, session.playerId, turn); - if ("error" in reel) return send(socket, { type: "error", message: reel.error }); + // turn -1 shares the whole finished game; anything else, one turn. + const check = turn === -1 + ? catchUpSteps(room, session.playerId, 0, true) + : momentSteps(room, session.playerId, turn); + if ("error" in check) return send(socket, { type: "error", message: check.error }); const share = mintShare(room.id, turn); send(socket, { type: "share", id: share.id, turn }); break; diff --git a/packages/web/public/terrain3d/safe.png b/packages/web/public/terrain3d/safe.png new file mode 100644 index 0000000000000000000000000000000000000000..7cfb3b6d96568cf55e73cb0639c7d037f898a72b GIT binary patch literal 1771 zcmah~X;2g97X7lALK?5< z0032QFE{eeWPB_XVyE{xg+2oSQRD6A{B;WC_4R3@xjR;HXFuHCd34z=F!IcD{dqDk zXHR3#p<7h-_W|meg$q<}O^(m#qlg`?!p)P6*}u#A2pg8(8z!bD%?pPg*{p>GCXF0l z>RJ-6DTdkIg(F%Yo&`amrLFG;7yvEpfL2jC$^QppdtTTsfyd1)3t`PvCF? zi%$ek3ogE?0vE$mQXj%=n!Lg}y9wcW{~{RY5k*Fz?2D@Y-2`KgPvaR6v`80%ttZXQ z!u8{Guf&WTWXyS%C}(ea=7xF@XNQ5;u7f2+2o%FQFFr#AT|})j&Qy@d&id~%>Q1($ zunQuIc5a&W%x(j~f3Asg4Zc zn?SgC1HXF**SB_EjJ*OT0?h7ZKuarc()q67l?gM@3F0Lj?GzmXbPb{z2N-?FU9?3p z?j~Fn8iK1>S*I#g(imc}h9VevbjId_b`#d11GF^%rxwv5f^qMjwT+z}g~?M=!NI}Zu;21SRqO}p8b{kwe}!4u+y8EpNuxbCv$U*!_b$~2j}H?J3~Zm9*0L@- zgzR8IXxrXCmL6wMj{`Fci|ZR38*CZVhm@(*Q1YKYTzK^16>;s!JZD2K#4zukGwmy!BVw-KmZ<_#0{fbS zz5vq43wl|bV#UV1o^)%glT#4gZ6sv1~24iaswI02xDvj3M%~u&qx39 zpU)@D(jKlyM4ws*lDQ&Zhd(-F;#rj+Z^vt!?Z;rF%(#a2VqM0=hsW<~rd*)t#G!mz zZ;RE;P5f4FU>yOU&p+)em2!R&oXsJdH}HuiZBjRM@6b?jz;VvfVqFlKx|mars6|Rv zSI14{rX{Dl6uux<$qkD(*l3%JD&Bei$&<@mc{HwiCU7#_8IRa#3F?t|EkN3Pdh93^ z%4waIp|hEaMtYMEhW1q-vyiG2QMIsVbHaZaSBw>q zJdcrZj<;8^v)&MfXmxsenuH1|%6Z2v*0oq`9roS>@-yi4Ff}zbUB%epnOg03d986& z33c$#e%UN17j?A*862+%RpCA> zmZhU3#rM-Uv-o>_|89Ex{F7gBUJNC-B=d}xbe?qc_{FCm$d;AO@PYejJT}eCqi+Sk z>mUETV8A@wzTtEEFqMbT9Ti_*jB`(kCI7ztgnIzA0ssTtoGUwL?Ff_7_+ClGY#tzl1TH86XRB9?A7Nr~UxI z=E+E!mjHm^IZpMhiu?z)+h+M%Aj~NN0EH6{mDVCtQL$}U+uZbM(*ckiX)Q86i;k8J zx!t2!w#PaEk|PzJ6Fq*~=~q$KJv=+nm8zk@%VIhJBA~q~8bLq5#FlelrZBZvhjr#N z%uxXdNNH7eM&E3rA6DMmq=@-y-l|UI0&7$NmV$E6rx5*lW5IoA9YUZw4RF?$84&54 zqU-rmSaDkPVFehtBLLPo0Ps>~faF)mH(FStKb=|pVtdG>{ulY>YBhkh7M=vBMql!O zuMO7NpUVtj6zCue(iPrAKb;xIPyL^MBxlL=3wlK{?hCs z09^EFMnZdu0(Bqy3c&l6+_O5Rv$f_=s89h&qEC{c^3!CxJ^m%YC&Acm#l%wyOcel* zK2d+M;86Op5v_;8^moSU4DiukIB#eN2mogE%yEB1=}%_+&jRSutQf$~18s^v1d=9O z$Y4-1c*h%||CSx-(yGLE@@EAAQJ*8cihiug8x;2)a2@K<&b9DafOSqyfr+S|{hTit zaC9LYxn2}J__Jm}9o;kPz(i(YT{?|)GNV!eqWtKz^M2xDQ?V%btv*QsL|UyMgEd_!94sS-@%8`Y zz>j#c)#pFhl`?<^@d{4jMr%YRZD?pe_9Ua;|3eq{H*fAcZ(|k!KUg?({uIW?4h<-P zKKd{$1ejQy0x(Cl)j7@~D7;q#MThA|^hsG%OYqCF{4YdEuKbdS3^g#fB1MS1<;la> z8xA4*pnp1l1;9kRz&ZnV8kE{lzYCPDi{>R}W~bj{N)Xc&$WPaMeS~z0AtNDz8??z> z1UB8?OArG9J#-Ec5o9;m)ij0k6||-N6-S!p<%C#QG~j&*07PT0m-SR>)l`rZ!%?uF zdV&O%EKlH3TBp&+;M_ibIqDIa`>%C;{rzt|w_nW7KC@E=^Rh!i_uSZ;0qwM&atiO( zC!b0a2>g5Bu649pMTKw)z#+8mGG2t4q4N$7|IqO)-Nh5nfrJ)RV#X2xh_^?U5X6L| z8asrdXiWTb*V^*Mpr!zbL!0ufX2ziLP!#cV*A|_#Pby$307QuvofiS9jj1J{s5Ybk zc<*VzUtaPo1+gm-icbjuA;;+1*|hN}p?eYJ*eAT5RtjMP0BjC9)(Qe99o)nz)A>kT zrwoCz0sy{*jfVz;LMO~Uu%WuIoyr+77ebRunJfU%k$~|2UE4yKcvLGmwK73Z)z#bK zbgmQ|qFQzf8mO4gI_6GyN?$)>uCH+_1E7T+oOfTn`G)CtZEPD>XxI+~S;~1#Vdp8m z@g}JV0EEM@#f5U*CyMy`+ym-Bs|73@JCA~21YjxLy3)aNZS{!*XuqW|f9Ie2$v;`C z0C2?wP3Rd4q%ux1qM82Vx}-+2t(8^zHv!IlX^s6c_D2N(g%3LAX@JX1wicrzCjaWc u6!G)^0{{U3|D_4pBme*a21!IgR09APSasCO_251L0000)YxXbu zJ8>#n>lP&3Q7&}+nCx1}dCaz{Nn7v7qi>4IdCrcCbK5JW)~uS~Y#Muc=FaU|Z3`6> zlUC^KPk#7s-5wvNt@hEgOSPK6t$bX*%*r*jVX`~3!G}+_r9*yFqF%bHLBH zbJ?acp5xVvz8=D6QNE-0--%OCUj9|ro@`)tVfOL{>sb;jOP*^!GEUgK&q2ud(9&s3 zTeUnVbh9NSh?*bBdK$TXCtt!tnY#>ShqAu#IXvZ;TCQ?%j$5C9v`XW#g8#o}E_DbJ z?$5s;*)^fc_Q)Kyj~9%hUY-m-lHO2bdqjfUWK+y+RZbH(42P`=n^$_AZ^HU^iQ=GW zo25WC$Xxb)mde8=+ji7fI0?PrF4vcjo_tVrasK`ZGz3kb3;M6A@&kYXBUvJ5HE-;j8Xbio>kULd>)p6?uE0S;ZedoIn zc5zWv61$At+=)wG^#7UuSo&=7qzAhWAI`4cZo)We){7;^CBKeO1Nk=y}t-|kGme6M$BcJ}s1 zdneg0Pt7;|`t|FceqX=t>6yJVogZ897a=-sY1^Y{yKdfBrh@kC=Uqd;riTgpEtVG1 zG>;FSOYWX#)n9qFh=8akfxH*V6$r-AudVZhBO-vA2S{jEdd%;ze()h1$V!ywoxxbp zKHfXAIX#$KuVAA0&iCjyt!`S~vZmo{iq_Rlzo+@UPMqhABR~RK5v2WVf(gG)gjyY} z%_Be^P7S9vOJLjmiQr0qJh*HmIr`#EhmEsdxS(z>b=;Q!(?xwig#`LO_H@ zM6t4S)tO+_zLOsI4+P_ZI(`PuX}UX6#oyKWtzoXC4gIh}9S8jQjm6MeqhQJAj{6ti zzVNZdn>)Hx?!2S(s<4fQH4TefFKWCt%ok7DO^tY2$EJ>Ti*8%AD6X5TY%uy~@+vVO zKDoE&b2_&b$6rY9gu^=GKGHnL!+Mx9SB7%2R2>$g`w?C1wOnN*a=*&zxEJuJEYo7m zmj|!D;6rn^bZ%F65x%0Q$ww0>8@S}5=D2kQ4H6Sc7WS%_va?e-8 ze8s7uZD;^OG zZ)v+>;mz~@imbU?p|a}Ybu$ED)Qu-!J@H?so{Sep@>v{=?1~7BZ|it~0%;Oo=1L%q z4k`nfT7M9)(zK$MyxOW`aa~+y&s=DIX^!jSG9q-9_~}0e5!X6L#awO>H!g|Qr%>5f-m%Dd5(nI zj#*d1?(4M+Y8KTltX)~%SiK-_&mPZ@=S#{=t0MVoj|1Wh}skb zqG-12nYe)%&`cRjiGWNyHgB{uI=$xf%x1r8%0UFn7LgHR<4@Rttjxju?(j$L)i#wO zLP@A;siKDEbwBF;Y5#qRx1hhSQ>XWxCoVr0+j1%^inCB)-)B}s7$WkdvIW< zVct1eKPk_e_s4=^>_O5ZWHql)B5ax#QNh8FJC)D~3m1|gTkg*3iM6rW~x?XZzHCMNaPJ6nt_d$ z1IQT^LS3ye8N$waRZp1!t)$NO{cPY9S^Rl8HPmr*;yYQ~HE%=f|D=*57h#n0NC?Jl zDso;MBpS~onQM@?K3>Zw6cX4`)q=FobZ(r#R^^VH=PvEIq3y;68|VK~`;GJd8id^v zr13)M+W`^r6lA3ZWE5FP(1JNf0?meJ#5Z$)+p|45!B z7^diPXRXxwso>H1AD(l>AE6g);D3R9iC(C3RRz(cAox#33F!K9_kU9Mm{tU2>rN?o z=ZW{fsnB&nh@Xlo3k^o{WI-3ttj>j#@8plAbyX)r+H!8kl) z$TMrnppHVz*gU66h_e#Vc4OO$_Kowtr0BS@?KVYNd2-5ZmI9YRKO2ajE_6c$Ha|%w zN)krjcr?qa;ZAP`uT+=>%?a&{A@`^Y($prI`E7);abhfvM6;ortt4dZr2%8!0AhX$ zhi7ntnG6x`(TH@h!qg;|Rn_8-lsnWU%5~f{Z=J)sqLPQo96!=v;7iov_fIo z-l`}@Lfds0+((83ZH(DmplOH`dryBalr;^Eya;SzL$7facM{Vq)hp2PR{xBD(yLZgus@)%Oto29GO3n=n-pG%~_`CgpqC@E}Ogkk$xLpb5GsQ=YpH z9vFOAVQRRAnYtVdn?y}wrm6Bq6AjgW2;GPfw~)|Gt%-=SNChC!5CCZ`c7Z?(XIH>( znU=A~OUcS0NU+#ZR$(`J5YG2VAeUNSma9#i1Q!7r=jF3n7=vSwnU~|{tueh54Whvb zxVX_rs;82`34<3Uvpu=@^m>f`o2s9T)+?KSM|Ic`A2U@6&zq4*kjH_kGL0PAe}Yr< z;8cs+gg`$s1SkbiB9lOpJ$W7Oo7<^^1PSaoo;VjtkaawpYK1Hb%S;L9)uvi6MB?E_ zjaXn7IAoOQ8%2?Uj$M&eM~P=lf#dV8Zds?8b5+YWG~fu(3Y_4@nmRBIGgDm<;4$VD zL?G2oEyBUfX(U9<9)$-}ww%|6s%+X~Q^AFZh=?eV(V8tIYk8vyGli+>yqrP8H85Jr zLTwHf(?cZhU0sL^2(Tp4XEJqq?o};2v5Gs8(4R7~TNu4WVoChoOf)P+b?7L*nh|bI zgt?1HTWEz1GNFYq8%ss(Y)lC^^)l)liJ3eRdmL?*nQ57EbDbt1Tus6Wl1vB0N?+W< z5g}9;*G-oFv0!VNv{tM~A7AaBz{)huWREZ{|lD!#*1nOYO$nQ%nV~#6HTepzLB5{E=2mgZcOK>L6id#g{knMtqmNEB zpo6-G;JbkRoU0JKfO*$pj7C$W*3hTS=!ZH)fjZ$xFx6%Rk;k*|fd|A*CSK{K$WMsoymXWhpLt?)RSx2h|X>?*093Oy9(_WSN2TVb9N|X@Q7u-uLvB)=!;cu`p z4|~{3CtvD$gsZ%N#=VYXC3}jk{3}}B=hDDFOIBQj!G~&_mKoS1)L}8K1;K?8@cvJd z`|zmPZ+&|NVSQSGUyBX{$H8Vl!jN#Ih@8-Q?N~9Ovo)yW-tw?185SWYXKnWxrma1% z^zO*!%O>b>KSK1eu4j{v9ebwxVTWf=xTi~UcM?&-?`Z?*-*F0LpG%<-=2~@Jd|O2b z9m3(KY%Tg_A?BT??b_DmHn69$n>y@|`+F5)D`+A@7~Om3ys5+-TD*eTxu;j=Ut}Q zCE>j|S&b=ovG`IC;xo=7#?^)~#WanBS)Rr6;6=~#un?7p5Rb=W`6fbd(6C#yY#>ek zh(B|zJDuBjgvLmNQ9r7SEcNam*jdbR>+!2v?jV>3!kG^xF6CZ~WZsL5vx{9^6jUXC zK+G-@Tu~r(Vx^ji(qgzDx-me{Axz6?ZGew&AKmrAPmJ#eYprirVZO90)Dox@l0g_n z^B84IIpG>4veGTSJucSX+OoP11j=^Qai{g=NhgFCLudOoNV!8!gLtg;n#x47NOa|F zGm?^!e7WZbL6>L3KXNC-?SbzAA?bq*!EeRp;S2r08@c zL^&0Bxh|N-SUhqxpMymdR*mrs2HalbKd&ssIVc z0DUdjtb;L(UzD7fcQK~Mq=m(89P&#jMTXr5CDeDnc=~@;#y9_`_&k%K?M@f-HZ(`h zmujO}_^YikRs2q3X5!yD!M%WPUlouIc4%sn0aMShgDy$Ug@%+==@fDb-vW-Yk7tMNNni;U|jsg2j$5bUB4x~Sdx06J6Aw9|0M%YJd^!;S=Ib10TeanEwaxiTdp!W;c}%IL(I z##QwXHm$0EG>+y~4R9Sbxa*pxg6A{dDZNQAYBJaMFBcdx%1_2I*l^Zd~x&DB*DX&Q(K4_&N|@N z9d5wK(JyjZ?xO5bsq98ZLMr-ar7{R&=4*t|G!C62@hH$H)LC^}F?eKXrACJ8 zrv%TI{J;TU7K!jRB6O(@>~CNcKH0u;{*T*lYG0#{TNB((BulLD^_s^nmP-5`KAa~y z=sG6WjU;~ak3!n%vlk(1BIdQsocCLQwzwcsrxgVS6)KMX%L#nnGfx(n3ok-n7CF5Y z{of}i>Lr*+e_b6{IR{ySh|FbklBnZ~oT`ij$Mr8fnsD>QAmSrPo64AsNbV0CbK1C+=P(FyfBm5beTUAJ z{9L8w=K>|+*wfvZ69QY_#qlTw>H3$os z@pE1#WBLvZY=!tepwzjm@8F net.requestShare()} onclose={() => net.closeMoment()} /> {:else if net.catchUp && net.catchUp.length > 0} - net.closeCatchUp()} /> + net.requestShare(-1) : null} + onclose={() => net.closeCatchUp()} /> {:else if local.replaySteps && local.replaySteps.length > 0} (local.replaySteps = null)} /> {/if} diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 88a4f51..72e6aaa 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -462,7 +462,7 @@ {/if} - {#if moment && onshare} + {#if onshare}