diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index 058c99a..6cadd19 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -1869,9 +1869,10 @@
{:else if view}
-
+
{#if prefs.liveFp && view.you && !net.spectating}
-
setPref("liveFp", false)} />
+ setPref("liveFp", false)}
+ onstride={(side) => tryMove(side)} canStride={yourMoment} />
{/if}
void;
+ /** Issue a real move command: the pane is the cockpit on your turn. */
+ onstride?: ((side: Side) => void) | null;
+ canStride?: boolean;
} = $props();
const povId = $derived(view.you);
@@ -27,6 +30,44 @@
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
+ /** While the player steers, the director keeps its hands off the
+ * idle facings (actor-watching, corridor rescue); aims and cutaways
+ * still fire — your own magic always turns your head. */
+ let manualUntil = 0;
+ let turning = false;
+
+ const SIDES4 = ["E", "S", "W", "N"] as const;
+ function manualTurn(dir: -1 | 1) {
+ if (turning) return;
+ turning = true;
+ manualUntil = performance.now() + 4000;
+ const from = cam.facing;
+ const to = from + dir * (Math.PI / 2);
+ const t0 = performance.now();
+ const tick = (now: number) => {
+ const w = Math.min(1, (now - t0) / 180);
+ cam.facing = from + (to - from) * (w * w * (3 - 2 * w));
+ if (w < 1) requestAnimationFrame(tick);
+ else turning = false;
+ };
+ requestAnimationFrame(tick);
+ }
+ function manualStride(back: boolean) {
+ if (!canStride || !onstride) 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);
+ }
+ function onKey(e: KeyboardEvent) {
+ const t = e.target as HTMLElement | null;
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
+ const k = e.key.toLowerCase();
+ if (k === "arrowleft" || k === "a") { e.preventDefault(); manualTurn(-1); }
+ else if (k === "arrowright" || k === "d") { e.preventDefault(); manualTurn(1); }
+ else if (k === "arrowup" || k === "w") { e.preventDefault(); manualStride(false); }
+ else if (k === "arrowdown" || k === "s") { e.preventDefault(); manualStride(true); }
+ }
let cutawayShot = $state(false);
let fpFx = $state([]);
let actorPos = $state>({});
@@ -130,7 +171,7 @@
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
- } else {
+ } else if (performance.now() >= manualUntil) {
// Another wizard acting in your sight line turns your head.
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
@@ -143,7 +184,8 @@
}
}
}
- if (!aimed && (willCut || castRay(v, tx, ty, targetFacing).dist < 0.8)) {
+ 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.
let deepest = -1;
@@ -185,12 +227,17 @@
});
+
+
{#if me}
+ {#if canStride}
+
← → turn · ↑ ↓ walk
+ {/if}
{/if}
@@ -212,4 +259,13 @@
padding: 0.1rem 0.4rem;
}
.live-fp-hide:hover { color: #e9e1cb; }
+ .live-fp-hint {
+ position: absolute;
+ bottom: 0.45rem;
+ left: 0.6rem;
+ color: rgba(233, 225, 203, 0.55);
+ font-family: "Courier Prime", monospace;
+ font-size: 0.7rem;
+ pointer-events: none;
+ }