🎮ArcadeLab

精致3D台球(计分+音效版)

by LaserComet83
260 lines9.2 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>精致3D台球(计分+音效版)</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{overflow:hidden;background:#111;font-family:system-ui}
canvas{display:block;margin:auto}
#tip{position:absolute;top:12px;left:12px;color:#fff;background:rgba(0,0,0,0.6);padding:8px 12px;border-radius:8px;font-size:14px}
#ui{position:absolute;top:12px;right:12px;color:#fff;background:rgba(0,0,0,0.6);padding:10px 16px;border-radius:8px;font-size:18px}
#resetBtn{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);padding:10px 20px;font-size:16px;border:none;background:#228b44;color:white;border-radius:8px;cursor:pointer}
#resetBtn:hover{background:#28a752}
</style>
</head>
<body>
<div id="tip">鼠标拖动瞄准,松开左键击球|滚轮缩放视角</div>
<div id="ui">进球:<span id="score">0</span></div>
<button id="resetBtn">重新开局</button>

<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
<script>
// ========== 音效引擎(无外部音频文件) ==========
let audioCtx = null;
function initAudio(){
    if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playSound(freq, dur, vol=0.3){
    if(!audioCtx) return;
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.type='sine';
    osc.frequency.value = freq;
    gain.gain.setValueAtTime(vol, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
    osc.connect(gain); gain.connect(audioCtx.destination);
    osc.start(); osc.stop(audioCtx.currentTime+dur);
}
function playHit(){playSound(220,0.12,0.25)}
function playBallCollision(){playSound(330,0.08,0.18)}
function playPocket(){playSound(440,0.25,0.35)}

// ========== 场景初始化 ==========
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x181818);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 12);
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.minDistance = 4;
controls.maxDistance = 22;
controls.maxPolarAngle = Math.PI/2 - 0.08;

//灯光
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(0,12,0);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048,2048);
scene.add(dirLight);
scene.add(new THREE.AmbientLight(0x404040, 0.6));

//球台
const tableGeo = new THREE.BoxGeometry(8,0.4,4);
const tableMat = new THREE.MeshStandardMaterial({color:0x1a6b37,roughness:0.7,metalness:0});
const table = new THREE.Mesh(tableGeo, tableMat);
table.receiveShadow = true;
scene.add(table);
const railMat = new THREE.MeshStandardMaterial({color:0x592e14,roughness:0.6});
const rail1 = new THREE.Mesh(new THREE.BoxGeometry(8.4,0.6,0.22), railMat);
rail1.position.set(0,0.1,1.9);
rail1.castShadow=true;rail1.receiveShadow=true;scene.add(rail1);
const rail2 = rail1.clone(); rail2.position.set(0,0.1,-1.9); scene.add(rail2);
const rail3 = new THREE.Mesh(new THREE.BoxGeometry(0.22,0.6,4), railMat);
rail3.position.set(-3.9,0.1,0); scene.add(rail3);
const rail4 = rail3.clone(); rail4.position.set(3.9,0.1,0); scene.add(rail4);

//球洞
function makeHole(x,z){
  const holeGeo = new THREE.CylinderGeometry(0.32,0.32,0.5,24);
  const holeMat = new THREE.MeshStandardMaterial({color:0x000});
  const hole = new THREE.Mesh(holeGeo, holeMat);
  hole.position.set(x,-0.2,z);
  scene.add(hole);
}
makeHole(-3.8,1.8); makeHole(3.8,1.8); makeHole(0,1.8);
makeHole(-3.8,-1.8); makeHole(3.8,-1.8); makeHole(0,-1.8);

//台球数据
let balls = [];
const ballRadius = 0.22;
const ballGeo = new THREE.SphereGeometry(ballRadius,32,32);
const ballColors = [0xffffff,0xff2222,0x2244ff,0xffdd00,0x9922bb,0xff7700,0x22bb22,0x111111,
0xbb2222,0x3366ff,0xffee44,0xaa33cc,0xff8822,0x33cc33,0x444444];
let score = 0;
const scoreDom = document.getElementById('score');

function createBall(x,z,color){
  const mat = new THREE.MeshStandardMaterial({color,roughness:0.2,metalness:0.1});
  const mesh = new THREE.Mesh(ballGeo, mat);
  mesh.position.set(x, ballRadius, z);
  mesh.castShadow = true;
  mesh.receiveShadow = true;
  scene.add(mesh);
  return {mesh,vel:new THREE.Vector3(0,0,0), color};
}

function resetGame(){
    //清理旧球
    balls.forEach(b=>scene.remove(b.mesh));
    balls=[];
    score=0;
    scoreDom.innerText=score;
    //母球
    balls.push(createBall(-2.8,0,0xffffff));
    //三角摆球
    let idx=1;
    const startX = 2.6;
    const startZ = 0;
    for(let row=0;row<5;row++){
      for(let col=0;col<=row;col++){
        const offZ = (row)*0.38;
        balls.push(createBall(startX, startZ - offZ + row*0.19, ballColors[idx++]));
      }
    }
}
resetGame();

