diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 412ad12..9998a1b 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -258,6 +258,21 @@ cell = v.players.find((p) => p.id === e.defender)?.position ?? null; } else if (e.type === "objectThrown" && e.attacker === povId) { cell = e.landedAt; + } else if ( + // Deeds done to an EDGE — a lock picked or jammed, a door + // unlocked, a wall raised, razed, or drowned — turn the eyes + // to the edge's midpoint just the same. + "edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge && + (("caster" in e && e.caster === povId) || ("player" in e && e.player === povId)) + ) { + const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" }; + const m = edgeMid(edge.cell, edge.side); + // edgeMid sits ON the boundary; step half a texel toward the + // cell so the same-cell check below behaves. + if (Math.abs(m.x - (me.position.x + 0.5)) > 0.3 || Math.abs(m.y - (me.position.y + 0.5)) > 0.3) { + return { x: m.x - 0.5, y: m.y - 0.5 }; + } + continue; } if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell; } diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index d3350b4..0a07436 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -373,7 +373,7 @@ }, ex, ey); if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } - sprites.sort((a, b) => b.depth - a.depth); + sprites.sort((a, b) => b.sort - a.sort); for (const s of sprites) { const img = imageFor(s.src) ?? (s.fallback @@ -391,6 +391,7 @@ if (s.warped) { if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue; } else if (s.depth >= warpDistCol[col]!) continue; + if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue; const texX = ((col - s.left) / (s.right - s.left)); if (img) { ctx.drawImage( @@ -488,11 +489,18 @@ glow?: boolean; alpha?: number; fallback?: string; + clampL?: number; + clampR?: number; + /** Sort key only: the near-bias orders sprites among THEMSELVES (a + * hedge over the wizard standing in it) but must never let one + * cheat past a wall — occlusion always uses the true depth. */ + sort: number; } function project( b: { x: number; y: number; src: string; scale: number; rise: number; aspect?: number; alpha?: number; glow?: boolean; bias?: number; - fallback?: string; warped?: boolean; warpId?: number }, + fallback?: string; warped?: boolean; warpId?: number; + clip?: { x: number; y: number } }, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; @@ -511,9 +519,40 @@ ? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H) : size; const bottom = half + wallH / 2 - b.rise * wallH; + // A cell-bound volume is windowed to its own square: the billboard's + // camera-facing plane is intersected with the cell, and only that + // lateral segment may paint — an obliquely-viewed hedge can no longer + // poke its ends through the neighboring walls. (Virtual warp copies + // skip this — their clip cell lives in another frame.) + let clampL: number | undefined; + let clampR: number | undefined; + if (b.clip && !b.warped) { + const sinF = Math.sin(facing), cosF = Math.cos(facing); + let sMin = -Infinity, sMax = Infinity; + if (Math.abs(sinF) > 1e-6) { + const a1 = (b.x - b.clip.x) / sinF; + const a2 = (b.x - (b.clip.x + 1)) / sinF; + sMin = Math.max(sMin, Math.min(a1, a2)); + sMax = Math.min(sMax, Math.max(a1, a2)); + } + if (Math.abs(cosF) > 1e-6) { + const a1 = (b.clip.y - b.y) / cosF; + const a2 = (b.clip.y + 1 - b.y) / cosF; + sMin = Math.max(sMin, Math.min(a1, a2)); + sMax = Math.min(sMax, Math.max(a1, a2)); + } + if (sMin <= sMax && Number.isFinite(sMin) && Number.isFinite(sMax)) { + const sideC = -relX * sinF + relY * cosF; + const fl = (W / 2) / Math.tan(FOV / 2); + const e1 = W / 2 + ((sideC + sMin) / depth) * fl; + const e2 = W / 2 + ((sideC + sMax) / depth) * fl; + clampL = Math.min(e1, e2); + clampR = Math.max(e1, e2); + } + } return { - src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, - alpha: b.alpha, glow: b.glow, fallback: b.fallback, + src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, + alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR, left: screenX - wide / 2, right: screenX + wide / 2, top: bottom - size, bottom, }; diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 08bd281..6e2fe3b 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -309,6 +309,10 @@ export interface Billboard { fallback?: string; /** Identity for growth animation: a creature's id, or a cell key. */ key?: string; + /** Cell-bound volumes never paint outside their own square: a + * camera-facing hedge viewed obliquely would otherwise poke its ends + * through the neighboring walls. */ + clip?: { x: number; y: number }; /** A reflection seen through a warp mouth, not the thing itself — * visible only through THAT warp's columns, beyond its mouth. */ warped?: boolean; @@ -402,21 +406,25 @@ export function billboards( out.push({ ...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind, scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k, + clip: { x: tx, y: ty }, }); } else if (content.kind === "ooze") { out.push({ ...at, src: "/terrain3d/jello.png", fallback: "jello", scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k, + clip: { x: tx, y: ty }, }); } else if (content.kind === "dust") { out.push({ ...at, src: "/terrain3d/dust.png", fallback: "dust", scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k, + clip: { x: tx, y: ty }, }); } else if (content.kind === "safe") { out.push({ ...at, src: "/terrain3d/safe.png", fallback: "safe", scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", + clip: { x: Math.floor(at.x), y: Math.floor(at.y) }, }); } }