Credibility pass: comments say what the code can't, and the seams are gone
Engine: pit-rim exits live in one helper (pitRimExits) shared by the resolver and the automaton; chargeReach names the six-stride cap; dust sight is inDust + dustAtEnd; PushPending is exported once; the force-field counter builds its events in one list; drawUnderSlowDeath says what it draws under. Test groups are named for the rule they pin, not the revision that introduced it. Client: one smoothstep; one edgeScar per battle-scarred edge; one board-zone rule (which also seats the caption strip on phones); the compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery replaces two hand-rolled matchMedia listeners; sprites carry their sizes as plain numbers and their CSS in one rule each; canvas helpers are formatted like the rest of the tree. Comments that told how a cel or a fallback used to look now say what it draws. The actions-over notice no longer blames a pickup when slime ended the turn. Server and ops: dataRoot() is the one data directory; headlineOf builds its deeds without a cast; the rollup and skills read the same way the code behaves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
85690f0e23
commit
6e49fc9020
+27
-30
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { motion } from "./motion";
|
||||
import { attentionLabel, net, spellName } from "./net.svelte";
|
||||
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
|
||||
import { MediaQuery } from "svelte/reactivity";
|
||||
import { FISTS } from "./fpv/paintedFx";
|
||||
import Board from "./Board.svelte";
|
||||
import LiveFirstPerson from "./LiveFirstPerson.svelte";
|
||||
@@ -16,7 +17,7 @@
|
||||
import { prefs, savePrefs } from "./prefs.svelte";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
@@ -305,7 +306,6 @@
|
||||
let rotateCW = $state(true);
|
||||
/** pick-lock / master-key: prop the door for others once it opens. */
|
||||
let holdDoor = $state(false);
|
||||
/** mega-monster: which stat the chosen monster doubles. */
|
||||
/** MEGA-MONSTER aimed at a monster: which of its two numbers to double. */
|
||||
let megaPrompt = $state<{ instanceId: string; creatureId: string } | null>(null);
|
||||
function castMega(boost: "life" | "movement") {
|
||||
@@ -345,8 +345,9 @@
|
||||
);
|
||||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
|
||||
/** Picking up an object ends the turn's actions: the hand goes quiet until
|
||||
* the draw — but never while a counter, shield, or discard is owed. */
|
||||
/** Picking up an object or sliding into slime ends the turn's actions: the
|
||||
* hand goes quiet until the draw — but never while a counter, shield, or
|
||||
* discard is owed. */
|
||||
const actionsSpent = $derived(
|
||||
view != null && isYourTurn && view.turn.actionsEnded &&
|
||||
!youMustRespond && !youMustShield && !youMustDiscard,
|
||||
@@ -465,16 +466,15 @@
|
||||
* the spell being built (or answered), controls and all — table talk
|
||||
* returns the moment the cast resolves. Phones keep the under-board
|
||||
* strips; a rail below the fold is no place to aim from. */
|
||||
const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)");
|
||||
let wideScreen = $state(wideQuery?.matches ?? true);
|
||||
wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches));
|
||||
const wideQuery = new MediaQuery("(min-width: 901px)", true);
|
||||
const wideScreen = $derived(wideQuery.current);
|
||||
/** A wide, short screen — a laptop in landscape — stands the hand beside
|
||||
* the board unless the player has said otherwise: a tall maze under a
|
||||
* bottom hand leaves a postage stamp, and a first turn deserves better. */
|
||||
const shortWideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 1180px) and (max-height: 820px)");
|
||||
let shortWide = $state(shortWideQuery?.matches ?? false);
|
||||
shortWideQuery?.addEventListener("change", (e) => (shortWide = e.matches));
|
||||
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWide);
|
||||
* bottom hand leaves a postage stamp, and a first turn deserves better.
|
||||
* (The narrower "short window" rule in the stylesheet only shrinks the
|
||||
* cards; this one needs room for a card column, hence the wider floor.) */
|
||||
const shortWideQuery = new MediaQuery("(min-width: 1180px) and (max-height: 820px)", false);
|
||||
const handLeftOn = $derived(prefs.handLeftChosen ? prefs.handLeft : shortWideQuery.current);
|
||||
const castPanelOn = $derived(wideScreen && !local.active &&
|
||||
(selectedCard != null || (youMustRespond && !!view?.stack)));
|
||||
|
||||
@@ -588,14 +588,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Attachment plumbing for a cast command: the chosen mods and the
|
||||
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
|
||||
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
|
||||
const castingDouble = $derived(
|
||||
selectedCreature && view
|
||||
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
|
||||
: null,
|
||||
);
|
||||
/** Attachment plumbing for a cast command: the chosen mods, the attached
|
||||
* NUMBER (an explicit numberInstanceIds wins), and the double it casts through. */
|
||||
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
|
||||
applyMods(cmd);
|
||||
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
|
||||
@@ -669,7 +669,7 @@
|
||||
discardSelection = next;
|
||||
return;
|
||||
}
|
||||
if (actionsSpent) return; // picking up an object ended the turn's actions
|
||||
if (actionsSpent) return;
|
||||
if (!isYourTurn && !yourMoment) {
|
||||
// Live interruption: Interrupt / Opportunity Fire may be played during
|
||||
// another player's turn (when no attack is pending).
|
||||
@@ -945,8 +945,7 @@
|
||||
// A card in hand is aimed, not walked with: a tap on a bare square
|
||||
// while holding one is a miss, never a stride into whatever is there.
|
||||
if (selectedCard) {
|
||||
net.error = `${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`;
|
||||
setTimeout(() => { if (net.error?.startsWith(cardDef(selectedCard!.cardId).name)) net.error = null; }, 5000);
|
||||
net.flash(`${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard, a creature, or a slime — put the card down to walk`);
|
||||
return;
|
||||
}
|
||||
// A cell click is a move if the cell is one legal step away (the server
|
||||
@@ -981,7 +980,7 @@
|
||||
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (view.squareContents[cellKey(pit)]?.kind !== "pit") continue;
|
||||
for (const out of SIDES) {
|
||||
if (out === (side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E")) continue;
|
||||
if (out === opposite(side)) continue;
|
||||
const foot = stepTarget(view.board, pit, out);
|
||||
if (foot.kind !== "blocked" && cellKey(foot.to) === cellKey(cell)) { tryMove(side, undefined, out); return; }
|
||||
}
|
||||
@@ -1533,10 +1532,10 @@
|
||||
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
||||
const sightTrace = $derived(view ? stackSightTrace(view) : null);
|
||||
|
||||
/** The step light: with no card in hand, your square and the squares a
|
||||
* stride reaches are lit and the rest of the maze falls into shadow. */
|
||||
/** A first game's first turn lights itself; after that only by choice. */
|
||||
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
|
||||
/** The step light: with no card in hand, your square and the squares a
|
||||
* stride reaches are lit and the rest of the maze falls into shadow. */
|
||||
const stepLight = $derived(
|
||||
!!view && !!me && (prefs.stepLightAlways || firstTurnEver) && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
|
||||
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
|
||||
@@ -1651,7 +1650,7 @@
|
||||
<span class="mast-sub">{fpvWorkshop ? "the eyes workshop" : tokenWorkshop ? "the token workshop" : "the flourish workshop"}</span>
|
||||
<a class="mast-leave" href="/">← back to the table</a>
|
||||
<a class="mast-leave" href="/clips">the clips</a>
|
||||
<a class="mast-leave mast-help-solo" href="/?tokens">tokens</a>
|
||||
<a class="mast-leave mast-right" href="/?tokens">tokens</a>
|
||||
<a class="mast-leave" href="/?fx">flourishes</a>
|
||||
<a class="mast-leave" href="/?fpv">eyes</a>
|
||||
</header>
|
||||
@@ -1813,7 +1812,7 @@
|
||||
<div class="attack-actions">
|
||||
{#each view.pushPending.exits as d (d)}
|
||||
<button class="stamp primary" onclick={() => dispatch({ type: "pushChoice", direction: d })}>
|
||||
{d === "N" ? "North" : d === "S" ? "South" : d === "E" ? "East" : "West"}
|
||||
{SIDE_NAMES[d]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2803,10 +2802,10 @@
|
||||
{/if}
|
||||
{#if view.turn.actionsEnded}
|
||||
{#if me?.carriedTreasureId}
|
||||
picking up ended your actions for this turn, and the treasure comes with you —
|
||||
your actions for this turn are over, and the treasure comes with you —
|
||||
<strong>End turn</strong> now, then carry it home to score.
|
||||
{:else}
|
||||
picking up ended your actions for this turn — <strong>End turn</strong> when ready.
|
||||
your actions for this turn are over — <strong>End turn</strong> when ready.
|
||||
{/if}
|
||||
{:else}
|
||||
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
|
||||
@@ -3372,7 +3371,7 @@
|
||||
.workshop-mast { margin: 0 1rem 0; }
|
||||
.mast-home { text-decoration: none; }
|
||||
.mast-home:hover { color: #fff; }
|
||||
.mast-help-solo { margin-left: auto; }
|
||||
.mast-help-solo, .mast-right { margin-left: auto; }
|
||||
.hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
|
||||
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
|
||||
|
||||
@@ -3500,7 +3499,6 @@
|
||||
margin: 0.2rem 0 0.1rem;
|
||||
}
|
||||
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
|
||||
.board-zone { position: relative; flex-direction: column; align-items: stretch; }
|
||||
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
|
||||
.step-caption {
|
||||
align-self: flex-start;
|
||||
@@ -3816,7 +3814,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
||||
.board-zone { position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0; }
|
||||
/* Hand at the left: the cards stand in their own column beside the
|
||||
* board while the hint strips and action buttons stay beneath it —
|
||||
* the strip dissolves into the grid so its children place themselves.
|
||||
@@ -3831,7 +3829,7 @@
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
.paper-rail { overflow-y: auto; min-height: 0; padding-right: 2px; }
|
||||
.board-zone { flex-direction: column; align-items: stretch; min-height: 0; }
|
||||
.board-zone { min-height: 0; }
|
||||
.table-stack { flex: 1 1 0; min-height: 0; }
|
||||
.board-frame { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.board-viewport { height: auto; flex: 1 1 0; min-height: 0; }
|
||||
@@ -3979,7 +3977,6 @@
|
||||
padding-left: 0.3rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.score-row.active .score-name { font-weight: 700; }
|
||||
.score-temper {
|
||||
font-family: "Caveat", cursive;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -461,15 +461,11 @@
|
||||
{@const wx = Number(key.split(":")[1]?.split(",")[0])}
|
||||
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
|
||||
{@const frac = Math.min(1, dmg / 20)}
|
||||
{#if kind === "V"}
|
||||
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{:else}
|
||||
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{/if}
|
||||
{@const scar = kind === "V"
|
||||
? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
|
||||
: edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
|
||||
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{/each}
|
||||
|
||||
<!-- illusions YOU know are fake: ghostly dashed lines -->
|
||||
@@ -910,7 +906,6 @@
|
||||
stroke: rgba(240, 231, 209, 0.9);
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
><title>{tip}</title></rect>
|
||||
{/if}
|
||||
|
||||
<!-- Pigment and hardware sit over the original wall/door hit target. -->
|
||||
<!-- stone highlights or wood grain and lock hardware; the rect beneath takes the taps -->
|
||||
<g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true">
|
||||
{#if state === "wall"}
|
||||
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
fill="#bd3b1b" class="fw-bar"
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
|
||||
{/if}
|
||||
<!-- Drawn flame poses share the original bases and hit area. -->
|
||||
<!-- tongues of flame, each on its own beat; the bar beneath is the hit target -->
|
||||
{#each TONGUES as f (f.t)}
|
||||
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
|
||||
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
|
||||
|
||||
@@ -223,8 +223,8 @@
|
||||
is this table's reading. A slime shows how many it holds, and a
|
||||
peek names them, since every cast into it was seen.</p>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and obey every rule; a tier changes what
|
||||
they draw and know, never how correctly they play.</p>
|
||||
view a human seat gets and play by the rules the engine enforces on
|
||||
everyone; a tier changes what they draw and know.</p>
|
||||
<p>A turn need not be taken at once. Games wait on the lobby ledger
|
||||
for days; the browser holds your seat; a replay of any game can be
|
||||
watched from above or through a wizard's eyes and shared by link.</p>
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
stroke: #9d8cc1;
|
||||
stroke-width: 0.4;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
animation: shimmer-drift 1.4s ease-in-out infinite;
|
||||
}
|
||||
.illusion-art { pointer-events: none; }
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
if (motion.reduced) { cam.facing = to; turning = false; return; }
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const w = motion.reduced ? 1 : Math.min(1, (now - t0) / 180);
|
||||
const w = Math.min(1, (now - t0) / 180);
|
||||
cam.facing = from + (to - from) * (smoothstep(w));
|
||||
if (w < 1) turnRaf = requestAnimationFrame(tick);
|
||||
else turning = false;
|
||||
|
||||
@@ -9,23 +9,23 @@ export const edgeWisps = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
|
||||
return [0.06, 0.38, 0.71].map((t, i) => {
|
||||
const sign = i === 1 ? -1 : 1;
|
||||
return `M ${p(t)} C ${p(t+.07,-3*sign)} ${p(t+.2,-2.5*sign)} ${p(t+.23,.5*sign)} Q ${p(t+.18,-.6*sign)} ${p(t+.15,1.2*sign)} Q ${p(t+.12,-1.6*sign)} ${p(t)}`;
|
||||
}).join(' ');
|
||||
return `M ${p(t)} C ${p(t+0.07,-3*sign)} ${p(t+0.2,-2.5*sign)} ${p(t+0.23,0.5*sign)} Q ${p(t+0.18,-0.6*sign)} ${p(t+0.15,1.2*sign)} Q ${p(t+0.12,-1.6*sign)} ${p(t)}`;
|
||||
}).join(" ");
|
||||
};
|
||||
|
||||
/** An irregular fracture with branches that become longer as damage increases. */
|
||||
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => {
|
||||
const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4;
|
||||
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
|
||||
return `M ${p(.03)} L ${p(.18,-width)} ${p(.34,.6*width)} ${p(.51,-.5*width)} ${p(.7,width)} ${p(.86,-.6*width)} ${p(.98)} M ${p(.34,.6*width)} L ${p(.4,1.25*width)} ${p(.47,1.4*width)} M ${p(.7,width)} L ${p(.64,-1.1*width)} ${p(.57,-1.35*width)}`;
|
||||
return `M ${p(0.03)} L ${p(0.18,-width)} ${p(0.34,0.6*width)} ${p(0.51,-0.5*width)} ${p(0.7,width)} ${p(0.86,-0.6*width)} ${p(0.98)} M ${p(0.34,0.6*width)} L ${p(0.4,1.25*width)} ${p(0.47,1.4*width)} M ${p(0.7,width)} L ${p(0.64,-1.1*width)} ${p(0.57,-1.35*width)}`;
|
||||
};
|
||||
|
||||
/** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
|
||||
export const webThreads = (r: number) => {
|
||||
const p = (x: number, y: number) => `${x*r} ${y*r}`;
|
||||
return `M ${p(-1,-.6)} L ${p(1,.65)} M ${p(-.75,1)} L ${p(.65,-1)} M ${p(-1,.6)} L ${p(1,-.4)} M ${p(-.15,-1)} L ${p(.18,1)} M ${p(-.75,-.45)} Q ${p(-.15,-.2)} ${p(.4,-.62)} Q ${p(.3,-.15)} ${p(.72,-.28)} M ${p(-.65,.39)} Q ${p(-.25,.1)} ${p(-.47,.63)} Q ${p(0,.4)} ${p(.12,.7)} Q ${p(.35,.35)} ${p(.72,.46)}`;
|
||||
return `M ${p(-1,-0.6)} L ${p(1,0.65)} M ${p(-0.75,1)} L ${p(0.65,-1)} M ${p(-1,0.6)} L ${p(1,-0.4)} M ${p(-0.15,-1)} L ${p(0.18,1)} M ${p(-0.75,-0.45)} Q ${p(-0.15,-0.2)} ${p(0.4,-0.62)} Q ${p(0.3,-0.15)} ${p(0.72,-0.28)} M ${p(-0.65,0.39)} Q ${p(-0.25,0.1)} ${p(-0.47,0.63)} Q ${p(0,0.4)} ${p(0.12,0.7)} Q ${p(0.35,0.35)} ${p(0.72,0.46)}`;
|
||||
};
|
||||
|
||||
/** Open painted curls around a warp's precise center. */
|
||||
export const warpCurls = (x: number, y: number, r: number) =>
|
||||
`M ${x-r} ${y+r*.15} C ${x-r*1.1} ${y-r*.75} ${x+r*.4} ${y-r*1.2} ${x+r*.72} ${y-r*.4} Q ${x+r*.05} ${y-r*.85} ${x-r*.58} ${y-r*.25} M ${x+r} ${y-r*.1} C ${x+r*.95} ${y+r*.8} ${x-r*.3} ${y+r*1.18} ${x-r*.7} ${y+r*.45} Q ${x-r*.1} ${y+r*.85} ${x+r*.6} ${y+r*.25}`;
|
||||
`M ${x-r} ${y+r*0.15} C ${x-r*1.1} ${y-r*0.75} ${x+r*0.4} ${y-r*1.2} ${x+r*0.72} ${y-r*0.4} Q ${x+r*0.05} ${y-r*0.85} ${x-r*0.58} ${y-r*0.25} M ${x+r} ${y-r*0.1} C ${x+r*0.95} ${y+r*0.8} ${x-r*0.3} ${y+r*1.18} ${x-r*0.7} ${y+r*0.45} Q ${x-r*0.1} ${y+r*0.85} ${x+r*0.6} ${y+r*0.25}`;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// by the same depth buffer the walls wrote.
|
||||
import { motion } from "../motion";
|
||||
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
|
||||
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
|
||||
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing as floorRing } from "./paintedFx";
|
||||
import { materialTextures } from "./textures";
|
||||
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
|
||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||
@@ -198,7 +198,8 @@
|
||||
}
|
||||
|
||||
function draw(time: number) {
|
||||
// Keep the final game state and static hazard/target cues visible.
|
||||
// With effects off the frame still draws — walls, bodies, hazards —
|
||||
// but nothing moves: no fx, and the ambient pulses freeze at t=0.
|
||||
const activeFx = motion.effects ? fx : [];
|
||||
const ambientTime = motion.effects ? time : 0;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
@@ -724,7 +725,7 @@
|
||||
ctx.rotate(pose.rotation);
|
||||
paintFist(ctx, pose.size, f.swing);
|
||||
ctx.restore();
|
||||
if (pose.radius > H * 0.19 * 0.01) {
|
||||
if (pose.radius > 0.5) {
|
||||
ctx.save();
|
||||
ctx.translate(pose.impactX, pose.impactY);
|
||||
ctx.rotate(-0.14);
|
||||
@@ -776,12 +777,6 @@
|
||||
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half);
|
||||
}
|
||||
|
||||
/** An ellipse of light on the floor at a body's feet: `rgb` as "r,g,b",
|
||||
* the stroke at `alpha`, the fill fainter. */
|
||||
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
|
||||
rgb: string, alpha: number, fill: number) {
|
||||
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
|
||||
}
|
||||
|
||||
/** Paint the hover cue for whatever stands under the crosshair. */
|
||||
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import FirstPerson from "./FirstPerson.svelte";
|
||||
import Replay from "../Replay.svelte";
|
||||
import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast";
|
||||
import { buildScreenplaySteps, screenplayByName, SCREENPLAYS } from "./screenplays";
|
||||
import { buildScreenplaySteps, screenplayByName, SCREENPLAYS, type ScreenplayStep } from "./screenplays";
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const seed = Number(q.get("seed") ?? 42);
|
||||
@@ -206,7 +206,7 @@
|
||||
// a refused move reports itself instead of a blank stage.
|
||||
const scriptName = q.get("script");
|
||||
const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
|
||||
const { scriptSteps, scriptError } = (() => {
|
||||
function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
|
||||
if (scriptName && !scriptPlay) {
|
||||
return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
|
||||
}
|
||||
@@ -215,7 +215,8 @@
|
||||
} catch (e) {
|
||||
return { scriptSteps: [], scriptError: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
})();
|
||||
}
|
||||
const { scriptSteps, scriptError } = loadScript();
|
||||
|
||||
// Minimap geometry (top-down, one small square per cell).
|
||||
const MM = 9;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// camera shakes for the raycaster to draw. What happens ACROSS the room
|
||||
// is watched; what happens to YOU is felt.
|
||||
|
||||
import { fallbackCels, paintCels } from "./paintedFx";
|
||||
import { fallbackCels, paintCels, smoothstep } from "./paintedFx";
|
||||
export { smoothstep };
|
||||
import { fxForEvents } from "../fx";
|
||||
import { CELL } from "../fx-sprites/geom";
|
||||
import { castRay, edgeMid, warpMotion } from "./raycast";
|
||||
@@ -55,9 +56,6 @@ export function slateBand(p: number): [number, number] {
|
||||
return [smoothstep((p - 0.68) / 0.32), 1];
|
||||
}
|
||||
|
||||
/** The one easing every tween here shares. */
|
||||
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
|
||||
|
||||
/** Eased growth 0..1 for a conjuration's rise. */
|
||||
export function growProgress(p: number): number {
|
||||
if (p <= 0) return 0.01;
|
||||
@@ -306,7 +304,7 @@ export function fpFxForEvents(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Inked stand-ins in each original palette, replaced when the PNG loads. */
|
||||
/** Core and rim colors for each conjuration's inked stand-in. */
|
||||
const FX_COLORS: Record<string, [string, string]> = {
|
||||
fireball: ["#ffe27a", "#e0431a"],
|
||||
bolt: ["#ffffff", "#7ab0ff"],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */
|
||||
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
|
||||
export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
|
||||
const INK = "#2b2218";
|
||||
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
|
||||
|
||||
/** Poses modeled on the original right-facing and oncoming fist emoji:
|
||||
* folded fingers face the viewer; the thumb stays outside their curl. */
|
||||
/** Two fist poses: a punch thrown (wrist left, knuckles right) and one
|
||||
* arriving (knuckles to the viewer). The thumb stays outside the curl. */
|
||||
export const FISTS: Record<"out" | "in", Cel[]> = {
|
||||
out: [
|
||||
// Wrist at left, back of hand above, bent index at the right edge.
|
||||
@@ -33,7 +34,7 @@ export const FISTS: Record<"out" | "in", Cel[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Deliberately uneven impact silhouette and hand-cut lettering; no font dependency. */
|
||||
/** The POW starburst and its lettering, as paths: the canvas has no font to wait on. */
|
||||
export const POW: Cel[] = [
|
||||
{ d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 },
|
||||
{ d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 },
|
||||
@@ -57,58 +58,97 @@ export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
|
||||
ctx.restore();
|
||||
}
|
||||
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
|
||||
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore();
|
||||
}
|
||||
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
|
||||
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore();
|
||||
ctx.save();
|
||||
ctx.scale(size / 100, size / 100);
|
||||
paintCels(ctx, FISTS[swing]);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Same endpoints and landing beats as the original animation. */
|
||||
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
|
||||
ctx.save();
|
||||
ctx.scale(radius / 100, radius / 100);
|
||||
paintCels(ctx, POW);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Where the fist is at progress `p`: thrown, it lunges to 0.42 and
|
||||
* retracts; arriving, it closes to 0.45, holds, and fades. The POW pops
|
||||
* at the landing beat and shrinks away. */
|
||||
export function punchPose(p: number, out: boolean, W: number, H: number) {
|
||||
const ease = (v: number) => v * v * (3 - 2 * v);
|
||||
// Clamp before easing: otherwise the incoming fist reverses past p=.45.
|
||||
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45));
|
||||
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55;
|
||||
const tx = W * .56, ty = H * .5;
|
||||
const land = out ? .36 : .4, q = (p - land) / (1 - land);
|
||||
const pop = p < land ? 0 : q < .18 ? ease(q / .18) * 1.15 : q < .7 ? 1.15 - .15 * ease((q - .18) / .52) : 1 - ease((q - .7) / .3);
|
||||
const ease = smoothstep;
|
||||
// Clamp before easing: past 0.45 the arriving fist would reverse.
|
||||
const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : ease(Math.min(1, 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 land = out ? 0.36 : 0.4, q = (p - land) / (1 - land);
|
||||
const pop = p < land ? 0 : 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);
|
||||
return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
|
||||
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a),
|
||||
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1,
|
||||
rotation: out ? -.6 : .15, radius: H * .19 * pop,
|
||||
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 };
|
||||
size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
|
||||
alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
|
||||
rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
|
||||
impactX: out ? tx - W * 0.02 : W * 0.5, impactY: H * 0.34 };
|
||||
}
|
||||
|
||||
export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
|
||||
const hub = [left + w * .5, top + h * .45];
|
||||
const rim = [[.02,.06],[.5,0],[.98,.08],[1,.5],[.96,.94],[.5,1],[.04,.92],[0,.5]].map(([u,v]) => [left + u! * w, top + v! * h]);
|
||||
const at = (i: number, t: number) => { const r = rim[i % 8]!; return [hub[0]! + (r[0]! - hub[0]!) * t, hub[1]! + (r[1]! - hub[1]!) * t]; };
|
||||
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round";
|
||||
type Pt = [number, number];
|
||||
const hub: Pt = [left + w * 0.5, top + h * 0.45];
|
||||
const rim: Pt[] = ([[0.02, 0.06], [0.5, 0], [0.98, 0.08], [1, 0.5], [0.96, 0.94], [0.5, 1], [0.04, 0.92], [0, 0.5]] as Pt[])
|
||||
.map(([u, v]) => [left + u * w, top + v * h]);
|
||||
const at = (i: number, t: number): Pt => {
|
||||
const r = rim[i % 8] as Pt;
|
||||
return [hub[0] + (r[0] - hub[0]) * t, hub[1] + (r[1] - hub[1]) * t];
|
||||
};
|
||||
ctx.save();
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); }
|
||||
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) {
|
||||
const a = at(i,t), b = at(i+1,t);
|
||||
ctx.moveTo(a[0]!,a[1]!);
|
||||
ctx.quadraticCurveTo((a[0]!+b[0]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
|
||||
for (const r of rim) {
|
||||
ctx.moveTo(hub[0], hub[1]);
|
||||
ctx.lineTo(r[0], r[1]);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore();
|
||||
// Three sagging rings, each strand bowed toward the hub.
|
||||
for (const t of [0.27, 0.53, 0.8]) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = at(i, t), b = at(i + 1, t);
|
||||
ctx.moveTo(a[0], a[1]);
|
||||
ctx.quadraticCurveTo((a[0] + b[0]) * 0.36 + hub[0] * 0.28, (a[1] + b[1]) * 0.36 + hub[1] * 0.28, b[0], b[1]);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = "rgba(25,25,30,0.65)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(245,245,240,0.9)";
|
||||
ctx.lineWidth = 1.3;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function paintFloorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number) {
|
||||
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill();
|
||||
export function paintFloorRing(
|
||||
ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(${rgb},${fill})`;
|
||||
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]];
|
||||
for (const [start,end] of segments) for (let i=0; i<=20; i++) {
|
||||
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5));
|
||||
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k;
|
||||
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
|
||||
ctx.beginPath();
|
||||
const segments: [number, number][] = [[0.06, 1.75], [1.87, 3.48], [3.62, 5.04], [5.18, 6.22]];
|
||||
for (const [start, end] of segments) {
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
const t = start + (end - start) * i / 20;
|
||||
const k = 1 - 0.018 * (1 + Math.sin(t * 5));
|
||||
const x = cx + Math.cos(t) * rx * k, y = cy + Math.sin(t) * ry * k;
|
||||
if (!i) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Small, legible loading/error sprites using the original fallback palettes. */
|
||||
/** The inked silhouette drawn in a conjuration's palette until its PNG loads. */
|
||||
export function fallbackCels(name: string, core: string, rim: string): Cel[] {
|
||||
const outer: Record<string, string> = {
|
||||
fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z",
|
||||
@@ -135,5 +175,5 @@ export function fallbackCels(name: string, core: string, rim: string): Cel[] {
|
||||
rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z",
|
||||
};
|
||||
const kind = outer[name] ? name : "spark";
|
||||
return [{d:outer[kind]!,fill:rim,width:3},{d:inside[kind]!,fill:core,width:1.5}];
|
||||
return [{ d: outer[kind]!, fill: rim, width: 3 }, { d: inside[kind]!, fill: core, width: 1.5 }];
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
</script>
|
||||
|
||||
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
||||
<path d={puff(c.x - 6, c.y, (7) * 1)} fill="#b7a786" class="puff" />
|
||||
<path d={puff(c.x + 5, c.y - 2, (5) * 1)} fill="#b7a786" class="puff back late" />
|
||||
<path d={puff(c.x, c.y + 3, (6) * 1)} fill="#b7a786" class="puff later" />
|
||||
<path d={puff(c.x - 6, c.y, 7)} fill="#b7a786" class="puff" />
|
||||
<path d={puff(c.x + 5, c.y - 2, 5)} fill="#b7a786" class="puff back late" />
|
||||
<path d={puff(c.x, c.y + 3, 6)} fill="#b7a786" class="puff later" />
|
||||
<g class="grit">
|
||||
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} />
|
||||
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} />
|
||||
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} />
|
||||
<path d={puff(c.x - 4, c.y - 5, 1.3)} />
|
||||
<path d={puff(c.x + 6, c.y + 2, 1.6)} />
|
||||
<path d={puff(c.x + 1, c.y - 2, 0.9)} />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||
/>
|
||||
{/each}
|
||||
<path d={glint(c.x, c.y, (3.5) * 1.6)} class="heart" />
|
||||
<path d={glint(c.x, c.y, 5.6)} class="heart" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -47,7 +47,7 @@
|
||||
.spark { animation: spark-out 1.05s ease-out forwards; }
|
||||
.trail.late, .spark.late { animation-delay: 0.07s; }
|
||||
.trail.later, .spark.later { animation-delay: 0.14s; }
|
||||
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
|
||||
.heart { fill: #fffdf0; transform-box: fill-box; transform-origin: center; animation: heart-pop 0.4s ease-out forwards; }
|
||||
@keyframes trail-out {
|
||||
0% { opacity: 0; transform: scale(0.15); }
|
||||
25% { opacity: 1; }
|
||||
@@ -63,5 +63,4 @@
|
||||
0% { opacity: 1; transform: scale(0.5); }
|
||||
100% { opacity: 0; transform: scale(3); }
|
||||
}
|
||||
.heart { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
<path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" />
|
||||
<g class="blobs">
|
||||
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" />
|
||||
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" />
|
||||
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" />
|
||||
<path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
|
||||
<path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
|
||||
<path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -19,7 +19,7 @@
|
||||
transform-origin: center;
|
||||
animation: splat-wobble 0.7s ease-out forwards;
|
||||
}
|
||||
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
|
||||
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out both; }
|
||||
.blob.late { animation-delay: 0.06s; }
|
||||
.blob.later { animation-delay: 0.12s; }
|
||||
@keyframes splat-wobble {
|
||||
@@ -33,5 +33,4 @@
|
||||
50% { transform: translateY(-7px); }
|
||||
100% { opacity: 0; transform: translateY(2px); }
|
||||
}
|
||||
.blob { animation-fill-mode: both; }
|
||||
</style>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
|
||||
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
|
||||
<g class="rim-dust">
|
||||
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" />
|
||||
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" />
|
||||
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" />
|
||||
<path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
|
||||
<path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
|
||||
<path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -32,8 +32,9 @@
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
animation: hole-open 0.75s ease-out forwards;
|
||||
transform-box: fill-box; transform-origin: center;
|
||||
}
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); opacity: 0; transform-box: fill-box; transform-origin: center; animation: dust-drift 0.7s ease-out 0.15s forwards; }
|
||||
.dust.late { animation-delay: 0.24s; }
|
||||
.dust.later { animation-delay: 0.32s; }
|
||||
@keyframes hole-open {
|
||||
@@ -51,6 +52,4 @@
|
||||
30% { opacity: 0.8; }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
|
||||
}
|
||||
.lip { transform-box: fill-box; transform-origin: center; }
|
||||
.dust { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{/if}
|
||||
<g class="corner-dust">
|
||||
{#each CORNERS as p, i (i)}
|
||||
<path d={puff(p.x, p.y, (3.5) * 1)} class={`dust d${i}`} />
|
||||
<path d={puff(p.x, p.y, 3.5)} class={`dust d${i}`} />
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
fill: rgba(160, 150, 130, 0.85);
|
||||
opacity: 0;
|
||||
animation: dust-kick 0.6s ease-out 0.85s forwards;
|
||||
transform-box: fill-box; transform-origin: center;
|
||||
}
|
||||
.dust.d1 { animation-delay: 0.9s; }
|
||||
.dust.d2 { animation-delay: 0.95s; }
|
||||
@@ -128,5 +129,4 @@
|
||||
30% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
|
||||
}
|
||||
.dust { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
|
||||
</g>
|
||||
<path d={glint(c.x, c.y, (3) * 1.7)} class="core" />
|
||||
<path d={glint(c.x, c.y, 5.1)} class="core" />
|
||||
<g class="motes">
|
||||
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} />
|
||||
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" />
|
||||
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" />
|
||||
<path d={glint(c.x - 10, c.y - 9, 2.21)} />
|
||||
<path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
|
||||
<path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
stroke-width: 3.2;
|
||||
animation: ring-out 0.55s ease-out forwards;
|
||||
}
|
||||
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in forwards; }
|
||||
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in both; }
|
||||
.drop.late { animation-delay: 0.07s; }
|
||||
.drop.later { animation-delay: 0.13s; }
|
||||
@keyframes pool-spread {
|
||||
@@ -48,5 +48,4 @@
|
||||
45% { transform: translateY(-8px); }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
.drop { animation-fill-mode: both; }
|
||||
</style>
|
||||
|
||||
@@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({
|
||||
y: c.y * CELL + CELL / 2,
|
||||
});
|
||||
|
||||
|
||||
/** Small cel silhouettes, expressed in board coordinates so their animation
|
||||
* origins stay attached to the existing cell and edge anchors. */
|
||||
export const glint = (x: number, y: number, r: number) =>
|
||||
`M ${x} ${y-r} Q ${x+r*.13} ${y-r*.13} ${x+r*.84} ${y+.04*r} Q ${x+r*.15} ${y+r*.15} ${x+.06*r} ${y+r} Q ${x-r*.15} ${y+r*.13} ${x-r*.88} ${y} Q ${x-r*.14} ${y-r*.15} ${x} ${y-r} Z`;
|
||||
`M ${x} ${y-r} Q ${x+r*0.13} ${y-r*0.13} ${x+r*0.84} ${y+0.04*r} Q ${x+r*0.15} ${y+r*0.15} ${x+0.06*r} ${y+r} Q ${x-r*0.15} ${y+r*0.13} ${x-r*0.88} ${y} Q ${x-r*0.14} ${y-r*0.15} ${x} ${y-r} Z`;
|
||||
export const puff = (x: number, y: number, r: number) =>
|
||||
`M ${x-r} ${y+r*.3} C ${x-r*1.2} ${y-r*.15} ${x-r*.85} ${y-r*.6} ${x-r*.5} ${y-r*.5} C ${x-r*.65} ${y-r*1.12} ${x+r*.15} ${y-r*1.1} ${x+r*.32} ${y-r*.55} C ${x+r*.95} ${y-r*.75} ${x+r*1.22} ${y+r*.1} ${x+r*.75} ${y+r*.4} C ${x+r*.58} ${y+r*.9} ${x-r*.72} ${y+r*.8} ${x-r} ${y+r*.3} Z`;
|
||||
`M ${x-r} ${y+r*0.3} C ${x-r*1.2} ${y-r*0.15} ${x-r*0.85} ${y-r*0.6} ${x-r*0.5} ${y-r*0.5} C ${x-r*0.65} ${y-r*1.12} ${x+r*0.15} ${y-r*1.1} ${x+r*0.32} ${y-r*0.55} C ${x+r*0.95} ${y-r*0.75} ${x+r*1.22} ${y+r*0.1} ${x+r*0.75} ${y+r*0.4} C ${x+r*0.58} ${y+r*0.9} ${x-r*0.72} ${y+r*0.8} ${x-r} ${y+r*0.3} Z`;
|
||||
export const drop = (x: number, y: number, r: number) =>
|
||||
`M ${x+r*.2} ${y-r*1.7} C ${x+r*.45} ${y-r*.65} ${x+r*1.12} ${y-r*.1} ${x+r*.8} ${y+r*.6} C ${x+r*.45} ${y+r*1.25} ${x-r*.85} ${y+r*.9} ${x-r*.8} ${y+r*.1} C ${x-r*.75} ${y-r*.6} ${x-r*.1} ${y-r} ${x+r*.2} ${y-r*1.7} Z`;
|
||||
`M ${x+r*0.2} ${y-r*1.7} C ${x+r*0.45} ${y-r*0.65} ${x+r*1.12} ${y-r*0.1} ${x+r*0.8} ${y+r*0.6} C ${x+r*0.45} ${y+r*1.25} ${x-r*0.85} ${y+r*0.9} ${x-r*0.8} ${y+r*0.1} C ${x-r*0.75} ${y-r*0.6} ${x-r*0.1} ${y-r} ${x+r*0.2} ${y-r*1.7} Z`;
|
||||
export const impact = (x: number, y: number, r: number) =>
|
||||
`M ${x-r*.06} ${y-r} L ${x+r*.22} ${y-r*.34} ${x+r*.91} ${y-r*.58} ${x+r*.46} ${y-r*.05} ${x+r} ${y+r*.39} ${x+r*.31} ${y+r*.28} ${x+r*.2} ${y+r*.92} ${x-r*.12} ${y+r*.44} ${x-r*.74} ${y+r*.72} ${x-r*.4} ${y+r*.14} ${x-r} ${y-r*.18} ${x-r*.34} ${y-r*.27} Z`;
|
||||
`M ${x-r*0.06} ${y-r} L ${x+r*0.22} ${y-r*0.34} ${x+r*0.91} ${y-r*0.58} ${x+r*0.46} ${y-r*0.05} ${x+r} ${y+r*0.39} ${x+r*0.31} ${y+r*0.28} ${x+r*0.2} ${y+r*0.92} ${x-r*0.12} ${y+r*0.44} ${x-r*0.74} ${y+r*0.72} ${x-r*0.4} ${y+r*0.14} ${x-r} ${y-r*0.18} ${x-r*0.34} ${y-r*0.27} Z`;
|
||||
export const ward = (x: number, y: number, r: number) =>
|
||||
`M ${x} ${y-r} Q ${x+r*.45} ${y-r*.63} ${x+r*.9} ${y-r*.68} L ${x+r*.82} ${y+r*.18} Q ${x+r*.7} ${y+r*.66} ${x} ${y+r} Q ${x-r*.75} ${y+r*.59} ${x-r*.81} ${y+r*.15} L ${x-r*.9} ${y-r*.68} Q ${x-r*.42} ${y-r*.65} ${x} ${y-r} Z`;
|
||||
`M ${x} ${y-r} Q ${x+r*0.45} ${y-r*0.63} ${x+r*0.9} ${y-r*0.68} L ${x+r*0.82} ${y+r*0.18} Q ${x+r*0.7} ${y+r*0.66} ${x} ${y+r} Q ${x-r*0.75} ${y+r*0.59} ${x-r*0.81} ${y+r*0.15} L ${x-r*0.9} ${y-r*0.68} Q ${x-r*0.42} ${y-r*0.65} ${x} ${y-r} Z`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Websocket client + reactive session state (Svelte 5 runes).
|
||||
|
||||
import type { Command, GameEvent, GameView } from "@wizwar/engine";
|
||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
|
||||
const SERVER_URL =
|
||||
@@ -23,6 +23,9 @@ export function spellName(cardId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The compass, for captions and buttons. */
|
||||
export const SIDE_NAMES: Record<Side, string> = { N: "North", E: "East", S: "South", W: "West" };
|
||||
|
||||
export function humanize(e: GameEvent): string | null {
|
||||
switch (e.type) {
|
||||
case "gameStarted": {
|
||||
@@ -66,7 +69,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
|
||||
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
|
||||
case "pushChosen": return `${e.pushee} breaks ${e.direction === "N" ? "north" : e.direction === "S" ? "south" : e.direction === "E" ? "east" : "west"}.`;
|
||||
case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
|
||||
case "treasureTorn": return e.toFloor
|
||||
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
|
||||
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
|
||||
@@ -246,9 +249,11 @@ const ACTING = new Set([
|
||||
"climbedFromPit", "spellTrapped",
|
||||
]);
|
||||
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
|
||||
const any = e as unknown as Record<string, unknown>;
|
||||
for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string;
|
||||
if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string;
|
||||
if ("caster" in e && typeof e.caster === "string") return e.caster;
|
||||
if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
|
||||
if ("by" in e && typeof e.by === "string") return e.by;
|
||||
if ("owner" in e && typeof e.owner === "string") return e.owner;
|
||||
if (ACTING.has(e.type) && "player" in e && typeof e.player === "string") return e.player;
|
||||
return turnOwner ?? undefined;
|
||||
}
|
||||
|
||||
@@ -620,12 +625,18 @@ class Net {
|
||||
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
/** A toast the table shows for a moment, as it shows the server's refusals. */
|
||||
flash(message: string): void {
|
||||
this.error = message;
|
||||
setTimeout(() => { if (this.error === message) this.error = null; }, 5000);
|
||||
}
|
||||
|
||||
create(name: string): void {
|
||||
this.you = name;
|
||||
this.spectating = false;
|
||||
|
||||
@@ -59,8 +59,6 @@ function load(): Prefs {
|
||||
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
|
||||
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
||||
cautions: p.cautions !== false,
|
||||
// A first game is against an apprentice; a browser that has finished
|
||||
// one is promoted to adept unless the tier was picked by hand.
|
||||
botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice",
|
||||
instantReplay: p.instantReplay !== false,
|
||||
liveFp: p.liveFp === true,
|
||||
|
||||
Reference in New Issue
Block a user