From b9e762bca2f0f83298f24d0804039f0dfc05feca Mon Sep 17 00:00:00 2001 From: Darrin Massena Date: Sun, 13 Sep 2026 23:17:31 -0700 Subject: [PATCH] Add Clairvoyant previews for every moving ball --- README.md | 5 +++- index.html | 5 ++++ physics/aim-preview.test.mjs | 34 ++++++++++++++++++++++++ src/aim-guide.js | 14 ++++++++-- src/aim-guide.test.mjs | 12 +++++++++ src/aim-prediction.test.mjs | 15 +++++++++++ src/computer-worker.js | 4 +-- src/pool.js | 51 +++++++++++++++++++++++++++--------- src/shot-simulation.js | 10 ++++--- 9 files changed, 128 insertions(+), 22 deletions(-) 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 @@

+
+ +

Preview every moving ball while you aim. Each path matches its ball’s color. Uses the Look ahead limit below.

+
+
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(); } }