Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ <h2 id="results-title" tabindex="-1"></h2>
</div>
</div>

<div class="field">
<button id="clairvoyant-toggle" class="switch" aria-pressed="false" aria-describedby="clairvoyant-description"><span>Clairvoyant</span><span class="switch-track" aria-hidden="true"></span></button>
<p id="clairvoyant-description" class="arcade-description">Preview every moving ball while you aim. Each path matches its ball’s color. Uses the Look ahead limit below.</p>
</div>

<div class="field look-ahead-setting">
<label class="range-label" for="look-ahead">Look ahead <output id="look-ahead-value" for="look-ahead">All bounces</output></label>
<input id="look-ahead" type="range" min="0" max="6" step="1" value="6" aria-valuetext="All bounces" aria-describedby="look-ahead-description">
Expand Down
34 changes: 34 additions & 0 deletions physics/aim-preview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
});
14 changes: 12 additions & 2 deletions src/aim-guide.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 <tonemapping_fragment>
#include <colorspace_fragment>
}`,
Expand Down
12 changes: 12 additions & 0 deletions src/aim-guide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
15 changes: 15 additions & 0 deletions src/aim-prediction.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
4 changes: 2 additions & 2 deletions src/computer-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 38 additions & 13 deletions src/pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -923,6 +928,7 @@ function showGameControls(show) {
buildBallSetPicker(setBallStyle, setPlanetSaturation);
setPlanetSaturation(planetSaturation);
syncLookAhead();
syncClairvoyant();
updateBallSetPicker(ballStyle, null);
if (!spinEl) {
spinEl = document.getElementById('spin');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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'}`;
Expand Down
10 changes: 6 additions & 4 deletions src/shot-simulation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) }));
Expand All @@ -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) {
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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(); }
}