710 lines
24 KiB
JavaScript
710 lines
24 KiB
JavaScript
/**
|
||
* Software 3D renderer + animation state machine for the oracle vessel.
|
||
*
|
||
* Deliberately Canvas 2D rather than WebGL: the scene is a low-poly hexagonal prism
|
||
* plus a hexagonal plate, so painter's-algorithm depth sorting with per-face lighting
|
||
* costs almost nothing, and it renders identically on machines with no GL drivers
|
||
* (headless browsers, VMs, older devices) where a WebGL context cannot be created.
|
||
*
|
||
* The vessel is convex and the plate floats inside it, so sorting faces by camera
|
||
* distance and drawing back-to-front is sufficient: no z-buffer needed.
|
||
*/
|
||
|
||
import {
|
||
affineFromThreePoints,
|
||
clamp,
|
||
dot,
|
||
hexToRgb,
|
||
lerp,
|
||
mat4LookAt,
|
||
mat4Mul,
|
||
mat4Perspective,
|
||
mat4RotateX,
|
||
mat4RotateY,
|
||
mat4RotateZ,
|
||
mat4Translate,
|
||
mixRgb,
|
||
normalize,
|
||
projectToScreen,
|
||
rgbCss,
|
||
smoothstep,
|
||
transformDir,
|
||
transformPoint
|
||
} from './math3d.js';
|
||
import { createPlate, createVessel } from './meshes.js';
|
||
|
||
const TAU = Math.PI * 2;
|
||
const LIGHT = normalize([-0.42, 0.7, 0.86]);
|
||
const VIEW = [0, 0, 1];
|
||
|
||
const BODY_BASE = hexToRgb('#191919');
|
||
const BODY_EDGE = hexToRgb('#2b2b30');
|
||
const CHAMBER_RGB = hexToRgb('#07070b');
|
||
const PLATE_BASE = hexToRgb('#1b1b1f');
|
||
const SPECULAR = [236, 245, 255];
|
||
|
||
const TONES = {
|
||
affirmative: hexToRgb('#10b981'),
|
||
negative: hexToRgb('#ef4444'),
|
||
neutral: hexToRgb('#38bdf8'),
|
||
offline: hexToRgb('#8f8f96'),
|
||
idle: hexToRgb('#38bdf8')
|
||
};
|
||
|
||
// Two slightly perturbed lights give the large faces a soft falloff without the cost
|
||
// of per-vertex normals. Faceted faces stay flat so the silhouette reads as machined.
|
||
const LIGHT_NEAR = normalize([LIGHT[0] - 0.35, LIGHT[1] + 0.32, LIGHT[2]]);
|
||
const LIGHT_FAR = normalize([LIGHT[0] + 0.32, LIGHT[1] - 0.36, LIGHT[2]]);
|
||
const FONT_STACK = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||
|
||
/** Phases: rolling -> (answer arrives) -> revealing -> ready -> idle. */
|
||
export class OracleBall {
|
||
constructor(canvas, { reducedMotion = false } = {}) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.reducedMotion = reducedMotion;
|
||
this.vessel = createVessel();
|
||
this.plate = createPlate();
|
||
this.radiusCss = 172;
|
||
this.width = 0;
|
||
this.height = 0;
|
||
this.dpr = 1;
|
||
|
||
this.clock = 0;
|
||
this.lastFrame = 0;
|
||
this.running = false;
|
||
this.raf = 0;
|
||
this.visible = true;
|
||
|
||
this.phase = 'idle';
|
||
this.rollStart = 0;
|
||
this.revealStart = 0;
|
||
this.holdTime = 0;
|
||
this.rollDuration = 1.25;
|
||
this.revealDuration = 0.9;
|
||
|
||
this.answerLines = ['CONCENTRATE', '& ASK'];
|
||
this.tone = 'idle';
|
||
this.pendingAnswer = null;
|
||
|
||
this.pointer = { x: 0, y: 0 };
|
||
this.tilt = { x: 0, y: 0 };
|
||
this.shake = 0;
|
||
this.glow = 0;
|
||
this.resolveRoll = null;
|
||
}
|
||
|
||
/* ─── public API ─── */
|
||
|
||
/** Starts the render loop, sizing, and pointer parallax. Safe to call twice. */
|
||
attach() {
|
||
if (this.running || !this.ctx) return;
|
||
this.running = true;
|
||
this._resize();
|
||
this._observeSize();
|
||
this._observeVisibility();
|
||
this.canvas.addEventListener('pointermove', this._onPointerMove);
|
||
this.canvas.addEventListener('pointerleave', this._onPointerLeave);
|
||
this.lastFrame = performance.now();
|
||
this.raf = requestAnimationFrame(this._frame);
|
||
}
|
||
|
||
detach() {
|
||
this.running = false;
|
||
cancelAnimationFrame(this.raf);
|
||
this.canvas.removeEventListener('pointermove', this._onPointerMove);
|
||
this.canvas.removeEventListener('pointerleave', this._onPointerLeave);
|
||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||
if (this.intersectionObserver) this.intersectionObserver.disconnect();
|
||
}
|
||
|
||
setReducedMotion(reduced) {
|
||
this.reducedMotion = reduced;
|
||
}
|
||
|
||
/** Resolves once the shake animation has played. */
|
||
roll() {
|
||
this.phase = 'rolling';
|
||
this.rollStart = this.clock;
|
||
this.holdTime = 0;
|
||
this.glow = 0;
|
||
this.answerLines = null;
|
||
return new Promise((resolve) => {
|
||
// Backstop so a paused render loop (hidden tab, off-screen canvas) can never
|
||
// leave the caller waiting forever.
|
||
setTimeout(() => {
|
||
if (this.resolveRoll === resolve) {
|
||
this.resolveRoll = null;
|
||
resolve();
|
||
}
|
||
}, Math.round(this.rollDuration * 1000) + 500);
|
||
this.resolveRoll = resolve;
|
||
});
|
||
}
|
||
|
||
/** Surfaces the die with the verdict text; ignored before a roll. */
|
||
answer(text, tone = 'neutral') {
|
||
const lines = wrapAnswer(text);
|
||
this.pendingAnswer = { lines, tone };
|
||
this.answerLines = lines;
|
||
this.tone = tone;
|
||
this.revealStart = this.clock;
|
||
this.tumbleAtReveal = this.platePose ? { ...this.platePose } : { rx: 0, ry: 0, rz: 0, y: -0.5 };
|
||
this.phase = 'revealing';
|
||
}
|
||
|
||
/** Returns the vessel to the resting state and clears the verdict. */
|
||
reset() {
|
||
this.answerLines = ['CONCENTRATE', '& ASK'];
|
||
this.tone = 'idle';
|
||
this.phase = 'idle';
|
||
this.glow = 0;
|
||
this.pendingAnswer = null;
|
||
}
|
||
|
||
/* ─── wiring ─── */
|
||
|
||
_onPointerMove = (event) => {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
this.pointer.x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1);
|
||
this.pointer.y = clamp(((event.clientY - rect.top) / rect.height) * 2 - 1, -1, 1);
|
||
};
|
||
|
||
_onPointerLeave = () => {
|
||
this.pointer.x = 0;
|
||
this.pointer.y = 0;
|
||
};
|
||
|
||
_observeSize() {
|
||
if (typeof ResizeObserver === 'function') {
|
||
this.resizeObserver = new ResizeObserver(() => this._resize());
|
||
this.resizeObserver.observe(this.canvas.parentElement || this.canvas);
|
||
}
|
||
window.addEventListener('resize', this._onWindowResize);
|
||
}
|
||
|
||
_onWindowResize = () => this._resize();
|
||
|
||
_observeVisibility() {
|
||
if (typeof IntersectionObserver === 'function') {
|
||
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||
this.visible = entries.some((entry) => entry.isIntersecting);
|
||
});
|
||
this.intersectionObserver.observe(this.canvas);
|
||
}
|
||
}
|
||
|
||
_resize() {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
const size = Math.max(160, Math.min(rect.width || 344, 460));
|
||
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
this.width = size;
|
||
this.height = size;
|
||
this.canvas.width = Math.round(size * this.dpr);
|
||
this.canvas.height = Math.round(size * this.dpr);
|
||
this.radiusCss = size;
|
||
}
|
||
|
||
/* ─── per-frame simulation ─── */
|
||
|
||
_frame = (now) => {
|
||
if (!this.running) return;
|
||
const dt = Math.min((now - this.lastFrame) / 1000, 0.05);
|
||
this.lastFrame = now;
|
||
// Freeze the clock while hidden or scrolled away so nothing jumps on return.
|
||
if (this.visible && !document.hidden) {
|
||
this.clock += dt;
|
||
this._update(dt);
|
||
this._render();
|
||
}
|
||
this.raf = requestAnimationFrame(this._frame);
|
||
};
|
||
|
||
_update(dt) {
|
||
const ease = Math.min(1, dt * 6);
|
||
this.tilt.x += ((this.reducedMotion ? 0 : this.pointer.y * 0.2) - this.tilt.x) * ease;
|
||
this.tilt.y += ((this.reducedMotion ? 0 : this.pointer.x * 0.3) - this.tilt.y) * ease;
|
||
|
||
const elapsed = this.clock - this.rollStart;
|
||
|
||
if (this.phase === 'rolling') {
|
||
this.rollProgress = this.reducedMotion ? 1 : clamp(elapsed / this.rollDuration, 0, 1);
|
||
this.shake = this.reducedMotion
|
||
? 0
|
||
: (1 - this.rollProgress * 0.6) * Math.sin(elapsed * 24) * 0.42;
|
||
this.liquidSlosh = this.reducedMotion ? 0 : 1;
|
||
if (elapsed >= this.rollDuration) {
|
||
this.holdTime += dt;
|
||
if (this.resolveRoll) {
|
||
const resolve = this.resolveRoll;
|
||
this.resolveRoll = null;
|
||
resolve();
|
||
}
|
||
}
|
||
this.platePose = this._tumblePose(elapsed);
|
||
this.answerAlpha = 0;
|
||
this.glow = this.reducedMotion ? 0 : 0.12 + Math.sin(elapsed * 9) * 0.06;
|
||
} else if (this.phase === 'revealing') {
|
||
const t = this.reducedMotion ? 1 : clamp((this.clock - this.revealStart) / this.revealDuration, 0, 1);
|
||
const settle = smoothstep(0, 0.72, t);
|
||
const from = this.tumbleAtReveal || { rx: 0, ry: 0, rz: 0, y: -0.5 };
|
||
// Unwind the tumble to zero so the plate always lands face-on to the camera.
|
||
this.platePose = {
|
||
rx: from.rx * (1 - settle),
|
||
ry: from.ry * (1 - settle),
|
||
rz: from.rz * (1 - settle),
|
||
y: lerp(from.y, 0.06, settle)
|
||
};
|
||
this.shake = this.reducedMotion ? 0 : (1 - t) * Math.sin(t * 18) * 0.12;
|
||
this.liquidSlosh = this.reducedMotion ? 0 : 1 - t;
|
||
this.answerAlpha = smoothstep(0.5, 1, t);
|
||
this.glow = Math.sin(t * Math.PI) * 1.1 + 0.16;
|
||
if (t >= 1) this.phase = 'ready';
|
||
} else {
|
||
const bob = this.reducedMotion ? 0 : Math.sin(this.clock * 1.15) * 0.022;
|
||
this.platePose = {
|
||
rx: this.reducedMotion ? 0 : Math.sin(this.clock * 0.42) * 0.06,
|
||
ry: this.reducedMotion ? 0 : Math.sin(this.clock * 0.55) * 0.24,
|
||
rz: this.reducedMotion ? 0 : Math.sin(this.clock * 0.37) * 0.05,
|
||
y: 0.02 + bob
|
||
};
|
||
this.liquidSlosh = this.reducedMotion ? 0 : 0.25;
|
||
this.answerAlpha = this.answerLines ? 1 : 0;
|
||
this.glow = this.phase === 'ready' ? 0.16 + Math.sin(this.clock * 1.6) * 0.04 : 0.06;
|
||
}
|
||
}
|
||
|
||
/** Churning pose used while the vessel is being shaken. */
|
||
_tumblePose(elapsed) {
|
||
const dive = smoothstep(0, this.rollDuration * 0.85, elapsed);
|
||
if (this.reducedMotion) {
|
||
return { rx: 0.5, ry: 0.6, rz: 0.2, y: lerp(0.02, -0.24, dive) };
|
||
}
|
||
return {
|
||
rx: elapsed * 5.2,
|
||
ry: elapsed * 3.05,
|
||
rz: elapsed * 2.15,
|
||
y: lerp(0.02, -0.5, dive)
|
||
};
|
||
}
|
||
|
||
get toneRgb() {
|
||
return TONES[this.tone] || TONES.idle;
|
||
}
|
||
|
||
/* ─── rendering ─── */
|
||
|
||
_render() {
|
||
const { ctx } = this;
|
||
const w = this.width;
|
||
const h = this.height;
|
||
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
||
|
||
// Camera sits far enough that the vessel fills ~72% of the canvas height,
|
||
// leaving room for the shake, the rim glow, and the cast shadow.
|
||
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
|
||
const proj = mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40);
|
||
this.viewProj = mat4Mul(proj, view);
|
||
|
||
const pose = this.platePose || { rx: 0, ry: 0, rz: 0, y: 0.02 };
|
||
const shakeZ = this.shake * 0.5;
|
||
const vesselModel = mat4Mul(
|
||
mat4RotateZ(shakeZ),
|
||
mat4Mul(
|
||
mat4RotateY(this.tilt.y + this.shake * 0.28),
|
||
mat4RotateX(this.tilt.x + Math.cos(this.clock * 9) * this.shake * 0.22)
|
||
)
|
||
);
|
||
const plateModel = mat4Mul(
|
||
mat4Translate(0, pose.y, 0),
|
||
mat4Mul(mat4RotateZ(pose.rz), mat4Mul(mat4RotateY(pose.ry), mat4RotateX(pose.rx)))
|
||
);
|
||
|
||
this._drawGroundGlow(w, h);
|
||
this._drawContactShadow(w, h);
|
||
|
||
const vessel = this._projectMesh(this.vessel.mesh, vesselModel);
|
||
const plate = this._projectMesh(this.plate.mesh, plateModel);
|
||
const windowPoly = this._projectWindow(vesselModel);
|
||
const liquid = windowPoly
|
||
? this._liquidGeometry(vesselModel, windowPoly)
|
||
: null;
|
||
|
||
const tone = this.toneRgb;
|
||
|
||
// 1. shell behind the chamber: back cap first, then the side facets
|
||
for (const face of vessel.faces) {
|
||
if (face.kind === 'ring') continue;
|
||
const base = face.kind === 'back' ? CHAMBER_RGB : BODY_BASE;
|
||
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'back' ? 0.05 : 0.55));
|
||
}
|
||
|
||
// 2. chamber contents, clipped to the window opening
|
||
if (windowPoly && liquid) {
|
||
ctx.save();
|
||
this._clipPolygon(windowPoly);
|
||
this._fillLiquid(liquid.points, tone);
|
||
for (const face of plate.faces) {
|
||
const base = face.kind === 'answer' ? PLATE_BASE : BODY_EDGE;
|
||
this._fillFace(face, this._faceStyle(face, base, tone, face.kind === 'answer' ? 0.35 : 0.8));
|
||
}
|
||
if (this.answerLines && this.answerAlpha > 0.01) {
|
||
this._drawAnswerText(plate, windowPoly);
|
||
}
|
||
this._overlayLiquid(liquid, tone);
|
||
ctx.restore();
|
||
}
|
||
|
||
// 3. the front frame, nearest to the camera, drawn last
|
||
for (const face of vessel.faces) {
|
||
if (face.kind !== 'ring') continue;
|
||
this._fillFace(face, this._faceStyle(face, BODY_EDGE, tone, 0.5));
|
||
}
|
||
|
||
if (windowPoly) this._strokeWindow(windowPoly, tone);
|
||
}
|
||
|
||
/** Projects a mesh and sorts its faces back-to-front. */
|
||
_projectMesh(mesh, model) {
|
||
const mvp = mat4Mul(this.viewProj, model);
|
||
const screen = [];
|
||
const depth = [];
|
||
for (const vertex of mesh.vertices) {
|
||
const clip = transformPoint(mvp, vertex);
|
||
depth.push(clip[3]);
|
||
screen.push(projectToScreen(clip, this.width, this.height));
|
||
}
|
||
const faces = [];
|
||
for (const face of mesh.faces) {
|
||
if (face.indices.some((index) => !screen[index])) continue;
|
||
let sum = 0;
|
||
for (const index of face.indices) sum += depth[index];
|
||
faces.push({
|
||
kind: face.kind,
|
||
smooth: face.smooth,
|
||
normal: transformDir(model, face.normal),
|
||
depth: sum / face.indices.length,
|
||
points: face.indices.map((index) => screen[index])
|
||
});
|
||
}
|
||
faces.sort((a, b) => b.depth - a.depth);
|
||
return { faces, screen };
|
||
}
|
||
|
||
/** Projects the hexagonal opening onto the screen. */
|
||
_projectWindow(vesselModel) {
|
||
const mvp = mat4Mul(this.viewProj, vesselModel);
|
||
const { points, z } = this.vessel.window;
|
||
const projected = points.map(([x, y]) =>
|
||
projectToScreen(transformPoint(mvp, [x, y, z]), this.width, this.height)
|
||
);
|
||
return projected.every(Boolean) ? projected : null;
|
||
}
|
||
|
||
/** Screen-space liquid body: the window clipped to everything below the surface. */
|
||
_liquidGeometry(vesselModel, windowPoly) {
|
||
const mvp = mat4Mul(this.viewProj, vesselModel);
|
||
const { radius, z } = this.vessel.window;
|
||
const slosh = this.liquidSlosh || 0;
|
||
const level = -0.09 + Math.sin(this.clock * 8.5) * 0.05 * slosh;
|
||
const tilt = Math.sin(this.clock * 6.3) * 0.16 * slosh;
|
||
const left = projectToScreen(
|
||
transformPoint(mvp, [-radius * 1.35, level + tilt, z * 0.2]),
|
||
this.width,
|
||
this.height
|
||
);
|
||
const right = projectToScreen(
|
||
transformPoint(mvp, [radius * 1.35, level - tilt, z * 0.2]),
|
||
this.width,
|
||
this.height
|
||
);
|
||
if (!left || !right) return null;
|
||
return { points: clipPolygonBelow(windowPoly, left, right), surface: [left, right] };
|
||
}
|
||
|
||
_drawGroundGlow(w, h) {
|
||
const { ctx } = this;
|
||
const cx = w / 2;
|
||
const cy = h * 0.52;
|
||
const radius = w * 0.62;
|
||
const intensity = 0.3 + this.glow * 0.9;
|
||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||
gradient.addColorStop(0, rgbCss(this.toneRgb, Math.min(0.34, 0.2 * intensity)));
|
||
gradient.addColorStop(0.5, rgbCss(this.toneRgb, Math.min(0.12, 0.07 * intensity)));
|
||
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, w, h);
|
||
}
|
||
|
||
_drawContactShadow(w, h) {
|
||
const { ctx } = this;
|
||
const lift = (this.platePose ? 0 : 0) + 0.02;
|
||
ctx.save();
|
||
ctx.translate(w / 2, h * 0.5 + h * 0.4 + lift);
|
||
ctx.scale(1, 0.2);
|
||
const radius = w * 0.31;
|
||
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
|
||
gradient.addColorStop(0, 'rgba(0,0,0,0.6)');
|
||
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, radius, 0, TAU);
|
||
ctx.fillStyle = gradient;
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ─── shading & paint helpers ─── */
|
||
|
||
/** Blinn-Phong-ish shade: ambient + Lambert, specular, and a verdict-tinted rim. */
|
||
_shade(base, normal, light, tone, rimStrength) {
|
||
const diffuse = Math.max(0, dot(normal, light));
|
||
const half = normalize([light[0], light[1], light[2] + 1]);
|
||
const spec = Math.pow(Math.max(0, dot(normal, half)), 40) * 0.5;
|
||
const rim = Math.pow(1 - clamp(dot(normal, VIEW), 0, 1), 3) * rimStrength;
|
||
const lit = 0.13 + diffuse * 0.95;
|
||
const rgb = [
|
||
base[0] * lit + SPECULAR[0] * spec,
|
||
base[1] * lit + SPECULAR[1] * spec,
|
||
base[2] * lit + SPECULAR[2] * spec
|
||
];
|
||
return mixRgb(rgb, tone, clamp(rim * 0.55, 0, 1));
|
||
}
|
||
|
||
/** Solid fill for faceted faces, soft gradient for the large curved ones. */
|
||
_faceStyle(face, base, tone, rimStrength) {
|
||
if (!face.smooth) {
|
||
return rgbCss(this._shade(base, face.normal, LIGHT, tone, rimStrength));
|
||
}
|
||
const near = this._shade(base, face.normal, LIGHT_NEAR, tone, rimStrength);
|
||
const far = this._shade(base, face.normal, LIGHT_FAR, tone, rimStrength);
|
||
const bounds = boundsOf(face.points);
|
||
const gradient = this.ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.maxX, bounds.maxY);
|
||
gradient.addColorStop(0, rgbCss(mixRgb(near, SPECULAR, 0.05)));
|
||
gradient.addColorStop(1, rgbCss(far));
|
||
return gradient;
|
||
}
|
||
|
||
_pathFromPoints(points) {
|
||
const ctx = this.ctx;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x, points[0].y);
|
||
for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y);
|
||
ctx.closePath();
|
||
}
|
||
|
||
_clipPolygon(points) {
|
||
this._pathFromPoints(points);
|
||
this.ctx.clip();
|
||
}
|
||
|
||
_fillFace(face, style) {
|
||
if (face.points.length < 3) return;
|
||
const ctx = this.ctx;
|
||
this._pathFromPoints(face.points);
|
||
ctx.fillStyle = style;
|
||
ctx.fill();
|
||
// Hairline edge keeps the facets reading as machined metal rather than flat shapes.
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||
ctx.stroke();
|
||
}
|
||
|
||
/* ─── liquid ─── */
|
||
|
||
_fillLiquid(points, tone) {
|
||
if (points.length < 3) return;
|
||
const ctx = this.ctx;
|
||
const bounds = boundsOf(points);
|
||
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
|
||
gradient.addColorStop(0, rgbCss(mixRgb(hexToRgb('#050a16'), tone, 0.24)));
|
||
gradient.addColorStop(0.6, rgbCss(hexToRgb('#03060d')));
|
||
gradient.addColorStop(1, rgbCss(mixRgb(hexToRgb('#010205'), tone, 0.12)));
|
||
this._pathFromPoints(points);
|
||
ctx.fillStyle = gradient;
|
||
ctx.fill();
|
||
}
|
||
|
||
_overlayLiquid(liquid, tone) {
|
||
const ctx = this.ctx;
|
||
const points = liquid.points;
|
||
if (points.length >= 3) {
|
||
const bounds = boundsOf(points);
|
||
const gradient = ctx.createLinearGradient(bounds.minX, bounds.minY, bounds.minX, bounds.maxY);
|
||
gradient.addColorStop(0, rgbCss(tone, 0.22));
|
||
gradient.addColorStop(1, rgbCss(tone, 0.03));
|
||
this._pathFromPoints(points);
|
||
ctx.fillStyle = gradient;
|
||
ctx.fill();
|
||
}
|
||
const [a, b] = liquid.surface;
|
||
// drifting specular streaks just under the surface
|
||
for (let i = 0; i < 3; i += 1) {
|
||
const t = (this.clock * 0.33 + i / 3) % 1;
|
||
ctx.beginPath();
|
||
ctx.ellipse(lerp(a.x, b.x, t), lerp(a.y, b.y, t) + 3, 15, 3, 0, 0, TAU);
|
||
ctx.fillStyle = rgbCss(SPECULAR, 0.05);
|
||
ctx.fill();
|
||
}
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.45), 0.55);
|
||
ctx.lineWidth = 1.4;
|
||
ctx.stroke();
|
||
}
|
||
|
||
_strokeWindow(windowPoly, tone) {
|
||
const ctx = this.ctx;
|
||
const bounds = boundsOf(windowPoly);
|
||
ctx.save();
|
||
this._pathFromPoints(windowPoly);
|
||
ctx.strokeStyle = rgbCss(mixRgb(tone, SPECULAR, 0.3), Math.min(0.75, 0.3 + this.glow * 0.35));
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
this._pathFromPoints(windowPoly);
|
||
ctx.clip();
|
||
const cx = (bounds.minX + bounds.maxX) / 2;
|
||
const cy = (bounds.minY + bounds.maxY) / 2;
|
||
const radius = Math.max(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) / 2;
|
||
const vignette = ctx.createRadialGradient(cx, cy, radius * 0.55, cx, cy, radius);
|
||
vignette.addColorStop(0, 'rgba(0,0,0,0)');
|
||
vignette.addColorStop(1, 'rgba(0,0,0,0.7)');
|
||
ctx.fillStyle = vignette;
|
||
ctx.fillRect(bounds.minX, bounds.minY, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY);
|
||
ctx.restore();
|
||
}
|
||
|
||
/* ─── verdict text mapped onto the die face ─── */
|
||
|
||
_drawAnswerText(plateView) {
|
||
const ctx = this.ctx;
|
||
const face = plateView.faces.find((candidate) => candidate.kind === 'answer');
|
||
if (!face || face.points.length < 6) return;
|
||
if (dot(face.normal, VIEW) < 0.3) return;
|
||
|
||
// The face keeps the plate's local vertex order, so three corners are enough to
|
||
// solve the affine transform from plate-local space into screen space.
|
||
const corners = [0, 3, 5].map((index) => ({
|
||
local: this.plate.points[index],
|
||
screen: [face.points[index].x, face.points[index].y]
|
||
}));
|
||
const matrix = affineFromThreePoints(corners[0], corners[1], corners[2]);
|
||
if (!matrix) return;
|
||
|
||
const lines = this.answerLines;
|
||
if (!lines || !lines.length) return;
|
||
const radius = this.plate.radius;
|
||
const usableWidth = radius * 1.5;
|
||
const maxChars = Math.max(4, ...lines.map((line) => line.length));
|
||
let fontSize = clamp(
|
||
Math.min(usableWidth / (maxChars * 0.62), (radius * 1.05) / lines.length),
|
||
0.05,
|
||
0.2
|
||
);
|
||
const pixelsPerUnit = Math.hypot(matrix[0], matrix[1]) / this.dpr;
|
||
|
||
ctx.save();
|
||
ctx.setTransform(
|
||
this.dpr * matrix[0],
|
||
this.dpr * matrix[1],
|
||
this.dpr * matrix[2],
|
||
this.dpr * matrix[3],
|
||
this.dpr * matrix[4],
|
||
this.dpr * matrix[5]
|
||
);
|
||
this._pathFromPoints(this.plate.points.map(([x, y]) => ({ x, y })));
|
||
ctx.clip();
|
||
|
||
// Shrink to fit if the monospace metrics run wide of the plate.
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
ctx.font = `700 ${fontSize}px ${FONT_STACK}`;
|
||
const widest = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
||
if (widest <= usableWidth) break;
|
||
fontSize *= usableWidth / widest;
|
||
}
|
||
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.globalAlpha = this.answerAlpha;
|
||
ctx.fillStyle = '#f2f7ff';
|
||
ctx.shadowColor = rgbCss(this.toneRgb, 0.85);
|
||
ctx.shadowBlur = Math.max(2, fontSize * pixelsPerUnit * 0.45);
|
||
const lineHeight = fontSize * 1.3;
|
||
const top = -((lines.length - 1) * lineHeight) / 2;
|
||
lines.forEach((line, index) => {
|
||
ctx.fillText(line, 0, top + index * lineHeight);
|
||
});
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
/* ─── helpers (exported for tests) ─── */
|
||
|
||
function boundsOf(points) {
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
for (const point of points) {
|
||
if (point.x < minX) minX = point.x;
|
||
if (point.x > maxX) maxX = point.x;
|
||
if (point.y < minY) minY = point.y;
|
||
if (point.y > maxY) maxY = point.y;
|
||
}
|
||
return { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
/**
|
||
* Sutherland–Hodgman clip of a convex polygon against the half-plane below the line
|
||
* a→b. Screen space has y pointing down, so "below the surface line" is a positive
|
||
* cross product.
|
||
*/
|
||
export function clipPolygonBelow(points, a, b) {
|
||
const out = [];
|
||
const side = (p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||
for (let i = 0; i < points.length; i += 1) {
|
||
const current = points[i];
|
||
const next = points[(i + 1) % points.length];
|
||
const currentSide = side(current);
|
||
const nextSide = side(next);
|
||
if (currentSide >= 0) out.push(current);
|
||
if (currentSide >= 0 !== nextSide >= 0) {
|
||
const t = currentSide / (currentSide - nextSide);
|
||
out.push({
|
||
x: current.x + (next.x - current.x) * t,
|
||
y: current.y + (next.y - current.y) * t
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Upper-cases a verdict and wraps it into at most three plate-sized lines. */
|
||
export function wrapAnswer(text) {
|
||
const words = String(text || 'Cannot predict now')
|
||
.toUpperCase()
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.split(' ')
|
||
.filter(Boolean);
|
||
if (!words.length) return ['CANNOT', 'PREDICT'];
|
||
const lines = [];
|
||
let current = '';
|
||
for (const word of words) {
|
||
const candidate = current ? `${current} ${word}` : word;
|
||
if (!current || candidate.length <= 11) {
|
||
current = candidate;
|
||
} else {
|
||
lines.push(current);
|
||
current = word;
|
||
}
|
||
}
|
||
if (current) lines.push(current);
|
||
if (lines.length > 3) {
|
||
return [...lines.slice(0, 2), lines.slice(2).join(' ').slice(0, 14)];
|
||
}
|
||
return lines;
|
||
}
|