Possession: ride your creature's eyes; the cockpit stops flinching
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
5f91e30eb4
commit
1595118410
@@ -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}
|
||||
<Board
|
||||
{view}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { untrack } from "svelte";
|
||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
|
||||
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null }: {
|
||||
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null }: {
|
||||
view: GameView;
|
||||
/** The latest live event batch, numbered so each plays once, with
|
||||
* the view the server sent alongside it. */
|
||||
@@ -37,6 +37,9 @@
|
||||
/** The at-your-feet tray's grab: the pane cannot look down, so what
|
||||
* lies in your own square shows as clickable icons instead. */
|
||||
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => 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<string | null>(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}
|
||||
<div class="live-fp">
|
||||
<FirstPerson {view} povId={cutawayShot ? "" : povId}
|
||||
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||
fx={fpFx} posOverride={actorPos}
|
||||
ontarget={ontarget ?? undefined} {litCells} />
|
||||
ontarget={handleTarget} {litCells} />
|
||||
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
||||
<!-- The helm, tappable: edge strips turn and stride. -->
|
||||
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left">‹</button>
|
||||
@@ -306,7 +380,22 @@
|
||||
<button class="drive drive-back" onclick={() => manualStride(true)} aria-label="step back">﹀</button>
|
||||
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
|
||||
{/if}
|
||||
{#if onpickup && canStride && atFeet.length > 0}
|
||||
{#if possessed}
|
||||
<div class="ride-banner">
|
||||
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
|
||||
<span class="ride-stats">{Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"}</span>
|
||||
<button class="stamp tiny" onclick={() => (possessedId = null)}>back to your eyes</button>
|
||||
</div>
|
||||
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
|
||||
<div class="ride-row">
|
||||
{#each rideable as c (c.id)}
|
||||
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + 60000; }}>
|
||||
👁 ride the {c.kind.replace(/-/g, " ")}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if onpickup && canStride && atFeet.length > 0 && !possessed}
|
||||
<div class="feet-tray">
|
||||
<span class="feet-label">at your feet</span>
|
||||
{#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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user