console
<!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>追大便 🏃♂️💩</title>
<style>
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#0a0c16;font-family:system-ui,-apple-system,"PingFang SC",sans-serif;color:#fff;user-select:none}
#c{display:block;width:100%;height:100%;touch-action:none}
/* 顶部计分板 */
#hud{position:fixed;top:0;left:0;right:0;padding:14px 16px;display:flex;justify-content:space-between;gap:10px;pointer-events:none;font-weight:800;font-size:15px;z-index:5}
.pill{background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.16);padding:8px 15px;border-radius:999px;backdrop-filter:blur(10px);text-shadow:0 1px 6px rgba(0,0,0,.5)}
/* 左下角虚拟十字键 */
#dpad {
position: fixed;
bottom: max(30px, env(safe-area-inset-bottom));
left: 30px;
width: 160px;
height: 160px;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 5px;
z-index: 10;
}
.btn {
background: rgba(255,255,255,0.15);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
backdrop-filter: blur(5px);
touch-action: none;
}
.btn:active { background: rgba(255,255,255,0.4); }
.btn.up { grid-column: 2; grid-row: 1; }
.btn.left { grid-column: 1; grid-row: 2; }
.btn.right { grid-column: 3; grid-row: 2; }
.btn.down { grid-column: 2; grid-row: 3; }
/* 右下角控制按钮 */
#btns{position:fixed;bottom:max(30px,env(safe-area-inset-bottom));right:20px;display:flex;flex-direction:column;gap:12px;z-index:10}
button{font:inherit;font-weight:800;border:0;border-radius:999px;padding:12px 20px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.3);backdrop-filter:blur(10px);cursor:pointer}
button:active{transform:scale(.95)}
/* 遮罩层 */
#overlay{position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;background:rgba(8,10,20,.88);backdrop-filter:blur(10px);text-align:center;padding:28px}
#overlay.hidden{display:none}
#ovTitle{font-size:clamp(30px,8vw,54px);letter-spacing:3px;background:linear-gradient(90deg,#7fe3ff,#ffd166,#ff7fa8);-webkit-background-clip:text;background-clip:text;color:transparent}
#ovText{opacity:.82;line-height:1.9;font-size:15px;max-width:36ch}
#fileInput{display:none}
/* 上传照片按钮高亮 */
.highlight-btn { background: #ffd166; color: #111; border: none; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div class="pill">得分 <span id="score">0</span></div>
<div class="pill">⏱ <span id="time">60</span>s</div>
</div>
<!-- 左下角虚拟按键 -->
<div id="dpad">
<div class="btn up" data-key="arrowup">▲</div>
<div class="btn left" data-key="arrowleft">◀</div>
<div class="btn right" data-key="arrowright">▶</div>
<div class="btn down" data-key="arrowdown">▼</div>
</div>
<!-- 右下角功能按钮 -->
<div id="btns">
<button id="pick" class="highlight-btn">📷 上传照片</button>
<button id="restart">↻ 重新开始</button>
</div>
<input id="fileInput" type="file" accept="image/*">
<div id="overlay">
<h1 id="ovTitle">追 大 便</h1>
<p id="ovText">
使用屏幕左下角按键控制移动<br>
<b>点击“上传照片”</b>选择你的帅照<br>
去追那只乱跑的 💩,60 秒看你能抓到几只!
</p>
<button id="ovBtn" class="highlight-btn">开始游戏</button>
</div>
<script>
(() => {
'use strict';
const TAU = Math.PI * 2;
const clamp = (v,a,b) => v < a ? a : (v > b ? b : v);
const rand = (a,b) => a + Math.random() * (b - a);
const normAngle = a => { a = (a + Math.PI) % TAU; if (a < 0) a += TAU; return a - Math.PI; };
const cvs = document.getElementById('c');
const ctx = cvs.getContext('2d');
let W = window.innerWidth, H = window.innerHeight;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
cvs.width = Math.round(W * dpr);
cvs.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
player.x = clamp(player.x, player.r, W - player.r);
player.y = clamp(player.y, player.r, H - player.r);
poop.x = clamp(poop.x, poop.r, W - poop.r);
poop.y = clamp(poop.y, poop.r, H - poop.r);
}
const player = { x: 0, y: 0, vx: 0, vy: 0, r: 35 }; // 调大了照片尺寸
const poop = { x: 0, y: 0, angle: 0, targetAngle: 0, turnTimer: 0, r: 26, seed: Math.random() * 100 };
const state = {
running: false, score: 0, time: 60, particles: [], texts: [], shake: 0,
best: Number(localStorage.getItem('poopChaseBest') || 0)
};
let playerImg = null;
// 键盘输入
const keys = Object.create(null);
addEventListener('keydown', e => { keys[e.key.toLowerCase()] = true; });
addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
// 虚拟按键绑定
const dpadBtns = document.querySelectorAll('.btn');
dpadBtns.forEach(btn => {
const key = btn.dataset.key;
const press = (e) => {
e.preventDefault();
keys[key] = true;
};
const release = (e) => {
e.preventDefault();
keys[key] = false;
};
btn.addEventListener('touchstart', press, {passive: false});
btn.addEventListener('touchend', release, {passive: false});
btn.addEventListener('touchcancel', release, {passive: false});
btn.addEventListener('mousedown', press);
btn.addEventListener('mouseup', release);
btn.addEventListener('mouseleave', release);
});
// 音效
let ac = null;
function beep(freq = 880, dur = 0.1, type = 'triangle', vol = 0.12) {
try {
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
if (ac.state === 'suspended') ac.resume();
const o = ac.createOscillator(), g = ac.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, ac.currentTime);
g.gain.setValueAtTime(vol, ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
o.connect(g); g.connect(ac.destination);
o.start(); o.stop(ac.currentTime + dur);
} catch(_) {}
}
function spawnPoop() {
let tries = 0;
do {
poop.x = rand(poop.r + 20, W - poop.r - 20);
poop.y = rand(poop.r + 70, H - poop.r - 90);
tries++;
} while (Math.hypot(poop.x - player.x, poop.y - player.y) < Math.min(W, H) * 0.35 && tries < 60);
poop.angle = rand(0, TAU);
poop.targetAngle = poop.angle;
poop.turnTimer = rand(0.2, 0.5);
}
function resetGame() {
state.score = 0;
state.time = 60;
state.particles.length = 0;
state.texts.length = 0;
state.shake = 0;
player.x = W / 2;
player.y = H * 0.7;
player.vx = player.vy = 0;
spawnPoop();
updateHUD(true);
}
const $score = document.getElementById('score');
const $time = document.getElementById('time');
let lastScoreShown = -1, lastTimeShown = -1;
function updateHUD(force) {
if (force || state.score !== lastScoreShown) { $score.textContent = state.score; lastScoreShown = state.score; }
const t = Math.ceil(state.time);
if (force || t !== lastTimeShown) { $time.textContent = t; lastTimeShown = t; }
}
function updatePlayer(dt) {
const ACCEL = 3400;
const DAMP = 8;
const MAXV = 400;
let ax = 0, ay = 0;
if (keys['arrowleft'] || keys['a']) ax -= 1;
if (keys['arrowright'] || keys['d']) ax += 1;
if (keys['arrowup'] || keys['w']) ay -= 1;
if (keys['arrowdown'] || keys['s']) ay += 1;
if (ax || ay) { const l = Math.hypot(ax, ay); ax /= l; ay /= l; }
player.vx += ax * ACCEL * dt;
player.vy += ay * ACCEL * dt;
const damp = Math.exp(-DAMP * dt);
player.vx *= damp; player.vy *= damp;
const sp = Math.hypot(player.vx, player.vy);
if (sp > MAXV) { player.vx = player.vx / sp * MAXV; player.vy = player.vy / sp * MAXV; }
player.x += player.vx * dt;
player.y += player.vy * dt;
if (player.x < player.r) { player.x = player.r; player.vx = Math.abs(player.vx) * 0.4; }
if (player.x > W - player.r) { player.x = W - player.r; player.vx = -Math.abs(player.vx) * 0.4; }
if (player.y < player.r + 50) { player.y = player.r + 50; player.vy = Math.abs(player.vy) * 0.4; } // 避开顶部HUD
if (player.y > H - player.r) { player.y = H - player.r; player.vy = -Math.abs(player.vy) * 0.4; }
}
function updatePoop(dt, t) {
poop.turnTimer -= dt;
if (poop.turnTimer <= 0) {
poop.targetAngle = rand(0, TAU);
poop.turnTimer = rand(0.2, 0.62);
}
const diff = normAngle(poop.targetAngle - poop.angle);
poop.angle += diff * Math.min(1, 8 * dt);
let speed = 235 + 85 * Math.sin(t * 2.1 + poop.seed) + 45 * Math.sin(t * 5.3 + poop.seed * 2.7);
speed = clamp(speed, 150, 340);
poop.x += Math.cos(poop.angle) * speed * dt;
poop.y += Math.sin(poop.angle) * speed * dt;
const m = poop.r * 1.05;
let bounced = false;
if (poop.x < m) { poop.x = m; poop.angle = Math.PI - poop.angle; bounced = true; }
if (poop.x > W - m) { poop.x = W - m; poop.angle = Math.PI - poop.angle; bounced = true; }
if (poop.y < m + 50) { poop.y = m + 50; poop.angle = -poop.angle; bounced = true; }
if (poop.y > H - m) { poop.y = H - m; poop.angle = -poop.angle; bounced = true; }
if (bounced) {
poop.angle += rand(-0.45, 0.45);
poop.targetAngle = poop.angle;
poop.turnTimer = rand(0.25, 0.6);
}
}
function burst(x, y) {
for (let i = 0; i < 20; i++) {
const a = Math.random() * TAU, s = rand(90, 360);
state.particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: rand(0.4, 0.95), max: 0.95, r: rand(2, 6), hue: rand(15, 65) });
}
}
function onCatch() {
state.score++;
state.shake = 9;
beep(760 + Math.min(state.score, 12) * 55, 0.09, 'triangle', 0.13);
burst(poop.x, poop.y);
state.texts.push({ x: poop.x, y: poop.y, life: 0.85, max: 0.85, text: '+1' });
spawnPoop();
}
function updateParticles(dt) {
for (let i = state.particles.length - 1; i >= 0; i--) {
const p = state.particles[i];
p.life -= dt;
if (p.life <= 0) { state.particles.splice(i, 1); continue; }
p.x += p.vx * dt; p.y += p.vy * dt;
const f = Math.exp(-3.2 * dt); p.vx *= f; p.vy *= f;
}
for (let i = state.texts.length - 1; i >= 0; i--) {
const x = state.texts[i];
x.life -= dt; x.y -= 60 * dt;
if (x.life <= 0) state.texts.splice(i, 1);
}
if (state.shake > 0) state.shake = Math.max(0, state.shake - 60 * dt);
}
function update(dt, t) {
updatePlayer(dt);
updatePoop(dt, t);
if (Math.hypot(poop.x - player.x, poop.y - player.y) < player.r + poop.r * 0.85) { onCatch(); }
state.time -= dt;
if (state.time <= 0) { state.time = 0; endGame(); }
updateHUD(false);
}
function drawBackground() {
const g = ctx.createRadialGradient(W / 2, H * 0.35, 40, W / 2, H * 0.55, Math.max(W, H) * 0.85);
g.addColorStop(0, '#1c2244'); g.addColorStop(0.55, '#12162b'); g.addColorStop(1, '#07080f');
ctx.fillStyle = g; ctx.fillRect(-40, -40, W + 80, H + 80);
const step = 64;
ctx.fillStyle = 'rgba(255,255,255,0.055)';
for (let x = step / 2; x < W; x += step) for (let y = step / 2; y < H; y += step) ctx.fillRect(x - 1, y - 1, 2, 2);
}
function drawPlayer(t) {
const r = player.r;
ctx.save();
ctx.translate(player.x, player.y);
const glow = ctx.createRadialGradient(0, 0, r * 0.5, 0, 0, r * 2.1);
glow.addColorStop(0, 'rgba(120,220,255,0.30)');
glow.addColorStop(1, 'rgba(120,220,255,0)');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(0, 0, r * 2.1, 0, TAU); ctx.fill();
if (playerImg) {
// 绘制照片
ctx.save();
ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.clip();
// 保持比例缩放填满圆形
const s = Math.max((r * 2) / playerImg.width, (r * 2) / playerImg.height);
const w = playerImg.width * s, h = playerImg.height * s;
ctx.drawImage(playerImg, -w / 2, -h / 2, w, h);
ctx.restore();
// 照片外圈
ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU);
ctx.lineWidth = 4; ctx.strokeStyle = '#7fe3ff'; ctx.stroke();
} else {
// 默认黄色笑脸占位
ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU);
ctx.fillStyle = '#ffd166'; ctx.fill();
ctx.lineWidth = 3.5; ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.stroke();
ctx.fillStyle = '#2b2b3a';
ctx.beginPath(); ctx.arc(-r * 0.32, -r * 0.16, r * 0.13, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc( r * 0.32, -r * 0.16, r * 0.13, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(0, r * 0.10, r * 0.30, 0.18 * Math.PI, 0.82 * Math.PI);
ctx.lineWidth = Math.max(2, r * 0.10); ctx.strokeStyle = '#2b2b3a'; ctx.lineCap = 'round'; ctx.stroke();
}
ctx.restore();
}
function drawPoop(t) {
ctx.save();
ctx.translate(poop.x, poop.y);
ctx.save();
ctx.globalAlpha = 0.30;
ctx.beginPath(); ctx.ellipse(0, poop.r * 1.15, poop.r * 0.85, poop.r * 0.28, 0, 0, TAU);
ctx.fillStyle = '#000'; ctx.fill();
ctx.restore();
ctx.rotate(Math.sin(t * 5 + poop.seed) * 0.12);
const size = poop.r * 2.35;
ctx.font = `${size}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('💩', 0, 0);
ctx.restore();
}
function drawEffects() {
for (const p of state.particles) {
const a = clamp(p.life / p.max, 0, 1);
ctx.globalAlpha = a;
ctx.fillStyle = `hsl(${p.hue} 100% 62%)`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * a, 0, TAU); ctx.fill();
}
ctx.globalAlpha = 1;
for (const x of state.texts) {
const a = clamp(x.life / x.max, 0, 1);
ctx.globalAlpha = a;
ctx.font = '800 26px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffd166'; ctx.strokeStyle = 'rgba(0,0,0,.45)';
ctx.lineWidth = 4; ctx.strokeText(x.text, x.x, x.y); ctx.fillText(x.text, x.x, x.y);
}
ctx.globalAlpha = 1;
}
function draw(t) {
ctx.save();
if (state.shake > 0.2) ctx.translate(rand(-state.shake, state.shake), rand(-state.shake, state.shake));
drawBackground();
drawPoop(t);
drawPlayer(t);
drawEffects();
ctx.restore();
}
let last = 0;
function loop(ts) {
if (!last) last = ts;
let dt = (ts - last) / 1000; last = ts;
dt = Math.min(dt, 0.05);
const t = ts / 1000;
if (state.running) update(dt, t);
updateParticles(dt);
draw(t);
requestAnimationFrame(loop);
}
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovText = document.getElementById('ovText');
const ovBtn = document.getElementById('ovBtn');
function showOverlay(title, html, btn) {
ovTitle.textContent = title;
ovText.innerHTML = html;
ovBtn.textContent = btn;
overlay.classList.remove('hidden');
}
function startGame() {
overlay.classList.add('hidden');
resetGame();
state.running = true;
beep(520, 0.08, 'sine', 0.1);
setTimeout(() => beep(780, 0.12, 'sine', 0.1), 110);
}
function endGame() {
state.running = false;
if (state.score > state.best) {
state.best = state.score;
localStorage.setItem('poopChaseBest', String(state.best));
}
beep(420, 0.18, 'sawtooth', 0.09);
setTimeout(() => beep(300, 0.28, 'sawtooth', 0.08), 170);
showOverlay('时间到!', `你抓到了 <b style="color:#ffd166;font-size:1.5em">${state.score}</b> 只 💩<br>历史最高:<b>${state.best}</b>`, '再来一局');
}
ovBtn.addEventListener('click', startGame);
document.getElementById('restart').addEventListener('click', () => { startGame(); });
// 照片上传逻辑
const fileInput = document.getElementById('fileInput');
document.getElementById('pick').addEventListener('click', () => fileInput.click());
function loadPhoto(file) {
if (!file || !file.type.startsWith('image/')) return;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
playerImg = img;
URL.revokeObjectURL(url);
beep(980, 0.08, 'sine', 0.09);
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
fileInput.addEventListener('change', e => loadPhoto(e.target.files[0]));
resize();
resetGame();
state.running = false;
addEventListener('resize', resize);
requestAnimationFrame(loop);
})();
</script>
</body>
</html>