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구는 흰 공(수구)으로 두 개의 빨간 공을 모두 맞추면 득점하는 게임입니다.
✅ 두께와 분리각
- 정면(100%): 수구는 멈추고 적구만 앞으로 나갑니다.
- 절반(50%): 수구는 약 60도, 적구는 30도 방향으로 분리됩니다.
- 얇게: 수구가 많이 꺾이지 않고 진행합니다.
* 연습 모드의 '두께 조절' 슬라이더를 움직여 분리각을 확인하세요.
);
}
if (type === 'five_half') {
return (
파이브 앤 하프 시스템 (Five & Half)
3쿠션의 가장 기본이 되는 시스템입니다.
수구수 (Start)
- 장축 코너: 50
- 중간: 45, 40, 35...
);
}
return (
플러스 시스템 (Plus System)
수구와 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}
>
시스템 설정
{gameType === '3cushion' ? (
<>
가이드라인 보기
>
) : (
4구 연습 가이드
- '배치' 모드에서 두께 슬라이더를 움직여보세요.
- 내 공이 적구를 맞고 튕겨 나가는 분리각(점선)을 미리 확인할 수 있습니다.
- 적구(빨간 공)의 진행 방향(실선)을 보고 다음 공 포지션을 예측하세요.
)}
도움말
{mode === 'theory' ?
: (
가이드 라인: 4구 모드에서는 가장 가까운 공을 기준으로 두께에 따른 분리각을 보여줍니다.
당점 활용: 컨트롤 박스에서 당점을 설정하면 실제 샷에서 회전이 적용됩니다. (가이드라인은 무회전 기준 분리각을 우선 보여줍니다)
)}
);
}