Forfeit legs earn no scolding; the pane sees your boots
Picking up an object ends the turn's actions AND movement — yet the end-turn caution still tallied the unspendable points as regret. The unspent-movement reason now stays quiet once actions have ended: those points cannot be spent, only mourned. And the raycaster has no downward pitch, so whatever lay in your own square — a treasure, a dropped blade — was invisible from inside the cockpit. An "at your feet" tray now rides the pane's bottom edge on your turn: your square's floor treasures and objects as clickable icons, each a one-tap pickup through the same commands the board's buttons send. 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
3d2bf17585
commit
19752e55e1
@@ -971,8 +971,10 @@
|
|||||||
if (view && me) {
|
if (view && me) {
|
||||||
const left = view.turn.movementAllowance - view.turn.movementUsed;
|
const left = view.turn.movementAllowance - view.turn.movementUsed;
|
||||||
// Unspent legs are no regret when the walk ended ON someone's gold —
|
// Unspent legs are no regret when the walk ended ON someone's gold —
|
||||||
// stopping there was the point.
|
// stopping there was the point — nor when a pickup or drop already
|
||||||
if (left > 0 && !treasuresHere.some((t) => t.owner !== view.you)) {
|
// forfeited them: those points cannot be spent, only mourned.
|
||||||
|
if (left > 0 && !view.turn.actionsEnded &&
|
||||||
|
!treasuresHere.some((t) => t.owner !== view.you)) {
|
||||||
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
|
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
|
||||||
}
|
}
|
||||||
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||||
@@ -1901,7 +1903,10 @@
|
|||||||
{#if prefs.liveFp && view.you && !net.spectating}
|
{#if prefs.liveFp && view.you && !net.spectating}
|
||||||
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
||||||
onstride={(side) => tryMove(side)} canStride={yourMoment}
|
onstride={(side) => tryMove(side)} canStride={yourMoment}
|
||||||
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells} />
|
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
|
||||||
|
onpickup={(w) => dispatch(w.kind === "treasure"
|
||||||
|
? { type: "pickUpTreasure", treasureId: w.id }
|
||||||
|
: { type: "pickUpObject", instanceId: w.id })} />
|
||||||
{/if}
|
{/if}
|
||||||
<Board
|
<Board
|
||||||
{view}
|
{view}
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
// here first.
|
// here first.
|
||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
import type { FpvTarget } from "./fpv/raycast";
|
import type { FpvTarget } from "./fpv/raycast";
|
||||||
|
import { tokenArt } from "./art";
|
||||||
|
import { objectArt } from "./art";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
import { castRay } from "./fpv/raycast";
|
import { castRay } from "./fpv/raycast";
|
||||||
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
import type { GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null }: {
|
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null }: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
/** The latest live event batch, numbered so each plays once, with
|
/** The latest live event batch, numbered so each plays once, with
|
||||||
* the view the server sent alongside it. */
|
* the view the server sent alongside it. */
|
||||||
@@ -32,11 +34,35 @@
|
|||||||
onfacing?: ((side: Side) => void) | null;
|
onfacing?: ((side: Side) => void) | null;
|
||||||
/** Cell-card eligibility, shared with the board's dimming aid. */
|
/** Cell-card eligibility, shared with the board's dimming aid. */
|
||||||
litCells?: Set<string> | null;
|
litCells?: Set<string> | null;
|
||||||
|
/** 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;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const povId = $derived(view.you);
|
const povId = $derived(view.you);
|
||||||
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
|
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
|
||||||
|
|
||||||
|
/** What lies in YOUR square, grabbable: floor treasures and dropped
|
||||||
|
* objects. The raycaster has no downward pitch — the tray is the eye's
|
||||||
|
* substitute for looking at your own boots. */
|
||||||
|
const atFeet = $derived.by(() => {
|
||||||
|
if (!me || me.carriedTreasureId) return [];
|
||||||
|
const k = `${me.position.x},${me.position.y}`;
|
||||||
|
const items: { kind: "treasure" | "object"; id: string; art: string | null; label: string }[] = [];
|
||||||
|
for (const t of view.treasures) {
|
||||||
|
if (!t.position || t.carriedBy) continue;
|
||||||
|
if (`${t.position.x},${t.position.y}` !== k) continue;
|
||||||
|
if (t.owner === view.you && k === `${me.home.x},${me.home.y}`) continue; // safe at home
|
||||||
|
const ci = view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0;
|
||||||
|
items.push({ kind: "treasure", id: t.id, art: tokenArt(`treasure-${ci % 6}`, "objects"), label: `${t.owner}'s treasure` });
|
||||||
|
}
|
||||||
|
for (const o of view.groundObjects[k] ?? []) {
|
||||||
|
const file = objectArt(o.cardId);
|
||||||
|
items.push({ kind: "object", id: o.instanceId, art: file ? tokenArt(file, "objects") : null, label: o.cardId.replace(/-/g, " ") });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
const cam = $state({ x: 0, y: 0, facing: 0 });
|
const cam = $state({ x: 0, y: 0, facing: 0 });
|
||||||
let camReady = false;
|
let camReady = false;
|
||||||
/** While the player steers, the director keeps its hands off the
|
/** While the player steers, the director keeps its hands off the
|
||||||
@@ -261,6 +287,21 @@
|
|||||||
<button class="drive drive-back" onclick={() => manualStride(true)} aria-label="step back">﹀</button>
|
<button class="drive drive-back" onclick={() => manualStride(true)} aria-label="step back">﹀</button>
|
||||||
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
|
<div class="live-fp-hint">← → turn · ↑ ↓ walk</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if onpickup && canStride && atFeet.length > 0}
|
||||||
|
<div class="feet-tray">
|
||||||
|
<span class="feet-label">at your feet</span>
|
||||||
|
{#each atFeet as item (item.id)}
|
||||||
|
<button class="feet-item" title={item.label}
|
||||||
|
onclick={() => onpickup?.({ kind: item.kind, id: item.id })}>
|
||||||
|
{#if item.art}
|
||||||
|
<img src={item.art} alt={item.label} />
|
||||||
|
{:else}
|
||||||
|
<span class="feet-fallback">{item.label}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -314,6 +355,38 @@
|
|||||||
.drive-left { left: 0; top: 15%; bottom: 15%; width: 13%; }
|
.drive-left { left: 0; top: 15%; bottom: 15%; width: 13%; }
|
||||||
.drive-right { right: 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); }
|
.drive-right:hover { background: linear-gradient(to left, rgba(0, 0, 0, 0.22), transparent); }
|
||||||
|
.feet-tray {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(13, 12, 18, 0.78);
|
||||||
|
border: 1px solid #3a3428;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.feet-label {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8d8672;
|
||||||
|
}
|
||||||
|
.feet-item {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #4a4536;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.feet-item:hover { border-color: #c9a72a; }
|
||||||
|
.feet-item img { width: 30px; height: 30px; object-fit: contain; }
|
||||||
|
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
|
||||||
.drive-fwd { top: 0; left: 20%; right: 20%; height: 16%; }
|
.drive-fwd { top: 0; left: 20%; right: 20%; height: 16%; }
|
||||||
.drive-fwd:hover { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent); }
|
.drive-fwd:hover { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent); }
|
||||||
.drive-back { bottom: 0; left: 20%; right: 20%; height: 15%; }
|
.drive-back { bottom: 0; left: 20%; right: 20%; height: 15%; }
|
||||||
|
|||||||
Reference in New Issue
Block a user