import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Target, BookOpen, Play, RefreshCw, Trophy, Info, Settings, MousePointer2, Crosshair, Move, GripHorizontal, Circle, Grid } from 'lucide-react'; // --- Constants & Types --- const TABLE_WIDTH = 800; const TABLE_HEIGHT = 400; const BALL_RADIUS = 10; const CUSHION_WIDTH = 20; // Physics Constants const FRICTION = 0.985; const WALL_BOUNCE = 0.7; const BALL_BOUNCE = 0.9; const STOP_THRESHOLD = 0.05; // Colors const COLOR_TABLE = '#2e7d32'; const COLOR_FRAME = '#5d4037'; const COLOR_CUE_BALL = '#ffffff'; const COLOR_YELLOW = '#ffeb3b'; const COLOR_RED = '#e53935'; type Point = { x: number; y: number }; type Vector = { x: number; y: number }; type Ball = Point & Vector & { color: string; id: string }; type GameMode = 'practice' | 'quiz' | 'theory'; type GameType = '3cushion' | '4ball'; type SystemType = 'five_half' | 'plus'; type InteractionMode = 'edit' | 'shoot'; // --- Physics Helper Functions --- const resolveCollision = (b1: Ball, b2: Ball) => { const dx = b2.x - b1.x; const dy = b2.y - b1.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < BALL_RADIUS * 2) { const angle = Math.atan2(dy, dx); const overlap = BALL_RADIUS * 2 - dist; const moveX = (overlap / 2) * Math.cos(angle); const moveY = (overlap / 2) * Math.sin(angle); b1.x -= moveX; b1.y -= moveY; b2.x += moveX; b2.y += moveY; const nx = dx / dist; const ny = dy / dist; const dvx = b1.vx - b2.vx; const dvy = b1.vy - b2.vy; // Mass is equal const p = 2 * (nx * dvx + ny * dvy) / 2; b1.vx -= p * nx * BALL_BOUNCE; b1.vy -= p * ny * BALL_BOUNCE; b2.vx += p * nx * BALL_BOUNCE; b2.vy += p * ny * BALL_BOUNCE; } }; const SystemExplanation = ({ type, gameType }: { type: SystemType, gameType: GameType }) => { if (gameType === '4ball') { return (

4구 (Four Ball) 기초

4구는 흰 공(수구)으로 두 개의 빨간 공을 모두 맞추면 득점하는 게임입니다.

✅ 두께와 분리각

