488 lines
14 KiB
HTML
488 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>Bridge Builder</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #87ceeb; 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();
|
|
}
|
|
|
|
// Construction melody
|
|
const melody = [
|
|
{n: 392, d: 0.3}, {n: 440, d: 0.3}, {n: 494, d: 0.3}, {n: 523, d: 0.6},
|
|
{n: 494, d: 0.3}, {n: 440, d: 0.3}, {n: 392, d: 0.6},
|
|
{n: 349, d: 0.3}, {n: 392, d: 0.3}, {n: 440, d: 0.3}, {n: 494, d: 0.6},
|
|
{n: 523, d: 0.3}, {n: 587, d: 0.3}, {n: 523, d: 0.6}, {n: 0, d: 0.3}
|
|
];
|
|
|
|
const bass = [
|
|
{n: 98, d: 0.6}, {n: 110, d: 0.6}, {n: 131, d: 0.6}, {n: 110, d: 0.6},
|
|
{n: 87, d: 0.6}, {n: 98, d: 0.6}, {n: 110, d: 0.6}, {n: 131, d: 0.6}
|
|
];
|
|
|
|
let melodyIdx = 0, bassIdx = 0, nextMelody = 0, nextBass = 0;
|
|
|
|
function playTone(freq, dur, type = 'square', vol = 0.08) {
|
|
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 === 'build') {
|
|
osc.frequency.value = 500;
|
|
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
|
|
} else if (type === 'release') {
|
|
osc.frequency.setValueAtTime(400, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(200, audioCtx.currentTime + 0.15);
|
|
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);
|
|
} else if (type === 'success') {
|
|
osc.frequency.setValueAtTime(523, audioCtx.currentTime);
|
|
osc.frequency.setValueAtTime(659, audioCtx.currentTime + 0.15);
|
|
osc.frequency.setValueAtTime(784, audioCtx.currentTime + 0.3);
|
|
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.45);
|
|
} else if (type === 'fail') {
|
|
osc.frequency.setValueAtTime(300, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(100, audioCtx.currentTime + 0.3);
|
|
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
|
|
osc.type = 'sawtooth';
|
|
}
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + 0.5);
|
|
}
|
|
|
|
function updateMusic() {
|
|
if (!audioCtx || !musicEnabled || !musicPlaying) return;
|
|
const now = audioCtx.currentTime;
|
|
if (now >= nextMelody) {
|
|
const note = melody[melodyIdx];
|
|
playTone(note.n, note.d, 'triangle', 0.06);
|
|
nextMelody = now + note.d;
|
|
melodyIdx = (melodyIdx + 1) % melody.length;
|
|
}
|
|
if (now >= nextBass) {
|
|
const note = bass[bassIdx];
|
|
playTone(note.n, note.d, 'triangle', 0.1);
|
|
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);
|
|
|
|
let gameRunning = false;
|
|
let gameOver = false;
|
|
let levelComplete = false;
|
|
let score = 0;
|
|
let level = 1;
|
|
|
|
// Game state
|
|
let building = false;
|
|
let bridgeAngle = 0;
|
|
let bridgeLength = 0;
|
|
let testingBridge = false;
|
|
let car = null;
|
|
|
|
let leftCliff = { x: 0, y: 0, w: 0, h: 0 };
|
|
let rightCliff = { x: 0, y: 0, w: 0, h: 0 };
|
|
let gap = 0;
|
|
|
|
function generateLevel() {
|
|
const availH = safeBottom - safeTop;
|
|
const cliffY = safeTop + availH * 0.6;
|
|
const cliffH = safeBottom - cliffY + 50;
|
|
|
|
// Gap increases with level
|
|
gap = 100 + level * 30 + Math.random() * 50;
|
|
|
|
const leftW = 80 + Math.random() * 60;
|
|
const rightW = 80 + Math.random() * 60;
|
|
|
|
leftCliff = { x: 0, y: cliffY, w: W / 2 - gap / 2, h: cliffH };
|
|
rightCliff = { x: W / 2 + gap / 2, y: cliffY, w: W - (W / 2 + gap / 2), h: cliffH };
|
|
|
|
bridgeAngle = 0;
|
|
bridgeLength = 0;
|
|
building = false;
|
|
testingBridge = false;
|
|
car = null;
|
|
levelComplete = false;
|
|
}
|
|
|
|
function init() {
|
|
score = 0;
|
|
level = 1;
|
|
gameOver = false;
|
|
gameRunning = true;
|
|
generateLevel();
|
|
startMusic();
|
|
}
|
|
|
|
function startBuilding() {
|
|
if (testingBridge || levelComplete || gameOver) return;
|
|
building = true;
|
|
bridgeLength = 0;
|
|
playSfx('build');
|
|
}
|
|
|
|
function stopBuilding() {
|
|
if (!building) return;
|
|
building = false;
|
|
testingBridge = true;
|
|
playSfx('release');
|
|
|
|
// Start the car
|
|
car = {
|
|
x: leftCliff.w - 40,
|
|
y: leftCliff.y - 20,
|
|
w: 40,
|
|
h: 20,
|
|
vx: 2,
|
|
vy: 0,
|
|
onBridge: false,
|
|
finished: false
|
|
};
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
initAudio();
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (gameOver) init();
|
|
else if (levelComplete) {
|
|
level++;
|
|
generateLevel();
|
|
} else if (!building && !testingBridge) {
|
|
startBuilding();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keyup', e => {
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
if (building) stopBuilding();
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mousedown', e => {
|
|
initAudio();
|
|
if (gameOver) init();
|
|
else if (levelComplete) { level++; generateLevel(); }
|
|
else if (!building && !testingBridge) startBuilding();
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', () => {
|
|
if (building) stopBuilding();
|
|
});
|
|
|
|
canvas.addEventListener('touchstart', e => {
|
|
e.preventDefault();
|
|
initAudio();
|
|
if (gameOver) init();
|
|
else if (levelComplete) { level++; generateLevel(); }
|
|
else if (!building && !testingBridge) startBuilding();
|
|
});
|
|
|
|
canvas.addEventListener('touchend', e => {
|
|
e.preventDefault();
|
|
if (building) stopBuilding();
|
|
});
|
|
|
|
function update() {
|
|
if (!gameRunning || gameOver) return;
|
|
|
|
// Build bridge
|
|
if (building) {
|
|
bridgeLength += 2;
|
|
// Slight random wobble while building
|
|
bridgeAngle = Math.sin(bridgeLength * 0.1) * 0.02;
|
|
}
|
|
|
|
// Test bridge with car
|
|
if (testingBridge && car) {
|
|
const bridgeStartX = leftCliff.w;
|
|
const bridgeStartY = leftCliff.y;
|
|
const bridgeEndX = bridgeStartX + bridgeLength;
|
|
const bridgeEndY = bridgeStartY + Math.tan(bridgeAngle) * bridgeLength;
|
|
|
|
// Car physics
|
|
car.vy += 0.3; // Gravity
|
|
car.x += car.vx;
|
|
car.y += car.vy;
|
|
|
|
// Check if car is on cliff
|
|
if (car.x + car.w > 0 && car.x < leftCliff.w && car.y + car.h >= leftCliff.y) {
|
|
car.y = leftCliff.y - car.h;
|
|
car.vy = 0;
|
|
}
|
|
|
|
// Check if car is on bridge
|
|
if (car.x + car.w > bridgeStartX && car.x < bridgeEndX) {
|
|
const t = (car.x + car.w / 2 - bridgeStartX) / bridgeLength;
|
|
const bridgeY = bridgeStartY + t * (bridgeEndY - bridgeStartY);
|
|
|
|
if (car.y + car.h >= bridgeY - 5) {
|
|
car.y = bridgeY - car.h;
|
|
car.vy = 0;
|
|
car.onBridge = true;
|
|
}
|
|
}
|
|
|
|
// Check if car reached other side
|
|
if (car.x >= rightCliff.x && car.y + car.h >= rightCliff.y - 10) {
|
|
car.y = rightCliff.y - car.h;
|
|
car.vy = 0;
|
|
car.vx = 0;
|
|
car.finished = true;
|
|
|
|
// Success!
|
|
levelComplete = true;
|
|
const bonus = Math.max(0, 100 - Math.abs(bridgeLength - gap));
|
|
score += 100 + bonus + level * 10;
|
|
playSfx('success');
|
|
}
|
|
|
|
// Check if car fell
|
|
if (car.y > H) {
|
|
// Failed!
|
|
gameOver = true;
|
|
gameRunning = false;
|
|
musicPlaying = false;
|
|
playSfx('fail');
|
|
}
|
|
}
|
|
|
|
updateMusic();
|
|
}
|
|
|
|
function draw() {
|
|
// Sky gradient
|
|
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
|
|
skyGrad.addColorStop(0, '#87ceeb');
|
|
skyGrad.addColorStop(1, '#e0f7fa');
|
|
ctx.fillStyle = skyGrad;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Safe zones
|
|
ctx.fillStyle = '#6bb9f0';
|
|
ctx.fillRect(0, 0, W, safeTop);
|
|
ctx.fillStyle = '#5d4037';
|
|
ctx.fillRect(0, safeBottom, W, H - safeBottom);
|
|
|
|
// Clouds
|
|
ctx.fillStyle = '#fff';
|
|
for (let i = 0; i < 3; i++) {
|
|
const x = 100 + i * 200;
|
|
const y = safeTop + 40 + i * 20;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 25, 0, Math.PI * 2);
|
|
ctx.arc(x + 30, y - 10, 30, 0, Math.PI * 2);
|
|
ctx.arc(x + 60, y, 25, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Header
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = 'bold 22px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`Score: ${score}`, 20, 35);
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(`Level ${level}`, W / 2, 35);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`Gap: ${Math.round(gap)}px`, W - 20, 35);
|
|
|
|
// Water/void
|
|
ctx.fillStyle = '#1e88e5';
|
|
ctx.fillRect(leftCliff.w, leftCliff.y + 30, rightCliff.x - leftCliff.w, safeBottom - leftCliff.y);
|
|
|
|
// Left cliff
|
|
ctx.fillStyle = '#5d4037';
|
|
ctx.fillRect(leftCliff.x, leftCliff.y, leftCliff.w, leftCliff.h);
|
|
ctx.fillStyle = '#8bc34a';
|
|
ctx.fillRect(leftCliff.x, leftCliff.y - 15, leftCliff.w, 20);
|
|
|
|
// Right cliff
|
|
ctx.fillStyle = '#5d4037';
|
|
ctx.fillRect(rightCliff.x, rightCliff.y, rightCliff.w, rightCliff.h);
|
|
ctx.fillStyle = '#8bc34a';
|
|
ctx.fillRect(rightCliff.x, rightCliff.y - 15, rightCliff.w, 20);
|
|
|
|
// Bridge
|
|
if (bridgeLength > 0) {
|
|
const bridgeStartX = leftCliff.w;
|
|
const bridgeStartY = leftCliff.y;
|
|
|
|
ctx.save();
|
|
ctx.translate(bridgeStartX, bridgeStartY);
|
|
ctx.rotate(bridgeAngle);
|
|
|
|
ctx.fillStyle = '#8B4513';
|
|
ctx.fillRect(0, -8, bridgeLength, 8);
|
|
|
|
// Bridge planks
|
|
ctx.fillStyle = '#654321';
|
|
for (let i = 0; i < bridgeLength; i += 15) {
|
|
ctx.fillRect(i, -8, 3, 8);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Building indicator
|
|
if (!testingBridge && !levelComplete && !gameOver) {
|
|
ctx.fillStyle = building ? '#4caf50' : '#ff9800';
|
|
ctx.font = 'bold 18px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(
|
|
building ? `Building: ${bridgeLength}px (Release to test!)` : 'Hold to build bridge!',
|
|
W / 2, leftCliff.y - 50
|
|
);
|
|
|
|
// Target marker
|
|
ctx.strokeStyle = '#4caf50';
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([5, 5]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(leftCliff.w, leftCliff.y - 20);
|
|
ctx.lineTo(rightCliff.x, rightCliff.y - 20);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// Car
|
|
if (car) {
|
|
ctx.fillStyle = '#e74c3c';
|
|
ctx.fillRect(car.x, car.y, car.w, car.h);
|
|
// Wheels
|
|
ctx.fillStyle = '#333';
|
|
ctx.beginPath();
|
|
ctx.arc(car.x + 8, car.y + car.h, 6, 0, Math.PI * 2);
|
|
ctx.arc(car.x + car.w - 8, car.y + car.h, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// Window
|
|
ctx.fillStyle = '#87ceeb';
|
|
ctx.fillRect(car.x + 10, car.y + 3, car.w - 15, car.h - 8);
|
|
}
|
|
|
|
// Controls hint
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '14px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Hold Space/Tap to build bridge, release to test!', W / 2, safeBottom + 25);
|
|
|
|
if (levelComplete) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#4caf50';
|
|
ctx.font = 'bold 36px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('LEVEL COMPLETE!', W / 2, H / 2 - 50);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '24px Arial';
|
|
ctx.fillText(`Bridge: ${bridgeLength}px | Gap: ${Math.round(gap)}px`, W / 2, H / 2);
|
|
|
|
ctx.fillStyle = '#4caf50';
|
|
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('NEXT LEVEL', W / 2, H / 2 + 62);
|
|
}
|
|
|
|
if (gameOver) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.85)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = '#e74c3c';
|
|
ctx.font = 'bold 36px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('BRIDGE TOO SHORT!', W / 2, H / 2 - 50);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '24px Arial';
|
|
ctx.fillText(`Final Score: ${score}`, W / 2, H / 2);
|
|
|
|
ctx.fillStyle = '#2196f3';
|
|
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 RESTART', 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 && !levelComplete) { gameRunning = true; startMusic(); } };
|
|
window.setMusicEnabled = (v) => { musicEnabled = v; if (!v) musicPlaying = false; };
|
|
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
|
|
|
init();
|
|
gameLoop();
|
|
</script>
|
|
</body>
|
|
</html>
|