From 15951184102f137c23773143c0315b11dc88587c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 14:56:29 -0400 Subject: [PATCH] Possession: ride your creature's eyes; the cockpit stops flinching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane learns the menagerie. On your turn, a "ride" button appears for each creature you command with moves or claws unspent; mounting seats the camera in its skull facing its longest corridor, hides its own hide from its eyes, and hands it the helm — chevrons and arrows stride IT, an adjacent floor click steps it, and clicking an enemy sharing its square lands its blow. A banner shows the mount's remaining legs; one tap (or the turn passing, or the mount dying) returns you to your own eyes, where the director reseats you fresh. Two cockpit-dignity fixes from live play ride along: a cutaway to your own body now plays only for things done TO you — dropping a dagger no longer flings your eyes out of your skull — and a standing camera is never "rescued" off a wall you deliberately faced; the deepest-corridor default applies only to fresh cuts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/App.svelte | 4 +- packages/web/src/LiveFirstPerson.svelte | 135 +++++++++++++++++++++--- packages/web/src/fpv/raycast.ts | 1 + 3 files changed, 127 insertions(+), 13 deletions(-) diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index d365406..9ccb6b7 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1906,7 +1906,9 @@ ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells} onpickup={(w) => dispatch(w.kind === "treasure" ? { type: "pickUpTreasure", treasureId: w.id } - : { type: "pickUpObject", instanceId: w.id })} /> + : { type: "pickUpObject", instanceId: w.id })} + onCreatureMove={(creatureId, direction) => dispatch({ type: "moveCreature", creatureId, direction })} + onCreatureAttack={(creatureId, targetId) => dispatch({ type: "creatureAttack", creatureId, targetId })} /> {/if} void) | null; + /** Possession: ride a creature's eyes and drive it from the pane. */ + onCreatureMove?: ((creatureId: string, side: Side) => void) | null; + onCreatureAttack?: ((creatureId: string, targetId: string) => void) | null; } = $props(); const povId = $derived(view.you); @@ -63,6 +66,39 @@ return items; }); + /** Riding a creature: the pane looks through ITS eyes and the helm + * drives ITS legs, until you step back into your own skull. */ + let possessedId = $state(null); + const possessed = $derived( + possessedId ? view.creatures.find((c) => c.id === possessedId) ?? null : null); + /** Creatures you may ride right now: yours (and the democratic + * monster), with movement or an attack still unspent. */ + const rideable = $derived(canStride + ? view.creatures.filter((c) => + (c.controllerId === view.you || c.kind === "democratic-monster") && + !c.justCreated && + (c.movementUsed < c.movesPerTurn || !c.attackUsed)) + : []); + // A dead or vanished mount — or the turn passing — dismounts you. + $effect(() => { + if (possessedId && (!possessed || !canStride)) possessedId = null; + }); + // Mounting seats the eyes in the creature's skull facing its longest + // corridor; the rider steers from there. + let seatedMount: string | null = null; + $effect(() => { + if (!possessedId || !possessed) { seatedMount = null; return; } + if (seatedMount === possessedId) return; + seatedMount = possessedId; + const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5; + let deepest = -1, face = 0; + for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { + const h = castRay(view, mx, my, a); + if (h.dist > deepest) { deepest = h.dist; face = a; } + } + cam.x = mx; cam.y = my; cam.facing = face; + }); + const cam = $state({ x: 0, y: 0, facing: 0 }); let camReady = false; /** While the player steers, the director keeps its hands off the @@ -87,12 +123,34 @@ }; requestAnimationFrame(tick); } + /** Pane clicks: a possessed mount claims them first — adjacent floor + * steps it, a same-square enemy takes its blow — and only the rider's + * own eyes pass clicks through to the board's handlers. */ + function handleTarget(t: FpvTarget) { + if (!possessed) { ontarget?.(t); return; } + const c = possessed; + if (t.kind === "player" && onCreatureAttack) { + const p = view.players.find((q) => q.id === t.id); + if (p && p.position.x === c.position.x && p.position.y === c.position.y) { + onCreatureAttack(c.id, t.id); + return; + } + } + if (t.kind === "cell" && onCreatureMove) { + const dx = t.cell.x - c.position.x, dy = t.cell.y - c.position.y; + if (Math.abs(dx) + Math.abs(dy) === 1) { + onCreatureMove(c.id, dx === 1 ? "E" : dx === -1 ? "W" : dy === 1 ? "S" : "N"); + } + } + } + function manualStride(back: boolean) { - if (!canStride || !onstride) return; + if (!canStride) return; manualUntil = performance.now() + 4000; const q = Math.round(cam.facing / (Math.PI / 2)); const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!; - onstride(side); + if (possessed) onCreatureMove?.(possessed.id, side); + else onstride?.(side); } function onKey(e: KeyboardEvent) { const t = e.target as HTMLElement | null; @@ -161,6 +219,19 @@ const b = batch; const m = me; if (!m) { camReady = false; return; } + // Possessed: the director stands aside. The camera sits in the + // mount's skull, keeps whatever facing the rider has chosen, and + // hard-follows the mount's steps; dismounting reseats the wizard. + const mount = possessed; + if (mount) { + if (b) directedBatch = b.n; // consume batches so dismounting doesn't replay them + const mx = mount.position.x + 0.5, my = mount.position.y + 0.5; + if (Math.hypot(untrack(() => cam.x) - mx, untrack(() => cam.y) - my) > 0.01) { + cam.x = mx; cam.y = my; + } + camReady = false; // the wizard's eyes reseat on dismount + return; + } const v = view; const tx = m.position.x + 0.5; const ty = m.position.y + 0.5; @@ -216,9 +287,12 @@ } else if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; - } else if (aim?.self) { + } else if (aim?.self && (actor !== povId || hurled)) { + // A cutaway to your own body is replay theater: in the cockpit it + // plays only for things done TO you, never for your own hands — + // dropping a dagger must not fling your eyes out of your skull. return takeCutaway(m.position.x + 0.5, m.position.y + 0.5); - } else if (aim) { + } else if (aim && !aim.self) { const ax = aim.x + 0.5, ay = aim.y + 0.5; const toAim = Math.hypot(ax - tx, ay - ty); const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); @@ -238,10 +312,10 @@ } } } - if (!aimed && performance.now() >= manualUntil && - (willCut || castRay(v, tx, ty, targetFacing).dist < 0.8)) { - // Nothing asks to be watched and the eyes would idle nose-to-brick: - // face the deepest corridor from where the wizard stands. + if (!aimed && willCut && performance.now() >= manualUntil) { + // A fresh CUT with nothing asking to be watched faces the deepest + // corridor — but a STANDING camera is never "rescued": where you + // pointed your own head is where it stays, brick or no brick. let deepest = -1; for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const h = castRay(v, tx, ty, a); @@ -293,10 +367,10 @@ {#if me}
- + ontarget={handleTarget} {litCells} /> @@ -306,7 +380,22 @@
← → turn · ↑ ↓ walk
{/if} - {#if onpickup && canStride && atFeet.length > 0} + {#if possessed} +
+ riding the {possessed.kind.replace(/-/g, " ")} + {Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"} + +
+ {:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)} +
+ {#each rideable as c (c.id)} + + {/each} +
+ {/if} + {#if onpickup && canStride && atFeet.length > 0 && !possessed}
at your feet {#each atFeet as item (item.id)} @@ -374,6 +463,28 @@ .drive-left { left: 0; top: 15%; bottom: 15%; width: 13%; } .drive-right { right: 0; top: 15%; bottom: 15%; width: 13%; } .drive-right:hover { background: linear-gradient(to left, rgba(0, 0, 0, 0.22), transparent); } + .ride-row, .ride-banner { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + background: rgba(13, 12, 18, 0.78); + border: 1px solid #3a3428; + border-radius: 6px; + padding: 4px 10px; + z-index: 3; + } + .ride-label { + font-family: "Oswald", sans-serif; + font-size: 0.62rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #c9a72a; + } + .ride-stats { font-size: 0.7rem; color: #8d8672; } .feet-tray { position: absolute; bottom: 8px; diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 87220fd..30675c0 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -403,6 +403,7 @@ export function billboards( }); } for (const c of view.creatures) { + if (c.id === povId) continue; // possessed: you do not see your own hide const o = posOverride?.[c.id]; out.push({ x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,