497 lines
14 KiB
HTML
497 lines
14 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>Fruit Slicer</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #2d1b4e; 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();
|
|
}
|
|
|
|
// Upbeat fruity melody
|
|
const melody = [
|
|
{n: 523, d: 0.2}, {n: 587, d: 0.2}, {n: 659, d: 0.2}, {n: 698, d: 0.4},
|
|
{n: 659, d: 0.2}, {n: 587, d: 0.2}, {n: 523, d: 0.4},
|
|
{n: 698, d: 0.2}, {n: 784, d: 0.2}, {n: 880, d: 0.2}, {n: 784, d: 0.4},
|
|
{n: 698, d: 0.2}, {n: 659, d: 0.2}, {n: 587, d: 0.4}, {n: 0, d: 0.2}
|
|
];
|
|
|
|
const bass = [
|
|
{n: 131, d: 0.4}, {n: 165, d: 0.4}, {n: 175, d: 0.4}, {n: 165, d: 0.4},
|
|
{n: 147, d: 0.4}, {n: 131, d: 0.4}, {n: 165, d: 0.4}, {n: 196, d: 0.4}
|
|
];
|
|
|
|
let melodyIdx = 0, bassIdx = 0, nextMelody = 0, nextBass = 0;
|
|
|
|
function playTone(freq, dur, type = 'square', vol = 0.1) {
|
|
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 === 'slice') {
|
|
osc.frequency.setValueAtTime(800, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(400, audioCtx.currentTime + 0.1);
|
|
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
|
|
} else if (type === 'bomb') {
|
|
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.3);
|
|
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
|
|
osc.type = 'sawtooth';
|
|
} else if (type === 'miss') {
|
|
osc.frequency.value = 150;
|
|
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);
|
|
}
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + 0.4);
|
|
}
|
|
|
|
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.08);
|
|
nextMelody = now + note.d;
|
|
melodyIdx = (melodyIdx + 1) % melody.length;
|
|
}
|
|
if (now >= nextBass) {
|
|
const note = bass[bassIdx];
|
|
playTone(note.n, note.d, 'triangle', 0.12);
|
|
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;
|
|
|
|
function resize() {
|
|
W = window.innerWidth;
|
|
H = window.innerHeight;
|
|
canvas.width = W;
|
|
canvas.height = H;
|
|
safeTop = H * 0.1;
|
|
safeBottom = H * 0.9;
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
const FRUITS = [
|
|
{ emoji: '🍎', color: '#ef4444', points: 10 },
|
|
{ emoji: '🍊', color: '#f97316', points: 10 },
|
|
{ emoji: '🍋', color: '#eab308', points: 10 },
|
|
{ emoji: '🍇', color: '#8b5cf6', points: 15 },
|
|
{ emoji: '🍉', color: '#22c55e', points: 15 },
|
|
{ emoji: '🍓', color: '#dc2626', points: 20 }
|
|
];
|
|
|
|
let gameRunning = false;
|
|
let gameOver = false;
|
|
let score = 0;
|
|
let lives = 3;
|
|
let combo = 0;
|
|
let spawnTimer = 0;
|
|
let gameTime = 0;
|
|
|
|
let fruits = [];
|
|
let slices = [];
|
|
let particles = [];
|
|
let trail = [];
|
|
|
|
function init() {
|
|
fruits = [];
|
|
slices = [];
|
|
particles = [];
|
|
trail = [];
|
|
score = 0;
|
|
lives = 3;
|
|
combo = 0;
|
|
spawnTimer = 0;
|
|
gameTime = 0;
|
|
gameOver = false;
|
|
gameRunning = true;
|
|
startMusic();
|
|
}
|
|
|
|
function spawnFruit() {
|
|
const isBomb = gameTime > 300 && Math.random() < 0.15; // No bombs early
|
|
const x = 50 + Math.random() * (W - 100);
|
|
const vx = (Math.random() - 0.5) * 4;
|
|
const vy = -12 - Math.random() * 4;
|
|
|
|
if (isBomb) {
|
|
fruits.push({
|
|
x, y: H + 50,
|
|
vx, vy,
|
|
size: 50,
|
|
isBomb: true,
|
|
sliced: false
|
|
});
|
|
} else {
|
|
const type = FRUITS[Math.floor(Math.random() * FRUITS.length)];
|
|
fruits.push({
|
|
x, y: H + 50,
|
|
vx, vy,
|
|
size: 60,
|
|
type,
|
|
sliced: false,
|
|
rotation: 0,
|
|
rotSpeed: (Math.random() - 0.5) * 0.1
|
|
});
|
|
}
|
|
}
|
|
|
|
function addParticles(x, y, color, count = 10) {
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const spd = 2 + Math.random() * 4;
|
|
particles.push({
|
|
x, y,
|
|
dx: Math.cos(angle) * spd,
|
|
dy: Math.sin(angle) * spd,
|
|
life: 30,
|
|
color,
|
|
size: 5 + Math.random() * 5
|
|
});
|
|
}
|
|
}
|
|
|
|
function addSlice(f) {
|
|
const halfSize = f.size / 2;
|
|
slices.push({
|
|
x: f.x - halfSize / 2, y: f.y,
|
|
vx: f.vx - 3, vy: f.vy - 2,
|
|
size: halfSize,
|
|
type: f.type,
|
|
rotation: f.rotation,
|
|
rotSpeed: -0.2
|
|
});
|
|
slices.push({
|
|
x: f.x + halfSize / 2, y: f.y,
|
|
vx: f.vx + 3, vy: f.vy - 2,
|
|
size: halfSize,
|
|
type: f.type,
|
|
rotation: f.rotation,
|
|
rotSpeed: 0.2
|
|
});
|
|
}
|
|
|
|
let isSlicing = false;
|
|
let lastX = 0, lastY = 0;
|
|
|
|
function startSlice(x, y) {
|
|
isSlicing = true;
|
|
lastX = x;
|
|
lastY = y;
|
|
trail = [{ x, y, time: Date.now() }];
|
|
}
|
|
|
|
function moveSlice(x, y) {
|
|
if (!isSlicing || gameOver) return;
|
|
|
|
trail.push({ x, y, time: Date.now() });
|
|
if (trail.length > 20) trail.shift();
|
|
|
|
// Check for fruit hits
|
|
for (let i = fruits.length - 1; i >= 0; i--) {
|
|
const f = fruits[i];
|
|
if (f.sliced) continue;
|
|
|
|
const dx = f.x - x;
|
|
const dy = f.y - y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist < f.size / 2 + 20) { // Generous hitbox
|
|
if (f.isBomb) {
|
|
// Hit bomb!
|
|
lives--;
|
|
f.sliced = true;
|
|
addParticles(f.x, f.y, '#333', 20);
|
|
playSfx('bomb');
|
|
combo = 0;
|
|
if (lives <= 0) {
|
|
gameOver = true;
|
|
gameRunning = false;
|
|
musicPlaying = false;
|
|
}
|
|
} else {
|
|
// Slice fruit!
|
|
f.sliced = true;
|
|
combo++;
|
|
score += f.type.points * Math.min(combo, 5);
|
|
addSlice(f);
|
|
addParticles(f.x, f.y, f.type.color, 15);
|
|
playSfx('slice');
|
|
}
|
|
fruits.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
lastX = x;
|
|
lastY = y;
|
|
}
|
|
|
|
function endSlice() {
|
|
isSlicing = false;
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', e => { initAudio(); if (gameOver) init(); else startSlice(e.clientX, e.clientY); });
|
|
canvas.addEventListener('mousemove', e => moveSlice(e.clientX, e.clientY));
|
|
canvas.addEventListener('mouseup', endSlice);
|
|
|
|
canvas.addEventListener('touchstart', e => {
|
|
e.preventDefault();
|
|
initAudio();
|
|
if (gameOver) { init(); return; }
|
|
startSlice(e.touches[0].clientX, e.touches[0].clientY);
|
|
});
|
|
canvas.addEventListener('touchmove', e => {
|
|
e.preventDefault();
|
|
moveSlice(e.touches[0].clientX, e.touches[0].clientY);
|
|
});
|
|
canvas.addEventListener('touchend', endSlice);
|
|
|
|
document.addEventListener('keydown', e => {
|
|
initAudio();
|
|
if ((e.key === ' ' || e.key === 'Enter') && gameOver) init();
|
|
});
|
|
|
|
function update() {
|
|
if (!gameRunning || gameOver) return;
|
|
|
|
gameTime++;
|
|
|
|
// Spawn fruits
|
|
spawnTimer++;
|
|
const spawnInterval = Math.max(30, 90 - gameTime / 100);
|
|
if (spawnTimer >= spawnInterval) {
|
|
spawnFruit();
|
|
spawnTimer = 0;
|
|
|
|
// Sometimes spawn multiple
|
|
if (Math.random() < 0.3 && gameTime > 200) {
|
|
setTimeout(() => spawnFruit(), 100);
|
|
}
|
|
}
|
|
|
|
// Update fruits
|
|
for (let i = fruits.length - 1; i >= 0; i--) {
|
|
const f = fruits[i];
|
|
f.x += f.vx;
|
|
f.vy += 0.3; // Gravity
|
|
f.y += f.vy;
|
|
if (!f.isBomb) f.rotation += f.rotSpeed;
|
|
|
|
// Check if missed (fell off screen)
|
|
if (f.y > H + 100) {
|
|
if (!f.sliced && !f.isBomb) {
|
|
// Missed a fruit!
|
|
lives--;
|
|
combo = 0;
|
|
playSfx('miss');
|
|
if (lives <= 0) {
|
|
gameOver = true;
|
|
gameRunning = false;
|
|
musicPlaying = false;
|
|
}
|
|
}
|
|
fruits.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Update slices
|
|
for (let i = slices.length - 1; i >= 0; i--) {
|
|
const s = slices[i];
|
|
s.x += s.vx;
|
|
s.vy += 0.4;
|
|
s.y += s.vy;
|
|
s.rotation += s.rotSpeed;
|
|
|
|
if (s.y > H + 100) slices.splice(i, 1);
|
|
}
|
|
|
|
// Update particles
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
const p = particles[i];
|
|
p.x += p.dx;
|
|
p.y += p.dy;
|
|
p.dy += 0.2;
|
|
p.life--;
|
|
if (p.life <= 0) particles.splice(i, 1);
|
|
}
|
|
|
|
// Clean old trail points
|
|
const now = Date.now();
|
|
trail = trail.filter(t => now - t.time < 100);
|
|
|
|
updateMusic();
|
|
}
|
|
|
|
function draw() {
|
|
// Background gradient
|
|
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
|
grad.addColorStop(0, '#2d1b4e');
|
|
grad.addColorStop(1, '#1a0f2e');
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Safe zones
|
|
ctx.fillStyle = '#1a0f2e';
|
|
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, 40);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`Lives: ${'❤️'.repeat(lives)}`, W - 20, 40);
|
|
|
|
if (combo > 1) {
|
|
ctx.textAlign = 'center';
|
|
ctx.fillStyle = '#ffd700';
|
|
ctx.fillText(`${combo}x COMBO!`, W / 2, 40);
|
|
}
|
|
|
|
// Particles
|
|
for (const p of particles) {
|
|
ctx.fillStyle = p.color;
|
|
ctx.globalAlpha = p.life / 30;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Slices
|
|
for (const s of slices) {
|
|
ctx.save();
|
|
ctx.translate(s.x, s.y);
|
|
ctx.rotate(s.rotation);
|
|
ctx.font = `${s.size}px Arial`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(s.type.emoji, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Fruits
|
|
for (const f of fruits) {
|
|
ctx.save();
|
|
ctx.translate(f.x, f.y);
|
|
if (!f.isBomb) ctx.rotate(f.rotation);
|
|
ctx.font = `${f.size}px Arial`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(f.isBomb ? '💣' : f.type.emoji, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Slice trail
|
|
if (trail.length > 1) {
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 4;
|
|
ctx.lineCap = 'round';
|
|
ctx.beginPath();
|
|
ctx.moveTo(trail[0].x, trail[0].y);
|
|
for (let i = 1; i < trail.length; i++) {
|
|
ctx.lineTo(trail[i].x, trail[i].y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Glow
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.lineWidth = 12;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Controls hint
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '14px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Swipe to slice fruits! Avoid bombs!', W / 2, safeBottom + 25);
|
|
|
|
if (gameOver) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.85)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 40px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('GAME OVER', W / 2, H / 2 - 50);
|
|
ctx.font = '28px Arial';
|
|
ctx.fillText(`Final Score: ${score}`, W / 2, H / 2);
|
|
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.beginPath();
|
|
ctx.roundRect(W / 2 - 100, H / 2 + 30, 200, 50, 10);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 18px Arial';
|
|
ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 62);
|
|
}
|
|
}
|
|
|
|
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>
|