Milestone one: the pane becomes the cockpit
FPV play begins. On your turn the pane takes the helm: arrows or WASD — quarter-turns are free camera, a stride issues a REAL move command through the same cautions and refusals as a board click, and a wall bounces you with the table's own toast. While the player steers, the director keeps its hands off the idle facings; your own magic still turns your head, and cutaways still fire. The layout inverts to match the mode: the 3D view is the primary window into the game, the top-down board reduced to a keymap beneath it, with the control hint in the pane's corner. Typing in chat is never hijacked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c7109fcddf
commit
b8bb60cf6d
@@ -12,14 +12,17 @@
|
||||
import { castRay } from "./fpv/raycast";
|
||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||
import { untrack } from "svelte";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
|
||||
let { view, batch, onhide }: {
|
||||
let { view, batch, onhide, onstride = null, canStride = false }: {
|
||||
view: GameView;
|
||||
/** The latest live event batch, numbered so each plays once, with
|
||||
* the view the server sent alongside it. */
|
||||
batch: { n: number; events: GameEvent[]; view: GameView } | null;
|
||||
onhide: () => 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<FpFx[]>([]);
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
@@ -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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if me}
|
||||
<div class="live-fp">
|
||||
<FirstPerson {view} povId={cutawayShot ? "" : povId}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={720} height={280}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||
fx={fpFx} posOverride={actorPos} />
|
||||
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)">✕</button>
|
||||
{#if canStride}
|
||||
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user