388 lines
11 KiB
HTML
388 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>Whack-a-Mole</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #8B4513; touch-action: none; font-family: Arial, sans-serif; }
|
|
canvas { display: block; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="game"></canvas>
|
|
<script>
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
let audioCtx = null;
|
|
let musicEnabled = true;
|
|
let sfxEnabled = true;
|
|
let musicPlaying = false;
|
|
|
|
function initAudio() {
|
|
if (!audioCtx) audioCtx = new AudioContext();
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
}
|
|
|
|
// Playful carnival melody
|
|
const melody = [
|
|
{n: 523, d: 0.2}, {n: 659, d: 0.2}, {n: 784, d: 0.2}, {n: 659, d: 0.2},
|
|
{n: 523, d: 0.2}, {n: 659, d: 0.2}, {n: 784, d: 0.4},
|
|
{n: 698, d: 0.2}, {n: 659, d: 0.2}, {n: 587, d: 0.2}, {n: 523, d: 0.4},
|
|
{n: 587, d: 0.2}, {n: 659, d: 0.2}, {n: 698, d: 0.2}, {n: 784, d: 0.2},
|
|
{n: 880, d: 0.4}, {n: 784, d: 0.2}, {n: 698, d: 0.2}, {n: 659, d: 0.4}, {n: 0, d: 0.2}
|
|
];
|
|
|
|
const bass = [
|
|
{n: 131, d: 0.4}, {n: 165, d: 0.4}, {n: 196, d: 0.4}, {n: 165, d: 0.4},
|
|
{n: 175, d: 0.4}, {n: 147, d: 0.4}, {n: 131, d: 0.4}, {n: 165, d: 0.4},
|
|
{n: 196, d: 0.4}, {n: 220, d: 0.4}, {n: 196, d: 0.4}, {n: 165, d: 0.4}
|
|
];
|
|
|
|
let melodyIdx = 0, bassIdx = 0, nextMelody = 0, nextBass = 0;
|
|
|
|
function playTone(freq, dur, type = 'square', vol = 0.12) {
|
|
if (!audioCtx || !musicEnabled || freq === 0) return;
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + dur * 0.9);
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + dur);
|
|
}
|
|
|
|
function playSfx(type) {
|
|
if (!audioCtx || !sfxEnabled) return;
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
if (type === 'whack') {
|
|
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + 0.1);
|
|
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
|
|
osc.type = 'sawtooth';
|
|
} else if (type === 'pop') {
|
|
osc.frequency.setValueAtTime(400, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.1);
|
|
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
|
|
osc.type = 'square';
|
|
} else if (type === 'miss') {
|
|
osc.frequency.value = 150;
|
|
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);
|
|
osc.type = 'triangle';
|
|
}
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + 0.3);
|
|
}
|
|
|
|
function updateMusic() {
|
|
if (!audioCtx || !musicEnabled || !musicPlaying) return;
|
|
const now = audioCtx.currentTime;
|
|
if (now >= nextMelody) {
|
|
const note = melody[melodyIdx];
|
|
playTone(note.n, note.d, 'square', 0.1);
|
|
nextMelody = now + note.d;
|
|
melodyIdx = (melodyIdx + 1) % melody.length;
|
|
}
|
|
if (now >= nextBass) {
|
|
const note = bass[bassIdx];
|
|
playTone(note.n, note.d, 'triangle', 0.18);
|
|
nextBass = now + note.d;
|
|
bassIdx = (bassIdx + 1) % bass.length;
|
|
}
|
|
}
|
|
|
|
function startMusic() {
|
|
if (!musicPlaying && musicEnabled) {
|
|
initAudio();
|
|
musicPlaying = true;
|
|
nextMelody = audioCtx.currentTime;
|
|
nextBass = audioCtx.currentTime;
|
|
}
|
|
}
|
|
|
|
const canvas = document.getElementById('game');
|
|
const ctx = canvas.getContext('2d');
|
|
let W, H, safeTop, safeBottom, gameArea;
|
|
|
|
function resize() {
|
|
W = window.innerWidth;
|
|
H = window.innerHeight;
|
|
canvas.width = W;
|
|
canvas.height = H;
|
|
safeTop = H * 0.1;
|
|
safeBottom = H * 0.9;
|
|
gameArea = { x: 20, y: safeTop + 20, w: W - 40, h: safeBottom - safeTop - 40 };
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
let gameRunning = false;
|
|
let gameOver = false;
|
|
let score = 0;
|
|
let timeLeft = 60;
|
|
let moleUpTime = 2500;
|
|
let lastMoleTime = 0;
|
|
let currentMole = -1;
|
|
let moleState = 'down'; // down, up, hit
|
|
let moleY = 0;
|
|
let holes = [];
|
|
|
|
function initHoles() {
|
|
holes = [];
|
|
const cols = 3, rows = 3;
|
|
const holeW = Math.min(gameArea.w / cols - 20, 100);
|
|
const holeH = holeW * 0.6;
|
|
const gapX = (gameArea.w - cols * holeW) / (cols + 1);
|
|
const gapY = (gameArea.h - rows * holeH) / (rows + 1);
|
|
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
holes.push({
|
|
x: gameArea.x + gapX + c * (holeW + gapX),
|
|
y: gameArea.y + gapY + r * (holeH + gapY),
|
|
w: holeW,
|
|
h: holeH
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
score = 0;
|
|
timeLeft = 60;
|
|
moleUpTime = 2500;
|
|
currentMole = -1;
|
|
moleState = 'down';
|
|
moleY = 0;
|
|
initHoles();
|
|
gameOver = false;
|
|
gameRunning = true;
|
|
lastMoleTime = Date.now();
|
|
startMusic();
|
|
}
|
|
|
|
function showMole() {
|
|
if (moleState !== 'down') return;
|
|
currentMole = Math.floor(Math.random() * 9);
|
|
moleState = 'rising';
|
|
moleY = 0;
|
|
playSfx('pop');
|
|
}
|
|
|
|
function handleClick(x, y) {
|
|
if (gameOver) { init(); return; }
|
|
if (!gameRunning) return;
|
|
|
|
for (let i = 0; i < holes.length; i++) {
|
|
const h = holes[i];
|
|
if (x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h) {
|
|
if (i === currentMole && (moleState === 'up' || moleState === 'rising')) {
|
|
moleState = 'hit';
|
|
score += 10;
|
|
playSfx('whack');
|
|
setTimeout(() => {
|
|
moleState = 'down';
|
|
currentMole = -1;
|
|
}, 300);
|
|
} else {
|
|
playSfx('miss');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
initAudio();
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
if (gameOver) init();
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('click', e => {
|
|
initAudio();
|
|
handleClick(e.clientX, e.clientY);
|
|
});
|
|
|
|
canvas.addEventListener('touchstart', e => {
|
|
e.preventDefault();
|
|
initAudio();
|
|
handleClick(e.touches[0].clientX, e.touches[0].clientY);
|
|
});
|
|
|
|
let lastTime = Date.now();
|
|
let timerAccum = 0;
|
|
|
|
function update() {
|
|
const now = Date.now();
|
|
const dt = now - lastTime;
|
|
lastTime = now;
|
|
|
|
if (!gameRunning || gameOver) return;
|
|
|
|
// Timer
|
|
timerAccum += dt;
|
|
if (timerAccum >= 1000) {
|
|
timeLeft--;
|
|
timerAccum = 0;
|
|
if (timeLeft <= 0) {
|
|
gameOver = true;
|
|
gameRunning = false;
|
|
musicPlaying = false;
|
|
}
|
|
}
|
|
|
|
// Mole animation
|
|
if (moleState === 'rising') {
|
|
moleY += 5;
|
|
if (moleY >= 100) {
|
|
moleY = 100;
|
|
moleState = 'up';
|
|
lastMoleTime = now;
|
|
}
|
|
} else if (moleState === 'up') {
|
|
if (now - lastMoleTime > moleUpTime) {
|
|
moleState = 'falling';
|
|
}
|
|
} else if (moleState === 'falling') {
|
|
moleY -= 5;
|
|
if (moleY <= 0) {
|
|
moleY = 0;
|
|
moleState = 'down';
|
|
currentMole = -1;
|
|
}
|
|
} else if (moleState === 'down') {
|
|
if (now - lastMoleTime > 500) {
|
|
showMole();
|
|
}
|
|
}
|
|
|
|
// Gradually speed up
|
|
if (moleUpTime > 1000) moleUpTime -= 0.5;
|
|
|
|
updateMusic();
|
|
}
|
|
|
|
function draw() {
|
|
// Background
|
|
ctx.fillStyle = '#5D4037';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Safe zones
|
|
ctx.fillStyle = '#4E342E';
|
|
ctx.fillRect(0, 0, W, safeTop);
|
|
ctx.fillRect(0, safeBottom, W, H - safeBottom);
|
|
|
|
// Header
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 24px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`Score: ${score}`, 20, 35);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`Time: ${timeLeft}s`, W - 20, 35);
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('WHACK-A-MOLE', W / 2, 35);
|
|
|
|
// Grass background
|
|
ctx.fillStyle = '#4CAF50';
|
|
ctx.fillRect(gameArea.x, gameArea.y, gameArea.w, gameArea.h);
|
|
|
|
// Draw holes
|
|
holes.forEach((h, i) => {
|
|
// Hole shadow
|
|
ctx.fillStyle = '#2E1B0F';
|
|
ctx.beginPath();
|
|
ctx.ellipse(h.x + h.w / 2, h.y + h.h * 0.7, h.w / 2, h.h / 3, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Mole
|
|
if (i === currentMole && moleState !== 'down') {
|
|
const moleH = h.h * 0.8 * (moleY / 100);
|
|
const moleW = h.w * 0.7;
|
|
const moleX = h.x + (h.w - moleW) / 2;
|
|
const moleBottom = h.y + h.h * 0.7;
|
|
const moleTop = moleBottom - moleH;
|
|
|
|
// Mole body
|
|
ctx.fillStyle = moleState === 'hit' ? '#ff6b6b' : '#8D6E63';
|
|
ctx.beginPath();
|
|
ctx.ellipse(moleX + moleW / 2, moleTop + moleH / 2, moleW / 2, moleH / 2, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Eyes
|
|
if (moleY > 50) {
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath();
|
|
ctx.arc(moleX + moleW * 0.35, moleTop + moleH * 0.35, 8, 0, Math.PI * 2);
|
|
ctx.arc(moleX + moleW * 0.65, moleTop + moleH * 0.35, 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = moleState === 'hit' ? '#ff0000' : '#000';
|
|
ctx.beginPath();
|
|
ctx.arc(moleX + moleW * 0.35, moleTop + moleH * 0.35, 4, 0, Math.PI * 2);
|
|
ctx.arc(moleX + moleW * 0.65, moleTop + moleH * 0.35, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Nose
|
|
ctx.fillStyle = '#E91E63';
|
|
ctx.beginPath();
|
|
ctx.arc(moleX + moleW / 2, moleTop + moleH * 0.55, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Hole front (covers mole bottom)
|
|
ctx.fillStyle = '#3E2723';
|
|
ctx.beginPath();
|
|
ctx.ellipse(h.x + h.w / 2, h.y + h.h * 0.7, h.w / 2, h.h / 4, 0, 0, Math.PI);
|
|
ctx.fill();
|
|
});
|
|
|
|
if (gameOver) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.8)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 40px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText("TIME'S UP!", W / 2, H / 2 - 40);
|
|
ctx.font = '28px Arial';
|
|
ctx.fillText(`Final Score: ${score}`, W / 2, H / 2 + 10);
|
|
|
|
ctx.fillStyle = '#4CAF50';
|
|
ctx.beginPath();
|
|
ctx.roundRect(W / 2 - 100, H / 2 + 40, 200, 50, 10);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 18px Arial';
|
|
ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 72);
|
|
}
|
|
}
|
|
|
|
function gameLoop() {
|
|
update();
|
|
draw();
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
window.gameScore = 0;
|
|
Object.defineProperty(window, 'gameScore', { get: () => score });
|
|
window.pauseGame = () => { gameRunning = false; musicPlaying = false; };
|
|
window.resumeGame = () => { if (!gameOver) { gameRunning = true; startMusic(); } };
|
|
window.setMusicEnabled = (v) => { musicEnabled = v; if (!v) musicPlaying = false; };
|
|
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
|
|
|
init();
|
|
gameLoop();
|
|
</script>
|
|
</body>
|
|
</html>
|