* 연습 모드의 '두께 조절' 슬라이더를 움직여 분리각을 확인하세요.
); } if (type === 'five_half') { return (

파이브 앤 하프 시스템 (Five & Half)

3쿠션의 가장 기본이 되는 시스템입니다.

수구수 - 3쿠션수 = 1쿠션수

수구수 (Start)
  • 장축 코너: 50
  • 중간: 45, 40, 35...
3쿠션수 (Arrival)
  • 코너: 20, 포인트당: 10씩 증가
); } return (

플러스 시스템 (Plus System)

수구와 3쿠션 지점을 합산하여 코너를 공략하는 시스템입니다.

수구수 + 1쿠션수 = 3쿠션수

); }; export default function BilliardApp() { // --- State --- const [mode, setMode] = useState('practice'); const [gameType, setGameType] = useState('3cushion'); const [interactionMode, setInteractionMode] = useState('edit'); const [system, setSystem] = useState('five_half'); // Dynamic Ball State const [balls, setBalls] = useState>({ white: { x: 200, y: 300, vx: 0, vy: 0, color: COLOR_CUE_BALL, id: 'white' }, yellow: { x: 600, y: 100, vx: 0, vy: 0, color: COLOR_YELLOW, id: 'yellow' }, red: { x: 600, y: 300, vx: 0, vy: 0, color: COLOR_RED, id: 'red' }, }); const [target3C, setTarget3C] = useState(20); const [showPath, setShowPath] = useState(true); const [score, setScore] = useState(0); const [quizMessage, setQuizMessage] = useState(""); // Cue & Spin State const [cueAngle, setCueAngle] = useState(0); const [cuePower, setCuePower] = useState(0); const [isShooting, setIsShooting] = useState(false); const [hitOffset, setHitOffset] = useState({ x: 0, y: 0 }); // -1 to 1 (normalized) const [recommendedSpin, setRecommendedSpin] = useState({ x: 0, y: 0 }); // Control Box State const [controlPos, setControlPos] = useState({ x: 20, y: 320 }); const [isDraggingControl, setIsDraggingControl] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const canvasRef = useRef(null); const isDraggingBall = useRef(null); const requestRef = useRef(); const isAiming = useRef(false); const innerWidth = TABLE_WIDTH - CUSHION_WIDTH * 2; const innerHeight = TABLE_HEIGHT - CUSHION_WIDTH * 2; const offsetX = CUSHION_WIDTH; const offsetY = CUSHION_WIDTH; // --- Initialize / Reset Balls based on Game Type --- const initBalls = useCallback((type: GameType) => { if (type === '3cushion') { setBalls({ white: { x: 200, y: 300, vx: 0, vy: 0, color: COLOR_CUE_BALL, id: 'white' }, yellow: { x: 600, y: 100, vx: 0, vy: 0, color: COLOR_YELLOW, id: 'yellow' }, red: { x: 600, y: 300, vx: 0, vy: 0, color: COLOR_RED, id: 'red' }, }); setTarget3C(20); } else { setBalls({ white: { x: 200, y: 300, vx: 0, vy: 0, color: COLOR_CUE_BALL, id: 'white' }, yellow: { x: 600, y: 100, vx: 0, vy: 0, color: COLOR_YELLOW, id: 'yellow' }, red1: { x: 500, y: 200, vx: 0, vy: 0, color: COLOR_RED, id: 'red1' }, red2: { x: 500, y: 250, vx: 0, vy: 0, color: COLOR_RED, id: 'red2' }, }); setTarget3C(30); // Center hit default } setIsShooting(false); }, []); const handleGameTypeChange = (type: GameType) => { setGameType(type); initBalls(type); setShowPath(true); }; // --- Physics Loop --- const updatePhysics = useCallback(() => { setBalls(prevBalls => { const nextBalls = { ...prevBalls }; let moving = false; const ballKeys = Object.keys(nextBalls); ballKeys.forEach(key => { const ball = nextBalls[key]; ball.x += ball.vx; ball.y += ball.vy; ball.vx *= FRICTION; ball.vy *= FRICTION; if (Math.abs(ball.vx) < STOP_THRESHOLD) ball.vx = 0; if (Math.abs(ball.vy) < STOP_THRESHOLD) ball.vy = 0; if (ball.vx !== 0 || ball.vy !== 0) moving = true; // Wall Collisions if (ball.x < CUSHION_WIDTH + BALL_RADIUS) { ball.x = CUSHION_WIDTH + BALL_RADIUS; ball.vx = -ball.vx * WALL_BOUNCE; } else if (ball.x > TABLE_WIDTH - CUSHION_WIDTH - BALL_RADIUS) { ball.x = TABLE_WIDTH - CUSHION_WIDTH - BALL_RADIUS; ball.vx = -ball.vx * WALL_BOUNCE; } if (ball.y < CUSHION_WIDTH + BALL_RADIUS) { ball.y = CUSHION_WIDTH + BALL_RADIUS; ball.vy = -ball.vy * WALL_BOUNCE; } else if (ball.y > TABLE_HEIGHT - CUSHION_WIDTH - BALL_RADIUS) { ball.y = TABLE_HEIGHT - CUSHION_WIDTH - BALL_RADIUS; ball.vy = -ball.vy * WALL_BOUNCE; } }); for (let i = 0; i < ballKeys.length; i++) { for (let j = i + 1; j < ballKeys.length; j++) { resolveCollision(nextBalls[ballKeys[i]], nextBalls[ballKeys[j]]); } } if (!moving && isShooting) { setIsShooting(false); } return nextBalls; }); }, [isShooting]); useEffect(() => { const animate = () => { updatePhysics(); requestRef.current = requestAnimationFrame(animate); }; requestRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(requestRef.current!); }, [updatePhysics]); // --- System Calculations & Visuals --- const calculatePath = () => { if (!balls.white) return null; if (gameType === '4ball') { let nearestKey = ''; let minDist = Infinity; Object.keys(balls).forEach(key => { if (key !== 'white' && key !== 'yellow') { // Aim for red balls const dx = balls[key].x - balls.white.x; const dy = balls[key].y - balls.white.y; const dist = Math.sqrt(dx*dx + dy*dy); if (dist < minDist) { minDist = dist; nearestKey = key; } } }); if (!nearestKey) return null; const targetBall = balls[nearestKey]; const thicknessFactor = (target3C - 30) / 30; // -1 to 1 const maxOffset = BALL_RADIUS * 1.9; const aimOffset = thicknessFactor * maxOffset; const dx = targetBall.x - balls.white.x; const dy = targetBall.y - balls.white.y; const dist = Math.sqrt(dx*dx + dy*dy); const nx = dx / dist; const ny = dy / dist; const px = -ny; const py = nx; const GBC = { x: targetBall.x - (Math.sqrt(4*BALL_RADIUS*BALL_RADIUS - aimOffset*aimOffset) * nx) + (aimOffset * px), y: targetBall.y - (Math.sqrt(4*BALL_RADIUS*BALL_RADIUS - aimOffset*aimOffset) * ny) + (aimOffset * py) }; const locX = GBC.x - targetBall.x; const locY = GBC.y - targetBall.y; const locLen = Math.sqrt(locX*locX + locY*locY); const normLocX = locX / locLen; const normLocY = locY / locLen; const tanX = -normLocY; const tanY = normLocX; const dot = nx * tanX + ny * tanY; const finalTanX = dot > 0 ? tanX : -tanX; const finalTanY = dot > 0 ? tanY : -tanY; const projectionDist = 300; let p2 = { x: GBC.x + finalTanX * projectionDist, y: GBC.y + finalTanY * projectionDist }; const walls = [ { val: CUSHION_WIDTH, axis: 'x', dir: 1 }, // Left { val: TABLE_WIDTH - CUSHION_WIDTH, axis: 'x', dir: -1 }, // Right { val: CUSHION_WIDTH, axis: 'y', dir: 1 }, // Top { val: TABLE_HEIGHT - CUSHION_WIDTH, axis: 'y', dir: -1 } // Bottom ]; let minT = Infinity; let wallHit = null; walls.forEach(w => { const dirVal = w.axis === 'x' ? finalTanX : finalTanY; const originVal = w.axis === 'x' ? GBC.x : GBC.y; if (dirVal !== 0) { const t = (w.val - originVal) / dirVal; if (t > 0 && t < minT) { const otherCoord = (w.axis === 'x' ? GBC.y : GBC.x) + t * (w.axis === 'x' ? finalTanY : finalTanX); const maxOther = w.axis === 'x' ? TABLE_HEIGHT : TABLE_WIDTH; if (otherCoord >= 0 && otherCoord <= maxOther) { minT = t; wallHit = w; } } } }); const bouncePath = []; if (wallHit && minT < Infinity) { const hitX = GBC.x + finalTanX * minT; const hitY = GBC.y + finalTanY * minT; bouncePath.push({x: hitX, y: hitY}); const refX = wallHit.axis === 'x' ? -finalTanX : finalTanX; const refY = wallHit.axis === 'y' ? -finalTanY : finalTanY; bouncePath.push({x: hitX + refX * 200, y: hitY + refY * 200}); } else { bouncePath.push(p2); } return { type: '4ball', ghostBall: GBC, targetBallPos: targetBall, cueBallPath: bouncePath, objectBallPath: { x: targetBall.x - normLocX * 100, y: targetBall.y - normLocY * 100 }, objectDir: { x: -normLocX, y: -normLocY } }; } else { const startVal = 50 - (balls.white.x - offsetX) / innerWidth * 40; const aimVal = startVal - target3C; let aimX = (aimVal / 50) * innerWidth + offsetX; if (aimX < offsetX) aimX = offsetX; if (aimX > innerWidth + offsetX) aimX = innerWidth + offsetX; const aimPoint = { x: aimX, y: offsetY }; const target3X = innerWidth - (target3C / 50) * innerWidth + offsetX; const target3Point = { x: target3X, y: innerHeight + offsetY }; let target4Point = { x: 0, y: 0 }; if (target3C >= 20) { const t = (target3C - 20) / 30; target4Point = { x: offsetX + t * (innerWidth * 0.5), y: offsetY }; } else { target4Point = { x: TABLE_WIDTH - CUSHION_WIDTH, y: TABLE_HEIGHT * 0.4 }; } const cushion2Point = { x: TABLE_WIDTH - CUSHION_WIDTH, y: TABLE_HEIGHT / 2 }; let spinX = 0; if (aimX < balls.white.x) spinX = -0.7; else spinX = 0.7; return { type: '3cushion', startVal, aimVal, aimPoint, cushion2Point, target3Point, target4Point, suggestedSpin: { x: spinX, y: 0.6 } }; } }; const sysData = calculatePath(); // --- Rendering --- const drawTable = (ctx: CanvasRenderingContext2D) => { ctx.clearRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT); ctx.fillStyle = COLOR_FRAME; ctx.fillRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT); ctx.fillStyle = COLOR_TABLE; ctx.fillRect(CUSHION_WIDTH, CUSHION_WIDTH, innerWidth, innerHeight); ctx.fillStyle = '#fff'; const diamondSize = 4; for (let i = 0; i <= 8; i++) { const x = CUSHION_WIDTH + (innerWidth / 8) * i; ctx.beginPath(); ctx.arc(x, CUSHION_WIDTH / 2, diamondSize, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(x, TABLE_HEIGHT - CUSHION_WIDTH / 2, diamondSize, 0, Math.PI * 2); ctx.fill(); if (gameType === '3cushion' && system === 'five_half') { ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = '10px Arial'; ctx.textAlign = 'center'; const startNum = 50 - i * 5; ctx.fillText(startNum.toString(), x, TABLE_HEIGHT - 2); ctx.fillText((i*10).toString(), x, 12); } ctx.fillStyle = '#fff'; } for (let i = 0; i <= 4; i++) { const y = CUSHION_WIDTH + (innerHeight / 4) * i; ctx.beginPath(); ctx.arc(CUSHION_WIDTH / 2, y, diamondSize, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(TABLE_WIDTH - CUSHION_WIDTH / 2, y, diamondSize, 0, Math.PI * 2); ctx.fill(); } }; const drawBalls = (ctx: CanvasRenderingContext2D) => { Object.values(balls).forEach(ball => { ctx.beginPath(); ctx.arc(ball.x, ball.y, BALL_RADIUS, 0, Math.PI * 2); ctx.fillStyle = ball.color; ctx.fill(); ctx.beginPath(); ctx.arc(ball.x + 2, ball.y + 2, BALL_RADIUS, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fill(); if (ball.id === 'white') { ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke(); if (!isShooting && (interactionMode === 'shoot' || interactionMode === 'edit')) { ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; ctx.beginPath(); const sx = ball.x + hitOffset.x * (BALL_RADIUS * 0.7); const sy = ball.y + hitOffset.y * (BALL_RADIUS * 0.7); ctx.arc(sx, sy, 2, 0, Math.PI * 2); ctx.fill(); } } }); }; const drawCue = (ctx: CanvasRenderingContext2D) => { if (interactionMode !== 'shoot' || isShooting) return; if (!balls.white) return; const lineLen = 100; const endX = balls.white.x + Math.cos(cueAngle) * lineLen; const endY = balls.white.y + Math.sin(cueAngle) * lineLen; ctx.beginPath(); ctx.moveTo(balls.white.x, balls.white.y); ctx.lineTo(endX, endY); ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.stroke(); ctx.setLineDash([]); const cueDist = 20 + cuePower * 0.5; const stickLen = 200; const cx = balls.white.x - Math.cos(cueAngle) * cueDist; const cy = balls.white.y - Math.sin(cueAngle) * cueDist; const bx = cx - Math.cos(cueAngle) * stickLen; const by = cy - Math.sin(cueAngle) * stickLen; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(bx, by); ctx.strokeStyle = '#d2b48c'; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.stroke(); const tipLen = 5; const tx = cx - Math.cos(cueAngle) * tipLen; const ty = cy - Math.sin(cueAngle) * tipLen; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty); ctx.strokeStyle = '#3366ff'; ctx.lineWidth = 6; ctx.stroke(); }; const drawSystemLines = (ctx: CanvasRenderingContext2D) => { if (!showPath || mode === 'quiz' || !sysData) return; if (isShooting) return; if (sysData.type === '3cushion') { // 3-Cushion Visuals ctx.beginPath(); ctx.moveTo(balls.white.x, balls.white.y); ctx.lineTo(sysData.aimPoint.x, sysData.aimPoint.y); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sysData.aimPoint.x, sysData.aimPoint.y); ctx.quadraticCurveTo(sysData.cushion2Point.x, sysData.cushion2Point.y, sysData.target3Point.x, sysData.target3Point.y - CUSHION_WIDTH); ctx.strokeStyle = 'yellow'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sysData.target3Point.x, sysData.target3Point.y - CUSHION_WIDTH); ctx.lineTo(sysData.target4Point.x, sysData.target4Point.y + CUSHION_WIDTH); ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)'; ctx.lineWidth = 2; ctx.setLineDash([10, 5]); ctx.stroke(); ctx.beginPath(); ctx.arc(sysData.aimPoint.x, sysData.aimPoint.y, 5, 0, Math.PI * 2); ctx.fillStyle = 'cyan'; ctx.fill(); ctx.beginPath(); ctx.arc(sysData.target3Point.x, sysData.target3Point.y - CUSHION_WIDTH, 6, 0, Math.PI * 2); ctx.fillStyle = 'orange'; ctx.fill(); } else if (sysData.type === '4ball' && sysData.ghostBall) { // --- 4-Ball Visuals --- const { ghostBall, cueBallPath, objectDir, targetBallPos } = sysData; // 1. Ghost Ball (Impact Position) ctx.beginPath(); ctx.arc(ghostBall.x, ghostBall.y, BALL_RADIUS, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; // Faint white ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.setLineDash([2,2]); ctx.stroke(); // 2. Line to Ghost Ball ctx.beginPath(); ctx.moveTo(balls.white.x, balls.white.y); ctx.lineTo(ghostBall.x, ghostBall.y); ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; ctx.setLineDash([5, 5]); ctx.stroke(); // 3. Object Ball Path (Red) - Solid Line ctx.beginPath(); ctx.moveTo(targetBallPos.x, targetBallPos.y); ctx.lineTo(targetBallPos.x + objectDir.x * 150, targetBallPos.y + objectDir.y * 150); ctx.strokeStyle = 'rgba(255, 100, 100, 0.8)'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.stroke(); // 4. Cue Ball Path (White) - Dashed/Solid with bounces if (cueBallPath.length > 0) { ctx.beginPath(); ctx.moveTo(ghostBall.x, ghostBall.y); cueBallPath.forEach(p => ctx.lineTo(p.x, p.y)); ctx.strokeStyle = 'cyan'; ctx.lineWidth = 2; ctx.setLineDash([5,5]); ctx.stroke(); } } }; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; drawTable(ctx); drawSystemLines(ctx); drawBalls(ctx); drawCue(ctx); }, [balls, system, showPath, target3C, mode, cueAngle, cuePower, interactionMode, hitOffset, gameType]); // --- Interaction Handlers (Responsive Scale) --- const getEventPos = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => { const canvas = canvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); const scaleX = TABLE_WIDTH / rect.width; const scaleY = TABLE_HEIGHT / rect.height; let clientX = 0; let clientY = 0; if ('touches' in e && e.touches.length > 0) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else if ('changedTouches' in e && e.changedTouches.length > 0) { clientX = e.changedTouches[0].clientX; clientY = e.changedTouches[0].clientY; } else { clientX = (e as React.MouseEvent).clientX; clientY = (e as React.MouseEvent).clientY; } return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY }; }; const handlePointerDown = (e: React.PointerEvent | React.MouseEvent | React.TouchEvent) => { if (mode === 'quiz') { checkAnswer(e); return; } const { x: mouseX, y: mouseY } = getEventPos(e); if (interactionMode === 'edit') { for (const key in balls) { const ball = balls[key]; const dx = mouseX - ball.x; const dy = mouseY - ball.y; if (dx*dx + dy*dy < BALL_RADIUS*BALL_RADIUS * 4) { isDraggingBall.current = key; return; } } } else if (interactionMode === 'shoot') { if (!isShooting) { isAiming.current = true; setCuePower(0); } } }; const handlePointerMove = (e: React.PointerEvent | React.MouseEvent | React.TouchEvent) => { if (isDraggingControl) { // Global mouse/touch for control box let clientX = 0; let clientY = 0; if ('touches' in e && e.touches.length > 0) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { clientX = (e as React.MouseEvent).clientX; clientY = (e as React.MouseEvent).clientY; } setControlPos({ x: clientX - dragOffset.x, y: clientY - dragOffset.y }); return; } const { x: mouseX, y: mouseY } = getEventPos(e); if (interactionMode === 'edit' && isDraggingBall.current) { const x = Math.max(CUSHION_WIDTH + BALL_RADIUS, Math.min(TABLE_WIDTH - CUSHION_WIDTH - BALL_RADIUS, mouseX)); const y = Math.max(CUSHION_WIDTH + BALL_RADIUS, Math.min(TABLE_HEIGHT - CUSHION_WIDTH - BALL_RADIUS, mouseY)); setBalls(prev => ({ ...prev, [isDraggingBall.current!]: { ...prev[isDraggingBall.current!], x, y, vx: 0, vy: 0 } })); } else if (interactionMode === 'shoot' && !isShooting && balls.white) { const dx = mouseX - balls.white.x; const dy = mouseY - balls.white.y; const angle = Math.atan2(dy, dx); setCueAngle(angle); if (isAiming.current) { const dist = Math.sqrt(dx*dx + dy*dy); const power = Math.min(Math.max((dist - 50) / 2, 0), 100); setCuePower(power); } } }; const handlePointerUp = () => { if (isDraggingControl) { setIsDraggingControl(false); } if (interactionMode === 'edit') { isDraggingBall.current = null; } else if (interactionMode === 'shoot' && isAiming.current) { isAiming.current = false; if (cuePower > 5) { shoot(cuePower); } setCuePower(0); } }; const startDragControl = (e: React.MouseEvent | React.TouchEvent) => { e.stopPropagation(); setIsDraggingControl(true); let clientX = 0; let clientY = 0; if ('touches' in e && e.touches.length > 0) { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { clientX = (e as React.MouseEvent).clientX; clientY = (e as React.MouseEvent).clientY; } setDragOffset({ x: clientX - controlPos.x, y: clientY - controlPos.y }); }; const handleSpinClick = (e: React.MouseEvent) => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const size = rect.width; const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; let nx = (clickX / size) * 2 - 1; let ny = (clickY / size) * 2 - 1; const dist = Math.sqrt(nx*nx + ny*ny); if (dist > 1) { nx /= dist; ny /= dist; } setHitOffset({ x: nx, y: ny }); }; const shoot = (power: number) => { if (!balls.white) return; setIsShooting(true); const speed = power * 0.35; // Simple Physics: Apply spin to velocity curve if needed, but for now linear shot // Could add curve based on hitOffset.x setBalls(prev => ({ ...prev, white: { ...prev.white, vx: Math.cos(cueAngle) * speed, vy: Math.sin(cueAngle) * speed } })); }; const resetBalls = () => { initBalls(gameType); }; const startQuiz = () => { setMode('quiz'); setGameType('3cushion'); setInteractionMode('edit'); setShowPath(false); setQuizMessage("노란 공을 맞추기 위한 1쿠션 지점을 클릭하세요!"); setBalls({ white: { x: 150 + Math.random() * 100, y: 300, vx:0, vy:0, color: COLOR_CUE_BALL, id: 'white' }, yellow: { x: 600, y: 100 + Math.random() * 200, vx:0, vy:0, color: COLOR_YELLOW, id: 'yellow' }, red: { x: 500 + Math.random() * 100, y: 100 + Math.random() * 200, vx:0, vy:0, color: COLOR_RED, id: 'red' } }); setTarget3C(Math.floor(Math.random() * 30) + 10); }; const checkAnswer = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => { if (mode !== 'quiz') return; const { x: clickX, y: clickY } = getEventPos(e); if (clickY > 50) return; const dist = Math.abs(clickX - (sysData as any).aimPoint.x); if (dist < 30) { setScore(s => s + 10); setQuizMessage("정답입니다! 정확한 계산이네요! 🎉"); setShowPath(true); setTimeout(() => startQuiz(), 2000); } else { setQuizMessage(`아쉽네요. 오차: ${Math.round(dist)}px. 다시 시도해보세요.`); setShowPath(true); } }; return (
handlePointerMove(e as any)} onTouchMove={(e) => handlePointerMove(e as any)} onMouseUp={handlePointerUp} onTouchEnd={handlePointerUp} >

