精致3D台球(计分+音效版)
by LaserComet83260 lines9.2 KB🛠️ Three.js (3D graphics)
<!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.