document.getElementById("resetBtn").onclick = ()=>{
    initAudio();
    resetGame();
}

//瞄准线
const aimGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(),new THREE.Vector3()]);
const aimMat = new THREE.LineBasicMaterial({color:0xff4444});
const aimLine = new THREE.Line(aimGeo, aimMat);
scene.add(aimLine);
aimLine.visible=false;

let mouseDown=false;
let mouseStart=new THREE.Vector2();
let mouseEnd=new THREE.Vector2();
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

window.addEventListener('mousedown',e=>{
  initAudio();
  mouseDown=true;
  mouseStart.set(e.clientX,e.clientY);
});
window.addEventListener('mousemove',e=>{
  pointer.x = (e.clientX/window.innerWidth)*2-1;
  pointer.y = -(e.clientY/window.innerHeight)*2+1;
  mouseEnd.set(e.clientX,e.clientY);
  if(mouseDown){
    raycaster.setFromCamera(pointer,camera);
    const p = balls[0].mesh.position;
    const dir = raycaster.ray.origin.clone().sub(p).normalize();
    const pts = [p, p.clone().add(dir.multiplyScalar(3))];
    aimLine.geometry.setFromPoints(pts);
    aimLine.visible=true;
  }
});
window.addEventListener('mouseup',()=>{
  if(!mouseDown) return;
  mouseDown=false;
  aimLine.visible=false;
  const cuePower = Math.min(mouseStart.distanceTo(mouseEnd)*0.008,1.6);
  raycaster.setFromCamera(pointer,camera);
  const dir = raycaster.ray.origin.clone().sub(balls[0].mesh.position).normalize();
  balls[0].vel.copy(dir.multiplyScalar(cuePower));
  playHit();
});

//物理
const friction = 0.985;
const bounce = 0.86;
function physics(){
  for(let i=0;i<balls.length;i++){
    const b = balls[i];
    b.mesh.position.add(b.vel);
    b.vel.multiplyScalar(friction);
    //台边碰撞
    const limitX = 4 - ballRadius;
    const limitZ = 2 - ballRadius;
    if(b.mesh.position.x>limitX){b.mesh.position.x=limitX;b.vel.x*=-bounce;}
    if(b.mesh.position.x<-limitX){b.mesh.position.x=-limitX;b.vel.x*=-bounce;}
    if(b.mesh.position.z>limitZ){b.mesh.position.z=limitZ;b.vel.z*=-bounce;}
    if(b.mesh.position.z<-limitZ){b.mesh.position.z=-limitZ;b.vel.z*=-bounce;}
    //落袋检测
    const holePos = [
      new THREE.Vector3(-3.8,0,1.8),
      new THREE.Vector3(3.8,0,1.8),
      new THREE.Vector3(0,0,1.8),
      new THREE.Vector3(-3.8,0,-1.8),
      new THREE.Vector3(3.8,0,-1.8),
      new THREE.Vector3(0,0,-1.8),
    ];
    for(const hp of holePos){
      const d = hp.distanceTo(b.mesh.position);
      if(d<0.3){
        b.vel.set(0,0,0);
        b.mesh.position.y = -10;
        playPocket();
        if(i===0){
            alert("母球进袋!点击重新开局");
        }else if(b.color === 0x111111){
            alert("黑八进袋!游戏结束!");
        }else{
            score++;
            scoreDom.innerText = score;
        }
      }
    }
  }
  //球碰撞
  for(let i=0;i<balls.length;i++){
    const ba = balls[i];
    for(let j=i+1;j<balls.length;j++){
      const bb = balls[j];
      const dist = ba.mesh.position.distanceTo(bb.mesh.position);
      const minD = ballRadius*2;
      if(dist < minD){
        playBallCollision();
        const n = ba.mesh.position.clone().sub(bb.mesh.position).normalize();
        const v1n = ba.vel.dot(n);
        const v2n = bb.vel.dot(n);
        ba.vel.add(n.clone().multiplyScalar((v2n - v1n)*bounce));
        bb.vel.add(n.clone().multiplyScalar((v1n - v2n)*bounce));
        const overlap = minD - dist;
        ba.mesh.position.add(n.clone().multiplyScalar(overlap*0.5));
        bb.mesh.position.add(n.clone().multiplyScalar(-overlap*0.5));
      }
    }
  }
}

window.addEventListener('resize',()=>{
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function animate(){
  requestAnimationFrame(animate);
  controls.update();
  physics();
  renderer.render(scene,camera);
}
animate();
</script>
</body>
</html>

Game Source: 精致3D台球(计分+音效版)

Creator: LaserComet83

Libraries: three

Complexity: complex (260 lines, 9.2 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: 3d-lasercomet83" to link back to the original. Then publish at arcadelab.ai/publish.