Hedges stay in their squares; the eyes turn to the lock they pick
Eric's rosebush-through-a-wall, hunted to two roots. A camera-facing billboard viewed obliquely rotates out of its square, so cell-bound volumes are now windowed to the exact segment where their plane crosses their own cell — no end may paint beyond it. And the real smoking gun: the near-bias that lets a hedge win the tie against the wizard standing in it was applied to the OCCLUSION depth too, letting the hedge cheat a fifth of a cell through any neighboring wall. The bias now orders sprites among themselves only; walls always judge the true depth. Also answered: edge-deeds turn the head. Picking or jamming a lock, unlocking with the Master Key, raising or razing or drowning a wall — any edge-bearing deed by the reel's own wizard now aims the camera at that edge's midpoint before the outcome shows, same as any other target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
285c91b2c8
commit
ccb5346a31
@@ -258,6 +258,21 @@
|
|||||||
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
|
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
|
||||||
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
} else if (e.type === "objectThrown" && e.attacker === povId) {
|
||||||
cell = e.landedAt;
|
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;
|
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,7 +373,7 @@
|
|||||||
}, ex, ey);
|
}, ex, ey);
|
||||||
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
|
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) {
|
for (const s of sprites) {
|
||||||
const img = imageFor(s.src) ??
|
const img = imageFor(s.src) ??
|
||||||
(s.fallback
|
(s.fallback
|
||||||
@@ -391,6 +391,7 @@
|
|||||||
if (s.warped) {
|
if (s.warped) {
|
||||||
if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue;
|
if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue;
|
||||||
} else if (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));
|
const texX = ((col - s.left) / (s.right - s.left));
|
||||||
if (img) {
|
if (img) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
@@ -488,11 +489,18 @@
|
|||||||
glow?: boolean;
|
glow?: boolean;
|
||||||
alpha?: number;
|
alpha?: number;
|
||||||
fallback?: string;
|
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(
|
function project(
|
||||||
b: { x: number; y: number; src: string; scale: number; rise: number;
|
b: { x: number; y: number; src: string; scale: number; rise: number;
|
||||||
aspect?: number; alpha?: number; glow?: boolean; bias?: 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,
|
ex: number, ey: number,
|
||||||
): Projected | null {
|
): Projected | null {
|
||||||
const relX = b.x - ex, relY = b.y - ey;
|
const relX = b.x - ex, relY = b.y - ey;
|
||||||
@@ -511,9 +519,40 @@
|
|||||||
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
|
||||||
: size;
|
: size;
|
||||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
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 {
|
return {
|
||||||
src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
|
||||||
alpha: b.alpha, glow: b.glow, fallback: b.fallback,
|
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
|
||||||
left: screenX - wide / 2, right: screenX + wide / 2,
|
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||||
top: bottom - size, bottom,
|
top: bottom - size, bottom,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -309,6 +309,10 @@ export interface Billboard {
|
|||||||
fallback?: string;
|
fallback?: string;
|
||||||
/** Identity for growth animation: a creature's id, or a cell key. */
|
/** Identity for growth animation: a creature's id, or a cell key. */
|
||||||
key?: string;
|
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 —
|
/** A reflection seen through a warp mouth, not the thing itself —
|
||||||
* visible only through THAT warp's columns, beyond its mouth. */
|
* visible only through THAT warp's columns, beyond its mouth. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
@@ -402,21 +406,25 @@ export function billboards(
|
|||||||
out.push({
|
out.push({
|
||||||
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
|
...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,
|
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") {
|
} else if (content.kind === "ooze") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/jello.png", fallback: "jello",
|
...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,
|
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") {
|
} else if (content.kind === "dust") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/dust.png", fallback: "dust",
|
...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,
|
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") {
|
} else if (content.kind === "safe") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
|
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
|
||||||
|
clip: { x: Math.floor(at.x), y: Math.floor(at.y) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user