Thumb of God gets its ceremony: prompt, sighted dimming, and the die

The cast always worked (click a square), but nothing guided it and
nothing marked its arrival. Now: a targeting prompt, eligibility
dimming to the caster's sighted squares (the engine demands LOS to the
aim point), and a flourish — the aiming mark, a shadow gathering, the
die crashing down out of a clear sky with a bounce and flung grit, a
drift line when fate moves it, and every scattered token streaking to
where it lands. In the /?fx gallery as die-drop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-17 23:09:40 -04:00
co-authored by Claude Fable 5
parent 5351e345c3
commit e412463aae
6 changed files with 140 additions and 1 deletions
+4
View File
@@ -352,6 +352,10 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
return out;
}
if (cardId === "thumb-of-god") {
// The die is aimed by sight; where it lands after drifting is fate's.
return sighted;
}
if (cardId === "stone-to-water") {
// The cast demands sight of the stone block, so only sighted ones light.
// The card equally targets stone WALLS — edges the cell-shadow cannot
+1
View File
@@ -271,6 +271,7 @@
"create-door": "click a wall for the new door",
"warp-wand": "click a wall to warp it open",
"stone-to-water": "click a stone wall, or a stone-filled square",
"thumb-of-god": "aim the die at a square in sight — it may drift on impact",
"dispel-creation": "click the creation — a square, a creature, or a conjured wall",
};
const EDGE_CARDS = new Set([
+1
View File
@@ -34,6 +34,7 @@
{ kind: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid },
{ kind: "die-drop", at: { x: 1, y: 1 }, aim: { x: 0, y: 2 } },
{ kind: "pit-fall", at: mid },
{ kind: "ooze-slip", at: mid },
{ kind: "tacks-ow", at: mid },
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"die-drop"> } = $props();
const c = $derived(center(fx.at));
const aim = $derived(center(fx.aim));
const drifted = $derived(fx.at.x !== fx.aim.x || fx.at.y !== fx.aim.y);
const DEBRIS = [15, 80, 150, 210, 275, 340];
</script>
<!-- The aiming mark: where the caster prayed it would land -->
<circle cx={aim.x} cy={aim.y} r="15" class="aim" />
{#if drifted}
<line x1={aim.x} y1={aim.y} x2={c.x} y2={c.y} class="drift" />
{/if}
<!-- The gathering shadow, then the die itself, out of a clear sky -->
<ellipse cx={c.x} cy={c.y + 8} rx="16" ry="7" class="die-shadow" />
<g class="die" style={`transform-origin: ${c.x}px ${c.y}px`}>
<g transform={`translate(${c.x} ${c.y})`}>
<rect x="-15" y="-15" width="30" height="30" rx="6" class="die-body" />
<!-- a four: the drift roll made manifest -->
<circle cx="-7" cy="-7" r="3.2" class="pip" />
<circle cx="7" cy="-7" r="3.2" class="pip" />
<circle cx="-7" cy="7" r="3.2" class="pip" />
<circle cx="7" cy="7" r="3.2" class="pip" />
</g>
</g>
<!-- Impact: ring and flung grit -->
<circle cx={c.x} cy={c.y} r="14" class="impact" />
{#each DEBRIS as deg, i (deg)}
<line
x1={c.x + 14 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 14 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 26 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 26 * Math.sin((deg * Math.PI) / 180)}
class={`grit g${i % 3}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.aim {
fill: none;
stroke: #8d2f23;
stroke-width: 2;
stroke-dasharray: 5 4;
opacity: 0;
animation: aim-mark 0.5s ease-out forwards;
}
.drift {
stroke: #8d2f23;
stroke-width: 1.5;
stroke-dasharray: 3 4;
opacity: 0;
animation: drift-line 0.5s ease-out 0.55s forwards;
}
.die-shadow {
fill: rgba(43, 34, 24, 0.5);
animation: shadow-loom 0.55s ease-in forwards;
transform-box: fill-box;
transform-origin: center;
}
.die {
animation: die-fall 0.55s cubic-bezier(0.5, 0, 0.9, 0.6) forwards,
die-settle 0.9s ease-out 0.55s forwards;
}
.die-body {
fill: #efe8d4;
stroke: #2b2218;
stroke-width: 2.5;
}
.pip { fill: #2b2218; }
.impact {
fill: none;
stroke: #7c6a4f;
stroke-width: 3;
opacity: 0;
transform-box: fill-box;
transform-origin: center;
animation: impact-ring 0.5s ease-out 0.55s forwards;
}
.grit {
stroke: #7c6a4f;
stroke-width: 2;
stroke-linecap: round;
opacity: 0;
animation: grit-fly 0.4s ease-out 0.58s forwards;
}
.grit.g1 { animation-delay: 0.62s; }
.grit.g2 { animation-delay: 0.66s; }
@keyframes aim-mark {
0% { opacity: 0; transform: scale(1.6); transform-origin: center; transform-box: fill-box; }
100% { opacity: 0.8; transform: scale(1); }
}
@keyframes drift-line {
0% { opacity: 0; }
100% { opacity: 0.7; }
}
@keyframes shadow-loom {
0% { opacity: 0; transform: scale(0.2); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes die-fall {
0% { opacity: 0; transform: translateY(-150px) scale(2.4) rotate(35deg); }
30% { opacity: 1; }
100% { transform: translateY(0) scale(1) rotate(0deg); }
}
@keyframes die-settle {
0% { transform: translateY(0) scale(1.15, 0.85); }
25% { transform: translateY(-6px) scale(1); }
45% { transform: translateY(0) scale(1.05, 0.95); }
100% { transform: translateY(0) scale(1); }
}
@keyframes impact-ring {
0% { opacity: 0.9; transform: scale(0.4); }
100% { opacity: 0; transform: scale(2.4); }
}
@keyframes grit-fly {
0% { opacity: 0.9; transform: scale(0.7); }
100% { opacity: 0; transform: scale(1.8); }
}
</style>
+2
View File
@@ -8,6 +8,7 @@ import Bolt from "./Bolt.svelte";
import Burst from "./Burst.svelte";
import ChaosSwirl from "./ChaosSwirl.svelte";
import Claw from "./Claw.svelte";
import DieDrop from "./DieDrop.svelte";
import DustPuff from "./DustPuff.svelte";
import Fireball from "./Fireball.svelte";
import Fireworks from "./Fireworks.svelte";
@@ -49,6 +50,7 @@ export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf<K> }> }
soul: Soul,
fireworks: Fireworks,
"chaos-swirl": ChaosSwirl,
"die-drop": DieDrop,
"pit-fall": PitFall,
"ooze-slip": OozeSlip,
"tacks-ow": TacksOw,
+10 -1
View File
@@ -22,7 +22,8 @@ export type FxShape =
| { kind: "portal"; cell: Cell; side: Side }
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
| { kind: "sector-slide"; from: Cell; to: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
| { kind: "edge-dust"; cell: Cell; side: Side }
| { kind: "die-drop"; at: Cell; aim: Cell };
export type BoardFx = FxShape & { id: number };
/** The narrow type of one effect kind (for sprite components). */
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
@@ -37,6 +38,7 @@ export function fxTtl(kind: BoardFx["kind"]): number {
case "portal": case "portal-cell":
case "soul": case "fireworks": case "chaos-swirl":
case "sector-spin": case "sector-slide": return 1500;
case "die-drop": return 1600;
default: return 900;
}
}
@@ -271,6 +273,13 @@ export function fxForEvents(
push({ kind: "pit-fall", at: e.at });
beat++;
break;
case "thumbOfGod":
push({ kind: "die-drop", at: e.landedAt, aim: e.aimedAt });
beat += 2; // the scattered tokens' streaks follow the impact
break;
case "tokenScattered":
push({ kind: "streak", a: cellMid(e.from), b: cellMid(e.to) });
break;
case "climbedFromPit": {
if (e.success) {
const at = posOf(e.player);