这是双人乒乓球模拟游戏HTML代码,支持两位玩家在同一设备上使用键盘操控球拍对战。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🏓 双人乒乓球</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0f0f1a;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Segoe UI', 'PingFang SC', Roboto, 'Helvetica Neue', sans-serif;
user-select: none;
overflow: hidden;
}
.game-wrapper {
background: linear-gradient(145deg, #1a1a2e, #16213e);
padding: 30px 40px 35px;
border-radius: 28px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.8), inset 0 0 0 1px rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
align-items: center;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 18px;
padding: 0 8px;
}
.score-container {
display: flex;
align-items: center;
gap: 28px;
font-size: 28px;
font-weight: 700;
letter-spacing: 1px;
}
.score-p1 {
color: #4fc3f7;
text-shadow: 0 0 20px rgba(79, 195, 247, 0.3);
}
.score-p2 {
color: #ff6b6b;
text-shadow: 0 0 20px rgba(255, 107, 107, 0.3);
}
.score-divider {
color: rgba(255, 255, 255, 0.15);
font-weight: 300;
}
.controls-hint {
display: flex;
gap: 30px;
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
letter-spacing: 0.5px;
}
.controls-hint span {
display: flex;
align-items: center;
gap: 8px;
}
.hint-key {
display: inline-block;
background: rgba(255, 255, 255, 0.08);
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.hint-p1 .hint-key {
color: #4fc3f7;
border-color: rgba(79, 195, 247, 0.2);
}
.hint-p2 .hint-key {
color: #ff6b6b;
border-color: rgba(255, 107, 107, 0.2);
}
canvas {
display: block;
width: 800px;
height: 500px;
border-radius: 16px;
background: #0d0d1f;
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04);
cursor: none;
touch-action: none;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: 16px;
padding: 0 8px;
}
.status-text {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.3px;
min-height: 22px;
}
.status-text .highlight {
color: rgba(255, 255, 255, 0.7);
}
.btn-restart {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.4);
padding: 6px 20px;
border-radius: 30px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.3px;
}
.btn-restart:hover {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.15);
}
/* 响应式 */
@media (max-width: 900px) {
.game-wrapper {
padding: 16px 18px 20px;
border-radius: 18px;
}
canvas {
width: 100%;
height: auto;
aspect-ratio: 800 / 500;
border-radius: 12px;
}
.header {
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.score-container {
font-size: 22px;
gap: 18px;
}
.controls-hint {
font-size: 11px;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
}
.footer {
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.status-text {
font-size: 12px;
text-align: center;
}
}
@media (max-width: 500px) {
.game-wrapper {
padding: 10px 10px 14px;
border-radius: 12px;
}
.score-container {
font-size: 18px;
gap: 12px;
}
.controls-hint {
font-size: 10px;
gap: 10px;
}
.hint-key {
padding: 1px 6px;
font-size: 10px;
}
}
</style>
</head>
<body>
<div class="game-wrapper">
<!-- 头部:比分 + 操作提示 -->
<div class="header">
<div class="score-container">
<span class="score-p1" id="scoreP1">0</span>
<span class="score-divider">⚡</span>
<span class="score-p2" id="scoreP2">0</span>
</div>
<div class="controls-hint">
<span class="hint-p1">
<span class="hint-key">W</span> ↑
<span class="hint-key">S</span> ↓
<span style="color:rgba(255,255,255,0.2);margin-left:2px;">玩家1</span>
</span>
<span class="hint-p2">
<span class="hint-key">↑</span> ↑
<span class="hint-key">↓</span> ↓
<span style="color:rgba(255,255,255,0.2);margin-left:2px;">玩家2</span>
</span>
</div>
</div>
<!-- 游戏画布 -->
<canvas id="gameCanvas" width="800" height="500"></canvas>
<!-- 底部:状态 + 重启按钮 -->
<div class="footer">
<div class="status-text" id="statusText">按 <span class="highlight">空格键</span> 开始</div>
<button class="btn-restart" id="restartBtn">⟳ 重新开始</button>
</div>
</div>
<script>
// ============================================================
// 🏓 双人乒乓球 - 完整游戏逻辑
// 控制:玩家1 (左) W/S | 玩家2 (右) ↑/↓
// 目标:先得 11 分获胜
// ============================================================
// -------- DOM 引用 --------
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const scoreP1El = document.getElementById('scoreP1');
const scoreP2El = document.getElementById('scoreP2');
const statusTextEl = document.getElementById('statusText');
const restartBtn = document.getElementById('restartBtn');
// -------- 固定参数 --------
const W = 800,
H = 500;
const PADDLE_W = 12;
const PADDLE_H = 80;
const BALL_RADIUS = 8;
const PADDLE_SPEED = 6;
const BALL_SPEED_INIT = 5.2;
const BALL_SPEED_MAX = 13;
const WIN_SCORE = 11;
const PADDLE_OFFSET = 30; // 球拍离边界的距离
// -------- 游戏状态 --------
const STATE = {
WAITING: 'waiting', // 等待开始
PLAYING: 'playing', // 进行中
SCORED: 'scored', // 得分后暂停
GAMEOVER: 'gameover' // 游戏结束
};
// -------- 游戏对象 --------
const player1 = {
x: PADDLE_OFFSET,
y: H / 2 - PADDLE_H / 2,
w: PADDLE_W,
h: PADDLE_H,
score: 0,
color: '#4fc3f7',
glowColor: 'rgba(79, 195, 247, 0.25)',
upKey: 'w',
downKey: 's',
name: '玩家1'
};
const player2 = {
x: W - PADDLE_OFFSET - PADDLE_W,
y: H / 2 - PADDLE_H / 2,
w: PADDLE_W,
h: PADDLE_H,
score: 0,
color: '#ff6b6b',
glowColor: 'rgba(255, 107, 107, 0.25)',
upKey: 'arrowup',
downKey: 'arrowdown',
name: '玩家2'
};
// 球
const ball = {
x: W / 2,
y: H / 2,
r: BALL_RADIUS,
vx: 0,
vy: 0,
speed: BALL_SPEED_INIT,
trail: \[\] // 轨迹
};
// 按键状态
const keys = {};
// 游戏控制
let gameState = STATE.WAITING;
let scoredTimer = 0;
let scoredMessage = '';
let scoredColor = '';
// -------- 辅助函数 --------
function resetBall(direction) {
ball.x = W / 2;
ball.y = H / 2 + (Math.random() - 0.5) * 80;
ball.speed = BALL_SPEED_INIT;
// direction: 1 朝右 (玩家2方向), -1 朝左 (玩家1方向)
const angle = (Math.random() - 0.5) * 0.8; // -40° ~ 40°
const dir = direction || (Math.random() > 0.5 ? 1 : -1);
ball.vx = Math.cos(angle) * ball.speed * dir;
ball.vy = Math.sin(angle) * ball.speed;
ball.trail = \[\];
}
function resetGame() {
player1.score = 0;
player2.score = 0;
player1.y = H / 2 - PADDLE_H / 2;
player2.y = H / 2 - PADDLE_H / 2;
updateScoreDisplay();
resetBall(1);
gameState = STATE.WAITING;
statusTextEl.innerHTML = '按 <span class="highlight">空格键</span> 开始';
scoredTimer = 0;
}
function updateScoreDisplay() {
scoreP1El.textContent = player1.score;
scoreP2El.textContent = player2.score;
}
function showScoredMessage(winner) {
if (winner === 1) {
scoredMessage = '🔥 玩家1 得分!';
scoredColor = player1.color;
} else {
scoredMessage = '🔥 玩家2 得分!';
scoredColor = player2.color;
}
statusTextEl.innerHTML = `<span style="color:{scoredColor}"\>{scoredMessage}</span>`;
}
function showGameOver(winner) {
if (winner === 1) {
scoredMessage = '🏆 玩家1 获胜!';
scoredColor = player1.color;
} else {
scoredMessage = '🏆 玩家2 获胜!';
scoredColor = player2.color;
}
statusTextEl.innerHTML = `<span style="color:{scoredColor};font-size:18px;font-weight:700;"\>{scoredMessage}</span> 按 空格键 重新开始`;
}
// -------- 物理 & 碰撞 --------
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function updatePaddles() {
// 玩家1
if (keysplayer1.upKey) {
player1.y -= PADDLE_SPEED;
}
if (keysplayer1.downKey) {
player1.y += PADDLE_SPEED;
}
player1.y = clamp(player1.y, 0, H - player1.h);
// 玩家2
if (keysplayer2.upKey) {
player2.y -= PADDLE_SPEED;
}
if (keysplayer2.downKey) {
player2.y += PADDLE_SPEED;
}
player2.y = clamp(player2.y, 0, H - player2.h);
}
function updateBall() {
if (gameState !== STATE.PLAYING) return;
// 移动球
ball.x += ball.vx;
ball.y += ball.vy;
// ---- 上下墙壁碰撞 ----
if (ball.y - ball.r < 0) {
ball.y = ball.r;
ball.vy = -ball.vy;
} else if (ball.y + ball.r > H) {
ball.y = H - ball.r;
ball.vy = -ball.vy;
}
// ---- 球拍碰撞 (玩家1) ----
const p1 = player1;
if (ball.vx < 0 &&
ball.x - ball.r < p1.x + p1.w &&
ball.x + ball.r > p1.x &&
ball.y > p1.y && ball.y < p1.y + p1.h) {
// 计算反弹角度
const relY = (ball.y - (p1.y + p1.h / 2)) / (p1.h / 2); // -1 ~ 1
const angle = relY * 0.75; // 最大 ±43°
const speed = Math.min(ball.speed + 0.3, BALL_SPEED_MAX);
ball.speed = speed;
ball.vx = Math.cos(angle) * speed;
ball.vy = Math.sin(angle) * speed;
ball.x = p1.x + p1.w + ball.r;
// 加一点轨迹特效
addTrail();
}
// ---- 球拍碰撞 (玩家2) ----
const p2 = player2;
if (ball.vx > 0 &&
ball.x + ball.r > p2.x &&
ball.x - ball.r < p2.x + p2.w &&
ball.y > p2.y && ball.y < p2.y + p2.h) {
const relY = (ball.y - (p2.y + p2.h / 2)) / (p2.h / 2);
const angle = relY * 0.75;
const speed = Math.min(ball.speed + 0.3, BALL_SPEED_MAX);
ball.speed = speed;
ball.vx = -Math.cos(angle) * speed;
ball.vy = Math.sin(angle) * speed;
ball.x = p2.x - ball.r;
addTrail();
}
// ---- 左右边界 (得分) ----
if (ball.x - ball.r < -20) {
// 玩家2得分
player2.score++;
updateScoreDisplay();
if (player2.score >= WIN_SCORE) {
gameState = STATE.GAMEOVER;
showGameOver(2);
} else {
gameState = STATE.SCORED;
scoredTimer = 60; // 约1秒 (60fps)
showScoredMessage(2);
resetBall(-1);
}
return;
}
if (ball.x + ball.r > W + 20) {
// 玩家1得分
player1.score++;
updateScoreDisplay();
if (player1.score >= WIN_SCORE) {
gameState = STATE.GAMEOVER;
showGameOver(1);
} else {
gameState = STATE.SCORED;
scoredTimer = 60;
showScoredMessage(1);
resetBall(1);
}
return;
}
// ---- 轨迹更新 ----
if (gameState === STATE.PLAYING) {
ball.trail.push({ x: ball.x, y: ball.y });
if (ball.trail.length > 14) ball.trail.shift();
}
}
function addTrail() {
for (let i = 0; i < 6; i++) {
ball.trail.push({
x: ball.x + (Math.random() - 0.5) * 6,
y: ball.y + (Math.random() - 0.5) * 6
});
}
if (ball.trail.length > 20) ball.trail.splice(0, ball.trail.length - 20);
}
// -------- 绘制函数 --------
function drawBackground() {
// 深色背景
const grad = ctx.createRadialGradient(400, 250, 100, 400, 250, 500);
grad.addColorStop(0, '#14142e');
grad.addColorStop(1, '#0a0a18');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// 球场网格微光
ctx.strokeStyle = 'rgba(255,255,255,0.02)';
ctx.lineWidth = 1;
for (let i = 0; i < W; i += 40) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, H);
ctx.stroke();
}
for (let i = 0; i < H; i += 40) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(W, i);
ctx.stroke();
}
// 中线 (虚线)
ctx.setLineDash(10, 14);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash(\[\]);
// 中心圆
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(W / 2, H / 2, 70, 0, Math.PI * 2);
ctx.stroke();
// 中心点
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.arc(W / 2, H / 2, 4, 0, Math.PI * 2);
ctx.fill();
}
function drawPaddle(p) {
// 发光
const grad = ctx.createRadialGradient(
p.x + p.w / 2, p.y + p.h / 2, 2,
p.x + p.w / 2, p.y + p.h / 2, p.h * 0.7
);
grad.addColorStop(0, p.glowColor);
grad.addColorStop(1, 'transparent');
ctx.fillStyle = grad;
ctx.fillRect(p.x - p.h * 0.3, p.y - p.h * 0.2, p.w + p.h * 0.6, p.h * 1.4);
// 主体
const r = 4;
ctx.shadowColor = p.color;
ctx.shadowBlur = 20;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.roundRect(p.x, p.y, p.w, p.h, r);
ctx.fill();
ctx.shadowBlur = 0;
// 高光
const hlGrad = ctx.createLinearGradient(p.x, p.y, p.x + p.w, p.y);
hlGrad.addColorStop(0, 'rgba(255,255,255,0.25)');
hlGrad.addColorStop(0.4, 'rgba(255,255,255,0.05)');
hlGrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hlGrad;
ctx.beginPath();
ctx.roundRect(p.x + 2, p.y + 4, p.w - 4, p.h - 8, 2);
ctx.fill();
// 边框光晕
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(p.x, p.y, p.w, p.h, r);
ctx.stroke();
// 中间装饰线
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(p.x + 3, p.y + 10, 2, p.h - 20);
}
function drawBall() {
// 轨迹
for (let i = 0; i < ball.trail.length; i++) {
const alpha = (i / ball.trail.length) * 0.4;
const radius = ball.r * (0.3 + 0.7 * (i / ball.trail.length));
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.beginPath();
ctx.arc(ball.traili.x, ball.traili.y, radius, 0, Math.PI * 2);
ctx.fill();
}
// 球体发光
const glow = ctx.createRadialGradient(
ball.x - 2, ball.y - 2, 1,
ball.x, ball.y, ball.r * 3
);
glow.addColorStop(0, 'rgba(255,255,255,0.3)');
glow.addColorStop(0.5, 'rgba(255,255,255,0.08)');
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r * 3, 0, Math.PI * 2);
ctx.fill();
// 球体
const grad = ctx.createRadialGradient(
ball.x - 3, ball.y - 4, 2,
ball.x, ball.y, ball.r
);
grad.addColorStop(0, '#ffffff');
grad.addColorStop(0.7, '#f0f0f0');
grad.addColorStop(1, '#c8c8c8');
ctx.shadowColor = 'rgba(255,255,255,0.5)';
ctx.shadowBlur = 25;
ctx.fillStyle = grad;
ctx.begin