<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>桌球 · Pool</title>
<style>
html,body{margin:0;height:100%;background:#14171c;display:flex;align-items:center;justify-content:center;font-family:"Microsoft YaHei",sans-serif;user-select:none;-webkit-user-select:none;overflow:hidden}
#wrap{position:relative}
canvas{display:block;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.6);touch-action:none}
#hud{position:absolute;top:10px;left:50%;transform:translateX(-50%);color:#dfe6ee;background:rgba(0,0,0,.45);padding:6px 16px;border-radius:999px;font-size:14px;letter-spacing:1px;pointer-events:none;white-space:nowrap}
#tip{position:absolute;bottom:-28px;left:50%;transform:translateX(-50%);color:#8b95a3;font-size:12px;white-space:nowrap}
#rb{position:absolute;bottom:-34px;right:0;background:none;border:none;color:#8b95a3;font-size:13px;cursor:pointer;font-family:inherit;padding:4px 8px}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="560" height="1000"></canvas>
<div id="hud"></div>
<div id="tip">手指拖动瞄准 · 按住蓄力,松开出杆 · 点右上角重开</div>
<button id="rb">↻ 重开</button>
</div>
<script>
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const W=cv.width,H=cv.height;
// 桌面参数(竖版)
const T={x:60,y:60,w:W-120,h:H-120}; // 库边内沿
const R=11; // 球半径
const POCKET_R=21; // 袋口半径
const FRICTION=0.985; // 摩擦系数
const STOP=0.05; // 停止阈值
const POCKETS=[
{x:T.x,y:T.y},{x:T.x+T.w/2,y:T.y},{x:T.x+T.w,y:T.y},
{x:T.x,y:T.y+T.h},{x:T.x+T.w/2,y:T.y+T.h},{x:T.x+T.w,y:T.y+T.h}
];
const COLORS={cue:'#f4f2ea',1:'#f7c948',2:'#3b6fe0',3:'#e04b3b',4:'#7a3bd0',5:'#ef8b2f',6:'#2fa864',7:'#8d2f3f',8:'#22252b',9:'#f7c948',10:'#3b6fe0',11:'#e04b3b',12:'#7a3bd0',13:'#ef8b2f',14:'#2fa864',15:'#8d2f3f'};
let balls=[],aim={on:false,x:W/2,y:H*0.25},power=0,powering=false,messages='',msgTime=0,sunkThisShot=[],moving=false;
// 自适应屏幕缩放
function fit(){
const s=Math.min((innerWidth-16)/W,(innerHeight-16)/H);
cv.style.width=(W*s)+'px';cv.style.height=(H*s)+'px';
}
addEventListener('resize',fit);fit();
function rack(){
balls=[];
balls.push({x:T.x+T.w/2,y:T.y+T.h*0.25,vx:0,vy:0,n:0,in:false});
const order=[1,9,2,10,8,3,11,7,14,4,5,13,15,6,12]; // 随意但8在中心
let i=0,dx=T.x+T.w/2,dy=T.y+T.h*0.72,gap=R*2+0.6;
for(let row=0;row<5;row++){
for(let k=0;k<=row;k++){
const n=order[i++];
balls.push({x:dx-row*(gap/2)+k*gap,y:dy+row*gap*Math.cos(Math.PI/6),vx:0,vy:0,n:n,in:false});
}
}
}
function drawTable(){
// 木框
ctx.fillStyle='#5a3a22';
roundRect(T.x-26,T.y-26,T.w+52,T.h+52,18);ctx.fill();
// 毛毡
const g=ctx.createRadialGradient(W/2,H/2,80,W/2,H/2,W*0.9);
g.addColorStop(0,'#1e7a4c');g.addColorStop(1,'#135c39');
ctx.fillStyle=g;
roundRect(T.x,T.y,T.w,T.h,6);ctx.fill();
// 库边
ctx.strokeStyle='rgba(255,255,255,.08)';ctx.lineWidth=2;
roundRect(T.x,T.y,T.w,T.h,6);ctx.stroke();
// 袋口
for(const p of POCKETS){
ctx.fillStyle='#0a0c0e';
ctx.beginPath();ctx.arc(p.x,p.y,POCKET_R,0,7);ctx.fill();
ctx.strokeStyle='#2a180d';ctx.lineWidth=4;ctx.stroke();
}
// 点位装饰
ctx.fillStyle='rgba(255,255,255,.25)';
for(const y of [0.25,0.5,0.75]){
ctx.beginPath();ctx.arc(T.x+T.w/2,T.y+T.h*y,2.5,0,7);ctx.fill();
}
}
function roundRect(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();
}
function drawBall(b){
if(b.in)return;
ctx.save();
ctx.fillStyle='rgba(0,0,0,.35)';
ctx.beginPath();ctx.ellipse(b.x+3,b.y+4,R,R*0.85,0,0,7);ctx.fill();
ctx.beginPath();ctx.arc(b.x,b.y,R,0,7);
ctx.fillStyle=COLORS[b.n]||'#fff';ctx.fill();
if(b.n>=9){
ctx.save();ctx.clip();
ctx.fillStyle='#f4f2ea';
ctx.fillRect(b.x-R,b.y-R*0.55,R*2,R*1.1);
ctx.restore();
}
if(b.n>0){
ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(b.x,b.y,R*0.48,0,7);ctx.fill();
ctx.fillStyle='#111';ctx.font='bold '+(R*0.75)+'px Arial';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(b.n,b.x,b.y+0.5);
}
const hg=ctx.createRadialGradient(b.x-R*0.4,b.y-R*0.4,1,b.x-R*0.4,b.y-R*0.4,R);
hg.addColorStop(0,'rgba(255,255,255,.85)');hg.addColorStop(0.3,'rgba(255,255,255,.15)');hg.addColorStop(1,'rgba(255,255,255,0)');
ctx.beginPath();ctx.arc(b.x,b.y,R,0,7);ctx.fillStyle=hg;ctx.fill();
ctx.restore();
}
function drawAim(){
const cue=balls[0];
if(cue.in||moving)return;
const ang=Math.atan2(aim.y-cue.y,aim.x-cue.x);
const len=power*160+40;
ctx.save();
ctx.setLineDash([6,8]);ctx.strokeStyle='rgba(255,255,255,.55)';ctx.lineWidth=1.5;
let ex=cue.x,ey=cue.y;
const dx=Math.cos(ang),dyv=Math.sin(ang);
for(let s=0;s<900;s+=4){
ex=cue.x+dx*s;ey=cue.y+dyv*s;
if(ex<T.x+R||ex>T.x+T.w-R||ey<T.y+R||ey>T.y+T.h-R)break;
}
ctx.beginPath();ctx.moveTo(cue.x,cue.y);ctx.lineTo(ex,ey);ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle='rgba(255,255,255,.7)';
ctx.beginPath();ctx.arc(ex,ey,R,0,7);ctx.stroke();
const px=cue.x-dx*(len+R+8),py=cue.y-dyv*(len+R+8);
const bx=cue.x-dx*(len+R+150),by=cue.y-dyv*(len+R+150);
const grad=ctx.createLinearGradient(px,py,bx,by);
grad.addColorStop(0,'#e8c98f');grad.addColorStop(0.06,'#1c1c22');grad.addColorStop(1,'#6b4423');
ctx.strokeStyle=grad;ctx.lineWidth=7;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(px,py);ctx.lineTo(bx,by);ctx.stroke();
if(powering||power>0.01){
ctx.fillStyle='rgba(0,0,0,.4)';
roundRect(cue.x-52,cue.y+R+14,104,10,5);ctx.fill();
ctx.fillStyle=power>0.8?'#e04b3b':power>0.5?'#ef8b2f':'#2fa864';
roundRect(cue.x-50,cue.y+R+16,100*power,6,3);ctx.fill();
}
ctx.restore();
}
function physics(dt){
moving=false;
for(const b of balls){
if(b.in)continue;
b.x+=b.vx*dt;b.y+=b.vy*dt;
b.vx*=FRICTION;b.vy*=FRICTION;
if(Math.hypot(b.vx,b.vy)<STOP){b.vx=0;b.vy=0}else moving=true;
if(b.x<T.x+R){b.x=T.x+R;b.vx=-b.vx*0.92}
if(b.x>T.x+T.w-R){b.x=T.x+T.w-R;b.vx=-b.vx*0.92}
if(b.y<T.y+R){b.y=T.y+R;b.vy=-b.vy*0.92}
if(b.y>T.y+T.h-R){b.y=T.y+T.h-R;b.vy=-b.vy*0.92}
for(const p of POCKETS){
if(Math.hypot(b.x-p.x,b.y-p.y)<POCKET_R*0.82){
b.in=true;sunkThisShot.push(b.n);
b.vx=b.vy=0;break;
}
}
}
for(let i=0;i<balls.length;i++)for(let j=i+1;j<balls.length;j++){
const a=balls[i],b=balls[j];
if(a.in||b.in)continue;
const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy);
if(d<R*2&&d>0){
const nx=dx/d,ny=dy/d,ov=(R*2-d)/2;
a.x-=nx*ov;a.y-=ny*ov;b.x+=nx*ov;b.y+=ny*ov;
const rvn=(b.vx-a.vx)*nx+(b.vy-a.vy)*ny;
if(rvn<0){
const imp=-rvn*0.96;
a.vx-=imp*nx;a.vy-=imp*ny;b.vx+=imp*nx;b.vy+=imp*ny;
moving=true;
}
}
}
}
let shotAnim=null;
function shoot(){
const cue=balls[0];
if(cue.in)return;
const ang=Math.atan2(aim.y-cue.y,aim.x-cue.x);
const sp=power*17+2;
shotAnim={ang,t:0,power};
setTimeout(()=>{
cue.vx=Math.cos(ang)*sp;cue.vy=Math.sin(ang)*sp;
power=0;shotAnim=null;
},140);
}
function updateHUD(){
const left=balls.filter(b=>!b.in&&b.n>0).length;
document.getElementById('hud').textContent=
'剩余目标球 '+left+(messages?' | '+messages:'');
}
function frame(){
physics(1);
if(!powering&&!moving&&!shotAnim)sunkCheck();
msgTime--;if(msgTime<=0)messages='';
updateHUD();
ctx.clearRect(0,0,W,H);
ctx.fillStyle='#14171c';ctx.fillRect(0,0,W,H);
drawTable();
for(const b of balls)if(b.n!==0)drawBall(b);
drawBall(balls[0]);
if(shotAnim){
const cue=balls[0],dx=Math.cos(shotAnim.ang),dy=Math.sin(shotAnim.ang);
const off=(1-shotAnim.t/140)*(60+shotAnim.power*90)+8+R;
const px=cue.x-dx*off,py=cue.y-dy*off;
const bx=px-dx*130,by=py-dy*130;
ctx.strokeStyle='#e8c98f';ctx.lineWidth=7;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(px,py);ctx.lineTo(bx,by);ctx.stroke();
} else if(!moving)drawAim();
requestAnimationFrame(frame);
}
function sunkCheck(){
const cueIn=sunkThisShot.includes(0);
const sunk=sunkThisShot.filter(n=>n>0);
if(cueIn){
messages='白球落袋!重新摆放';msgTime=240;
const c=balls[0];c.in=false;c.x=T.x+T.w/2;c.y=T.y+T.h*0.25;c.vx=c.vy=0;
for(const b of balls)if(!b.in&&b.n>0&&Math.hypot(b.x-c.x,b.y-c.y)<R*2.2)c.x-=R*2.5;
}else if(sunk.length){
messages='打进 '+sunk.join('、')+' 号!';msgTime=200;
}
sunkThisShot=[];
}
// 坐标换算(支持缩放后的显示尺寸)
function ptPos(e){
const r=cv.getBoundingClientRect();
const p=e.touches?e.touches[0]:e;
aim.x=(p.clientX-r.left)*W/r.width;
aim.y=(p.clientY-r.top)*H/r.height;
}
function canPlay(){const c=balls[0];return !moving&&!c.in&&!shotAnim;}
// 鼠标
cv.addEventListener('mousemove',ptPos);
cv.addEventListener('mousedown',()=>{if(canPlay()){powering=true;power=0;}});
addEventListener('mouseup',()=>{if(powering){powering=false;if(power>0.03)shoot();}});
// 触屏(手机)
cv.addEventListener('touchstart',e=>{
e.preventDefault();
ptPos(e);
if(canPlay()){powering=true;power=0;}
},{passive:false});
cv.addEventListener('touchmove',e=>{e.preventDefault();ptPos(e);},{passive:false});
addEventListener('touchend',()=>{if(powering){powering=false;if(power>0.03)shoot();}});
// 重开
document.getElementById('rb').addEventListener('click',()=>{rack();messages='新的一局';msgTime=120;});
addEventListener('keydown',e=>{if(e.key==='r'||e.key==='R'){rack();messages='新的一局';msgTime=120;}});
setInterval(()=>{if(powering)power=Math.min(1,power+0.02)},16);
rack();frame();
</script>
</body>
</html>