당구 트레이너

3쿠션 시스템 & 4구 게임 마스터

handlePointerDown(e)} onTouchStart={(e) => handlePointerDown(e)} className={`rounded shadow-inner w-full h-auto touch-none ${interactionMode === 'shoot' ? 'cursor-none' : 'cursor-move'}`} style={{ background: '#1a1a1a' }} /> {mode !== 'quiz' && (
e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} >
startDragControl(e)} onTouchStart={(e) => startDragControl(e)} >
{interactionMode === 'edit' && gameType === '3cushion' && (
3쿠션 목표값 {target3C}
setTarget3C(parseInt(e.target.value))} className="w-full accent-yellow-400 cursor-pointer h-2 bg-slate-600 rounded-lg appearance-none" />
수구:{Math.round((sysData as any).startVal)}
1쿠션:{Math.round((sysData as any).aimVal)}
추천 당점
)} {interactionMode === 'edit' && gameType === '4ball' && (
두께 조절 (Aim) {target3C < 25 ? '왼쪽' : target3C > 35 ? '오른쪽' : '정면'}
setTarget3C(parseInt(e.target.value))} className="w-full accent-green-400 cursor-pointer h-2 bg-slate-600 rounded-lg appearance-none" />
얇게(L) 정면 얇게(R)

가까운 빨간 공을 기준으로
예상 분리각을 보여줍니다.

)}
내 당점 설정 (수동)
{interactionMode === 'shoot' && cuePower > 0 && (
Power: {Math.round(cuePower)}%
)}
)} {mode === 'quiz' && (
{quizMessage} (점수: {score})
)}
수구
예상 경로

시스템 설정

{gameType === '3cushion' ? ( <>
가이드라인 보기
) : (
4구 연습 가이드
  • '배치' 모드에서 두께 슬라이더를 움직여보세요.
  • 내 공이 적구를 맞고 튕겨 나가는 분리각(점선)을 미리 확인할 수 있습니다.
  • 적구(빨간 공)의 진행 방향(실선)을 보고 다음 공 포지션을 예측하세요.
)}

도움말

{mode === 'theory' ? : (

가이드 라인: 4구 모드에서는 가장 가까운 공을 기준으로 두께에 따른 분리각을 보여줍니다.

당점 활용: 컨트롤 박스에서 당점을 설정하면 실제 샷에서 회전이 적용됩니다. (가이드라인은 무회전 기준 분리각을 우선 보여줍니다)

)}
); }