diff --git a/README.md b/README.md
index 8a89169..c2afa58 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,10 @@ collisions throughout the rack, cushions, and Black Hole Gravity. White follows
gold follows the first object hit. Stop rings mark their simulated final positions when they
remain on the table. A faint, horizontal line from the cue ball to first impact updates immediately and remains visible
while predictions update. Obsolete simulations are cancelled; incomplete previews do not
-claim a stopping position. Width and brightness reflect power. The **Look ahead** slider in settings limits the cue-ball
+claim a stopping position. Width and brightness reflect power. Enable **Clairvoyant** in settings
+to see paths for every moving ball, including combination shots and the break. Lines match the
+active ball set’s colors (planet colors for Planets); the black ball’s line has a light outline.
+Clairvoyant is off by default and remembered on this device. The **Look ahead** slider in settings limits the cue-ball
preview to 0–5 bounces or **All**; contacts with balls and cushions both count. Shorter settings
stop the simulation at that horizon too, truncating any object-ball path still in motion without
a stopping circle. A shot that settles sooner still shows its predicted resting position.
diff --git a/index.html b/index.html
index 6d21cad..8f412dd 100644
--- a/index.html
+++ b/index.html
@@ -174,6 +174,11 @@
+
+
Clairvoyant
+
Preview every moving ball while you aim. Each path matches its ball’s color. Uses the Look ahead limit below.
+
+
Look ahead All bounces
diff --git a/physics/aim-preview.test.mjs b/physics/aim-preview.test.mjs
index 0acdbcf..2c7e0e6 100644
--- a/physics/aim-preview.test.mjs
+++ b/physics/aim-preview.test.mjs
@@ -103,3 +103,37 @@ test('look-ahead counts ball contacts as well as cushions and retains early stop
assert.equal(soft.settled, true);
assert.equal(soft.paths[0].stopped, true);
});
+
+test('Clairvoyant follows secondary collisions and matches every moved ball’s live endpoint', () => {
+ const table = practiceTable([
+ { number: 0, x: -18, y: 0 }, { number: 5, x: -2, y: 0 },
+ { number: 2, x: 3, y: 0 }, { number: 14, x: 8, y: 0 }, { number: 1, x: -20, y: 12 },
+ ]);
+ const shot = { dir: { x: 1, y: 0 }, speed: 36 };
+ const normal = simulateShot(table, newMatch(), shot, true, { aimPreview: true });
+ const all = simulateShot(table, newMatch(), shot, true, { aimPreview: true, allBallPaths: true });
+ assert.deepEqual(normal.paths.map(p => p.number), [0, 5]);
+ assert.deepEqual(all.paths.map(p => p.number).sort((a,b) => a-b), [0, 2, 5, 14]);
+ assert.deepEqual(all.balls, normal.balls, 'recording more paths must not change physics');
+ assert.deepEqual(all.report, normal.report);
+ const actual = play(table, shot);
+ for (const path of all.paths) {
+ assert.ok(path.stopped);
+ const end = path.points.at(-1), ball = actual.find(b => b.number === path.number);
+ assert.ok(Math.hypot(end.x - ball.x, end.y - ball.y) < 0.01);
+ }
+});
+
+test('Clairvoyant includes the whole break and still honors shorter look ahead', () => {
+ const table = practiceTable(rackPositions());
+ const shot = { dir: { x: 1, y: 0 }, speed: 100 };
+ const full = simulateShot(table, newMatch(), shot, true, { aimPreview: true, allBallPaths: true });
+ assert.equal(full.paths.length, 16);
+ const short = simulateShot(table, newMatch(), shot, true, { aimPreview: true, allBallPaths: true, maxCueBounces: 0 });
+ assert.equal(short.settled, false);
+ assert.equal(short.paths[0].bounces.length, 1);
+ assert.ok(short.paths.every(path => !path.stopped));
+ for (const path of short.paths) {
+ assert.deepEqual(path.points, full.paths.find(p => p.number === path.number).points.slice(0, path.points.length));
+ }
+});
diff --git a/src/aim-guide.js b/src/aim-guide.js
index 877dd05..44c01e2 100644
--- a/src/aim-guide.js
+++ b/src/aim-guide.js
@@ -1,10 +1,18 @@
import * as THREE from 'three';
+import { BALL_COLORS } from './ballcaps.js';
+import { planetForBall } from './ball-sets.js';
+
+export function aimPathColor(number, style, clairvoyant) {
+ if (!clairvoyant) return number === 0 ? '#ffffff' : '#ffd27a';
+ if (style === 'planets') return planetForBall(number).color;
+ return number === 0 ? '#ffffff' : BALL_COLORS[(number - 1) % 8 + 1];
+}
export function createPowerGuide(color) {
// A ribbon gives real width on WebGL, where LineBasicMaterial's linewidth is ignored.
const geometry = new THREE.BufferGeometry();
const material = new THREE.ShaderMaterial({
- uniforms: { color: { value: new THREE.Color(color) }, opacity: { value: 0 } },
+ uniforms: { color: { value: new THREE.Color(color) }, opacity: { value: 0 }, outline: { value: 0 } },
transparent: true, depthWrite: false, side: THREE.DoubleSide,
vertexShader: `
varying vec2 vUv;
@@ -15,11 +23,13 @@ export function createPowerGuide(color) {
fragmentShader: `
uniform vec3 color;
uniform float opacity;
+ uniform float outline;
varying vec2 vUv;
void main() {
float tip = 1.0 - smoothstep(0.85, 1.0, vUv.x);
float edge = 1.0 - smoothstep(0.3, 0.5, abs(vUv.y - 0.5));
- gl_FragColor = vec4(color, opacity * tip * edge);
+ vec3 ink = mix(color, vec3(0.9), outline * smoothstep(0.22, 0.42, abs(vUv.y - 0.5)));
+ gl_FragColor = vec4(ink, opacity * tip * edge);
#include
#include
}`,
diff --git a/src/aim-guide.test.mjs b/src/aim-guide.test.mjs
index 5ab6fff..c89a396 100644
--- a/src/aim-guide.test.mjs
+++ b/src/aim-guide.test.mjs
@@ -30,3 +30,15 @@ test('look ahead truncates at the next contact and never marks it as a resting p
assert.equal(limitCuePath(path, 3), path);
assert.equal(limitCuePath(path, Infinity), path);
});
+
+test('Clairvoyant colors follow the active ball set, including stripes and the sun', async () => {
+ const { aimPathColor } = await import('./aim-guide.js');
+ const { BALL_COLORS } = await import('./ballcaps.js');
+ const { planetForBall } = await import('./ball-sets.js');
+ for (let number = 0; number <= 15; number++) {
+ assert.equal(aimPathColor(number, 'planets', true), planetForBall(number).color);
+ assert.equal(aimPathColor(number, 'balls', true), number === 0 ? '#ffffff' : BALL_COLORS[(number - 1) % 8 + 1]);
+ assert.equal(aimPathColor(number, 'heads', true), aimPathColor(number, 'balls', true));
+ assert.equal(aimPathColor(number, 'planets', false), number === 0 ? '#ffffff' : '#ffd27a');
+ }
+});
diff --git a/src/aim-prediction.test.mjs b/src/aim-prediction.test.mjs
index e145f66..ec094db 100644
--- a/src/aim-prediction.test.mjs
+++ b/src/aim-prediction.test.mjs
@@ -46,3 +46,18 @@ test('worker errors and disposed worker messages cannot display a false endpoint
workers[1].onmessage({ data: { id: messages.at(-1).id, paths: ['valid'] } });
assert.deepEqual(p.result.paths, ['valid']);
});
+
+test('changing Clairvoyant invalidates stale paths without resnapshotting the table', () => {
+ const { prediction: p, messages, workers } = fixture();
+ p.update({ speed: 30, clairvoyant: false });
+ const oldId = messages[0].id;
+ p.update({ speed: 30, clairvoyant: true });
+ assert.equal(messages[1].shot.clairvoyant, true);
+ assert.equal(messages[1].table, undefined);
+ workers[0].onmessage({ data: { id: oldId, paths: ['old'] } });
+ assert.equal(p.result, null);
+ workers[0].onmessage({ data: { id: messages[1].id, paths: ['all'] } });
+ assert.deepEqual(p.result.paths, ['all']);
+ p.update({ speed: 30, clairvoyant: false });
+ assert.equal(p.result, null);
+});
diff --git a/src/computer-worker.js b/src/computer-worker.js
index b66d335..0093020 100644
--- a/src/computer-worker.js
+++ b/src/computer-worker.js
@@ -6,8 +6,8 @@ import { AimWorkerRunner } from './aim-worker-runner.js';
import { newMatch } from './eight-ball.js';
const ready = RAPIER.init();
const aimRunner = new AimWorkerRunner(function* ({ table, shot }) {
- const result = yield* simulateShotSteps(table, newMatch(), shot, true, { aimPreview: true, yieldEvery: 64, maxCueBounces: shot.lookAhead ?? Infinity });
- return { paths: result.paths, settled: result.settled };
+ const result = yield* simulateShotSteps(table, newMatch(), shot, true, { aimPreview: true, allBallPaths: !!shot.clairvoyant, yieldEvery: 64, maxCueBounces: shot.lookAhead ?? Infinity });
+ return { paths: result.paths, settled: result.settled, first: result.report.first };
}, result => self.postMessage(result));
self.onmessage = async ({ data }) => {
try {
diff --git a/src/pool.js b/src/pool.js
index 553db3b..d1bd8d8 100644
--- a/src/pool.js
+++ b/src/pool.js
@@ -28,7 +28,7 @@ import { inviteRoom, startupRoom, rememberGameChoice } from './room-navigation.j
import { GameplayAnalytics, sendGameAnalytics } from './game-analytics.js';
import { groupLabel, playerName, playerText, rackOutcome, turnStatus } from './match-copy.js';
import { setOverheadCamera, withinCueTarget, setGuideLine } from './table-view.js';
-import { createPowerGuide, setPowerPath, limitCuePath } from './aim-guide.js';
+import { createPowerGuide, setPowerPath, limitCuePath, aimPathColor } from './aim-guide.js';
import { AimPrediction } from './aim-prediction.js';
import { CueAim } from './cue-aim.js';
import { cuePose } from './cue-pose.js';
@@ -78,6 +78,8 @@ let attractMode = false, attractWait = 0;
const COMPUTER_CUE_TIME = 0.18;
let difficulty = 'tricky', onlineShotSeq = null, onlineShooter = null;
let gravityEnabled = false;
+let clairvoyant = false;
+try { clairvoyant = localStorage.getItem('pool.clairvoyant') === 'true'; } catch {}
let lookAhead = 6; // 0–5 bounces; 6 means the full simulated path.
try {
const saved = localStorage.getItem('pool.lookAhead');
@@ -352,14 +354,16 @@ function build() {
// ---- simulated aiming paths and the cue stick ----
guide = new THREE.Group(); addMesh(guide);
guide.line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.3 }));
- guide.objLine = createPowerGuide('#ffd27a');
- guide.cueLine = createPowerGuide('#ffffff');
- for (const line of [guide.objLine, guide.cueLine]) {
+ guide.pathLines = Array.from({ length: 16 }, (_, number) => {
+ const line = createPowerGuide(number === 0 ? '#ffffff' : '#ffd27a');
+ line.number = number;
line.stop = new THREE.Mesh(new THREE.TorusGeometry(R, 0.045, 6, 48),
new THREE.MeshBasicMaterial({ color: line.material.uniforms.color.value, transparent: true, opacity: 0.55, depthWrite: false }));
- guide.add(line.stop);
- }
- guide.add(guide.line, guide.objLine, guide.cueLine); guide.visible = false;
+ guide.add(line, line.stop);
+ return line;
+ });
+ guide.cueLine = guide.pathLines[0];
+ guide.add(guide.line); guide.visible = false;
cueStick = new THREE.Group();
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.3, 24, 16), new THREE.MeshStandardMaterial({ color: '#e2c48f', roughness: 0.35 }));
const butt = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.42, 18, 16), new THREE.MeshStandardMaterial({ map: woodMap(512, 1, 21, ['#2a1408', '#3d1f0c', '#1e0f06', '#4a2a12']), roughness: 0.4 }));
@@ -455,6 +459,7 @@ async function setBallStyle(id) {
}
stopReplay();
ballStyle = rackStyle = style;
+ if (guide) guide.prediction = null;
try { localStorage.setItem('playful.ballStyle', style); } catch {}
for (const ball of allBalls()) {
planetSet?.detach(ball.mesh);
@@ -923,6 +928,7 @@ function showGameControls(show) {
buildBallSetPicker(setBallStyle, setPlanetSaturation);
setPlanetSaturation(planetSaturation);
syncLookAhead();
+ syncClairvoyant();
updateBallSetPicker(ballStyle, null);
if (!spinEl) {
spinEl = document.getElementById('spin');
@@ -963,6 +969,16 @@ function showGameControls(show) {
if (guide) guide.prediction = null;
try { localStorage.setItem('pool.lookAhead', String(lookAhead)); } catch {}
});
+ document.getElementById('clairvoyant-toggle').addEventListener('click', () => {
+ clairvoyant = !clairvoyant;
+ syncClairvoyant();
+ aimPrediction.clear();
+ if (guide) {
+ guide.prediction = null;
+ for (const line of guide.pathLines) line.visible = line.stop.visible = false;
+ }
+ try { localStorage.setItem('pool.clairvoyant', String(clairvoyant)); } catch {}
+ });
document.getElementById('black-hole-gravity-toggle').addEventListener('click', () => {
if (!canChangeGravity()) return;
gravityEnabled = !gravityEnabled;
@@ -1021,7 +1037,8 @@ const aimPrediction = new AimPrediction(
// circles are updated separately and always belong to the current aim.
function updateGuide(c, dir, pull, shotSpin = { x: 0, y: 0 }) {
const power = THREE.MathUtils.clamp(pull / MAX_PULL, 0, 1);
- const prediction = pull >= 0.3 ? aimPrediction.update({ dir: { x: dir.x, y: dir.y }, speed: power * MAX_SPEED, spin: shotSpin, lookAhead: lookAhead === 6 ? null : lookAhead }) : null;
+ const revealAll = clairvoyant && !!aiming?.control;
+ const prediction = pull >= 0.3 ? aimPrediction.update({ dir: { x: dir.x, y: dir.y }, speed: power * MAX_SPEED, spin: shotSpin, lookAhead: lookAhead === 6 ? null : lookAhead, clairvoyant: revealAll }) : null;
if (pull < 0.3) aimPrediction.clear();
const hit = world.castShape(c, { x: 0, y: 0, z: 0, w: 1 }, { x: dir.x, y: dir.y, z: 0 },
aimCastBall, 0, 200, false, undefined, undefined, feltCol, cue.body);
@@ -1033,16 +1050,21 @@ function updateGuide(c, dir, pull, shotSpin = { x: 0, y: 0 }) {
setGuideLine(guide.line, start, end);
guide.line.visible = true;
if (!prediction) {
- guide.cueLine.visible = guide.objLine.visible = false;
- guide.cueLine.stop.visible = guide.objLine.stop.visible = false;
+ for (const line of guide.pathLines) line.visible = line.stop.visible = false;
guide.prediction = null;
return;
}
if (guide.prediction === prediction) return;
guide.prediction = prediction;
- for (const line of [guide.cueLine, guide.objLine]) {
- const fullPath = prediction.paths.find(p => line === guide.cueLine ? p.number === 0 : p.number !== 0);
- const path = line === guide.cueLine ? limitCuePath(fullPath, lookAhead === 6 ? Infinity : lookAhead) : fullPath;
+ for (const line of guide.pathLines) {
+ const number = line.number;
+ const fullPath = revealAll || number === 0 || number === prediction.first
+ ? prediction.paths.find(p => p.number === number) : null;
+ const path = number === 0 ? limitCuePath(fullPath, lookAhead === 6 ? Infinity : lookAhead) : fullPath;
+ const color = aimPathColor(number, ballStyle, revealAll);
+ line.material.uniforms.color.value.set(color);
+ line.material.uniforms.outline.value = revealAll && number === 8 ? 1 : 0;
+ line.stop.material.color.set(color);
setPowerPath(line, path?.points || [], BALL_Z, power);
line.stop.visible = line.visible && path.stopped;
if (line.stop.visible) {
@@ -1122,6 +1144,9 @@ function canChangeGravity() {
return gameMode !== 'online' && !rackMotion && !activeShot && !arcade.active &&
!replayView.active && !dragging && !placing && tableStill();
}
+function syncClairvoyant() {
+ document.getElementById('clairvoyant-toggle').setAttribute('aria-pressed', String(clairvoyant));
+}
function syncLookAhead() {
const slider = document.getElementById('look-ahead');
const label = lookAhead === 6 ? 'All bounces' : lookAhead === 0 ? 'First impact only' : `${lookAhead} ${lookAhead === 1 ? 'bounce' : 'bounces'}`;
diff --git a/src/shot-simulation.js b/src/shot-simulation.js
index fd4f132..81a2f62 100644
--- a/src/shot-simulation.js
+++ b/src/shot-simulation.js
@@ -25,7 +25,7 @@ export function simulateShot(...args) {
// Yielding never advances or changes physics. Interactive workers can abandon
// an obsolete shot between batches; return() still frees the restored world.
-export function* simulateShotSteps(table, state, shot, trace = false, { arcade = false, solo = false, aimPreview = false, yieldEvery = 0, maxCueBounces = Infinity } = {}) {
+export function* simulateShotSteps(table, state, shot, trace = false, { arcade = false, solo = false, aimPreview = false, allBallPaths = false, yieldEvery = 0, maxCueBounces = Infinity } = {}) {
const world = RAPIER.World.restoreSnapshot(table.snapshot), queue = new RAPIER.EventQueue(true);
try {
const balls = table.handles.map(b => ({ number: b.number, body: world.getRigidBody(b.handle) }));
@@ -43,7 +43,7 @@ export function* simulateShotSteps(table, state, shot, trace = false, { arcade =
}
// Read a bounded trace without running a second shot or changing its physics.
// Player aim retains longer paths; computer search uses shorter samples.
- const paths = trace ? balls.filter(b => arcade || b.number === 0 || !aimPreview && b.number === shot.target).map(b => ({ number: b.number, points: [], bounces: [], body: b.body, ended: false })) : null;
+ const paths = trace ? balls.filter(b => arcade || b.number === 0 || aimPreview && allBallPaths || !aimPreview && b.number === shot.target).map(b => ({ number: b.number, points: [], bounces: [], body: b.body, ended: false })) : null;
const initial = aimPreview ? new Map(balls.map(b => [b.number, b.body.translation()])) : null;
const sample = () => {
for (const path of paths) {
@@ -79,7 +79,7 @@ export function* simulateShotSteps(table, state, shot, trace = false, { arcade =
if (na === 0 && nb > 0) report.first = nb;
if (nb === 0 && na > 0) report.first = na;
}
- if (aimPreview && paths?.length === 1 && report.first !== null) {
+ if (aimPreview && !allBallPaths && paths?.length === 1 && report.first !== null) {
const number = report.first, p = initial.get(number);
paths.push({ number, body: byNumber.get(number), points: [{ x: p.x, y: p.y }], ended: false });
}
@@ -141,6 +141,8 @@ export function* simulateShotSteps(table, state, shot, trace = false, { arcade =
const ballsAfter = balls.filter(b => b.body.isEnabled()).map(b => ({ number: b.number, x: b.body.translation().x, y: b.body.translation().y }));
return { ...(solo ? resolveSoloShot : resolveShot)(state, report), report, balls: ballsAfter, settled,
...(tracker && { evidence: tracker.evidence(), arcade: tracker.report() }),
- ...(paths && { paths: paths.map(({ number, points, stopped, bounces }) => ({ number, points, ...(aimPreview && { stopped, bounces: bounces || [] }) })) }) };
+ ...(paths && { paths: paths.filter(path => !aimPreview || !allBallPaths || path.number === 0 ||
+ path.points.some(p => Math.hypot(p.x - path.points[0].x, p.y - path.points[0].y) > 0.02))
+ .map(({ number, points, stopped, bounces }) => ({ number, points, ...(aimPreview && { stopped, bounces: bounces || [] }) })) }) };
} finally { queue.free(); world.free(); }
}