diff --git a/src/game.ts b/src/game.ts new file mode 100644 index 0000000..c57da5e --- /dev/null +++ b/src/game.ts @@ -0,0 +1,446 @@ +/** + * Game mode entry point. + * + * Creates a GameController with the selected rules, sets up 3D rendering, + * cue input handler, trajectory preview, and game UI. + */ + +import * as THREE from 'three' +import Ball from './lib/ball' +import type { ReplayData } from './lib/simulation' +import { EventType } from './lib/simulation' +import SimulationScene from './lib/scene/simulation-scene' +import { CueStick } from './lib/scene/cue-stick' +import { CueInput } from './lib/input/cue-input' +import { computeTrajectoryPreview, type PreviewResult } from './lib/input/trajectory-preview' +import { GameController } from './lib/game/game-controller' +import { createGameBridge, type GameBridge } from './lib/game/game-bridge' +import type { GameRules } from './lib/game/rules' +import { createConfig } from './lib/config' +import type Vector2D from './lib/vector2d' + +export interface GameInstance { + destroy: () => void + bridge: GameBridge +} + +export function startGame(rules: GameRules, containerElement: HTMLElement): GameInstance { + const tableConfig = rules.getTableConfig() + + // Build a SimulationConfig compatible with the game + const config = createConfig() + config.tableWidth = tableConfig.width + config.tableHeight = tableConfig.height + config.ballTextureSet = rules.getBallTextureSet() + config.physicsProfile = 'pool' + config.showCircles = false + config.showTails = false + config.showCollisions = false + config.showCollisionPreview = false + config.showFutureTrails = false + config.showBallInspector = false + config.showStats = false + config.tableColor = '#1a6e3a' // green felt + + // Three.js renderer + const renderer = new THREE.WebGLRenderer({ antialias: true }) + renderer.setSize(window.innerWidth, window.innerHeight) + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.shadowMap.enabled = config.shadowsEnabled + renderer.shadowMap.type = THREE.PCFSoftShadowMap + containerElement.appendChild(renderer.domElement) + + // 2D overlay canvas + const canvas2D = document.createElement('canvas') + canvas2D.width = config.tableWidth / 2 + canvas2D.height = config.tableHeight / 2 + const ctx2D = canvas2D.getContext('2d')! + + // State + let simulationScene: SimulationScene | null = null + let cueStick: CueStick | null = null + let cueInput: CueInput | null = null + let animationFrameId: number | null = null + let ballState: { [key: string]: Ball } = {} + let circleIds: string[] = [] + let nextEvent: ReplayData | undefined + let simulatedResults: ReplayData[] = [] + let currentProgress = 0 + let prevTimestamp: number | null = null + let isSimulating = false + let previewResult: PreviewResult | null = null + + // Ball positions for trajectory preview + const ballPositions = new Map() + + // Game bridge + const bridge = createGameBridge({ + onShoot: () => { + if (cueInput) cueInput.shoot() + }, + onPlaceCueBall: (position: Vector2D) => { + controller.placeCueBall(position) + }, + onNewGame: () => { + cleanup() + controller.startGame() + }, + onBackToMenu: () => { + instance.destroy() + window.location.hash = '' + }, + onToggleMode: () => { + if (!cueInput) return + const newMode = cueInput.getMode() === 'aim' ? 'camera' : 'aim' + cueInput.setMode(newMode) + bridge.update({ inputMode: newMode }) + }, + }) + + // Game controller + const controller = new GameController(rules, ['Player 1', 'Player 2'], { + onStateChange: (state) => { + bridge.update({ + gameState: state, + scores: rules.getScoreDisplay(state), + validTargets: rules.getValidTargets(state), + isSimulating: state.phase === 'simulating', + }) + + if (state.phase === 'aiming') { + if (cueInput) { + cueInput.setEnabled(true) + cueInput.reset() + } + if (cueStick) cueStick.show() + } + }, + onSimulationStart: (balls, _initialEvents) => { + cleanupScene() + isSimulating = true + currentProgress = 0 + prevTimestamp = null + nextEvent = undefined + simulatedResults = [] + + // Build ball state + ballState = {} + circleIds = [] + const replayCircles: Ball[] = [] + for (const ball of balls) { + ballState[ball.id] = ball + circleIds.push(ball.id) + replayCircles.push(ball) + ballPositions.set(ball.id, [ball.position[0], ball.position[1]]) + } + + // Create 3D scene + simulationScene = new SimulationScene(canvas2D, replayCircles, config, renderer.domElement) + cueStick = new CueStick() + simulationScene.scene.add(cueStick.mesh) + cueStick.hide() + + // Setup cue input + const cueBallId = rules.getCueBallId() + const cueBall = ballState[cueBallId] + if (cueBall) { + if (cueInput) cueInput.destroy() + cueInput = new CueInput( + simulationScene.camera, + renderer.domElement, + tableConfig.width, + tableConfig.height, + { + onAimUpdate: (direction) => { + bridge.update({ aimDirection: direction }) + const snap = bridge.getSnapshot() + if (cueStick) { + cueStick.update( + [cueBall.position[0], cueBall.position[1]], + direction, + snap.aimPower, + tableConfig.width, + tableConfig.height, + rules.getBallRadius(), + ) + } + updateTrajectoryPreview(direction, snap.aimPower) + }, + onShoot: () => { + if (cueStick) cueStick.hide() + if (cueInput) cueInput.setEnabled(false) + previewResult = null + const snap = bridge.getSnapshot() + controller.takeShot({ + direction: snap.aimDirection, + power: snap.aimPower, + strikeOffset: snap.strikeOffset, + elevation: snap.elevation, + }) + }, + }, + ) + cueInput.setCueBallPosition([cueBall.position[0], cueBall.position[1]]) + cueInput.setControls(simulationScene.getOrbitControls()) + } + + // Add pocket visuals + addPocketVisuals(simulationScene.scene) + + renderer.render(simulationScene.scene, simulationScene.camera) + startAnimationLoop() + }, + onReplayData: (events) => { + if (!nextEvent && events.length > 0) { + nextEvent = events.shift() + } + simulatedResults = simulatedResults.concat(events) + }, + onShotComplete: (result) => { + isSimulating = false + bridge.update({ lastShotResult: result, isSimulating: false }) + + // Update cue ball position for input handler + const cueBallId = rules.getCueBallId() + const cueBallPos = ballPositions.get(cueBallId) + if (cueBallPos && cueInput) { + cueInput.setCueBallPosition(cueBallPos) + } + }, + onSimulationDone: () => { + // Simulation complete — controller will call onShotComplete + }, + }) + + function updateTrajectoryPreview(direction: number, power: number) { + const cueBallId = rules.getCueBallId() + const cueBallPos = ballPositions.get(cueBallId) + if (!cueBallPos) return + + const speed = power * rules.getMaxShotSpeed() + const objectBalls = new Map() + for (const [id, pos] of ballPositions) { + if (id !== cueBallId) objectBalls.set(id, pos) + } + + previewResult = computeTrajectoryPreview( + cueBallPos, + direction, + speed, + objectBalls, + rules.getBallRadius(), + tableConfig, + rules.getPhysicsConfig(), + ) + } + + function addPocketVisuals(scene: THREE.Scene) { + for (const pocket of tableConfig.pockets) { + const geometry = new THREE.CircleGeometry(pocket.radius, 32) + const material = new THREE.MeshBasicMaterial({ color: 0x111111 }) + const mesh = new THREE.Mesh(geometry, material) + mesh.rotation.x = -Math.PI / 2 + mesh.position.set( + pocket.center[0] - tableConfig.width / 2, + -0.5, + pocket.center[1] - tableConfig.height / 2, + ) + scene.add(mesh) + } + } + + function drawTrajectoryPreview() { + if (!previewResult || !ctx2D || isSimulating) return + const scale = canvas2D.width / tableConfig.width + + ctx2D.save() + + // Dotted aim line + ctx2D.strokeStyle = 'rgba(255, 255, 255, 0.5)' + ctx2D.lineWidth = 1.5 + ctx2D.setLineDash([6, 6]) + ctx2D.beginPath() + for (let i = 0; i < previewResult.cuePath.length; i++) { + const p = previewResult.cuePath[i] + const x = p[0] * scale + const y = (tableConfig.height - p[1]) * scale // flip Y for canvas + if (i === 0) ctx2D.moveTo(x, y) + else ctx2D.lineTo(x, y) + } + ctx2D.stroke() + ctx2D.setLineDash([]) + + // Ghost ball at contact point + if (previewResult.contactPoint) { + const cp = previewResult.contactPoint + const x = cp[0] * scale + const y = (tableConfig.height - cp[1]) * scale + const r = rules.getBallRadius() * scale + + ctx2D.strokeStyle = 'rgba(255, 255, 255, 0.4)' + ctx2D.lineWidth = 1 + ctx2D.beginPath() + ctx2D.arc(x, y, r, 0, Math.PI * 2) + ctx2D.stroke() + } + + // Object ball deflection line + if (previewResult.objectBallDeflection && previewResult.contactBallId) { + const objPos = ballPositions.get(previewResult.contactBallId) + if (objPos) { + ctx2D.strokeStyle = 'rgba(255, 200, 0, 0.4)' + ctx2D.lineWidth = 1 + ctx2D.setLineDash([4, 4]) + ctx2D.beginPath() + ctx2D.moveTo(objPos[0] * scale, (tableConfig.height - objPos[1]) * scale) + ctx2D.lineTo( + previewResult.objectBallDeflection[0] * scale, + (tableConfig.height - previewResult.objectBallDeflection[1]) * scale, + ) + ctx2D.stroke() + ctx2D.setLineDash([]) + } + } + + // Cue ball deflection line + if (previewResult.cueBallDeflection && previewResult.contactPoint) { + ctx2D.strokeStyle = 'rgba(255, 255, 255, 0.3)' + ctx2D.lineWidth = 1 + ctx2D.setLineDash([4, 4]) + ctx2D.beginPath() + ctx2D.moveTo( + previewResult.contactPoint[0] * scale, + (tableConfig.height - previewResult.contactPoint[1]) * scale, + ) + ctx2D.lineTo( + previewResult.cueBallDeflection[0] * scale, + (tableConfig.height - previewResult.cueBallDeflection[1]) * scale, + ) + ctx2D.stroke() + ctx2D.setLineDash([]) + } + + ctx2D.restore() + } + + function applyEventSnapshots(event: ReplayData) { + for (const snapshot of event.snapshots) { + const ball = ballState[snapshot.id] + if (!ball) continue + + ball.position[0] = snapshot.position[0] + ball.position[1] = snapshot.position[1] + ball.velocity[0] = snapshot.velocity[0] + ball.velocity[1] = snapshot.velocity[1] + ball.radius = snapshot.radius + ball.time = snapshot.time + if (snapshot.angularVelocity) ball.angularVelocity = [...snapshot.angularVelocity] + if (snapshot.motionState !== undefined) ball.motionState = snapshot.motionState + ball.trajectory.a[0] = snapshot.trajectoryA[0] + ball.trajectory.a[1] = snapshot.trajectoryA[1] + ball.trajectory.b[0] = snapshot.velocity[0] + ball.trajectory.b[1] = snapshot.velocity[1] + ball.trajectory.c[0] = snapshot.position[0] + ball.trajectory.c[1] = snapshot.position[1] + if (snapshot.angularAlpha) { + ball.angularTrajectory.alpha = [...snapshot.angularAlpha] + ball.angularTrajectory.omega0 = [...snapshot.angularOmega0] + } + + // Update stored position + ballPositions.set(snapshot.id, [snapshot.position[0], snapshot.position[1]]) + } + + // Handle ball pocketed: remove from scene + if (event.type === EventType.BallPocketed) { + const ballId = event.snapshots[0]?.id + if (ballId && simulationScene) { + // Ball will naturally stop rendering since it's removed from physics + // but we should hide it in the scene + ballPositions.delete(ballId) + } + } + } + + function startAnimationLoop() { + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId) + + function step(timestamp: number) { + const deltaMs = prevTimestamp ? timestamp - prevTimestamp : 0 + prevTimestamp = timestamp + + // Advance playback + if (isSimulating) { + currentProgress += deltaMs / 1000 + while (nextEvent && currentProgress >= nextEvent.time) { + applyEventSnapshots(nextEvent) + nextEvent = simulatedResults.shift() + } + } + + // 2D canvas rendering + ctx2D.fillStyle = config.tableColor + ctx2D.fillRect(0, 0, canvas2D.width, canvas2D.height) + + // Draw trajectory preview + drawTrajectoryPreview() + + // 3D rendering + if (simulationScene) { + simulationScene.renderAtTime(currentProgress) + renderer.render(simulationScene.scene, simulationScene.camera) + } + + animationFrameId = requestAnimationFrame(step) + } + + animationFrameId = requestAnimationFrame(step) + } + + function cleanupScene() { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } + } + + function cleanup() { + cleanupScene() + ballState = {} + circleIds = [] + simulatedResults = [] + nextEvent = undefined + previewResult = null + isSimulating = false + ballPositions.clear() + } + + // Handle window resize + const resizeHandler = () => { + renderer.setSize(window.innerWidth, window.innerHeight) + if (simulationScene) { + simulationScene.camera.aspect = window.innerWidth / window.innerHeight + simulationScene.camera.updateProjectionMatrix() + } + } + window.addEventListener('resize', resizeHandler) + + // Start the game + controller.startGame() + + const instance: GameInstance = { + destroy: () => { + cleanup() + controller.destroy() + if (cueInput) cueInput.destroy() + window.removeEventListener('resize', resizeHandler) + if (renderer.domElement.parentElement) { + renderer.domElement.parentElement.removeChild(renderer.domElement) + } + renderer.dispose() + }, + bridge, + } + + return instance +} diff --git a/src/index.ts b/src/index.ts index 57ba29f..e467e01 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,625 +1,121 @@ -import Ball from './lib/ball' -import type Vector3D from './lib/vector3d' -import { MotionState } from './lib/motion-state' -import { ReplayData } from './lib/simulation' -import CircleRenderer from './lib/renderers/circle-renderer' -import TailRenderer from './lib/renderers/tail-renderer' -import CollisionRenderer from './lib/renderers/collision-renderer' -import CollisionPreviewRenderer from './lib/renderers/collision-preview-renderer' -import FutureTrailRenderer from './lib/renderers/future-trail-renderer' -import * as THREE from 'three' -import SimulationScene, { type CameraState } from './lib/scene/simulation-scene' -import Stats from 'stats.js' -import { WorkerInitializationRequest, WorkerScenarioRequest, RequestMessageType } from './lib/worker-request' -import { WorkerResponse, isWorkerInitializationResponse, isWorkerSimulationResponse } from './lib/worker-response' -import { createConfig, SimulationConfig } from './lib/config' -import { createAdvancedUI } from './lib/ui' -import { defaultPhysicsConfig } from './lib/physics-config' -import { findScenario } from './lib/scenarios' -import { PlaybackController } from './lib/debug/playback-controller' -import { BallInspector } from './lib/debug/ball-inspector' -import { createSimulationBridge, computeBallData, type EventEntry, type BallEventSnapshot } from './lib/debug/simulation-bridge' -import { mountDebugOverlay } from './ui/index' - -const config = createConfig() - -// Support ?scenario=name URL parameter -const urlParams = new URLSearchParams(window.location.search) -const urlScenario = urlParams.get('scenario') -if (urlScenario) { - config.scenarioName = urlScenario -} - -// Buffer ahead in seconds (physics uses seconds as time unit) -const PRECALC = 10 - -let worker: Worker | null = null -let state: { [key: string]: Ball } = {} -let circleIds: string[] = [] -let replayCircles: Ball[] = [] -let nextEvent: ReplayData | undefined -let simulatedResults: ReplayData[] = [] -let fetchingMore = false -let simulationDone = false - -let threeRenderer: THREE.WebGLRenderer | null = null -let simulationScene: SimulationScene | null = null -let stats: Stats | null = null -let animationFrameId: number | null = null -let prevTimestamp: number | null = null -let resizeHandler: (() => void) | null = null -const playbackController = new PlaybackController() -const ballInspector = new BallInspector() -let currentProgress = 0 -let eventHistory: ReplayData[] = [] - -interface BallStateSnapshot { - position: Vector3D - velocity: Vector3D - radius: number - time: number - angularVelocity: Vector3D - motionState: MotionState - trajectoryA: [number, number] - angularAlpha: Vector3D - angularOmega0: Vector3D -} -let initialBallStates: Map | null = null -let lastConsumedEvent: EventEntry | null = null -let seekTarget: number | null = null -let savedCameraState: CameraState | null = null - -// --- Simulation Bridge (connects animation loop <-> React UI) --- -const bridge = createSimulationBridge(config, { - onRestartRequired: () => startSimulation(), - onPauseToggle: () => playbackController.togglePause(), - onStepForward: () => playbackController.requestStep(), - onStepBack: () => playbackController.requestStepBack(), - onStepToNextBallEvent: () => { - const ballId = bridge.getSnapshot().selectedBallId - if (ballId) playbackController.requestStepToBallEvent(ballId) - }, - onSeek: (time: number) => { - seekTarget = time - }, - onLiveUpdate: () => { - if (simulationScene) simulationScene.updateFromConfig(config) - if (threeRenderer) threeRenderer.shadowMap.enabled = config.shadowsEnabled - }, - clearBallSelection: () => ballInspector.clearSelection(), -}) - -function createCanvas(config: SimulationConfig) { - const millimeterToPixel = 1 / 2 - const canvas = document.createElement('canvas') - canvas.width = config.tableWidth * millimeterToPixel - canvas.height = config.tableHeight * millimeterToPixel - return canvas +/** + * Application entry point — routes between game modes and sandbox. + * + * Hash-based routing: + * (empty / #menu) → Main menu + * #sandbox → Debug sandbox (original mode) + * #game/eight-ball → 8-Ball Pool + * #game/nine-ball → 9-Ball Pool + * #game/snooker → Snooker + */ + +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MainMenu, type GameMode } from './ui/components/MainMenu' +import { GameUI } from './ui/components/GameUI' +import { startGame, type GameInstance } from './game' +import { startSandbox, type SandboxInstance } from './sandbox' +import { EightBallRules } from './lib/game/rules-eight-ball' +import { NineBallRules } from './lib/game/rules-nine-ball' +import { SnookerRules } from './lib/game/rules-snooker' +import type { GameRules } from './lib/game/rules' + +let sandboxInstance: SandboxInstance | null = null +let gameInstance: GameInstance | null = null +let reactRoot: Root | null = null +let uiContainer: HTMLDivElement | null = null + +function getUIContainer(): HTMLDivElement { + if (!uiContainer) { + uiContainer = document.createElement('div') + uiContainer.id = 'ui-root' + uiContainer.style.cssText = 'position:fixed;inset:0;z-index:50;pointer-events:none;' + document.body.appendChild(uiContainer) + } + return uiContainer } -let canvas2D = createCanvas(config) - -function startSimulation() { - // Save camera state before teardown - if (simulationScene) { - savedCameraState = simulationScene.getCameraState() +function getReactRoot(): Root { + if (!reactRoot) { + reactRoot = createRoot(getUIContainer()) } + return reactRoot +} - // Clean up previous simulation - if (animationFrameId !== null) { - cancelAnimationFrame(animationFrameId) - animationFrameId = null - } - if (worker) { - worker.terminate() - worker = null - } - if (resizeHandler) { - window.removeEventListener('resize', resizeHandler) - resizeHandler = null +function teardown() { + if (sandboxInstance) { + sandboxInstance.destroy() + sandboxInstance = null } - if (threeRenderer) { - document.body.removeChild(threeRenderer.domElement) - threeRenderer.dispose() - threeRenderer = null + if (gameInstance) { + gameInstance.destroy() + gameInstance = null } +} - // Reset state - state = {} - circleIds = [] - replayCircles = [] - nextEvent = undefined - simulatedResults = [] - fetchingMore = false - simulationDone = false - prevTimestamp = null - simulationScene = null - eventHistory = [] - initialBallStates = null - currentProgress = 0 - lastConsumedEvent = null - seekTarget = null - playbackController.reset() - // New canvas - canvas2D = createCanvas(config) - - // Start new worker - worker = new Worker(new URL('./lib/simulation.worker.ts', import.meta.url), { type: 'module' }) - - // Send either a scenario load or random initialization - const scenario = config.scenarioName ? findScenario(config.scenarioName) : undefined - if (scenario) { - // Override table dimensions from scenario - config.tableWidth = scenario.table.width - config.tableHeight = scenario.table.height - canvas2D = createCanvas(config) - - const scenarioMessage: WorkerScenarioRequest = { - type: RequestMessageType.LOAD_SCENARIO, - payload: { scenario }, - } - worker.postMessage(scenarioMessage) - } else { - const initMessage: WorkerInitializationRequest = { - type: RequestMessageType.INITIALIZE_SIMULATION, - payload: { - numBalls: config.numBalls, - tableHeight: config.tableHeight, - tableWidth: config.tableWidth, - physicsProfile: config.physicsProfile, - physicsOverrides: config.physicsOverrides, - }, - } - worker.postMessage(initMessage) +function getRulesForMode(mode: GameMode): GameRules { + switch (mode) { + case 'eight-ball': + return new EightBallRules() + case 'nine-ball': + return new NineBallRules() + case 'snooker': + return new SnookerRules() } - worker.addEventListener('message', (event: MessageEvent) => { - const response: WorkerResponse = event.data - - if (isWorkerInitializationResponse(response)) { - if (response.payload.status) { - worker!.postMessage({ - type: RequestMessageType.REQUEST_SIMULATION_DATA, - payload: { - time: PRECALC * 2, - }, - }) - } - } else if (isWorkerSimulationResponse(response)) { - const results = response.payload.data - if (response.payload.initialValues) { - state = response.payload.initialValues.snapshots.reduce( - (circles: { [key: string]: Ball }, snapshot) => { - const ball = new Ball( - snapshot.position, - snapshot.velocity, - snapshot.radius, - snapshot.time, - defaultPhysicsConfig.defaultBallParams.mass, - snapshot.id, - snapshot.angularVelocity, - ) - // Apply trajectory acceleration from snapshot for correct interpolation - if (snapshot.trajectoryA) { - ball.trajectory.a[0] = snapshot.trajectoryA[0] - ball.trajectory.a[1] = snapshot.trajectoryA[1] - } - if (snapshot.angularAlpha) { - ball.angularTrajectory.alpha = [...snapshot.angularAlpha] - ball.angularTrajectory.omega0 = [...snapshot.angularOmega0] - } - if (snapshot.motionState) { - ball.motionState = snapshot.motionState - } - circles[snapshot.id] = ball - return circles - }, - {}, - ) - - circleIds = Object.keys(state) - replayCircles = Object.values(state) - - // Capture initial ball states for step-back replay - initialBallStates = new Map() - for (const [id, ball] of Object.entries(state)) { - initialBallStates.set(id, { - position: [...ball.position], - velocity: [...ball.velocity], - radius: ball.radius, - time: ball.time, - angularVelocity: [...ball.angularVelocity], - motionState: ball.motionState, - trajectoryA: [ball.trajectory.a[0], ball.trajectory.a[1]], - angularAlpha: [...ball.angularTrajectory.alpha], - angularOmega0: [...ball.angularTrajectory.omega0], - }) - } - - nextEvent = results.shift() - queueMicrotask(initScene) - } - // If worker sends only the initial snapshot (time=0) or no real events, - // all balls are stationary — stop requesting more data - if (results.length === 0 || (results.length === 1 && results[0].time === 0)) { - simulationDone = true - } - simulatedResults = simulatedResults.concat(results) - fetchingMore = false - } - }) } -function initScene() { - const renderer = new THREE.WebGLRenderer() - renderer.setSize(window.innerWidth, window.innerHeight) - renderer.outputColorSpace = THREE.SRGBColorSpace - renderer.shadowMap.enabled = config.shadowsEnabled - renderer.shadowMap.type = THREE.PCFSoftShadowMap - document.body.appendChild(renderer.domElement) - threeRenderer = renderer +function showMenu() { + teardown() - const scene = new SimulationScene(canvas2D, replayCircles, config, renderer.domElement) - simulationScene = scene - if (savedCameraState) { - scene.restoreCamera(savedCameraState) - } - renderer.render(scene.scene, scene.camera) - - const circleRenderer = new CircleRenderer(canvas2D) - const tailRenderer = new TailRenderer(canvas2D, config.tailLength) - const collisionRenderer = new CollisionRenderer(canvas2D) - const collisionPreviewRenderer = new CollisionPreviewRenderer(canvas2D, config.collisionPreviewCount) - const futureTrailRenderer = new FutureTrailRenderer( - canvas2D, - config.futureTrailEventsPerBall, - config.futureTrailInterpolationSteps, - config.phantomBallOpacity, - config.showPhantomBalls, + getReactRoot().render( + createElement(MainMenu, { + onStartGame: (mode: GameMode) => { + window.location.hash = `game/${mode}` + }, + onSandbox: () => { + window.location.hash = 'sandbox' + }, + }), ) +} - // Ball inspector click handling - renderer.domElement.addEventListener('pointerdown', (e) => { - if (config.showBallInspector) { - ballInspector.handlePointerDown(e) - } - }) - renderer.domElement.addEventListener('pointerup', (e) => { - if (config.showBallInspector) { - ballInspector.handlePointerUp( - e, - state, - circleIds, - currentProgress, - scene.camera, - renderer.domElement, - config.tableWidth, - config.tableHeight, - ) - } - }) - - if (!stats) { - stats = new Stats() - stats.showPanel(0) - document.body.appendChild(stats.dom) - } - stats.dom.style.display = config.showStats ? 'block' : 'none' - - resizeHandler = () => { - const width = window.innerWidth - const height = window.innerHeight - renderer.setSize(width, height) - scene.camera.aspect = width / height - scene.camera.updateProjectionMatrix() - } - window.addEventListener('resize', resizeHandler) - - function snapshotBallState(ball: Ball, atTime: number): BallEventSnapshot { - const dt = atTime - ball.time - const vx = ball.trajectory.b[0] + 2 * ball.trajectory.a[0] * dt - const vy = ball.trajectory.b[1] + 2 * ball.trajectory.a[1] * dt - const pos = ball.positionAtTime(atTime) - return { - id: ball.id, - position: [pos[0], pos[1]], - velocity: [vx, vy], - speed: Math.sqrt(vx * vx + vy * vy), - angularVelocity: [ball.angularVelocity[0], ball.angularVelocity[1], ball.angularVelocity[2]], - motionState: ball.motionState, - acceleration: [ball.trajectory.a[0], ball.trajectory.a[1]], - } - } - - function applyEventSnapshots(event: ReplayData, skipHistory = false) { - if (!skipHistory) { - eventHistory.push(event) - } - - // Capture pre-event state for all involved balls - const deltas = event.snapshots.map((snapshot) => { - const circle = state[snapshot.id] - const before = snapshotBallState(circle, event.time) - return { id: snapshot.id, before } - }) - - // Apply post-event state - for (const snapshot of event.snapshots) { - const circle = state[snapshot.id] - circle.position[0] = snapshot.position[0] - circle.position[1] = snapshot.position[1] - circle.velocity[0] = snapshot.velocity[0] - circle.velocity[1] = snapshot.velocity[1] - circle.radius = snapshot.radius - circle.time = snapshot.time - if (snapshot.angularVelocity) { - circle.angularVelocity = [...snapshot.angularVelocity] - } - if (snapshot.motionState !== undefined) { - circle.motionState = snapshot.motionState - } - // Rebase trajectory to new reference time (event time) - circle.trajectory.a[0] = snapshot.trajectoryA[0] - circle.trajectory.a[1] = snapshot.trajectoryA[1] - circle.trajectory.b[0] = snapshot.velocity[0] - circle.trajectory.b[1] = snapshot.velocity[1] - circle.trajectory.c[0] = snapshot.position[0] - circle.trajectory.c[1] = snapshot.position[1] - if (snapshot.angularAlpha) { - circle.angularTrajectory.alpha = [...snapshot.angularAlpha] - circle.angularTrajectory.omega0 = [...snapshot.angularOmega0] - } - } - - // Build deltas with after state - const fullDeltas = deltas.map((d) => { - const circle = state[d.id] - return { - ...d, - after: snapshotBallState(circle, event.time), - } - }) - - // Build event entry with deltas - const entry: EventEntry = { - time: event.time, - type: event.type, - involvedBalls: event.snapshots.map((s) => s.id), - cushionType: event.cushionType, - deltas: fullDeltas, - } - - bridge.pushEvent(entry) - lastConsumedEvent = entry - } - - function step(timestamp: number) { - stats!.begin() - - // Delta-based time tracking — no wall-clock drift, no pause/unpause sync issues - const deltaMs = prevTimestamp ? timestamp - prevTimestamp : 0 - prevTimestamp = timestamp - - // Restore all balls to initial state - function restoreInitialState() { - for (const [id, snap] of initialBallStates!) { - const ball = state[id] - ball.position[0] = snap.position[0] - ball.position[1] = snap.position[1] - ball.position[2] = 0 - ball.velocity[0] = snap.velocity[0] - ball.velocity[1] = snap.velocity[1] - ball.velocity[2] = 0 - ball.radius = snap.radius - ball.time = snap.time - ball.angularVelocity = [...snap.angularVelocity] - ball.motionState = snap.motionState - ball.trajectory.a[0] = snap.trajectoryA[0] - ball.trajectory.a[1] = snap.trajectoryA[1] - ball.trajectory.b[0] = snap.velocity[0] - ball.trajectory.b[1] = snap.velocity[1] - ball.trajectory.c[0] = snap.position[0] - ball.trajectory.c[1] = snap.position[1] - ball.angularTrajectory.alpha = [...snap.angularAlpha] - ball.angularTrajectory.omega0 = [...snap.angularOmega0] - } - } +function launchGame(mode: GameMode) { + teardown() - // Replay a list of events from scratch (after restoreInitialState) - function replayEvents(events: ReplayData[]) { - eventHistory = [] - lastConsumedEvent = null - for (const event of events) { - applyEventSnapshots(event) - } - } + const rules = getRulesForMode(mode) + gameInstance = startGame(rules, document.body) - // --- Handle seek, step actions, or normal playback (mutually exclusive) --- + getReactRoot().render(createElement(GameUI, { bridge: gameInstance.bridge })) +} - if (seekTarget !== null && initialBallStates) { - // Seek: restore initial state, replay events up to target, set time to target - const target = seekTarget - seekTarget = null +function launchSandbox() { + teardown() - const allEvents: ReplayData[] = [...eventHistory] - if (nextEvent) allEvents.push(nextEvent) - allEvents.push(...simulatedResults) + // Hide React UI for sandbox (it has its own overlay) + getReactRoot().render(null) - const eventsToApply = allEvents.filter((e) => e.time <= target) - const eventsRemaining = allEvents.filter((e) => e.time > target) + sandboxInstance = startSandbox(document.body) +} - restoreInitialState() - nextEvent = eventsRemaining.shift() - simulatedResults = eventsRemaining - replayEvents(eventsToApply) - currentProgress = target - tailRenderer.clear() +function route() { + const hash = window.location.hash.replace(/^#/, '') - // Rebase all ball trajectories to the seek target time. - // After replay, each ball's trajectory origin is at its last event time. - // Rebasing advances the origin to `target` so that ball.position, - // ball.velocity, and ball.time all reflect the current progress. - // This is a lossless transformation — the physical trajectory is unchanged. - for (const id of circleIds) { - const ball = state[id] - const dt = target - ball.time - if (dt > 1e-9) { - const pos = ball.positionAtTime(target) - const vel = ball.velocityAtTime(target) - ball.position[0] = pos[0] - ball.position[1] = pos[1] - ball.velocity[0] = vel[0] - ball.velocity[1] = vel[1] - ball.time = target - ball.trajectory.c[0] = pos[0] - ball.trajectory.c[1] = pos[1] - ball.trajectory.b[0] = vel[0] - ball.trajectory.b[1] = vel[1] - // trajectory.a (acceleration) is unchanged — same physical trajectory - } - } + if (hash === 'sandbox') { + launchSandbox() + } else if (hash.startsWith('game/')) { + const mode = hash.replace('game/', '') as GameMode + if (['eight-ball', 'nine-ball', 'snooker'].includes(mode)) { + launchGame(mode) } else { - const action = playbackController.consumeAction() - if (action) { - // Step actions: process exactly one action, then render - if (action.type === 'step') { - if (nextEvent) { - applyEventSnapshots(nextEvent) - currentProgress = nextEvent.time - nextEvent = simulatedResults.shift() - } - } else if (action.type === 'stepBack') { - if (eventHistory.length > 0 && initialBallStates) { - const popped = eventHistory.pop()! - if (nextEvent) simulatedResults.unshift(nextEvent) - nextEvent = popped - restoreInitialState() - replayEvents([...eventHistory]) - currentProgress = eventHistory.length > 0 ? eventHistory[eventHistory.length - 1].time : 0 - } - } else if (action.type === 'stepToBall') { - const targetBallId = action.ballId - let found = false - while (nextEvent && !found) { - const involvesBall = nextEvent.snapshots.some((s) => s.id === targetBallId) - applyEventSnapshots(nextEvent) - if (involvesBall) { - currentProgress = nextEvent.time - found = true - } - nextEvent = simulatedResults.shift() - } - if (!found && eventHistory.length > 0) { - currentProgress = eventHistory[eventHistory.length - 1].time - } - } - } else { - // Normal playback: advance time and consume events - if (!playbackController.paused) { - currentProgress += (deltaMs / 1000) * config.simulationSpeed - } - while (nextEvent && currentProgress >= nextEvent.time) { - applyEventSnapshots(nextEvent) - nextEvent = simulatedResults.shift() - } - } - } - - // Fetch more simulation data if buffer is running low - if (nextEvent) { - const lastEvent = simulatedResults[simulatedResults.length - 1] - if (!simulationDone && !fetchingMore && lastEvent && lastEvent.time - currentProgress <= PRECALC) { - fetchingMore = true - worker!.postMessage({ - type: RequestMessageType.REQUEST_SIMULATION_DATA, - payload: { time: PRECALC }, - }) - } - } - - const progress = currentProgress - - // 2D canvas rendering - const ctx = canvas2D.getContext('2d')! - ctx.fillStyle = config.tableColor - ctx.fillRect(0, 0, canvas2D.width, canvas2D.height) - - // Update future trail renderer settings from config - futureTrailRenderer.updateSettings( - config.futureTrailEventsPerBall, - config.futureTrailInterpolationSteps, - config.phantomBallOpacity, - config.showPhantomBalls, - ) - - scene.renderAtTime(progress) - // Always render circles; other renderers require nextEvent - for (const circleId of circleIds) { - const circle = state[circleId] - if (config.showCircles) { - circleRenderer.render(circle, progress, nextEvent) - } - if (nextEvent) { - if (config.showTails) tailRenderer.render(circle, progress) - if (config.showCollisions) collisionRenderer.render(circle, progress, nextEvent, simulatedResults) - if (config.showCollisionPreview) collisionPreviewRenderer.render(circle, progress, nextEvent, simulatedResults) - if (config.showFutureTrails) - futureTrailRenderer.render(circle, progress, nextEvent, simulatedResults) - } + showMenu() } - - // Update live parameters - if (stats) { - stats.dom.style.display = config.showStats ? 'block' : 'none' - } - renderer.shadowMap.enabled = config.shadowsEnabled - - // Update bridge snapshot for React UI - const selectedId = ballInspector.getSelectedBallId() - const motionDist: Record = {} - for (const id of circleIds) { - const ms = state[id].motionState - motionDist[ms] = (motionDist[ms] || 0) + 1 - } - bridge.update({ - currentProgress: progress, - paused: playbackController.paused, - simulationSpeed: config.simulationSpeed, - selectedBallId: selectedId, - selectedBallData: selectedId && state[selectedId] ? computeBallData(state[selectedId], progress) : null, - ballCount: circleIds.length, - bufferDepth: simulatedResults.length, - simulationDone, - motionDistribution: motionDist, - canStepBack: eventHistory.length > 0, - maxTime: simulatedResults.length > 0 - ? simulatedResults[simulatedResults.length - 1].time - : nextEvent - ? nextEvent.time - : eventHistory.length > 0 - ? eventHistory[eventHistory.length - 1].time - : progress, - currentEvent: playbackController.paused ? lastConsumedEvent : null, - }) - - renderer.render(scene.scene, scene.camera) - stats!.end() - animationFrameId = window.requestAnimationFrame(step) + } else { + showMenu() } - animationFrameId = window.requestAnimationFrame(step) } -// --- UI Setup --- -// Advanced settings (Tweakpane, collapsed) -createAdvancedUI(config, { - onRestartRequired: () => startSimulation(), - onLiveUpdate: () => { - if (simulationScene) simulationScene.updateFromConfig(config) - if (threeRenderer) threeRenderer.shadowMap.enabled = config.shadowsEnabled - }, -}) - -// React debug overlay -mountDebugOverlay(bridge) +// Listen for hash changes +window.addEventListener('hashchange', route) -// Start initial simulation -startSimulation() +// Initial route +route() diff --git a/src/lib/collision.ts b/src/lib/collision.ts index 0012c7b..7fc6a28 100644 --- a/src/lib/collision.ts +++ b/src/lib/collision.ts @@ -4,16 +4,17 @@ import { SpatialGrid } from './spatial-grid' import { MotionState } from './motion-state' import type { PhysicsConfig } from './physics-config' import type { PhysicsProfile } from './physics/physics-profile' +import type { TableConfig } from './table-config' +import type { PocketDetector } from './physics/detection/pocket-detector' +import { QuarticPocketDetector } from './physics/detection/pocket-detector' +import { SegmentedCushionDetector } from './physics/detection/segmented-cushion-detector' -export enum Cushion { - North = 'NORTH', - East = 'EAST', - South = 'SOUTH', - West = 'WEST', -} +// Re-export Cushion from its own module (avoids circular dependency with detectors) +export { Cushion } from './cushion' +import { Cushion } from './cushion' export interface Collision { - type: 'Circle' | 'Cushion' + type: 'Circle' | 'Cushion' | 'Pocket' circles: Ball[] /** Absolute time when this collision is predicted to occur */ time: number @@ -33,6 +34,15 @@ export interface CushionCollision extends Collision { cushion: Cushion } +export interface PocketCollision { + type: 'Pocket' + circles: [Ball] + time: number + epochs: [number] + seq: number + pocketId: string +} + export interface StateTransitionEvent { type: 'StateTransition' time: number @@ -53,7 +63,7 @@ export interface CellTransitionEvent { seq: number } -export type TreeEvent = Collision | CellTransitionEvent | StateTransitionEvent +export type TreeEvent = Collision | PocketCollision | CellTransitionEvent | StateTransitionEvent /** * Checks whether an event is still valid by comparing each circle's current epoch @@ -94,6 +104,9 @@ export class CollisionFinder { private grid: SpatialGrid private physicsConfig: PhysicsConfig private profile: PhysicsProfile + private tableConfig: TableConfig | undefined + private pocketDetector: PocketDetector | undefined + private effectiveProfile: PhysicsProfile /** Monotonic counter ensuring deterministic event ordering */ private nextSeq: number = 0 @@ -108,6 +121,7 @@ export class CollisionFinder { circles: Ball[], physicsConfig: PhysicsConfig, profile: PhysicsProfile, + tableConfig?: TableConfig, ) { this.heap = new MinHeap() this.tableWidth = tableWidth @@ -115,8 +129,21 @@ export class CollisionFinder { this.circles = circles this.physicsConfig = physicsConfig this.profile = profile + this.tableConfig = tableConfig this.grid = new SpatialGrid(tableWidth, tableHeight, circles.length > 0 ? circles[0].radius * 4 : 150) + // If table has pockets, use segmented cushion detector and pocket detector + if (tableConfig && tableConfig.pockets.length > 0) { + this.pocketDetector = new QuarticPocketDetector() + const segmentedDetector = new SegmentedCushionDetector(tableConfig.cushionSegments) + this.effectiveProfile = { + ...profile, + cushionDetector: segmentedDetector, + } + } else { + this.effectiveProfile = profile + } + this.initialize() } @@ -131,19 +158,22 @@ export class CollisionFinder { } } - /** Schedule cushion, ball-ball, state transition, and cell transition events for a ball */ + /** Schedule cushion, ball-ball, pocket, state transition, and cell transition events for a ball */ private scheduleAllEvents(circle: Ball, skipBallBall = false) { - // Cushion collision (via detector from profile) - const cushionCollision = this.profile.cushionDetector.detect(circle, this.tableWidth, this.tableHeight) + // Cushion collision (via detector from profile — may be segmented for pocket tables) + const cushionCollision = this.effectiveProfile.cushionDetector.detect(circle, this.tableWidth, this.tableHeight) cushionCollision.seq = this.nextSeq++ this.heap.push(cushionCollision) + // Pocket detection (only for tables with pockets) + this.schedulePocketEvents(circle) + // Ball-ball collisions with neighbors (via detector from profile) if (!skipBallBall) { const neighbors = this.grid.getNearbyCircles(circle) for (const neighbor of neighbors) { if (circle.id >= neighbor.id) continue - const time = this.profile.ballBallDetector.detect(circle, neighbor) + const time = this.effectiveProfile.ballBallDetector.detect(circle, neighbor) if (time) { const collision: Collision = { type: 'Circle', @@ -164,8 +194,26 @@ export class CollisionFinder { this.scheduleNextCellTransition(circle) } + /** Schedule pocket entry events for a ball (if table has pockets) */ + private schedulePocketEvents(circle: Ball) { + if (!this.pocketDetector || !this.tableConfig) return + + const result = this.pocketDetector.detect(circle, this.tableConfig.pockets) + if (result) { + const pocketEvent: PocketCollision = { + type: 'Pocket', + circles: [circle], + time: result.time, + epochs: [circle.epoch], + seq: this.nextSeq++, + pocketId: result.pocketId, + } + this.heap.push(pocketEvent) + } + } + private scheduleStateTransition(circle: Ball) { - const model = this.profile.motionModels.get(circle.motionState) + const model = this.effectiveProfile.motionModels.get(circle.motionState) if (!model) return const transition = model.getTransitionTime(circle, this.physicsConfig) @@ -205,7 +253,7 @@ export class CollisionFinder { * the caller must then apply physics and call recompute() for each circle. * State transition events are also returned so the caller can record them. */ - pop(): Collision | StateTransitionEvent { + pop(): Collision | PocketCollision | StateTransitionEvent { for (;;) { const next = this.heap.pop()! @@ -236,6 +284,13 @@ export class CollisionFinder { return next as StateTransitionEvent } + if (next.type === 'Pocket') { + for (const circle of next.circles) { + circle.epoch++ + } + return next as PocketCollision + } + // Collision event: invalidate epochs for involved circles for (const circle of next.circles) { circle.epoch++ @@ -256,10 +311,10 @@ export class CollisionFinder { recompute(circleId: string, excludeIds?: Set) { const referenceCircle = this.circlesById.get(circleId)! - // Cushion collision (via detector from profile) + // Cushion collision (via detector from profile — may be segmented) // Airborne balls are above the table and don't interact with cushions if (referenceCircle.motionState !== MotionState.Airborne) { - const cushionCollision = this.profile.cushionDetector.detect( + const cushionCollision = this.effectiveProfile.cushionDetector.detect( referenceCircle, this.tableWidth, this.tableHeight, @@ -268,11 +323,14 @@ export class CollisionFinder { this.heap.push(cushionCollision) } + // Pocket detection + this.schedulePocketEvents(referenceCircle) + // Ball-ball collisions with neighbors (via detector from profile) const neighbors = this.grid.getNearbyCircles(referenceCircle) for (const neighbor of neighbors) { if (excludeIds && excludeIds.has(neighbor.id)) continue - const time = this.profile.ballBallDetector.detect(referenceCircle, neighbor) + const time = this.effectiveProfile.ballBallDetector.detect(referenceCircle, neighbor) if (time) { const collision: Collision = { type: 'Circle', @@ -291,4 +349,17 @@ export class CollisionFinder { this.scheduleNextCellTransition(referenceCircle) } + /** + * Remove a ball from the simulation (e.g. when pocketed). + * Increments the ball's epoch to invalidate all pending events, + * removes from spatial grid and tracking structures. + */ + removeBall(ball: Ball) { + ball.epoch++ + this.grid.removeCircle(ball) + this.circlesById.delete(ball.id) + const idx = this.circles.indexOf(ball) + if (idx !== -1) this.circles.splice(idx, 1) + } + } diff --git a/src/lib/cushion.ts b/src/lib/cushion.ts new file mode 100644 index 0000000..aabf062 --- /dev/null +++ b/src/lib/cushion.ts @@ -0,0 +1,6 @@ +export enum Cushion { + North = 'NORTH', + East = 'EAST', + South = 'SOUTH', + West = 'WEST', +} diff --git a/src/lib/game/game-bridge.ts b/src/lib/game/game-bridge.ts new file mode 100644 index 0000000..d7c0111 --- /dev/null +++ b/src/lib/game/game-bridge.ts @@ -0,0 +1,90 @@ +/** + * GameBridge — connects game state to React UI. + * Analogous to SimulationBridge but for game-specific state. + */ + +import type { GameState, ShotResult, ScoreDisplay } from './types' +import type { CueInputMode } from '../input/cue-input' +import type Vector2D from '../vector2d' + +export interface GameSnapshot { + gameState: GameState + aimDirection: number + aimPower: number + strikeOffset: Vector2D + elevation: number + currentPlayerName: string + scores: ScoreDisplay + validTargets: string[] + lastShotResult: ShotResult | null + /** Whether the game is currently in simulation playback */ + isSimulating: boolean + /** Current playback time during simulation */ + playbackTime: number + /** Current input mode (aim vs camera) */ + inputMode: CueInputMode +} + +export interface GameBridgeCallbacks { + onShoot: () => void + onPlaceCueBall: (position: Vector2D) => void + onNewGame: () => void + onBackToMenu: () => void + onToggleMode: () => void +} + +export interface GameBridge { + subscribe(listener: () => void): () => void + getSnapshot(): GameSnapshot + update(data: Partial): void + callbacks: GameBridgeCallbacks +} + +export function createGameBridge(callbacks: GameBridgeCallbacks): GameBridge { + let snapshot: GameSnapshot = { + gameState: { + players: [], + currentPlayerIndex: 0, + ballsOnTable: new Set(), + pottedBalls: [], + currentBreak: [], + fouls: [], + turnNumber: 1, + phase: 'aiming', + ballInHand: false, + }, + aimDirection: 0, + aimPower: 0.5, + strikeOffset: [0, 0], + elevation: 0, + currentPlayerName: '', + scores: { players: [] }, + validTargets: [], + lastShotResult: null, + isSimulating: false, + playbackTime: 0, + inputMode: 'aim', + } + + const listeners = new Set<() => void>() + + return { + subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + getSnapshot() { + return snapshot + }, + + update(data: Partial) { + snapshot = { ...snapshot, ...data } + for (const listener of listeners) { + listener() + } + }, + + callbacks, + } +} diff --git a/src/lib/game/game-controller.ts b/src/lib/game/game-controller.ts new file mode 100644 index 0000000..441e5e3 --- /dev/null +++ b/src/lib/game/game-controller.ts @@ -0,0 +1,370 @@ +/** + * GameController — orchestrates the game loop on the main thread. + * + * Manages game state, builds scenarios from ball positions + shot params, + * sends to the physics worker, collects replay events, evaluates shots + * via the GameRules interface, and handles turn management. + */ + +import Ball from '../ball' +import { defaultPhysicsConfig } from '../physics-config' +import type { ReplayData } from '../simulation' +import { EventType } from '../simulation' +import { RequestMessageType, type WorkerScenarioRequest } from '../worker-request' +import { + type WorkerResponse, + isWorkerInitializationResponse, + isWorkerSimulationResponse, +} from '../worker-response' +import type { BallSpec, Scenario } from '../scenarios' +import type { GameRules } from './rules' +import { type GameState, type ShotParams, type ShotResult, createInitialGameState } from './types' +import type Vector2D from '../vector2d' + +const MAX_SIMULATION_TIME = 60 // seconds — safety limit + +export interface GameControllerCallbacks { + /** Called when game state changes (for React UI updates) */ + onStateChange: (state: GameState) => void + /** Called when simulation starts — provides initial ball states for rendering */ + onSimulationStart: (balls: Ball[], replayEvents: ReplayData[]) => void + /** Called when new replay events arrive during simulation */ + onReplayData: (events: ReplayData[]) => void + /** Called when simulation is complete and shot has been evaluated */ + onShotComplete: (result: ShotResult) => void + /** Called when the simulation is done (all balls at rest) */ + onSimulationDone: () => void +} + +export class GameController { + private rules: GameRules + private state: GameState + private worker: Worker | null = null + private callbacks: GameControllerCallbacks + private ballPositions: Map = new Map() + private allReplayEvents: ReplayData[] = [] + private simulationDone = false + + constructor(rules: GameRules, playerNames: string[], callbacks: GameControllerCallbacks) { + this.rules = rules + this.callbacks = callbacks + this.state = createInitialGameState(playerNames) + } + + get gameState(): GameState { + return this.state + } + + get gameRules(): GameRules { + return this.rules + } + + /** Start a new game — sets up balls and initializes the table */ + startGame() { + this.state = createInitialGameState(this.state.players.map((p) => p.name)) + + const ballSpecs = this.rules.setupBalls() + + // Track which balls are on the table + for (const spec of ballSpecs) { + const id = spec.id ?? `ball-${this.state.ballsOnTable.size}` + this.state.ballsOnTable.add(id) + this.ballPositions.set(id, [spec.x, spec.y]) + } + + this.state.phase = 'aiming' + + // Initialize snooker target if playing snooker + if (this.rules.tableType === 'snooker') { + this.state.snookerTarget = 'red' + } + + this.callbacks.onStateChange(this.state) + + // Build initial scenario (all balls stationary) to get the scene rendered + this.runScenario(ballSpecs, true) + } + + /** Take a shot with the given parameters */ + takeShot(params: ShotParams) { + if (this.state.phase !== 'aiming') return + + this.state.phase = 'simulating' + this.state.currentBreak = [] + this.allReplayEvents = [] + this.simulationDone = false + this.callbacks.onStateChange(this.state) + + // Convert shot params to velocity and spin + const speed = params.power * this.rules.getMaxShotSpeed() + const vx = speed * Math.cos(params.direction) + const vy = speed * Math.sin(params.direction) + + // Compute spin from strike offset + const R = this.rules.getBallRadius() + const mass = this.rules.getPhysicsConfig().defaultBallParams.mass + const spinFactor = (2 * speed) / (mass * R) + const wx = -(params.strikeOffset[1] * spinFactor) // top/backspin + const wy = params.strikeOffset[0] * spinFactor // left/right english + const wz = 0 // no z-spin from normal cue strike + + // Elevation: add vertical velocity component for massé + const vz = params.elevation > 0 ? speed * Math.sin(params.elevation) * 0.3 : 0 + + // Build scenario from current ball positions + shot velocity on cue ball + const cueBallId = this.rules.getCueBallId() + const ballSpecs: BallSpec[] = [] + + for (const [id, pos] of this.ballPositions) { + if (id === cueBallId) { + ballSpecs.push({ + id, + x: pos[0], + y: pos[1], + vx, + vy, + vz, + spin: [wx, wy, wz], + }) + } else { + ballSpecs.push({ id, x: pos[0], y: pos[1] }) + } + } + + this.runScenario(ballSpecs, false) + } + + /** Place the cue ball at a specific position (ball-in-hand) */ + placeCueBall(position: Vector2D) { + const cueBallId = this.rules.getCueBallId() + this.ballPositions.set(cueBallId, [...position]) + if (!this.state.ballsOnTable.has(cueBallId)) { + this.state.ballsOnTable.add(cueBallId) + } + this.state.phase = 'aiming' + this.state.ballInHand = false + this.callbacks.onStateChange(this.state) + } + + /** Clean up worker */ + destroy() { + if (this.worker) { + this.worker.terminate() + this.worker = null + } + } + + private runScenario(ballSpecs: BallSpec[], isInitialSetup: boolean) { + // Terminate previous worker + if (this.worker) { + this.worker.terminate() + } + + this.worker = new Worker(new URL('../simulation.worker.ts', import.meta.url), { type: 'module' }) + + const tableConfig = this.rules.getTableConfig() + const scenario: Scenario = { + name: isInitialSetup ? 'game-setup' : 'game-shot', + description: '', + table: { width: tableConfig.width, height: tableConfig.height }, + balls: ballSpecs, + physics: 'pool', + tableType: this.rules.tableType, + duration: MAX_SIMULATION_TIME, + } + + const scenarioMessage: WorkerScenarioRequest = { + type: RequestMessageType.LOAD_SCENARIO, + payload: { scenario }, + } + + this.worker.postMessage(scenarioMessage) + + this.worker.addEventListener('message', (event: MessageEvent) => { + const response: WorkerResponse = event.data + + if (isWorkerInitializationResponse(response)) { + if (response.payload.status) { + // Request simulation data + this.worker!.postMessage({ + type: RequestMessageType.REQUEST_SIMULATION_DATA, + payload: { time: MAX_SIMULATION_TIME }, + }) + } + } else if (isWorkerSimulationResponse(response)) { + const results = response.payload.data + + if (response.payload.initialValues) { + // Build Ball objects for rendering + const balls = response.payload.initialValues.snapshots.map((snapshot) => { + const ball = new Ball( + snapshot.position, + snapshot.velocity, + snapshot.radius, + snapshot.time, + defaultPhysicsConfig.defaultBallParams.mass, + snapshot.id, + snapshot.angularVelocity, + ) + if (snapshot.trajectoryA) { + ball.trajectory.a[0] = snapshot.trajectoryA[0] + ball.trajectory.a[1] = snapshot.trajectoryA[1] + } + if (snapshot.angularAlpha) { + ball.angularTrajectory.alpha = [...snapshot.angularAlpha] + ball.angularTrajectory.omega0 = [...snapshot.angularOmega0] + } + if (snapshot.motionState) { + ball.motionState = snapshot.motionState + } + return ball + }) + + this.callbacks.onSimulationStart(balls, [response.payload.initialValues]) + } + + this.allReplayEvents.push(...results) + this.callbacks.onReplayData(results) + + // Check if simulation is done (no more events or all stationary) + if (results.length === 0 || (results.length === 1 && results[0].time === 0)) { + this.simulationDone = true + } + + if (this.simulationDone) { + this.callbacks.onSimulationDone() + + if (!isInitialSetup) { + this.onSimulationComplete() + } else { + // Initial setup: just record ball positions + this.updateBallPositionsFromEvents(this.allReplayEvents) + } + } else { + // Request more simulation data + this.worker!.postMessage({ + type: RequestMessageType.REQUEST_SIMULATION_DATA, + payload: { time: MAX_SIMULATION_TIME }, + }) + } + } + }) + } + + private onSimulationComplete() { + // Update ball positions from final state + this.updateBallPositionsFromEvents(this.allReplayEvents) + + // Evaluate the shot + this.state.phase = 'evaluating' + const shotResult = this.rules.evaluateShot(this.allReplayEvents, this.state) + + // Process pocketed balls + for (const event of this.allReplayEvents) { + if (event.type === EventType.BallPocketed) { + const ballId = event.snapshots[0].id + this.state.ballsOnTable.delete(ballId) + this.ballPositions.delete(ballId) + this.state.pottedBalls.push({ + ballId, + pocketId: event.pocketId!, + turnNumber: this.state.turnNumber, + }) + this.state.currentBreak.push({ + ballId, + pocketId: event.pocketId!, + turnNumber: this.state.turnNumber, + }) + } + } + + // Apply group assignment + if (shotResult.groupAssignment) { + const { playerIndex, group } = shotResult.groupAssignment + this.state.players[playerIndex].group = group + this.state.players[1 - playerIndex].group = group === 'solids' ? 'stripes' : 'solids' + } + + // Apply score + this.state.players[this.state.currentPlayerIndex].score += shotResult.scoreChange + + // Handle fouls + if (shotResult.foul) { + for (const reason of shotResult.foulReasons) { + this.state.fouls.push({ reason, turnNumber: this.state.turnNumber }) + } + } + + // Re-spot balls (snooker colors) + for (const respot of shotResult.respotBalls) { + this.state.ballsOnTable.add(respot.ballId) + this.ballPositions.set(respot.ballId, [...respot.position]) + } + + // Handle cue ball potted (ball-in-hand) + const cueBallId = this.rules.getCueBallId() + if (!this.state.ballsOnTable.has(cueBallId)) { + // Cue ball was potted — re-add it for ball-in-hand placement + this.state.ballInHand = true + } + + // Game over + if (shotResult.gameOver) { + this.state.phase = 'game-over' + this.state.winner = shotResult.winner + this.callbacks.onShotComplete(shotResult) + this.callbacks.onStateChange(this.state) + return + } + + // Update snooker target (red/color alternation) + if (this.state.snookerTarget !== undefined) { + if (shotResult.foul || shotResult.switchTurn) { + // After a foul or turn switch, next player targets reds (unless no reds left) + const redsLeft = [...this.state.ballsOnTable].some((id) => id.startsWith('red-')) + this.state.snookerTarget = redsLeft ? 'red' : 'color' + } else if (!shotResult.foul && shotResult.scoreChange > 0) { + // Valid pot — toggle target + const redsLeft = [...this.state.ballsOnTable].some((id) => id.startsWith('red-')) + if (redsLeft) { + this.state.snookerTarget = this.state.snookerTarget === 'red' ? 'color' : 'red' + } else { + this.state.snookerTarget = 'color' + } + } + } + + // Switch turn or continue + if (shotResult.switchTurn) { + this.state.currentPlayerIndex = 1 - this.state.currentPlayerIndex + this.state.turnNumber++ + } + + // Next phase + if (this.state.ballInHand) { + this.state.phase = 'placing-cue-ball' + } else { + this.state.phase = 'aiming' + } + + this.callbacks.onShotComplete(shotResult) + this.callbacks.onStateChange(this.state) + } + + /** + * Update stored ball positions from the last replay events. + * Uses the last snapshot of each ball to determine final positions. + */ + private updateBallPositionsFromEvents(events: ReplayData[]) { + // Process events in chronological order — last snapshot wins + for (const event of events) { + for (const snapshot of event.snapshots) { + if (event.type === EventType.BallPocketed) { + // Don't update position for pocketed balls + continue + } + this.ballPositions.set(snapshot.id, [snapshot.position[0], snapshot.position[1]]) + } + } + } +} diff --git a/src/lib/game/rules-eight-ball.ts b/src/lib/game/rules-eight-ball.ts new file mode 100644 index 0000000..31cfbba --- /dev/null +++ b/src/lib/game/rules-eight-ball.ts @@ -0,0 +1,271 @@ +/** + * 8-Ball Pool rules implementation. + * + * Standard rules: + * - 15 object balls (1-7 solids, 8-ball, 9-15 stripes) + cue ball + * - After break, first legally potted ball assigns groups + * - Must pot all balls in your group, then the 8-ball + * - Fouls: wrong first contact, cue potted, no cushion after contact, potting 8-ball early + */ + +import type { BallTextureSet } from '../scene/ball-textures' +import { defaultPhysicsConfig, type PhysicsConfig } from '../physics-config' +import type { BallSpec } from '../scenarios' +import { EventType, type ReplayData } from '../simulation' +import { createPoolTable, type TableConfig } from '../table-config' +import type { GameRules } from './rules' +import type { GameState, ShotResult, ScoreDisplay } from './types' + +const CUE_BALL_ID = 'cue' +const EIGHT_BALL_ID = 'ball-8' + +const SOLID_IDS = ['ball-1', 'ball-2', 'ball-3', 'ball-4', 'ball-5', 'ball-6', 'ball-7'] +const STRIPE_IDS = ['ball-9', 'ball-10', 'ball-11', 'ball-12', 'ball-13', 'ball-14', 'ball-15'] + +const BALL_RADIUS = 28.575 // American pool ball: 2.25 inches = 57.15mm diameter + +export class EightBallRules implements GameRules { + readonly name = '8-Ball' + readonly tableType = 'pool' as const + + private tableConfig = createPoolTable() + + getTableConfig(): TableConfig { + return this.tableConfig + } + + getPhysicsConfig(): PhysicsConfig { + return { + ...defaultPhysicsConfig, + defaultBallParams: { + ...defaultPhysicsConfig.defaultBallParams, + radius: BALL_RADIUS, + }, + } + } + + getBallTextureSet(): BallTextureSet { + return 'american' + } + + getCueBallId(): string { + return CUE_BALL_ID + } + + getMaxShotSpeed(): number { + return 5000 // mm/s + } + + getBallRadius(): number { + return BALL_RADIUS + } + + setupBalls(): BallSpec[] { + const tableConfig = this.tableConfig + const cy = tableConfig.height / 2 + + // Cue ball on the head string (1/4 from left) + const cueBall: BallSpec = { + id: CUE_BALL_ID, + x: tableConfig.width * 0.25, + y: cy, + } + + // Rack: triangle at the foot spot (3/4 from left) + const footX = tableConfig.width * 0.75 + const d = BALL_RADIUS * 2 + 0.01 // tiny gap + const rowSpacing = d * Math.cos(Math.PI / 6) + + // Standard 8-ball rack layout: + // Row 0: 1 ball + // Row 1: 2 balls + // Row 2: 3 balls (8-ball in center) + // Row 3: 4 balls + // Row 4: 5 balls + // Rules: 8-ball in center, one solid and one stripe in back corners + const rackOrder = [ + 'ball-1', // row 0 + 'ball-9', 'ball-2', // row 1 + 'ball-10', 'ball-8', 'ball-3', // row 2 (8-ball center) + 'ball-11', 'ball-4', 'ball-12', 'ball-5', // row 3 + 'ball-6', 'ball-13', 'ball-14', 'ball-7', 'ball-15', // row 4 + ] + + const rackBalls: BallSpec[] = [] + let idx = 0 + for (let row = 0; row < 5; row++) { + for (let col = 0; col <= row; col++) { + rackBalls.push({ + id: rackOrder[idx], + x: footX + row * rowSpacing, + y: cy + (col - row / 2) * d, + }) + idx++ + } + } + + return [cueBall, ...rackBalls] + } + + evaluateShot(events: ReplayData[], gameState: GameState): ShotResult { + const result: ShotResult = { + foul: false, + foulReasons: [], + switchTurn: true, + respotBalls: [], + scoreChange: 0, + gameOver: false, + } + + // Extract key information from replay events + const pottedBalls: { ballId: string; pocketId: string }[] = [] + let firstContactBallId: string | undefined + let cueBallPotted = false + let eightBallPotted = false + + for (const event of events) { + if (event.type === EventType.BallPocketed) { + const ballId = event.snapshots[0].id + const pocketId = event.pocketId! + pottedBalls.push({ ballId, pocketId }) + + if (ballId === CUE_BALL_ID) cueBallPotted = true + if (ballId === EIGHT_BALL_ID) eightBallPotted = true + } + + // First ball-ball collision involving the cue ball determines first contact + if (event.type === EventType.CircleCollision && !firstContactBallId) { + const involvedIds = event.snapshots.map((s) => s.id) + if (involvedIds.includes(CUE_BALL_ID)) { + firstContactBallId = involvedIds.find((id) => id !== CUE_BALL_ID) + } + } + } + + const currentPlayer = gameState.players[gameState.currentPlayerIndex] + const playerGroup = currentPlayer.group + + // Foul: cue ball potted (scratch) + if (cueBallPotted) { + result.foul = true + result.foulReasons.push('Cue ball potted (scratch)') + } + + // Foul: no ball contacted + if (!firstContactBallId && !cueBallPotted) { + result.foul = true + result.foulReasons.push('Cue ball did not contact any object ball') + } + + // Foul: wrong first contact (if groups are assigned) + if (firstContactBallId && playerGroup) { + const isValidTarget = this.isBallInGroup(firstContactBallId, playerGroup, gameState) + if (!isValidTarget && firstContactBallId !== EIGHT_BALL_ID) { + result.foul = true + result.foulReasons.push(`Wrong ball contacted first (hit ${firstContactBallId})`) + } + // Can only hit 8-ball first if all group balls are potted + if (firstContactBallId === EIGHT_BALL_ID) { + const groupBallsRemaining = this.getGroupBallsOnTable(playerGroup, gameState) + if (groupBallsRemaining.length > 0) { + result.foul = true + result.foulReasons.push('Hit 8-ball before clearing group') + } + } + } + + // 8-ball potted — game over (win or loss) + if (eightBallPotted) { + result.gameOver = true + if (result.foul) { + // Potting 8-ball on a foul = loss + result.winner = 1 - gameState.currentPlayerIndex + } else if (playerGroup) { + const groupBallsRemaining = this.getGroupBallsOnTable(playerGroup, gameState) + if (groupBallsRemaining.length > 0) { + // Potted 8-ball before clearing group = loss + result.winner = 1 - gameState.currentPlayerIndex + } else { + // Legally potted 8-ball after clearing group = win! + result.winner = gameState.currentPlayerIndex + } + } else { + // 8-ball potted on break or before groups assigned — loss + result.winner = 1 - gameState.currentPlayerIndex + } + return result + } + + // Group assignment: if groups not yet assigned and a ball was legally potted + if (!playerGroup && !result.foul) { + const objectBallsPotted = pottedBalls.filter((p) => p.ballId !== CUE_BALL_ID) + if (objectBallsPotted.length > 0) { + const firstPotted = objectBallsPotted[0].ballId + if (SOLID_IDS.includes(firstPotted)) { + result.groupAssignment = { playerIndex: gameState.currentPlayerIndex, group: 'solids' } + } else if (STRIPE_IDS.includes(firstPotted)) { + result.groupAssignment = { playerIndex: gameState.currentPlayerIndex, group: 'stripes' } + } + } + } + + // Determine if turn continues (potted a ball from own group legally) + if (!result.foul && playerGroup) { + const ownBallsPotted = pottedBalls.filter( + (p) => p.ballId !== CUE_BALL_ID && this.isBallInGroup(p.ballId, playerGroup, gameState), + ) + if (ownBallsPotted.length > 0) { + result.switchTurn = false + } + } + + // After group assignment, if the shooter potted their own ball, continue + if (!result.foul && result.groupAssignment && !playerGroup) { + result.switchTurn = false + } + + // Foul always means ball-in-hand for opponent + if (result.foul) { + result.switchTurn = true + } + + return result + } + + getValidTargets(gameState: GameState): string[] { + const player = gameState.players[gameState.currentPlayerIndex] + if (!player.group) { + // Groups not assigned — any object ball is valid + return [...gameState.ballsOnTable].filter((id) => id !== CUE_BALL_ID) + } + + const groupBalls = this.getGroupBallsOnTable(player.group, gameState) + if (groupBalls.length === 0) { + // All group balls potted — 8-ball is the target + return gameState.ballsOnTable.has(EIGHT_BALL_ID) ? [EIGHT_BALL_ID] : [] + } + + return groupBalls + } + + getScoreDisplay(gameState: GameState): ScoreDisplay { + return { + players: gameState.players.map((p, i) => ({ + name: p.name, + score: p.score, + active: i === gameState.currentPlayerIndex, + group: p.group ?? undefined, + })), + } + } + + private isBallInGroup(ballId: string, group: 'solids' | 'stripes', _gameState: GameState): boolean { + if (group === 'solids') return SOLID_IDS.includes(ballId) + return STRIPE_IDS.includes(ballId) + } + + private getGroupBallsOnTable(group: 'solids' | 'stripes', gameState: GameState): string[] { + const groupIds = group === 'solids' ? SOLID_IDS : STRIPE_IDS + return groupIds.filter((id) => gameState.ballsOnTable.has(id)) + } +} diff --git a/src/lib/game/rules-nine-ball.ts b/src/lib/game/rules-nine-ball.ts new file mode 100644 index 0000000..5eb1f0c --- /dev/null +++ b/src/lib/game/rules-nine-ball.ts @@ -0,0 +1,222 @@ +/** + * 9-Ball Pool rules implementation. + * + * Standard rules: + * - 9 object balls (1-9) + cue ball + * - Must hit the lowest numbered ball on the table first + * - Balls potted legally stay down + * - Win: pot the 9-ball on a legal shot (at any time, e.g. combo) + * - Fouls: wrong first contact, cue potted, no cushion after contact + */ + +import type { BallTextureSet } from '../scene/ball-textures' +import { defaultPhysicsConfig, type PhysicsConfig } from '../physics-config' +import type { BallSpec } from '../scenarios' +import { EventType, type ReplayData } from '../simulation' +import { createPoolTable, type TableConfig } from '../table-config' +import type { GameRules } from './rules' +import type { GameState, ShotResult, ScoreDisplay } from './types' + +const CUE_BALL_ID = 'cue' +const BALL_RADIUS = 28.575 // American pool ball + +export class NineBallRules implements GameRules { + readonly name = '9-Ball' + readonly tableType = 'pool' as const + + private tableConfig = createPoolTable() + + getTableConfig(): TableConfig { + return this.tableConfig + } + + getPhysicsConfig(): PhysicsConfig { + return { + ...defaultPhysicsConfig, + defaultBallParams: { + ...defaultPhysicsConfig.defaultBallParams, + radius: BALL_RADIUS, + }, + } + } + + getBallTextureSet(): BallTextureSet { + return 'american' + } + + getCueBallId(): string { + return CUE_BALL_ID + } + + getMaxShotSpeed(): number { + return 5000 + } + + getBallRadius(): number { + return BALL_RADIUS + } + + setupBalls(): BallSpec[] { + const tableConfig = this.tableConfig + const cy = tableConfig.height / 2 + + // Cue ball on the head string + const cueBall: BallSpec = { + id: CUE_BALL_ID, + x: tableConfig.width * 0.25, + y: cy, + } + + // Diamond rack at the foot spot (3/4 from left) + // 9-ball rack: diamond shape, 1-ball at front, 9-ball in center + const footX = tableConfig.width * 0.75 + const d = BALL_RADIUS * 2 + 0.01 + const rowSpacing = d * Math.cos(Math.PI / 6) + + // Diamond layout: + // Row 0: 1 ball (the 1-ball, at the apex) + // Row 1: 2 balls + // Row 2: 3 balls (9-ball in center) + // Row 3: 2 balls + // Row 4: 1 ball + const rackOrder = [ + 'ball-1', // row 0 + 'ball-2', 'ball-3', // row 1 + 'ball-4', 'ball-9', 'ball-5', // row 2 (9-ball center) + 'ball-6', 'ball-7', // row 3 + 'ball-8', // row 4 + ] + + const rackBalls: BallSpec[] = [] + const rows = [1, 2, 3, 2, 1] + let idx = 0 + for (let row = 0; row < rows.length; row++) { + const count = rows[row] + for (let col = 0; col < count; col++) { + rackBalls.push({ + id: rackOrder[idx], + x: footX + row * rowSpacing, + y: cy + (col - (count - 1) / 2) * d, + }) + idx++ + } + } + + return [cueBall, ...rackBalls] + } + + evaluateShot(events: ReplayData[], gameState: GameState): ShotResult { + const result: ShotResult = { + foul: false, + foulReasons: [], + switchTurn: true, + respotBalls: [], + scoreChange: 0, + gameOver: false, + } + + const pottedBalls: { ballId: string; pocketId: string }[] = [] + let firstContactBallId: string | undefined + let cueBallPotted = false + let nineBallPotted = false + + for (const event of events) { + if (event.type === EventType.BallPocketed) { + const ballId = event.snapshots[0].id + const pocketId = event.pocketId! + pottedBalls.push({ ballId, pocketId }) + + if (ballId === CUE_BALL_ID) cueBallPotted = true + if (ballId === 'ball-9') nineBallPotted = true + } + + if (event.type === EventType.CircleCollision && !firstContactBallId) { + const involvedIds = event.snapshots.map((s) => s.id) + if (involvedIds.includes(CUE_BALL_ID)) { + firstContactBallId = involvedIds.find((id) => id !== CUE_BALL_ID) + } + } + } + + // Determine the lowest numbered ball on the table + const lowestBall = this.getLowestBallOnTable(gameState) + + // Foul: cue ball potted + if (cueBallPotted) { + result.foul = true + result.foulReasons.push('Cue ball potted (scratch)') + } + + // Foul: no contact + if (!firstContactBallId && !cueBallPotted) { + result.foul = true + result.foulReasons.push('Cue ball did not contact any object ball') + } + + // Foul: wrong ball contacted first (must hit lowest numbered ball) + if (firstContactBallId && lowestBall && firstContactBallId !== lowestBall) { + result.foul = true + result.foulReasons.push(`Must hit ${lowestBall} first (hit ${firstContactBallId})`) + } + + // 9-ball potted + if (nineBallPotted) { + if (result.foul) { + // 9-ball potted on a foul — re-spot the 9-ball, don't end game + const spotPos = this.getRespotPosition() + result.respotBalls.push({ ballId: 'ball-9', position: spotPos }) + } else { + // Legal 9-ball pot = win! + result.gameOver = true + result.winner = gameState.currentPlayerIndex + return result + } + } + + // If any ball was legally potted, continue turn + if (!result.foul) { + const objectBallsPotted = pottedBalls.filter((p) => p.ballId !== CUE_BALL_ID) + if (objectBallsPotted.length > 0) { + result.switchTurn = false + } + } + + // Foul always means ball-in-hand + if (result.foul) { + result.switchTurn = true + } + + return result + } + + getValidTargets(gameState: GameState): string[] { + const lowest = this.getLowestBallOnTable(gameState) + return lowest ? [lowest] : [] + } + + getScoreDisplay(gameState: GameState): ScoreDisplay { + // 9-ball doesn't have traditional scoring — show balls remaining + const ballsRemaining = [...gameState.ballsOnTable].filter((id) => id !== CUE_BALL_ID).length + return { + players: gameState.players.map((p, i) => ({ + name: p.name, + score: p.score, + active: i === gameState.currentPlayerIndex, + group: `${ballsRemaining} balls left`, + })), + } + } + + private getLowestBallOnTable(gameState: GameState): string | null { + for (let i = 1; i <= 9; i++) { + const id = `ball-${i}` + if (gameState.ballsOnTable.has(id)) return id + } + return null + } + + private getRespotPosition(): [number, number] { + // Re-spot on the foot spot + return [this.tableConfig.width * 0.75, this.tableConfig.height / 2] + } +} diff --git a/src/lib/game/rules-snooker.ts b/src/lib/game/rules-snooker.ts new file mode 100644 index 0000000..09ad716 --- /dev/null +++ b/src/lib/game/rules-snooker.ts @@ -0,0 +1,344 @@ +/** + * Snooker rules implementation. + * + * Standard rules: + * - 15 reds (1 point each), 6 colors (yellow=2, green=3, brown=4, blue=5, pink=6, black=7) + * - Cue ball (white) + * - Alternating red/color sequence: pot a red, then nominate and pot a color + * - Colors are re-spotted after being potted (until all reds are gone) + * - After all reds are gone, colors must be potted in ascending value order + * - Foul minimum 4 points to opponent + */ + +import type { BallTextureSet } from '../scene/ball-textures' +import { defaultPhysicsConfig, type PhysicsConfig } from '../physics-config' +import type { BallSpec } from '../scenarios' +import { EventType, type ReplayData } from '../simulation' +import { createSnookerTable, type TableConfig } from '../table-config' +import type { GameRules } from './rules' +import type { GameState, ShotResult, ScoreDisplay } from './types' +import type Vector2D from '../vector2d' + +const CUE_BALL_ID = 'cue' +const BALL_RADIUS = 26.25 // Snooker ball: 52.5mm diameter + +// Color ball IDs and their point values +const COLOR_BALLS: { id: string; value: number; name: string }[] = [ + { id: 'yellow', value: 2, name: 'Yellow' }, + { id: 'green', value: 3, name: 'Green' }, + { id: 'brown', value: 4, name: 'Brown' }, + { id: 'blue', value: 5, name: 'Blue' }, + { id: 'pink', value: 6, name: 'Pink' }, + { id: 'black', value: 7, name: 'Black' }, +] + +const COLOR_IDS = COLOR_BALLS.map((c) => c.id) +const RED_PREFIX = 'red-' + +function isRed(ballId: string): boolean { + return ballId.startsWith(RED_PREFIX) +} + +function isColor(ballId: string): boolean { + return COLOR_IDS.includes(ballId) +} + +function getColorValue(ballId: string): number { + const color = COLOR_BALLS.find((c) => c.id === ballId) + return color?.value ?? 0 +} + +export class SnookerRules implements GameRules { + readonly name = 'Snooker' + readonly tableType = 'snooker' as const + + private tableConfig = createSnookerTable() + + // Spot positions (standard snooker table layout) + private colorSpots: Map + + constructor() { + const w = this.tableConfig.width + const h = this.tableConfig.height + const baulkLine = w * 0.2 // "D" line ~20% from bottom cushion + + this.colorSpots = new Map([ + ['yellow', [baulkLine, h / 2 + h * 0.1]], + ['green', [baulkLine, h / 2 - h * 0.1]], + ['brown', [baulkLine, h / 2]], + ['blue', [w / 2, h / 2]], + ['pink', [w * 0.75 - BALL_RADIUS * 2, h / 2]], + ['black', [w * 0.9, h / 2]], + ]) + } + + getTableConfig(): TableConfig { + return this.tableConfig + } + + getPhysicsConfig(): PhysicsConfig { + return { + ...defaultPhysicsConfig, + defaultBallParams: { + ...defaultPhysicsConfig.defaultBallParams, + radius: BALL_RADIUS, + mass: 0.142, // snooker balls are lighter (~142g) + }, + } + } + + getBallTextureSet(): BallTextureSet { + return 'snooker' + } + + getCueBallId(): string { + return CUE_BALL_ID + } + + getMaxShotSpeed(): number { + return 5000 + } + + getBallRadius(): number { + return BALL_RADIUS + } + + setupBalls(): BallSpec[] { + const w = this.tableConfig.width + const h = this.tableConfig.height + const baulkLine = w * 0.2 + + const balls: BallSpec[] = [] + + // Cue ball in the "D" + balls.push({ + id: CUE_BALL_ID, + x: baulkLine - 100, + y: h / 2, + }) + + // Color balls on their spots + for (const [id, pos] of this.colorSpots) { + balls.push({ id, x: pos[0], y: pos[1] }) + } + + // 15 reds in a triangle behind the pink spot + const pinkSpot = this.colorSpots.get('pink')! + const rackX = pinkSpot[0] + BALL_RADIUS * 2 + 2 // just behind pink + const d = BALL_RADIUS * 2 + 0.01 + const rowSpacing = d * Math.cos(Math.PI / 6) + + let redIdx = 0 + for (let row = 0; row < 5; row++) { + for (let col = 0; col <= row; col++) { + redIdx++ + balls.push({ + id: `${RED_PREFIX}${redIdx}`, + x: rackX + row * rowSpacing, + y: h / 2 + (col - row / 2) * d, + }) + } + } + + return balls + } + + evaluateShot(events: ReplayData[], gameState: GameState): ShotResult { + const result: ShotResult = { + foul: false, + foulReasons: [], + switchTurn: true, + respotBalls: [], + scoreChange: 0, + gameOver: false, + } + + const pottedBalls: { ballId: string; pocketId: string }[] = [] + let firstContactBallId: string | undefined + let cueBallPotted = false + + for (const event of events) { + if (event.type === EventType.BallPocketed) { + const ballId = event.snapshots[0].id + const pocketId = event.pocketId! + pottedBalls.push({ ballId, pocketId }) + if (ballId === CUE_BALL_ID) cueBallPotted = true + } + + if (event.type === EventType.CircleCollision && !firstContactBallId) { + const involvedIds = event.snapshots.map((s) => s.id) + if (involvedIds.includes(CUE_BALL_ID)) { + firstContactBallId = involvedIds.find((id) => id !== CUE_BALL_ID) + } + } + } + + const target = gameState.snookerTarget ?? 'red' + const redsOnTable = this.getRedsOnTable(gameState) + const inColorSequence = redsOnTable.length === 0 + + // Foul: cue ball potted + if (cueBallPotted) { + result.foul = true + result.foulReasons.push('Cue ball potted') + } + + // Foul: no contact + if (!firstContactBallId && !cueBallPotted) { + result.foul = true + result.foulReasons.push('Cue ball did not contact any ball') + } + + // Foul: wrong ball contacted first + if (firstContactBallId) { + if (inColorSequence) { + // Must hit the lowest remaining color + const lowestColor = this.getLowestRemainingColor(gameState) + if (lowestColor && firstContactBallId !== lowestColor) { + result.foul = true + result.foulReasons.push(`Must hit ${lowestColor} first`) + } + } else if (target === 'red') { + if (!isRed(firstContactBallId)) { + result.foul = true + result.foulReasons.push('Must hit a red ball first') + } + } else { + // target === 'color' — any color is valid first contact + if (isRed(firstContactBallId)) { + result.foul = true + result.foulReasons.push('Must hit a color ball first') + } + } + } + + // Calculate foul points (minimum 4, or value of the ball involved) + if (result.foul) { + let foulValue = 4 + // Foul value is the highest of: ball on, ball hit, ball potted + if (firstContactBallId && isColor(firstContactBallId)) { + foulValue = Math.max(foulValue, getColorValue(firstContactBallId)) + } + for (const potted of pottedBalls) { + if (isColor(potted.ballId)) { + foulValue = Math.max(foulValue, getColorValue(potted.ballId)) + } + } + + // Award foul points to opponent + const opponentIdx = 1 - gameState.currentPlayerIndex + result.scoreChange = 0 // no points for the fouling player + // We'll add foul points to opponent via a separate mechanism + // For now, encode as negative scoreChange (opponent gets points) + gameState.players[opponentIdx].score += foulValue + } + + // Process potted balls + if (!result.foul) { + let validPots = 0 + for (const potted of pottedBalls) { + if (potted.ballId === CUE_BALL_ID) continue + + if (inColorSequence) { + const lowestColor = this.getLowestRemainingColor(gameState) + if (potted.ballId === lowestColor) { + result.scoreChange += getColorValue(potted.ballId) + validPots++ + // Colors in final sequence stay down + } else { + // Wrong color potted in sequence — foul + result.foul = true + result.foulReasons.push(`Wrong color potted (${potted.ballId})`) + } + } else if (target === 'red' && isRed(potted.ballId)) { + result.scoreChange += 1 + validPots++ + } else if (target === 'color' && isColor(potted.ballId)) { + result.scoreChange += getColorValue(potted.ballId) + validPots++ + // Re-spot the color (reds still on table) + const spotPos = this.colorSpots.get(potted.ballId) + if (spotPos) { + result.respotBalls.push({ ballId: potted.ballId, position: [...spotPos] as Vector2D }) + } + } else { + // Wrong type of ball potted + result.foul = true + const expected = target === 'red' ? 'a red' : 'a color' + result.foulReasons.push(`Potted ${potted.ballId} when ${expected} was required`) + } + } + + // Continue turn if valid pot + if (validPots > 0 && !result.foul) { + result.switchTurn = false + } + } + + // Re-spot any colors potted on a foul (unless in final color sequence) + if (result.foul && !inColorSequence) { + for (const potted of pottedBalls) { + if (isColor(potted.ballId)) { + const spotPos = this.colorSpots.get(potted.ballId) + if (spotPos) { + result.respotBalls.push({ ballId: potted.ballId, position: [...spotPos] as Vector2D }) + } + } + } + } + + // Check game over: all balls potted + const remainingObjectBalls = [...gameState.ballsOnTable].filter((id) => id !== CUE_BALL_ID) + // Subtract balls just potted (not yet removed from gameState) + const justPotted = new Set(pottedBalls.map((p) => p.ballId)) + const willRemain = remainingObjectBalls.filter((id) => !justPotted.has(id) || result.respotBalls.some((r) => r.ballId === id)) + if (willRemain.length === 0) { + result.gameOver = true + // Winner is the player with the higher score + const p0Score = gameState.players[0].score + (gameState.currentPlayerIndex === 0 ? result.scoreChange : 0) + const p1Score = gameState.players[1].score + (gameState.currentPlayerIndex === 1 ? result.scoreChange : 0) + result.winner = p0Score >= p1Score ? 0 : 1 + } + + return result + } + + getValidTargets(gameState: GameState): string[] { + const redsOnTable = this.getRedsOnTable(gameState) + + if (redsOnTable.length === 0) { + // Color sequence — must pot lowest remaining color + const lowest = this.getLowestRemainingColor(gameState) + return lowest ? [lowest] : [] + } + + const target = gameState.snookerTarget ?? 'red' + if (target === 'red') { + return redsOnTable + } + + // Color target — any color on the table + return COLOR_IDS.filter((id) => gameState.ballsOnTable.has(id)) + } + + getScoreDisplay(gameState: GameState): ScoreDisplay { + return { + players: gameState.players.map((p, i) => ({ + name: p.name, + score: p.score, + active: i === gameState.currentPlayerIndex, + })), + } + } + + private getRedsOnTable(gameState: GameState): string[] { + return [...gameState.ballsOnTable].filter((id) => isRed(id)) + } + + private getLowestRemainingColor(gameState: GameState): string | null { + for (const color of COLOR_BALLS) { + if (gameState.ballsOnTable.has(color.id)) return color.id + } + return null + } +} diff --git a/src/lib/game/rules.ts b/src/lib/game/rules.ts new file mode 100644 index 0000000..d2f4a13 --- /dev/null +++ b/src/lib/game/rules.ts @@ -0,0 +1,54 @@ +/** + * GameRules interface — the contract that all game modes implement. + * + * Each game mode (8-ball, 9-ball, snooker) provides its own rules implementation + * that handles ball setup, shot evaluation, scoring, and win conditions. + */ + +import type { BallTextureSet } from '../scene/ball-textures' +import type { PhysicsConfig } from '../physics-config' +import type { BallSpec } from '../scenarios' +import type { ReplayData } from '../simulation' +import type { TableConfig } from '../table-config' +import type { GameState, ShotResult, ScoreDisplay } from './types' + +export interface GameRules { + readonly name: string + readonly tableType: 'pool' | 'snooker' + + /** Get the table configuration (dimensions, pockets, cushion segments) */ + getTableConfig(): TableConfig + + /** Get the physics configuration for this game type */ + getPhysicsConfig(): PhysicsConfig + + /** Get the ball texture set to use */ + getBallTextureSet(): BallTextureSet + + /** Get the initial ball layout for a new game */ + setupBalls(): BallSpec[] + + /** Get the cue ball ID */ + getCueBallId(): string + + /** + * Evaluate a completed shot. Called after all balls have come to rest. + * Processes replay events to determine fouls, scoring, turn changes, and game over. + */ + evaluateShot(events: ReplayData[], gameState: GameState): ShotResult + + /** + * Get the IDs of balls that are valid first-contact targets for the current player. + * Used for UI hints and foul detection. + */ + getValidTargets(gameState: GameState): string[] + + /** Get score display data for the UI */ + getScoreDisplay(gameState: GameState): ScoreDisplay + + /** Get the maximum shot power in mm/s */ + getMaxShotSpeed(): number + + /** Get the ball radius for this game type in mm */ + getBallRadius(): number +} diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts new file mode 100644 index 0000000..9b48def --- /dev/null +++ b/src/lib/game/types.ts @@ -0,0 +1,87 @@ +/** + * Core game state types shared across all game modes (8-ball, 9-ball, snooker). + */ + +import type Vector2D from '../vector2d' + +export interface Player { + name: string + score: number + /** For 8-ball: assigned group after first legal pot ('solids' | 'stripes' | null) */ + group?: 'solids' | 'stripes' | null +} + +export interface PottedBall { + ballId: string + pocketId: string + turnNumber: number +} + +export interface Foul { + reason: string + turnNumber: number + /** Points awarded to opponent (snooker) */ + points?: number +} + +export type GamePhase = 'aiming' | 'placing-cue-ball' | 'simulating' | 'evaluating' | 'game-over' + +export interface GameState { + players: Player[] + currentPlayerIndex: number + ballsOnTable: Set + pottedBalls: PottedBall[] + /** Balls potted during the current visit/turn */ + currentBreak: PottedBall[] + fouls: Foul[] + turnNumber: number + phase: GamePhase + /** Ball-in-hand: the incoming player must place the cue ball */ + ballInHand: boolean + /** For snooker: whether the next ball to pot should be a red or a color */ + snookerTarget?: 'red' | 'color' + /** Winner index (set when phase = 'game-over') */ + winner?: number +} + +export interface ShotResult { + foul: boolean + foulReasons: string[] + switchTurn: boolean + /** Balls that must be re-spotted (e.g. snooker colors) */ + respotBalls: { ballId: string; position: Vector2D }[] + scoreChange: number + gameOver: boolean + winner?: number + /** For 8-ball: group assignment that happened this shot */ + groupAssignment?: { playerIndex: number; group: 'solids' | 'stripes' } +} + +export interface ShotParams { + /** Aim direction in radians (0 = +x, π/2 = +y) */ + direction: number + /** Power from 0 to 1 */ + power: number + /** Strike offset on cue ball face, normalized to [-1, 1]. dx = english, dy = top/backspin */ + strikeOffset: Vector2D + /** Cue elevation angle in radians (0 = level, >0 = massé). Default 0. */ + elevation: number +} + +export interface ScoreDisplay { + players: { name: string; score: number; active: boolean; group?: string }[] +} + +export function createInitialGameState(playerNames: string[]): GameState { + return { + players: playerNames.map((name) => ({ name, score: 0 })), + currentPlayerIndex: 0, + ballsOnTable: new Set(), + pottedBalls: [], + currentBreak: [], + fouls: [], + turnNumber: 1, + phase: 'aiming', + ballInHand: false, + } +} diff --git a/src/lib/input/cue-input.ts b/src/lib/input/cue-input.ts new file mode 100644 index 0000000..7b663a2 --- /dev/null +++ b/src/lib/input/cue-input.ts @@ -0,0 +1,249 @@ +/** + * Cue input handler — manages aim direction via pointer events. + * + * Mobile-friendly design: + * - Single finger drag: aim the cue (sets direction) + * - Two-finger gesture: camera control (passed through to OrbitControls) + * - Shooting is done via UI button, not pointer-up (avoids accidental shots) + * + * Desktop: click-and-drag to aim, shoot via UI button or double-click. + */ + +import * as THREE from 'three' +import type { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import type Vector2D from '../vector2d' + +export type CueInputMode = 'aim' | 'camera' +export type CueInputState = 'idle' | 'aiming' | 'committed' + +export interface CueInputCallbacks { + /** Called continuously as the player adjusts aim */ + onAimUpdate: (direction: number) => void + /** Called when the player commits to a shot */ + onShoot: () => void +} + +export class CueInput { + private camera: THREE.PerspectiveCamera + private canvas: HTMLCanvasElement + private tableWidth: number + private tableHeight: number + private cueBallPos: Vector2D = [0, 0] + private callbacks: CueInputCallbacks + private controls: OrbitControls | null = null + + private state: CueInputState = 'idle' + private aimDirection = 0 + private enabled = true + private mode: CueInputMode = 'aim' + + // Multi-touch tracking + private activePointers = new Map() + private isAimDrag = false + + // Raycaster for screen-to-table conversion + private raycaster = new THREE.Raycaster() + private tablePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0) + + constructor( + camera: THREE.PerspectiveCamera, + canvas: HTMLCanvasElement, + tableWidth: number, + tableHeight: number, + callbacks: CueInputCallbacks, + ) { + this.camera = camera + this.canvas = canvas + this.tableWidth = tableWidth + this.tableHeight = tableHeight + this.callbacks = callbacks + + this.bindEvents() + } + + setEnabled(enabled: boolean) { + this.enabled = enabled + if (!enabled) { + this.state = 'idle' + this.isAimDrag = false + this.activePointers.clear() + this.enableOrbitControls(true) + } + } + + setControls(controls: OrbitControls) { + this.controls = controls + } + + setMode(mode: CueInputMode) { + this.mode = mode + // When switching to camera mode, stop any active aim drag + if (mode === 'camera') { + this.isAimDrag = false + this.state = 'idle' + this.enableOrbitControls(true) + } else { + // In aim mode, OrbitControls are only active during multi-touch + this.enableOrbitControls(false) + } + } + + getMode(): CueInputMode { + return this.mode + } + + setCueBallPosition(pos: Vector2D) { + this.cueBallPos = pos + } + + getState(): CueInputState { + return this.state + } + + getAimDirection(): number { + return this.aimDirection + } + + /** Called by UI shoot button */ + shoot() { + if (this.state !== 'idle' && this.state !== 'aiming') return + this.state = 'committed' + this.callbacks.onShoot() + } + + destroy() { + this.canvas.removeEventListener('pointerdown', this.handlePointerDown) + this.canvas.removeEventListener('pointermove', this.handlePointerMove) + this.canvas.removeEventListener('pointerup', this.handlePointerUp) + this.canvas.removeEventListener('pointercancel', this.handlePointerUp) + this.canvas.removeEventListener('dblclick', this.handleDoubleClick) + } + + /** Reset to idle state (after shot simulation completes) */ + reset() { + this.state = 'idle' + this.isAimDrag = false + this.activePointers.clear() + if (this.mode === 'aim') { + this.enableOrbitControls(false) + } + } + + private bindEvents() { + this.canvas.addEventListener('pointerdown', this.handlePointerDown) + this.canvas.addEventListener('pointermove', this.handlePointerMove) + this.canvas.addEventListener('pointerup', this.handlePointerUp) + this.canvas.addEventListener('pointercancel', this.handlePointerUp) + this.canvas.addEventListener('dblclick', this.handleDoubleClick) + + // Prevent default touch behaviors on the canvas + this.canvas.style.touchAction = 'none' + } + + private enableOrbitControls(enabled: boolean) { + if (this.controls) { + this.controls.enabled = enabled + } + } + + /** Convert screen coordinates to physics table coordinates */ + private screenToTable(clientX: number, clientY: number): Vector2D | null { + const rect = this.canvas.getBoundingClientRect() + const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1 + const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1 + + this.raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), this.camera) + + const intersection = new THREE.Vector3() + const hit = this.raycaster.ray.intersectPlane(this.tablePlane, intersection) + if (!hit) return null + + // Three.js coords (x, z) → physics coords (x, y) with table offset + const physicsX = intersection.x + this.tableWidth / 2 + const physicsY = intersection.z + this.tableHeight / 2 + + return [physicsX, physicsY] + } + + private handlePointerDown = (e: PointerEvent) => { + if (!this.enabled) return + + this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + + // In camera mode, let OrbitControls handle everything + if (this.mode === 'camera') return + + // Multi-touch: switch to camera control temporarily + if (this.activePointers.size > 1) { + this.isAimDrag = false + this.enableOrbitControls(true) + return + } + + // Single touch in aim mode: start aiming + const tablePos = this.screenToTable(e.clientX, e.clientY) + if (!tablePos) return + + this.isAimDrag = true + this.state = 'aiming' + this.enableOrbitControls(false) + + // Set aim direction from cue ball to pointer + const dx = tablePos[0] - this.cueBallPos[0] + const dy = tablePos[1] - this.cueBallPos[1] + this.aimDirection = Math.atan2(dy, dx) + this.callbacks.onAimUpdate(this.aimDirection) + } + + private handlePointerMove = (e: PointerEvent) => { + if (!this.enabled) return + + // Update tracked pointer position + if (this.activePointers.has(e.pointerId)) { + this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + } + + // In camera mode or multi-touch, let OrbitControls handle it + if (this.mode === 'camera' || this.activePointers.size > 1) return + + if (!this.isAimDrag) return + + const tablePos = this.screenToTable(e.clientX, e.clientY) + if (!tablePos) return + + // Update aim direction + const dx = tablePos[0] - this.cueBallPos[0] + const dy = tablePos[1] - this.cueBallPos[1] + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist > 1) { + this.aimDirection = Math.atan2(dy, dx) + } + + this.callbacks.onAimUpdate(this.aimDirection) + } + + private handlePointerUp = (e: PointerEvent) => { + if (!this.enabled) return + + this.activePointers.delete(e.pointerId) + + // If all fingers are up and we were in multi-touch camera, go back to aim mode + if (this.activePointers.size === 0 && this.mode === 'aim') { + this.enableOrbitControls(false) + } + + // End aim drag when the aiming finger lifts + // (but do NOT auto-shoot — the user must press the shoot button) + if (this.isAimDrag && this.activePointers.size === 0) { + this.isAimDrag = false + // Stay in 'aiming' state so the preview stays visible + } + } + + /** Desktop convenience: double-click to shoot */ + private handleDoubleClick = (_e: MouseEvent) => { + if (!this.enabled || this.mode !== 'aim') return + this.shoot() + } +} diff --git a/src/lib/input/trajectory-preview.ts b/src/lib/input/trajectory-preview.ts new file mode 100644 index 0000000..b32468e --- /dev/null +++ b/src/lib/input/trajectory-preview.ts @@ -0,0 +1,173 @@ +/** + * Trajectory preview — analytically computes the cue ball's path to first contact. + * + * Uses the same quartic detector as the physics engine to find the first + * ball-ball collision, and the quadratic solver for cushion hits. + * Renders a dotted aim line, ghost ball at contact, and deflection angles. + */ + +import Ball from '../ball' +import type Vector2D from '../vector2d' +import type { TableConfig } from '../table-config' +import { QuarticBallBallDetector } from '../physics/detection/ball-ball-detector' +import { QuadraticCushionDetector } from '../physics/detection/cushion-detector' +import { SegmentedCushionDetector } from '../physics/detection/segmented-cushion-detector' +import { createPoolPhysicsProfile } from '../physics/physics-profile' +import type { PhysicsConfig } from '../physics-config' + +export interface PreviewResult { + /** Points along the cue ball path (physics coordinates) */ + cuePath: Vector2D[] + /** Position where cue ball makes first contact (ball or cushion) */ + contactPoint: Vector2D | null + /** Type of first contact */ + contactType: 'ball' | 'cushion' | 'none' + /** ID of the object ball hit (if ball contact) */ + contactBallId: string | null + /** Deflection line for the object ball after contact */ + objectBallDeflection: Vector2D | null + /** Deflection line for the cue ball after contact */ + cueBallDeflection: Vector2D | null +} + +const ballBallDetector = new QuarticBallBallDetector() +const defaultCushionDetector = new QuadraticCushionDetector() + +/** + * Compute the trajectory preview for a cue shot. + * + * @param cueBallPos Current cue ball position + * @param direction Aim direction in radians + * @param speed Shot speed in mm/s + * @param objectBalls Map of ball ID → position for all object balls on table + * @param ballRadius Ball radius in mm + * @param tableConfig Table geometry + * @param physicsConfig Physics parameters + */ +export function computeTrajectoryPreview( + cueBallPos: Vector2D, + direction: number, + speed: number, + objectBalls: Map, + ballRadius: number, + tableConfig: TableConfig, + physicsConfig: PhysicsConfig, +): PreviewResult { + const result: PreviewResult = { + cuePath: [[...cueBallPos]], + contactPoint: null, + contactType: 'none', + contactBallId: null, + objectBallDeflection: null, + cueBallDeflection: null, + } + + const vx = speed * Math.cos(direction) + const vy = speed * Math.sin(direction) + + // Create a temporary cue ball for trajectory calculation + const profile = createPoolPhysicsProfile() + + const cueBall = new Ball( + [cueBallPos[0], cueBallPos[1], 0], + [vx, vy, 0], + ballRadius, + 0, + physicsConfig.defaultBallParams.mass, + '__preview_cue__', + [0, 0, 0], + { ...physicsConfig.defaultBallParams, radius: ballRadius }, + physicsConfig, + ) + cueBall.updateTrajectory(profile, physicsConfig) + + // Find earliest ball-ball collision + let earliestBallTime: number | undefined + let earliestBallId: string | undefined + + for (const [id, pos] of objectBalls) { + const objBall = new Ball( + [pos[0], pos[1], 0], + [0, 0, 0], + ballRadius, + 0, + physicsConfig.defaultBallParams.mass, + id, + [0, 0, 0], + { ...physicsConfig.defaultBallParams, radius: ballRadius }, + physicsConfig, + ) + objBall.updateTrajectory(profile, physicsConfig) + + const time = ballBallDetector.detect(cueBall, objBall) + if (time !== undefined && (earliestBallTime === undefined || time < earliestBallTime)) { + earliestBallTime = time + earliestBallId = id + } + } + + // Find earliest cushion collision + const cushionDetector = + tableConfig.pockets.length > 0 + ? new SegmentedCushionDetector(tableConfig.cushionSegments) + : defaultCushionDetector + const cushionCollision = cushionDetector.detect(cueBall, tableConfig.width, tableConfig.height) + const cushionTime = cushionCollision.time + + // Determine first contact + let contactTime: number + if (earliestBallTime !== undefined && earliestBallTime < cushionTime) { + contactTime = earliestBallTime + result.contactType = 'ball' + result.contactBallId = earliestBallId! + } else if (isFinite(cushionTime)) { + contactTime = cushionTime + result.contactType = 'cushion' + } else { + // No contact — show a long line + contactTime = 2 // 2 seconds ahead + } + + // Build path points along cue ball trajectory up to contact + const numPoints = 30 + for (let i = 1; i <= numPoints; i++) { + const t = (contactTime * i) / numPoints + const pos = cueBall.positionAtTime(t) + result.cuePath.push([pos[0], pos[1]]) + } + + // Contact point + const contactPos = cueBall.positionAtTime(contactTime) + result.contactPoint = [contactPos[0], contactPos[1]] + + // Compute deflection angles for ball contact + if (result.contactType === 'ball' && result.contactBallId) { + const objPos = objectBalls.get(result.contactBallId)! + + // Contact normal: from cue ball center to object ball center + const nx = objPos[0] - contactPos[0] + const ny = objPos[1] - contactPos[1] + const nLen = Math.sqrt(nx * nx + ny * ny) + if (nLen > 0.01) { + const nnx = nx / nLen + const nny = ny / nLen + + // Object ball goes along the contact normal + result.objectBallDeflection = [objPos[0] + nnx * ballRadius * 4, objPos[1] + nny * ballRadius * 4] + + // Cue ball deflects at ~90° to the object ball (for non-head-on hits) + const dot = vx * nnx + vy * nny + const tangentX = vx - dot * nnx + const tangentY = vy - dot * nny + const tangentLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY) + if (tangentLen > 0.01) { + result.cueBallDeflection = [ + contactPos[0] + (tangentX / tangentLen) * ballRadius * 4, + contactPos[1] + (tangentY / tangentLen) * ballRadius * 4, + ] + } + } + } + + return result +} diff --git a/src/lib/physics/detection/collision-detector.ts b/src/lib/physics/detection/collision-detector.ts index 52477ce..0e02ae2 100644 --- a/src/lib/physics/detection/collision-detector.ts +++ b/src/lib/physics/detection/collision-detector.ts @@ -8,6 +8,8 @@ * Implementations: * - QuarticBallBallDetector: solves quartic for two quadratic trajectories * - QuadraticCushionDetector: solves quadratic for ball vs axis-aligned wall + * - SegmentedCushionDetector: like Quadratic but with gaps at pocket mouths + * - QuarticPocketDetector: solves quartic for ball vs pocket acceptance circle */ import type Ball from '../../ball' @@ -22,3 +24,5 @@ export interface CushionDetector { /** Compute the earliest cushion collision for a ball. Always returns an event (may be at Infinity). */ detect(ball: Ball, tableWidth: number, tableHeight: number): CushionCollision } + +export { type PocketDetector, type PocketCollisionResult } from './pocket-detector' diff --git a/src/lib/physics/detection/cushion-detector.ts b/src/lib/physics/detection/cushion-detector.ts index 75dedc0..ce57fc4 100644 --- a/src/lib/physics/detection/cushion-detector.ts +++ b/src/lib/physics/detection/cushion-detector.ts @@ -6,7 +6,8 @@ */ import type Ball from '../../ball' -import { Cushion, type CushionCollision } from '../../collision' +import { Cushion } from '../../cushion' +import type { CushionCollision } from '../../collision' import { solveQuadratic } from '../../polynomial-solver' import type { CushionDetector } from './collision-detector' diff --git a/src/lib/physics/detection/pocket-detector.ts b/src/lib/physics/detection/pocket-detector.ts new file mode 100644 index 0000000..b5e3ec6 --- /dev/null +++ b/src/lib/physics/detection/pocket-detector.ts @@ -0,0 +1,149 @@ +/** + * Pocket collision detector. + * + * Detects when a ball's center enters a pocket's acceptance circle. + * With quadratic ball trajectories r(t) = a*t^2 + b*t + c, the squared + * distance from the pocket center is a quartic polynomial. We find the + * first time it drops below pocket_radius^2 using the same cubic-critical-point + * + bisection approach as the ball-ball detector. + */ + +import type Ball from '../../ball' +import type { PocketDef } from '../../table-config' +import { solveCubic } from '../../polynomial-solver' + +export interface PocketCollisionResult { + pocketId: string + /** Absolute time when the ball enters the pocket */ + time: number +} + +export interface PocketDetector { + /** Detect the earliest pocket entry for a ball, or undefined if none within trajectory validity. */ + detect(ball: Ball, pockets: PocketDef[]): PocketCollisionResult | undefined +} + +export class QuarticPocketDetector implements PocketDetector { + detect(ball: Ball, pockets: PocketDef[]): PocketCollisionResult | undefined { + let bestTime: number | undefined + let bestPocketId: string | undefined + + for (const pocket of pockets) { + const time = this.detectSinglePocket(ball, pocket) + if (time !== undefined && (bestTime === undefined || time < bestTime)) { + bestTime = time + bestPocketId = pocket.id + } + } + + if (bestTime !== undefined && bestPocketId !== undefined) { + return { pocketId: bestPocketId, time: bestTime } + } + return undefined + } + + private detectSinglePocket(ball: Ball, pocket: PocketDef): number | undefined { + const traj = ball.trajectory + const maxDt = traj.maxDt + + // Difference vector from pocket center: d(t) = r(t) - pocketCenter + // d(t) = A*t^2 + B*t + C where: + const Ax = traj.a[0] + const Ay = traj.a[1] + const Bx = traj.b[0] + const By = traj.b[1] + const Cx = traj.c[0] - pocket.center[0] + const Cy = traj.c[1] - pocket.center[1] + + const rSq = pocket.radius * pocket.radius + + // D(t) = |d(t)|^2 - rSq is a quartic polynomial + // D(t) = c4*t^4 + c3*t^3 + c2*t^2 + c1*t + c0 + const c4 = Ax * Ax + Ay * Ay + const c3 = 2 * (Ax * Bx + Ay * By) + const c2 = Bx * Bx + By * By + 2 * (Ax * Cx + Ay * Cy) + const c1 = 2 * (Bx * Cx + By * Cy) + const c0 = Cx * Cx + Cy * Cy - rSq + + // Already inside pocket + if (c0 <= 0) { + return ball.time + } + + // D'(t) = 4*c4*t^3 + 3*c3*t^2 + 2*c2*t + c1 + const criticalPoints = solveCubic(4 * c4, 3 * c3, 2 * c2, c1) + + // Evaluate D at critical points and endpoints to find sign change + const D = (t: number): number => { + const t2 = t * t + return c4 * t2 * t2 + c3 * t2 * t + c2 * t2 + c1 * t + c0 + } + + const evalPoints: number[] = [0] + for (const cp of criticalPoints) { + if (cp > 1e-12 && (isFinite(maxDt) ? cp < maxDt : true)) { + evalPoints.push(cp) + } + } + if (isFinite(maxDt)) { + evalPoints.push(maxDt) + } + evalPoints.sort((a, b) => a - b) + + // Find first interval where D transitions from >=0 to <0 + let bracketLo: number | undefined + let bracketHi: number | undefined + let prevD = c0 + + for (let i = 1; i < evalPoints.length; i++) { + const t = evalPoints[i] + const Dt = D(t) + if (prevD >= 0 && Dt < 0) { + bracketLo = evalPoints[i - 1] + bracketHi = t + break + } + prevD = Dt + } + + // Check midpoints if no sign change at evaluation points + if (bracketLo === undefined || bracketHi === undefined) { + prevD = c0 + for (let i = 1; i < evalPoints.length; i++) { + const t = evalPoints[i] + const Dt = D(t) + const mid = (evalPoints[i - 1] + t) / 2 + const Dmid = D(mid) + if ((prevD >= 0 || Dt >= 0) && Dmid < 0) { + if (prevD >= 0 && Dmid < 0) { + bracketLo = evalPoints[i - 1] + bracketHi = mid + } else { + bracketLo = mid + bracketHi = t + } + break + } + prevD = Dt + } + } + + if (bracketLo === undefined || bracketHi === undefined) { + return undefined + } + + // Bisect to find exact crossing (40 iterations ≈ 12 digits precision) + let lo = bracketLo + let hi = bracketHi + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2 + if (D(mid) > 0) lo = mid + else hi = mid + } + + const dt = (lo + hi) / 2 + if (dt < 1e-12) return undefined + + return dt + ball.time + } +} diff --git a/src/lib/physics/detection/segmented-cushion-detector.ts b/src/lib/physics/detection/segmented-cushion-detector.ts new file mode 100644 index 0000000..ba5bd1b --- /dev/null +++ b/src/lib/physics/detection/segmented-cushion-detector.ts @@ -0,0 +1,129 @@ +/** + * Segmented cushion collision detector for tables with pockets. + * + * Like QuadraticCushionDetector, solves a*t^2 + b*t + (c - wall) = 0 for each wall, + * but walls are broken into segments with gaps at pocket mouths. After finding the + * collision time, validates that the ball's position along the segment falls within + * [segment.start, segment.end]. + */ + +import type Ball from '../../ball' +import { Cushion } from '../../cushion' +import type { CushionCollision } from '../../collision' +import { solveQuadratic } from '../../polynomial-solver' +import type { CushionDetector } from './collision-detector' +import type { CushionSegment } from '../../table-config' + +const DIRECTION_TO_CUSHION: Record = { + north: Cushion.North, + east: Cushion.East, + south: Cushion.South, + west: Cushion.West, +} + +export class SegmentedCushionDetector implements CushionDetector { + private segments: CushionSegment[] + + constructor(segments: CushionSegment[]) { + this.segments = segments + } + + detect(circle: Ball, tableWidth: number, tableHeight: number): CushionCollision { + const traj = circle.trajectory + const r = circle.radius + const maxDt = traj.maxDt + + let minDt = Infinity + let bestCushion = Cushion.North + + for (const seg of this.segments) { + let wallPos: number + let a: number, b: number, c: number + let parallelA: number, parallelB: number, parallelC: number + + if (seg.axis === 'y') { + // North/South wall: y = value ∓ r + if (seg.direction === 'north') { + wallPos = seg.value - r + } else { + wallPos = seg.value + r + } + a = traj.a[1] + b = traj.b[1] + c = traj.c[1] - wallPos + // Parallel axis is x + parallelA = traj.a[0] + parallelB = traj.b[0] + parallelC = traj.c[0] + } else { + // East/West wall: x = value ∓ r + if (seg.direction === 'east') { + wallPos = seg.value - r + } else { + wallPos = seg.value + r + } + a = traj.a[0] + b = traj.b[0] + c = traj.c[0] - wallPos + // Parallel axis is y + parallelA = traj.a[1] + parallelB = traj.b[1] + parallelC = traj.c[1] + } + + const roots = solveQuadratic(a, b, c) + for (const dt of roots) { + if (dt > Number.EPSILON && dt < minDt && dt <= maxDt) { + // Check that collision point falls within segment bounds + const parallelPos = parallelA * dt * dt + parallelB * dt + parallelC + if (parallelPos >= seg.start && parallelPos <= seg.end) { + minDt = dt + bestCushion = DIRECTION_TO_CUSHION[seg.direction] + } + } + } + } + + // Direct contact checks (same as QuadraticCushionDetector but segment-aware) + const WALL_TOL = 0.01 + const VEL_TOL = 0.01 + const INSTANT_DT = 1e-12 + + for (const seg of this.segments) { + if (seg.axis === 'y') { + const parallelPos = traj.c[0] // current x position + if (parallelPos < seg.start || parallelPos > seg.end) continue + + if (seg.direction === 'north' && traj.c[1] > tableHeight - r - WALL_TOL && traj.b[1] > VEL_TOL && INSTANT_DT < minDt) { + minDt = INSTANT_DT + bestCushion = Cushion.North + } + if (seg.direction === 'south' && traj.c[1] < r + WALL_TOL && traj.b[1] < -VEL_TOL && INSTANT_DT < minDt) { + minDt = INSTANT_DT + bestCushion = Cushion.South + } + } else { + const parallelPos = traj.c[1] // current y position + if (parallelPos < seg.start || parallelPos > seg.end) continue + + if (seg.direction === 'east' && traj.c[0] > tableWidth - r - WALL_TOL && traj.b[0] > VEL_TOL && INSTANT_DT < minDt) { + minDt = INSTANT_DT + bestCushion = Cushion.East + } + if (seg.direction === 'west' && traj.c[0] < r + WALL_TOL && traj.b[0] < -VEL_TOL && INSTANT_DT < minDt) { + minDt = INSTANT_DT + bestCushion = Cushion.West + } + } + } + + return { + type: 'Cushion', + circles: [circle], + cushion: bestCushion, + time: minDt + circle.time, + epochs: [circle.epoch], + seq: 0, + } + } +} diff --git a/src/lib/renderers/future-trail-renderer.ts b/src/lib/renderers/future-trail-renderer.ts index 58234c2..c8f918c 100644 --- a/src/lib/renderers/future-trail-renderer.ts +++ b/src/lib/renderers/future-trail-renderer.ts @@ -7,6 +7,7 @@ const EVENT_STYLES: Record circles.every((b) => b.motionState === MotionState.Stationary) + // Track pocketed balls separately (removed from circles array by CollisionFinder) + const pocketedBallIds = new Set() + + // Check if all remaining balls are stationary (or all pocketed) + const allStationary = () => circles.length === 0 || circles.every((b) => b.motionState === MotionState.Stationary) // Pair collision rate tracker: detects Zeno cascades where external forces // keep pushing the same pair back together. Three tiers: @@ -250,6 +258,27 @@ export function simulate( continue } + // Pocket event — ball entered a pocket, remove it from simulation + if (event.type === 'Pocket') { + const pocketEvent = event as PocketCollision + const ball = pocketEvent.circles[0] + ball.advanceTime(pocketEvent.time) + currentTime = pocketEvent.time + + pocketedBallIds.add(ball.id) + + replay.push({ + time: currentTime, + type: EventType.BallPocketed, + snapshots: [snapshotBall(ball)], + pocketId: pocketEvent.pocketId, + }) + + // Remove ball from simulation — epoch increment + spatial grid removal + collisionFinder.removeBall(ball) + continue + } + // Collision event — advance and clamp (trajectory evaluation can overshoot walls) for (const circle of event.circles) { circle.advanceTime(event.time) diff --git a/src/lib/simulation.worker.ts b/src/lib/simulation.worker.ts index 5cfaa0e..0f8110f 100644 --- a/src/lib/simulation.worker.ts +++ b/src/lib/simulation.worker.ts @@ -8,6 +8,8 @@ import { createPoolPhysicsProfile, createSimple2DProfile } from './physics/physi import type { PhysicsProfile } from './physics/physics-profile' import type { PhysicsProfileName, PhysicsOverrides } from './config' import type { Scenario, BallSpec } from './scenarios' +import type { TableConfig } from './table-config' +import { createPoolTable, createSnookerTable } from './table-config' declare const self: DedicatedWorkerGlobalScope @@ -67,6 +69,7 @@ let circles: Ball[] = [] let time = 0 let physicsConfig: PhysicsConfig = defaultPhysicsConfig let profile: PhysicsProfile = createPoolPhysicsProfile() +let tableConfig: TableConfig | undefined // Respond to message from parent thread self.addEventListener('message', (event: MessageEvent) => { @@ -91,6 +94,7 @@ self.addEventListener('message', (event: MessageEvent) => { NUM_BALLS = request.payload.numBalls profile = createProfileByName(request.payload.physicsProfile) physicsConfig = applyPhysicsOverrides(defaultPhysicsConfig, request.payload.physicsOverrides) + tableConfig = request.payload.tableConfig console.time('initCircles') circles = generateCircles(NUM_BALLS, TABLE_WIDTH, TABLE_HEIGHT, Math.random, physicsConfig, profile) @@ -126,6 +130,15 @@ self.addEventListener('message', (event: MessageEvent) => { physicsConfig = defaultPhysicsConfig } + // Resolve table config from scenario table type + if (scenario.tableType === 'pool') { + tableConfig = createPoolTable() + } else if (scenario.tableType === 'snooker') { + tableConfig = createSnookerTable() + } else { + tableConfig = undefined // sandbox mode — no pockets + } + circles = createBallsFromScenario(scenario, physicsConfig, profile) NUM_BALLS = circles.length isInitialized = true @@ -148,8 +161,9 @@ self.addEventListener('message', (event: MessageEvent) => { time = time + request.payload.time console.log(`Simulating ${NUM_BALLS} balls for ${request.payload.time / 1000} seconds`) console.time('simulate') + const simOptions = tableConfig ? { tableConfig } : undefined if (needsInitialValues) { - const simulatedResults = simulate(TABLE_WIDTH, TABLE_HEIGHT, time, circles, physicsConfig, profile) + const simulatedResults = simulate(TABLE_WIDTH, TABLE_HEIGHT, time, circles, physicsConfig, profile, simOptions) const initialValues = simulatedResults.shift() const response: WorkerSimulationResponse = { type: ResponseMessageType.SIMULATION_DATA, @@ -161,7 +175,7 @@ self.addEventListener('message', (event: MessageEvent) => { console.timeEnd('simulate') self.postMessage(response) } else { - const simulatedResults = simulate(TABLE_WIDTH, TABLE_HEIGHT, time, circles, physicsConfig, profile) + const simulatedResults = simulate(TABLE_WIDTH, TABLE_HEIGHT, time, circles, physicsConfig, profile, simOptions) const response: WorkerSimulationResponse = { type: ResponseMessageType.SIMULATION_DATA, payload: { diff --git a/src/lib/table-config.ts b/src/lib/table-config.ts new file mode 100644 index 0000000..79ee8ea --- /dev/null +++ b/src/lib/table-config.ts @@ -0,0 +1,196 @@ +/** + * Table geometry configuration with pocket support. + * + * Defines pocket positions and cushion segments (walls with gaps at pocket mouths). + * Used by the segmented cushion detector and pocket detector to handle + * tables with pockets (pool, snooker) vs sandbox tables (no pockets). + */ + +import type Vector2D from './vector2d' + +export interface PocketDef { + id: string // e.g. 'top-left', 'bottom-center' + center: Vector2D // position in mm + radius: number // acceptance radius — ball center must enter this circle to be pocketed + mouthWidth: number // gap in cushion rail in mm +} + +export interface CushionSegment { + axis: 'x' | 'y' // which axis the wall is perpendicular to + value: number // wall position along that axis (e.g. tableWidth for East wall) + start: number // segment start along the parallel axis + end: number // segment end along the parallel axis + /** Which cushion direction this segment represents (for collision resolution) */ + direction: 'north' | 'east' | 'south' | 'west' +} + +export interface TableConfig { + width: number // mm + height: number // mm + pockets: PocketDef[] + cushionSegments: CushionSegment[] +} + +/** + * Build cushion segments from table dimensions and pocket definitions. + * Each wall is split into segments with gaps at pocket mouths. + */ +function buildCushionSegments( + width: number, + height: number, + pockets: PocketDef[], +): CushionSegment[] { + const segments: CushionSegment[] = [] + + // Helper: given a wall definition and pockets on that wall, + // produce segments with gaps cut out at pocket mouth positions. + function splitWall( + axis: 'x' | 'y', + value: number, + start: number, + end: number, + direction: CushionSegment['direction'], + wallPockets: { center: number; halfMouth: number }[], + ) { + // Sort pockets by position along the wall + const sorted = [...wallPockets].sort((a, b) => a.center - b.center) + let cursor = start + for (const p of sorted) { + const gapStart = p.center - p.halfMouth + const gapEnd = p.center + p.halfMouth + if (gapStart > cursor) { + segments.push({ axis, value, start: cursor, end: gapStart, direction }) + } + cursor = gapEnd + } + if (cursor < end) { + segments.push({ axis, value, start: cursor, end, direction }) + } + } + + // Categorize pockets by which wall(s) they're on + // Corner pockets affect two walls; center pockets affect one wall + const northPockets: { center: number; halfMouth: number }[] = [] + const southPockets: { center: number; halfMouth: number }[] = [] + const eastPockets: { center: number; halfMouth: number }[] = [] + const westPockets: { center: number; halfMouth: number }[] = [] + + const cornerTol = 100 // mm — how close to a corner to be considered a corner pocket + + for (const p of pockets) { + const halfMouth = p.mouthWidth / 2 + const nearLeft = p.center[0] < cornerTol + const nearRight = p.center[0] > width - cornerTol + const nearBottom = p.center[1] < cornerTol + const nearTop = p.center[1] > height - cornerTol + + // Corner pockets cut into both adjacent walls + if (nearTop || (!nearBottom && p.center[1] > height / 2 && Math.abs(p.center[1] - height) < cornerTol * 2)) { + northPockets.push({ center: p.center[0], halfMouth }) + } + if (nearBottom || (!nearTop && p.center[1] < height / 2 && Math.abs(p.center[1]) < cornerTol * 2)) { + southPockets.push({ center: p.center[0], halfMouth }) + } + if (nearRight || (!nearLeft && p.center[0] > width / 2 && Math.abs(p.center[0] - width) < cornerTol * 2)) { + eastPockets.push({ center: p.center[1], halfMouth }) + } + if (nearLeft || (!nearRight && p.center[0] < width / 2 && Math.abs(p.center[0]) < cornerTol * 2)) { + westPockets.push({ center: p.center[1], halfMouth }) + } + } + + // North wall: y = height, runs along x from 0 to width + splitWall('y', height, 0, width, 'north', northPockets) + // South wall: y = 0, runs along x from 0 to width + splitWall('y', 0, 0, width, 'south', southPockets) + // East wall: x = width, runs along y from 0 to height + splitWall('x', width, 0, height, 'east', eastPockets) + // West wall: x = 0, runs along y from 0 to height + splitWall('x', 0, 0, height, 'west', westPockets) + + return segments +} + +// ─── Standard table dimensions ────────────────────────────────────────────── + +const POOL_WIDTH = 2540 // mm (regulation 9-foot table playing surface) +const POOL_HEIGHT = 1270 + +const SNOOKER_WIDTH = 3569 // mm (regulation 12-foot table) +const SNOOKER_HEIGHT = 1778 + +// ─── Pocket configurations ────────────────────────────────────────────────── + +function sixPocketLayout( + width: number, + height: number, + cornerRadius: number, + centerRadius: number, + cornerMouth: number, + centerMouth: number, +): PocketDef[] { + return [ + // Corner pockets + { id: 'top-left', center: [0, height], radius: cornerRadius, mouthWidth: cornerMouth }, + { id: 'top-right', center: [width, height], radius: cornerRadius, mouthWidth: cornerMouth }, + { id: 'bottom-left', center: [0, 0], radius: cornerRadius, mouthWidth: cornerMouth }, + { id: 'bottom-right', center: [width, 0], radius: cornerRadius, mouthWidth: cornerMouth }, + // Center (side) pockets + { id: 'top-center', center: [width / 2, height], radius: centerRadius, mouthWidth: centerMouth }, + { id: 'bottom-center', center: [width / 2, 0], radius: centerRadius, mouthWidth: centerMouth }, + ] +} + +/** + * Standard pool table (9-foot): 2540x1270mm, 6 pockets. + * Corner pocket mouth ~115mm, center pocket mouth ~130mm. + */ +export function createPoolTable(): TableConfig { + const pockets = sixPocketLayout( + POOL_WIDTH, + POOL_HEIGHT, + 65, // corner acceptance radius + 70, // center acceptance radius + 115, // corner mouth width + 130, // center mouth width + ) + return { + width: POOL_WIDTH, + height: POOL_HEIGHT, + pockets, + cushionSegments: buildCushionSegments(POOL_WIDTH, POOL_HEIGHT, pockets), + } +} + +/** + * Standard snooker table (12-foot): 3569x1778mm, 6 pockets. + * Tighter pockets than pool: corner ~85mm mouth, center ~100mm mouth. + */ +export function createSnookerTable(): TableConfig { + const pockets = sixPocketLayout( + SNOOKER_WIDTH, + SNOOKER_HEIGHT, + 45, // corner acceptance radius (tighter than pool) + 50, // center acceptance radius + 85, // corner mouth width + 100, // center mouth width + ) + return { + width: SNOOKER_WIDTH, + height: SNOOKER_HEIGHT, + pockets, + cushionSegments: buildCushionSegments(SNOOKER_WIDTH, SNOOKER_HEIGHT, pockets), + } +} + +/** + * Sandbox table with no pockets — continuous walls (existing behavior). + */ +export function createSandboxTable(width: number, height: number): TableConfig { + return { + width, + height, + pockets: [], + cushionSegments: buildCushionSegments(width, height, []), + } +} diff --git a/src/lib/worker-request.ts b/src/lib/worker-request.ts index a8ff156..e8aa32e 100644 --- a/src/lib/worker-request.ts +++ b/src/lib/worker-request.ts @@ -6,6 +6,7 @@ export enum RequestMessageType { import type { PhysicsProfileName, PhysicsOverrides } from './config' import type { Scenario } from './scenarios' +import type { TableConfig } from './table-config' export interface InitializationRequestPayload { numBalls: number @@ -13,6 +14,7 @@ export interface InitializationRequestPayload { tableHeight: number physicsProfile: PhysicsProfileName physicsOverrides?: PhysicsOverrides + tableConfig?: TableConfig } export interface SimulationRequestPayload { diff --git a/src/sandbox.ts b/src/sandbox.ts new file mode 100644 index 0000000..c404653 --- /dev/null +++ b/src/sandbox.ts @@ -0,0 +1,668 @@ +/** + * Sandbox mode — the original simulation debug environment. + * + * Extracted from index.ts. Manages the worker lifecycle, event replay, + * animation loop, and debug UI (Tweakpane + React overlay). + */ + +import Ball from './lib/ball' +import type Vector3D from './lib/vector3d' +import { MotionState } from './lib/motion-state' +import { ReplayData } from './lib/simulation' +import CircleRenderer from './lib/renderers/circle-renderer' +import TailRenderer from './lib/renderers/tail-renderer' +import CollisionRenderer from './lib/renderers/collision-renderer' +import CollisionPreviewRenderer from './lib/renderers/collision-preview-renderer' +import FutureTrailRenderer from './lib/renderers/future-trail-renderer' +import * as THREE from 'three' +import SimulationScene, { type CameraState } from './lib/scene/simulation-scene' +import Stats from 'stats.js' +import { WorkerInitializationRequest, WorkerScenarioRequest, RequestMessageType } from './lib/worker-request' +import { WorkerResponse, isWorkerInitializationResponse, isWorkerSimulationResponse } from './lib/worker-response' +import { createConfig, SimulationConfig } from './lib/config' +import { createAdvancedUI } from './lib/ui' +import { defaultPhysicsConfig } from './lib/physics-config' +import { findScenario } from './lib/scenarios' +import { PlaybackController } from './lib/debug/playback-controller' +import { BallInspector } from './lib/debug/ball-inspector' +import { + createSimulationBridge, + computeBallData, + type EventEntry, + type BallEventSnapshot, +} from './lib/debug/simulation-bridge' +import { mountDebugOverlay } from './ui/index' + +export interface SandboxInstance { + destroy: () => void +} + +export function startSandbox(containerElement: HTMLElement): SandboxInstance { + const config = createConfig() + + // Support ?scenario=name URL parameter + const urlParams = new URLSearchParams(window.location.search) + const urlScenario = urlParams.get('scenario') + if (urlScenario) { + config.scenarioName = urlScenario + } + + // Buffer ahead in seconds (physics uses seconds as time unit) + const PRECALC = 10 + + let worker: Worker | null = null + let state: { [key: string]: Ball } = {} + let circleIds: string[] = [] + let replayCircles: Ball[] = [] + let nextEvent: ReplayData | undefined + let simulatedResults: ReplayData[] = [] + let fetchingMore = false + let simulationDone = false + + let threeRenderer: THREE.WebGLRenderer | null = null + let simulationScene: SimulationScene | null = null + let stats: Stats | null = null + let animationFrameId: number | null = null + let prevTimestamp: number | null = null + let resizeHandler: (() => void) | null = null + const playbackController = new PlaybackController() + const ballInspector = new BallInspector() + let currentProgress = 0 + let eventHistory: ReplayData[] = [] + + interface BallStateSnapshot { + position: Vector3D + velocity: Vector3D + radius: number + time: number + angularVelocity: Vector3D + motionState: MotionState + trajectoryA: [number, number] + angularAlpha: Vector3D + angularOmega0: Vector3D + } + let initialBallStates: Map | null = null + let lastConsumedEvent: EventEntry | null = null + let seekTarget: number | null = null + let savedCameraState: CameraState | null = null + + // --- Simulation Bridge (connects animation loop <-> React UI) --- + const bridge = createSimulationBridge(config, { + onRestartRequired: () => startSimulation(), + onPauseToggle: () => playbackController.togglePause(), + onStepForward: () => playbackController.requestStep(), + onStepBack: () => playbackController.requestStepBack(), + onStepToNextBallEvent: () => { + const ballId = bridge.getSnapshot().selectedBallId + if (ballId) playbackController.requestStepToBallEvent(ballId) + }, + onSeek: (time: number) => { + seekTarget = time + }, + onLiveUpdate: () => { + if (simulationScene) simulationScene.updateFromConfig(config) + if (threeRenderer) threeRenderer.shadowMap.enabled = config.shadowsEnabled + }, + clearBallSelection: () => ballInspector.clearSelection(), + }) + + function createCanvas(config: SimulationConfig) { + const millimeterToPixel = 1 / 2 + const canvas = document.createElement('canvas') + canvas.width = config.tableWidth * millimeterToPixel + canvas.height = config.tableHeight * millimeterToPixel + return canvas + } + + let canvas2D = createCanvas(config) + + function startSimulation() { + // Save camera state before teardown + if (simulationScene) { + savedCameraState = simulationScene.getCameraState() + } + + // Clean up previous simulation + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } + if (worker) { + worker.terminate() + worker = null + } + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler) + resizeHandler = null + } + if (threeRenderer) { + if (threeRenderer.domElement.parentElement) { + threeRenderer.domElement.parentElement.removeChild(threeRenderer.domElement) + } + threeRenderer.dispose() + threeRenderer = null + } + + // Reset state + state = {} + circleIds = [] + replayCircles = [] + nextEvent = undefined + simulatedResults = [] + fetchingMore = false + simulationDone = false + prevTimestamp = null + simulationScene = null + eventHistory = [] + initialBallStates = null + currentProgress = 0 + lastConsumedEvent = null + seekTarget = null + playbackController.reset() + // New canvas + canvas2D = createCanvas(config) + + // Start new worker + worker = new Worker(new URL('./lib/simulation.worker.ts', import.meta.url), { type: 'module' }) + + // Send either a scenario load or random initialization + const scenario = config.scenarioName ? findScenario(config.scenarioName) : undefined + if (scenario) { + // Override table dimensions from scenario + config.tableWidth = scenario.table.width + config.tableHeight = scenario.table.height + canvas2D = createCanvas(config) + + const scenarioMessage: WorkerScenarioRequest = { + type: RequestMessageType.LOAD_SCENARIO, + payload: { scenario }, + } + worker.postMessage(scenarioMessage) + } else { + const initMessage: WorkerInitializationRequest = { + type: RequestMessageType.INITIALIZE_SIMULATION, + payload: { + numBalls: config.numBalls, + tableHeight: config.tableHeight, + tableWidth: config.tableWidth, + physicsProfile: config.physicsProfile, + physicsOverrides: config.physicsOverrides, + }, + } + worker.postMessage(initMessage) + } + worker.addEventListener('message', (event: MessageEvent) => { + const response: WorkerResponse = event.data + + if (isWorkerInitializationResponse(response)) { + if (response.payload.status) { + worker!.postMessage({ + type: RequestMessageType.REQUEST_SIMULATION_DATA, + payload: { + time: PRECALC * 2, + }, + }) + } + } else if (isWorkerSimulationResponse(response)) { + const results = response.payload.data + if (response.payload.initialValues) { + state = response.payload.initialValues.snapshots.reduce( + (circles: { [key: string]: Ball }, snapshot) => { + const ball = new Ball( + snapshot.position, + snapshot.velocity, + snapshot.radius, + snapshot.time, + defaultPhysicsConfig.defaultBallParams.mass, + snapshot.id, + snapshot.angularVelocity, + ) + // Apply trajectory acceleration from snapshot for correct interpolation + if (snapshot.trajectoryA) { + ball.trajectory.a[0] = snapshot.trajectoryA[0] + ball.trajectory.a[1] = snapshot.trajectoryA[1] + } + if (snapshot.angularAlpha) { + ball.angularTrajectory.alpha = [...snapshot.angularAlpha] + ball.angularTrajectory.omega0 = [...snapshot.angularOmega0] + } + if (snapshot.motionState) { + ball.motionState = snapshot.motionState + } + circles[snapshot.id] = ball + return circles + }, + {}, + ) + + circleIds = Object.keys(state) + replayCircles = Object.values(state) + + // Capture initial ball states for step-back replay + initialBallStates = new Map() + for (const [id, ball] of Object.entries(state)) { + initialBallStates.set(id, { + position: [...ball.position], + velocity: [...ball.velocity], + radius: ball.radius, + time: ball.time, + angularVelocity: [...ball.angularVelocity], + motionState: ball.motionState, + trajectoryA: [ball.trajectory.a[0], ball.trajectory.a[1]], + angularAlpha: [...ball.angularTrajectory.alpha], + angularOmega0: [...ball.angularTrajectory.omega0], + }) + } + + nextEvent = results.shift() + queueMicrotask(initScene) + } + // If worker sends only the initial snapshot (time=0) or no real events, + // all balls are stationary — stop requesting more data + if (results.length === 0 || (results.length === 1 && results[0].time === 0)) { + simulationDone = true + } + simulatedResults = simulatedResults.concat(results) + fetchingMore = false + } + }) + } + + function initScene() { + const renderer = new THREE.WebGLRenderer() + renderer.setSize(window.innerWidth, window.innerHeight) + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.shadowMap.enabled = config.shadowsEnabled + renderer.shadowMap.type = THREE.PCFSoftShadowMap + containerElement.appendChild(renderer.domElement) + threeRenderer = renderer + + const scene = new SimulationScene(canvas2D, replayCircles, config, renderer.domElement) + simulationScene = scene + if (savedCameraState) { + scene.restoreCamera(savedCameraState) + } + renderer.render(scene.scene, scene.camera) + + const circleRenderer = new CircleRenderer(canvas2D) + const tailRenderer = new TailRenderer(canvas2D, config.tailLength) + const collisionRenderer = new CollisionRenderer(canvas2D) + const collisionPreviewRenderer = new CollisionPreviewRenderer(canvas2D, config.collisionPreviewCount) + const futureTrailRenderer = new FutureTrailRenderer( + canvas2D, + config.futureTrailEventsPerBall, + config.futureTrailInterpolationSteps, + config.phantomBallOpacity, + config.showPhantomBalls, + ) + + // Ball inspector click handling + renderer.domElement.addEventListener('pointerdown', (e) => { + if (config.showBallInspector) { + ballInspector.handlePointerDown(e) + } + }) + renderer.domElement.addEventListener('pointerup', (e) => { + if (config.showBallInspector) { + ballInspector.handlePointerUp( + e, + state, + circleIds, + currentProgress, + scene.camera, + renderer.domElement, + config.tableWidth, + config.tableHeight, + ) + } + }) + + if (!stats) { + stats = new Stats() + stats.showPanel(0) + containerElement.appendChild(stats.dom) + } + stats.dom.style.display = config.showStats ? 'block' : 'none' + + resizeHandler = () => { + const width = window.innerWidth + const height = window.innerHeight + renderer.setSize(width, height) + scene.camera.aspect = width / height + scene.camera.updateProjectionMatrix() + } + window.addEventListener('resize', resizeHandler) + + function snapshotBallState(ball: Ball, atTime: number): BallEventSnapshot { + const dt = atTime - ball.time + const vx = ball.trajectory.b[0] + 2 * ball.trajectory.a[0] * dt + const vy = ball.trajectory.b[1] + 2 * ball.trajectory.a[1] * dt + const pos = ball.positionAtTime(atTime) + return { + id: ball.id, + position: [pos[0], pos[1]], + velocity: [vx, vy], + speed: Math.sqrt(vx * vx + vy * vy), + angularVelocity: [ball.angularVelocity[0], ball.angularVelocity[1], ball.angularVelocity[2]], + motionState: ball.motionState, + acceleration: [ball.trajectory.a[0], ball.trajectory.a[1]], + } + } + + function applyEventSnapshots(event: ReplayData, skipHistory = false) { + if (!skipHistory) { + eventHistory.push(event) + } + + // Capture pre-event state for all involved balls + const deltas = event.snapshots.map((snapshot) => { + const circle = state[snapshot.id] + const before = snapshotBallState(circle, event.time) + return { id: snapshot.id, before } + }) + + // Apply post-event state + for (const snapshot of event.snapshots) { + const circle = state[snapshot.id] + circle.position[0] = snapshot.position[0] + circle.position[1] = snapshot.position[1] + circle.velocity[0] = snapshot.velocity[0] + circle.velocity[1] = snapshot.velocity[1] + circle.radius = snapshot.radius + circle.time = snapshot.time + if (snapshot.angularVelocity) { + circle.angularVelocity = [...snapshot.angularVelocity] + } + if (snapshot.motionState !== undefined) { + circle.motionState = snapshot.motionState + } + // Rebase trajectory to new reference time (event time) + circle.trajectory.a[0] = snapshot.trajectoryA[0] + circle.trajectory.a[1] = snapshot.trajectoryA[1] + circle.trajectory.b[0] = snapshot.velocity[0] + circle.trajectory.b[1] = snapshot.velocity[1] + circle.trajectory.c[0] = snapshot.position[0] + circle.trajectory.c[1] = snapshot.position[1] + if (snapshot.angularAlpha) { + circle.angularTrajectory.alpha = [...snapshot.angularAlpha] + circle.angularTrajectory.omega0 = [...snapshot.angularOmega0] + } + } + + // Build deltas with after state + const fullDeltas = deltas.map((d) => { + const circle = state[d.id] + return { + ...d, + after: snapshotBallState(circle, event.time), + } + }) + + // Build event entry with deltas + const entry: EventEntry = { + time: event.time, + type: event.type, + involvedBalls: event.snapshots.map((s) => s.id), + cushionType: event.cushionType, + deltas: fullDeltas, + } + + bridge.pushEvent(entry) + lastConsumedEvent = entry + } + + function step(timestamp: number) { + stats!.begin() + + // Delta-based time tracking — no wall-clock drift, no pause/unpause sync issues + const deltaMs = prevTimestamp ? timestamp - prevTimestamp : 0 + prevTimestamp = timestamp + + // Restore all balls to initial state + function restoreInitialState() { + for (const [id, snap] of initialBallStates!) { + const ball = state[id] + ball.position[0] = snap.position[0] + ball.position[1] = snap.position[1] + ball.position[2] = 0 + ball.velocity[0] = snap.velocity[0] + ball.velocity[1] = snap.velocity[1] + ball.velocity[2] = 0 + ball.radius = snap.radius + ball.time = snap.time + ball.angularVelocity = [...snap.angularVelocity] + ball.motionState = snap.motionState + ball.trajectory.a[0] = snap.trajectoryA[0] + ball.trajectory.a[1] = snap.trajectoryA[1] + ball.trajectory.b[0] = snap.velocity[0] + ball.trajectory.b[1] = snap.velocity[1] + ball.trajectory.c[0] = snap.position[0] + ball.trajectory.c[1] = snap.position[1] + ball.angularTrajectory.alpha = [...snap.angularAlpha] + ball.angularTrajectory.omega0 = [...snap.angularOmega0] + } + } + + // Replay a list of events from scratch (after restoreInitialState) + function replayEvents(events: ReplayData[]) { + eventHistory = [] + lastConsumedEvent = null + for (const event of events) { + applyEventSnapshots(event) + } + } + + // --- Handle seek, step actions, or normal playback (mutually exclusive) --- + + if (seekTarget !== null && initialBallStates) { + // Seek: restore initial state, replay events up to target, set time to target + const target = seekTarget + seekTarget = null + + const allEvents: ReplayData[] = [...eventHistory] + if (nextEvent) allEvents.push(nextEvent) + allEvents.push(...simulatedResults) + + const eventsToApply = allEvents.filter((e) => e.time <= target) + const eventsRemaining = allEvents.filter((e) => e.time > target) + + restoreInitialState() + nextEvent = eventsRemaining.shift() + simulatedResults = eventsRemaining + replayEvents(eventsToApply) + currentProgress = target + tailRenderer.clear() + + // Rebase all ball trajectories to the seek target time. + for (const id of circleIds) { + const ball = state[id] + const dt = target - ball.time + if (dt > 1e-9) { + const pos = ball.positionAtTime(target) + const vel = ball.velocityAtTime(target) + ball.position[0] = pos[0] + ball.position[1] = pos[1] + ball.velocity[0] = vel[0] + ball.velocity[1] = vel[1] + ball.time = target + ball.trajectory.c[0] = pos[0] + ball.trajectory.c[1] = pos[1] + ball.trajectory.b[0] = vel[0] + ball.trajectory.b[1] = vel[1] + } + } + } else { + const action = playbackController.consumeAction() + if (action) { + if (action.type === 'step') { + if (nextEvent) { + applyEventSnapshots(nextEvent) + currentProgress = nextEvent.time + nextEvent = simulatedResults.shift() + } + } else if (action.type === 'stepBack') { + if (eventHistory.length > 0 && initialBallStates) { + const popped = eventHistory.pop()! + if (nextEvent) simulatedResults.unshift(nextEvent) + nextEvent = popped + restoreInitialState() + replayEvents([...eventHistory]) + currentProgress = eventHistory.length > 0 ? eventHistory[eventHistory.length - 1].time : 0 + } + } else if (action.type === 'stepToBall') { + const targetBallId = action.ballId + let found = false + while (nextEvent && !found) { + const involvesBall = nextEvent.snapshots.some((s) => s.id === targetBallId) + applyEventSnapshots(nextEvent) + if (involvesBall) { + currentProgress = nextEvent.time + found = true + } + nextEvent = simulatedResults.shift() + } + if (!found && eventHistory.length > 0) { + currentProgress = eventHistory[eventHistory.length - 1].time + } + } + } else { + if (!playbackController.paused) { + currentProgress += (deltaMs / 1000) * config.simulationSpeed + } + while (nextEvent && currentProgress >= nextEvent.time) { + applyEventSnapshots(nextEvent) + nextEvent = simulatedResults.shift() + } + } + } + + // Fetch more simulation data if buffer is running low + if (nextEvent) { + const lastEvent = simulatedResults[simulatedResults.length - 1] + if (!simulationDone && !fetchingMore && lastEvent && lastEvent.time - currentProgress <= PRECALC) { + fetchingMore = true + worker!.postMessage({ + type: RequestMessageType.REQUEST_SIMULATION_DATA, + payload: { time: PRECALC }, + }) + } + } + + const progress = currentProgress + + // 2D canvas rendering + const ctx = canvas2D.getContext('2d')! + ctx.fillStyle = config.tableColor + ctx.fillRect(0, 0, canvas2D.width, canvas2D.height) + + // Update future trail renderer settings from config + futureTrailRenderer.updateSettings( + config.futureTrailEventsPerBall, + config.futureTrailInterpolationSteps, + config.phantomBallOpacity, + config.showPhantomBalls, + ) + + scene.renderAtTime(progress) + for (const circleId of circleIds) { + const circle = state[circleId] + if (config.showCircles) { + circleRenderer.render(circle, progress, nextEvent) + } + if (nextEvent) { + if (config.showTails) tailRenderer.render(circle, progress) + if (config.showCollisions) collisionRenderer.render(circle, progress, nextEvent) + if (config.showCollisionPreview) + collisionPreviewRenderer.render(circle, progress, nextEvent, simulatedResults) + if (config.showFutureTrails) futureTrailRenderer.render(circle, progress, nextEvent, simulatedResults) + } + } + + // Update live parameters + if (stats) { + stats.dom.style.display = config.showStats ? 'block' : 'none' + } + renderer.shadowMap.enabled = config.shadowsEnabled + + // Update bridge snapshot for React UI + const selectedId = ballInspector.getSelectedBallId() + const motionDist: Record = {} + for (const id of circleIds) { + const ms = state[id].motionState + motionDist[ms] = (motionDist[ms] || 0) + 1 + } + bridge.update({ + currentProgress: progress, + paused: playbackController.paused, + simulationSpeed: config.simulationSpeed, + selectedBallId: selectedId, + selectedBallData: selectedId && state[selectedId] ? computeBallData(state[selectedId], progress) : null, + ballCount: circleIds.length, + bufferDepth: simulatedResults.length, + simulationDone, + motionDistribution: motionDist, + canStepBack: eventHistory.length > 0, + maxTime: + simulatedResults.length > 0 + ? simulatedResults[simulatedResults.length - 1].time + : nextEvent + ? nextEvent.time + : eventHistory.length > 0 + ? eventHistory[eventHistory.length - 1].time + : progress, + currentEvent: playbackController.paused ? lastConsumedEvent : null, + }) + + renderer.render(scene.scene, scene.camera) + stats!.end() + animationFrameId = window.requestAnimationFrame(step) + } + animationFrameId = window.requestAnimationFrame(step) + } + + // --- UI Setup --- + // Advanced settings (Tweakpane, collapsed) + createAdvancedUI(config, { + onRestartRequired: () => startSimulation(), + onLiveUpdate: () => { + if (simulationScene) simulationScene.updateFromConfig(config) + if (threeRenderer) threeRenderer.shadowMap.enabled = config.shadowsEnabled + }, + }) + + // React debug overlay + mountDebugOverlay(bridge) + + // Start initial simulation + startSimulation() + + return { + destroy: () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } + if (worker) { + worker.terminate() + worker = null + } + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler) + resizeHandler = null + } + if (threeRenderer) { + if (threeRenderer.domElement.parentElement) { + threeRenderer.domElement.parentElement.removeChild(threeRenderer.domElement) + } + threeRenderer.dispose() + threeRenderer = null + } + if (stats) { + if (stats.dom.parentElement) { + stats.dom.parentElement.removeChild(stats.dom) + } + stats = null + } + }, + } +} diff --git a/src/ui/components/GameUI.tsx b/src/ui/components/GameUI.tsx new file mode 100644 index 0000000..07529ae --- /dev/null +++ b/src/ui/components/GameUI.tsx @@ -0,0 +1,574 @@ +import { useSyncExternalStore, useCallback, useRef } from 'react' +import type { GameBridge } from '../../lib/game/game-bridge' +import type Vector2D from '../../lib/vector2d' + +function useGameBridge(bridge: GameBridge) { + return useSyncExternalStore(bridge.subscribe, bridge.getSnapshot) +} + +interface GameUIProps { + bridge: GameBridge +} + +export function GameUI({ bridge }: GameUIProps) { + const snap = useGameBridge(bridge) + const { gameState, scores, lastShotResult, isSimulating, inputMode } = snap + + return ( +
+ {/* Score bar at top */} + + + {/* Foul notification */} + {lastShotResult?.foul && gameState.phase === 'aiming' && ( + + )} + + {/* Aim/Camera mode toggle — always visible during aiming */} + {gameState.phase === 'aiming' && ( + bridge.callbacks.onToggleMode()} /> + )} + + {/* Controls at bottom-right */} + {gameState.phase === 'aiming' && ( + bridge.update({ aimPower: p })} + onStrikeOffsetChange={(o) => bridge.update({ strikeOffset: o })} + /> + )} + + {/* Shoot button — prominent, bottom-center */} + {gameState.phase === 'aiming' && inputMode === 'aim' && ( + bridge.callbacks.onShoot()} /> + )} + + {/* Ball-in-hand indicator */} + {gameState.phase === 'placing-cue-ball' && } + + {/* Game over overlay */} + {gameState.phase === 'game-over' && ( + bridge.callbacks.onNewGame()} + onMenu={() => bridge.callbacks.onBackToMenu()} + /> + )} + + {/* Simulating indicator */} + {isSimulating && ( +
+ Simulating... +
+ )} +
+ ) +} + +function ScoreBar({ scores }: { scores: { players: { name: string; score: number; active: boolean; group?: string }[] } }) { + if (scores.players.length === 0) return null + return ( +
+ {scores.players.map((p, i) => ( +
+
{p.name}
+
{p.score}
+ {p.group &&
{p.group}
} +
+ ))} +
+ ) +} + +function FoulBanner({ reasons }: { reasons: string[] }) { + return ( +
+
FOUL
+ {reasons.map((r, i) => ( +
+ {r} +
+ ))} +
+ ) +} + +function BallInHandBanner() { + return ( +
+ Tap the table to place the cue ball +
+ ) +} + +function AimControls({ + power, + strikeOffset, + onPowerChange, + onStrikeOffsetChange, +}: { + power: number + strikeOffset: Vector2D + onPowerChange: (p: number) => void + onStrikeOffsetChange: (o: Vector2D) => void +}) { + return ( +
+ {/* Spin control circle */} + + + {/* Power slider */} + +
+ ) +} + +function SpinCircle({ + offset, + onChange, +}: { + offset: Vector2D + onChange: (o: Vector2D) => void +}) { + const circleRef = useRef(null) + const dragging = useRef(false) + + const handleDrag = useCallback( + (clientX: number, clientY: number) => { + const el = circleRef.current + if (!el) return + const rect = el.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + const r = rect.width / 2 + + let dx = (clientX - cx) / r + let dy = -(clientY - cy) / r // invert Y: up = positive + + // Clamp to circle + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist > 1) { + dx /= dist + dy /= dist + } + + onChange([dx, dy]) + }, + [onChange], + ) + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + dragging.current = true + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + handleDrag(e.clientX, e.clientY) + }, + [handleDrag], + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return + handleDrag(e.clientX, e.clientY) + }, + [handleDrag], + ) + + const handlePointerUp = useCallback(() => { + dragging.current = false + }, []) + + const size = 70 + + return ( +
+
+ Spin +
+
+ {/* Crosshair */} +
+
+ {/* Indicator dot */} +
+
+
+ ) +} + +function PowerBar({ power, onChange }: { power: number; onChange: (p: number) => void }) { + const barRef = useRef(null) + const dragging = useRef(false) + + const handleDrag = useCallback( + (clientY: number) => { + const el = barRef.current + if (!el) return + const rect = el.getBoundingClientRect() + const normalized = 1 - (clientY - rect.top) / rect.height + onChange(Math.max(0, Math.min(1, normalized))) + }, + [onChange], + ) + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + dragging.current = true + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + handleDrag(e.clientY) + }, + [handleDrag], + ) + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return + handleDrag(e.clientY) + }, + [handleDrag], + ) + + const handlePointerUp = useCallback(() => { + dragging.current = false + }, []) + + const barHeight = 160 + const barWidth = 36 + + // Color gradient from green (low power) to red (high power) + const hue = 120 - power * 120 // 120 = green, 0 = red + + return ( +
+
+ Power +
+
+ {/* Fill */} +
+ {/* Percentage label */} +
+ {Math.round(power * 100)} +
+
+
+ ) +} + +function GameOverOverlay({ + winner, + onNewGame, + onMenu, +}: { + winner: string + onNewGame: () => void + onMenu: () => void +}) { + return ( +
+
+
GAME OVER
+
+ {winner} wins! +
+
+ + +
+
+
+ ) +} + +function ModeToggle({ mode, onToggle }: { mode: string; onToggle: () => void }) { + return ( + + ) +} + +function ShootButton({ onShoot }: { onShoot: () => void }) { + return ( + + ) +} diff --git a/src/ui/components/MainMenu.tsx b/src/ui/components/MainMenu.tsx new file mode 100644 index 0000000..6135cc5 --- /dev/null +++ b/src/ui/components/MainMenu.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' + +export type GameMode = 'eight-ball' | 'nine-ball' | 'snooker' + +interface MainMenuProps { + onStartGame: (mode: GameMode) => void + onSandbox: () => void +} + +export function MainMenu({ onStartGame, onSandbox }: MainMenuProps) { + const [hoveredMode, setHoveredMode] = useState(null) + + const gameModes: { id: GameMode; label: string; description: string; color: string }[] = [ + { id: 'eight-ball', label: '8-Ball', description: 'Classic pool — solids vs stripes', color: '#2563eb' }, + { id: 'nine-ball', label: '9-Ball', description: 'Pot balls in order, sink the 9', color: '#f59e0b' }, + { id: 'snooker', label: 'Snooker', description: 'Reds and colors on a full-size table', color: '#dc2626' }, + ] + + return ( +
+

+ Billiards +

+

+ Choose a game mode +

+ +
+ {gameModes.map((mode) => ( + + ))} + + +
+
+ ) +}