314 lines
8.7 KiB
JavaScript
314 lines
8.7 KiB
JavaScript
// 清新跑酷 - Endless Runner (Mobile Portrait, Touch-friendly)
|
||
(() => {
|
||
const canvas = document.getElementById('gameCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const scoreEl = document.getElementById('score');
|
||
const restartBtn = document.getElementById('restartBtn');
|
||
const overlay = document.getElementById('overlay');
|
||
const overlayRestart = document.getElementById('overlayRestart');
|
||
const finalScoreEl = document.getElementById('finalScore');
|
||
|
||
let dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
|
||
let running = true;
|
||
let gameOver = false;
|
||
let lastTime = performance.now();
|
||
let elapsed = 0; // seconds
|
||
let score = 0;
|
||
|
||
const world = {
|
||
width: 360,
|
||
height: 640,
|
||
groundH: 90, // 地面高度(CSS像素)
|
||
baseSpeed: 240, // 初始速度(px/s)
|
||
speed: 240, // 当前速度(随难度提升)
|
||
gravity: 1800, // 重力(px/s^2)
|
||
jumpV: -864, // 跳跃初速度(px/s)
|
||
};
|
||
|
||
const player = {
|
||
x: 72,
|
||
y: 0, // 通过 resetPlayer 设置
|
||
w: 44,
|
||
h: 54,
|
||
vy: 0,
|
||
grounded: false,
|
||
color: '#2f7d5f'
|
||
};
|
||
|
||
const obstacles = [];
|
||
const coins = [];
|
||
let obstacleTimer = 0; // ms 到下一个障碍
|
||
let coinTimer = 0; // ms 到下一个道具
|
||
|
||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||
function rand(min, max) { return Math.random() * (max - min) + min; }
|
||
|
||
function resizeCanvas() {
|
||
dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
|
||
const cssWidth = Math.min(480, document.documentElement.clientWidth);
|
||
const cssHeight = document.documentElement.clientHeight;
|
||
|
||
canvas.style.width = cssWidth + 'px';
|
||
canvas.style.height = cssHeight + 'px';
|
||
canvas.width = Math.floor(cssWidth * dpr);
|
||
canvas.height = Math.floor(cssHeight * dpr);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 使用CSS像素绘制
|
||
|
||
world.width = cssWidth;
|
||
world.height = cssHeight;
|
||
world.groundH = Math.max(64, Math.floor(world.height * 0.14));
|
||
|
||
resetPlayer();
|
||
}
|
||
|
||
function resetPlayer() {
|
||
player.y = world.height - world.groundH - player.h;
|
||
player.vy = 0;
|
||
player.grounded = true;
|
||
}
|
||
|
||
function spawnObstacle() {
|
||
const w = rand(28, 56);
|
||
const h = rand(40, clamp(world.height * 0.28, 80, 140));
|
||
const y = world.height - world.groundH - h;
|
||
obstacles.push({ x: world.width + w, y, w, h, color: '#3ea573' });
|
||
|
||
// 以一定概率在障碍上方生成一个金币
|
||
if (Math.random() < 0.6) {
|
||
const cx = world.width + w + rand(10, 40);
|
||
const cy = y - rand(28, 56);
|
||
coins.push({ x: cx, y: cy, r: 10, color: '#f6c453' });
|
||
}
|
||
}
|
||
|
||
function spawnCoin() {
|
||
const r = 10;
|
||
const yTop = world.height * 0.35; // 道具浮在中上区域
|
||
const y = rand(yTop, world.height - world.groundH - 80);
|
||
coins.push({ x: world.width + 60, y, r, color: '#f6c453' });
|
||
}
|
||
|
||
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
|
||
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
|
||
}
|
||
|
||
function circleRectOverlap(cx, cy, r, rx, ry, rw, rh) {
|
||
const closestX = clamp(cx, rx, rx + rw);
|
||
const closestY = clamp(cy, ry, ry + rh);
|
||
const dx = cx - closestX;
|
||
const dy = cy - closestY;
|
||
return (dx * dx + dy * dy) <= r * r;
|
||
}
|
||
|
||
function jump() {
|
||
if (gameOver) return;
|
||
if (player.grounded) {
|
||
player.vy = world.jumpV;
|
||
player.grounded = false;
|
||
}
|
||
}
|
||
|
||
function update(dt) {
|
||
// 难度递增:速度随时间上涨,生成间隔缩短
|
||
elapsed += dt;
|
||
world.speed = world.baseSpeed + elapsed * 22; // 每秒加速
|
||
|
||
obstacleTimer -= dt * 1000;
|
||
coinTimer -= dt * 1000;
|
||
|
||
const minInterval = clamp(1400 - elapsed * 20, 700, 1600); // 障碍间隔(更远)
|
||
const coinInterval = clamp(1200 - elapsed * 25, 500, 1200); // 金币间隔
|
||
|
||
if (obstacleTimer <= 0) {
|
||
spawnObstacle();
|
||
obstacleTimer = rand(minInterval, minInterval * 1.35);
|
||
}
|
||
if (coinTimer <= 0) {
|
||
spawnCoin();
|
||
coinTimer = rand(coinInterval * 0.6, coinInterval);
|
||
}
|
||
|
||
// 玩家物理
|
||
player.vy += world.gravity * dt;
|
||
player.y += player.vy * dt;
|
||
const groundY = world.height - world.groundH - player.h;
|
||
if (player.y >= groundY) {
|
||
player.y = groundY;
|
||
player.vy = 0;
|
||
player.grounded = true;
|
||
}
|
||
|
||
// 移动障碍与金币
|
||
const dx = world.speed * dt;
|
||
for (let i = obstacles.length - 1; i >= 0; i--) {
|
||
const ob = obstacles[i];
|
||
ob.x -= dx;
|
||
if (ob.x + ob.w < 0) obstacles.splice(i, 1);
|
||
}
|
||
for (let i = coins.length - 1; i >= 0; i--) {
|
||
const c = coins[i];
|
||
c.x -= dx;
|
||
if (c.x + c.r < 0) coins.splice(i, 1);
|
||
}
|
||
|
||
// 碰撞检测:障碍
|
||
for (const ob of obstacles) {
|
||
if (rectsOverlap(player.x, player.y, player.w, player.h, ob.x, ob.y, ob.w, ob.h)) {
|
||
endGame();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 拾取金币
|
||
for (let i = coins.length - 1; i >= 0; i--) {
|
||
const c = coins[i];
|
||
if (circleRectOverlap(c.x, c.y, c.r, player.x, player.y, player.w, player.h)) {
|
||
score += 100; // 金币加分
|
||
coins.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
// 距离积分(随速度)
|
||
score += Math.floor(world.speed * dt * 0.2);
|
||
scoreEl.textContent = String(score);
|
||
}
|
||
|
||
function drawGround() {
|
||
const y = world.height - world.groundH;
|
||
// 地面阴影渐变
|
||
const grad = ctx.createLinearGradient(0, y, 0, world.height);
|
||
grad.addColorStop(0, 'rgba(60, 150, 110, 0.35)');
|
||
grad.addColorStop(1, 'rgba(60, 150, 110, 0.05)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, y, world.width, world.groundH);
|
||
|
||
// 地面纹理线
|
||
ctx.strokeStyle = 'rgba(47, 79, 63, 0.25)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, y);
|
||
ctx.lineTo(world.width, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawPlayer() {
|
||
ctx.fillStyle = player.color;
|
||
const r = 8; // 圆角
|
||
const x = player.x, y = player.y, w = player.w, h = player.h;
|
||
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();
|
||
ctx.fill();
|
||
|
||
// 前进指示条
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||
ctx.fillRect(x + 6, y + 10, 6, 12);
|
||
ctx.fillRect(x + 18, y + 10, 6, 12);
|
||
}
|
||
|
||
function drawObstacles() {
|
||
for (const ob of obstacles) {
|
||
// 渐变柱体
|
||
const g = ctx.createLinearGradient(ob.x, ob.y, ob.x, ob.y + ob.h);
|
||
g.addColorStop(0, '#52b985');
|
||
g.addColorStop(1, '#3ea573');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(ob.x, ob.y, ob.w, ob.h);
|
||
// 顶部高亮
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.fillRect(ob.x, ob.y, ob.w, 4);
|
||
}
|
||
}
|
||
|
||
function drawCoins() {
|
||
for (const c of coins) {
|
||
ctx.beginPath();
|
||
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = c.color;
|
||
ctx.fill();
|
||
// 外圈高光
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
function draw(now) {
|
||
// 清屏(CSS负责背景渐变,这里仅清理)
|
||
ctx.clearRect(0, 0, world.width, world.height);
|
||
|
||
drawGround();
|
||
drawPlayer();
|
||
drawObstacles();
|
||
drawCoins();
|
||
|
||
// 速度指示(右上角小提示)
|
||
ctx.fillStyle = 'rgba(47,79,63,0.35)';
|
||
ctx.font = '12px system-ui';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(`速度 ${Math.round(world.speed)}px/s`, world.width - 8, 18);
|
||
}
|
||
|
||
function endGame() {
|
||
running = false;
|
||
gameOver = true;
|
||
finalScoreEl.textContent = String(score);
|
||
overlay.hidden = false;
|
||
}
|
||
|
||
function resetGame() {
|
||
running = true;
|
||
gameOver = false;
|
||
obstacles.length = 0;
|
||
coins.length = 0;
|
||
obstacleTimer = 0;
|
||
coinTimer = rand(400, 900);
|
||
score = 0;
|
||
elapsed = 0;
|
||
resetPlayer();
|
||
overlay.hidden = true;
|
||
lastTime = performance.now();
|
||
}
|
||
|
||
function loop(now) {
|
||
const dt = Math.min(0.033, (now - lastTime) / 1000); // 限制最大步长
|
||
lastTime = now;
|
||
|
||
if (running) {
|
||
update(dt);
|
||
draw(now);
|
||
}
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
// 输入事件
|
||
function onKey(e) {
|
||
if (e.code === 'Space' || e.code === 'ArrowUp') {
|
||
e.preventDefault();
|
||
jump();
|
||
}
|
||
}
|
||
function onPointer() { jump(); }
|
||
|
||
restartBtn.addEventListener('click', () => {
|
||
resetGame();
|
||
});
|
||
overlayRestart.addEventListener('click', () => {
|
||
resetGame();
|
||
});
|
||
|
||
window.addEventListener('keydown', onKey, { passive: false });
|
||
window.addEventListener('mousedown', onPointer);
|
||
window.addEventListener('touchstart', onPointer, { passive: true });
|
||
window.addEventListener('resize', () => {
|
||
resizeCanvas();
|
||
});
|
||
|
||
// 初始化并启动
|
||
resizeCanvas();
|
||
requestAnimationFrame(loop);
|
||
})(); |