|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<title>Breakout Arcade</title> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<style> |
|
html, body { |
|
height: 100%; |
|
margin: 0; |
|
} |
|
|
|
body { |
|
background: black; |
|
color: white; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
overflow-x: auto; |
|
font-family: monospace; |
|
} |
|
|
|
#game-shell { |
|
position: relative; |
|
width: clamp(400px, 95vw, 1000px); |
|
} |
|
|
|
#game { |
|
display: block; |
|
touch-action: none; |
|
user-select: none; |
|
width: 100%; |
|
height: 560px; |
|
cursor: none; |
|
} |
|
|
|
.overlay { |
|
position: absolute; |
|
inset: 0; |
|
z-index: 10; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
background: rgba(0, 0, 0, 0.92); |
|
padding: 24px; |
|
box-sizing: border-box; |
|
} |
|
|
|
.overlay.hidden { |
|
display: none; |
|
} |
|
|
|
.panel { |
|
width: min(680px, 92%); |
|
border: 3px solid white; |
|
padding: 22px 26px; |
|
box-sizing: border-box; |
|
background: #050505; |
|
text-align: left; |
|
box-shadow: 0 0 0 4px #222; |
|
} |
|
|
|
.panel h1 { |
|
margin: 0 0 16px; |
|
text-align: center; |
|
font-size: 30px; |
|
letter-spacing: 3px; |
|
} |
|
|
|
.panel h2 { |
|
margin: 18px 0 8px; |
|
font-size: 16px; |
|
color: cyan; |
|
} |
|
|
|
.panel p { |
|
margin: 6px 0; |
|
line-height: 1.45; |
|
font-size: 14px; |
|
} |
|
|
|
.score-guide { |
|
display: grid; |
|
grid-template-columns: repeat(4, minmax(80px, 1fr)); |
|
gap: 8px; |
|
margin: 10px 0 4px; |
|
} |
|
|
|
.score-item { |
|
border: 1px solid #666; |
|
padding: 8px; |
|
text-align: center; |
|
font-weight: bold; |
|
} |
|
|
|
.swatch { |
|
display: block; |
|
height: 12px; |
|
margin-bottom: 6px; |
|
} |
|
|
|
#start-game { |
|
display: block; |
|
margin: 20px auto 0; |
|
padding: 10px 24px; |
|
border: 2px solid white; |
|
background: black; |
|
color: white; |
|
font: bold 16px monospace; |
|
cursor: pointer; |
|
} |
|
|
|
#start-game:hover { |
|
background: white; |
|
color: black; |
|
} |
|
|
|
#phone-block .panel { |
|
text-align: center; |
|
} |
|
|
|
@media (max-width: 599px) { |
|
#game-shell { |
|
width: 100vw; |
|
min-width: 0; |
|
} |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id="game-shell"> |
|
<canvas width="400" height="560" id="game"></canvas> |
|
|
|
<div id="instructions" class="overlay"> |
|
<div class="panel"> |
|
<h1>BREAKOUT</h1> |
|
|
|
<h2>BRICK VALUES</h2> |
|
<div class="score-guide"> |
|
<div class="score-item"><span class="swatch" style="background:red"></span>7 POINTS</div> |
|
<div class="score-item"><span class="swatch" style="background:orange"></span>5 POINTS</div> |
|
<div class="score-item"><span class="swatch" style="background:green"></span>3 POINTS</div> |
|
<div class="score-item"><span class="swatch" style="background:yellow"></span>1 POINT</div> |
|
</div> |
|
<p>Later levels use different colors and patterns, but keep the same 7 / 5 / 3 / 1 point tiers.</p> |
|
|
|
<h2>CONTROLS</h2> |
|
<p><strong>Keyboard:</strong> Left / Right Arrow keys move the paddle and waiting ball. Space serves the ball upward.</p> |
|
<p id="testing-control-note" style="display:none;"><strong>Testing:</strong> Press S to skip directly to the next level.</p> |
|
<p><strong>Mouse:</strong> Click the game to lock the mouse in. Move left/right to control the paddle. Press ESC to release the mouse.</p> |
|
<p><strong>Tablet:</strong> Touch and drag left/right to position the paddle and waiting ball. Tap to serve.</p> |
|
|
|
<h2>BONUSES</h2> |
|
<p>Every <strong>75 score points</strong>, an extra ball is added to the field (maximum 2 active balls).</p> |
|
<p>Clear an entire level without losing a life and you earn <strong>+1 life</strong>.</p> |
|
<p>Each higher level raises the minimum ball speed. If you lose a life, the next ball resets to that level's minimum speed instead of staying at the faster speed you reached.</p> |
|
|
|
<button id="start-game" type="button">START GAME</button> |
|
</div> |
|
</div> |
|
|
|
<div id="phone-block" class="overlay hidden"> |
|
<div class="panel"> |
|
<h1>SCREEN TOO SMALL</h1> |
|
<p>This version of Breakout is designed for desktop/laptop computers and tablets.</p> |
|
<p>Please use a screen at least 600 pixels wide.</p> |
|
</div> |
|
</div> |
|
</div> |
|
<script> |
|
const canvas = document.getElementById('game'); |
|
const instructions = document.getElementById('instructions'); |
|
const phoneBlock = document.getElementById('phone-block'); |
|
const startGameButton = document.getElementById('start-game'); |
|
const context = canvas.getContext('2d'); |
|
const testingControlNote = document.getElementById('testing-control-note'); |
|
|
|
const MIN_GAME_WIDTH = 400; |
|
const MAX_GAME_WIDTH = 1000; |
|
const HUD_HEIGHT = 64; |
|
const GAME_HEIGHT = 560 + HUD_HEIGHT; |
|
const BRICK_COLUMNS = 14; |
|
const MAX_BALLS = 2; |
|
const STARTING_LIVES = 5; |
|
const START_BALL_SPEED = 1.35; |
|
const BALL_SPEED_INCREASE = 0.015; |
|
const BALL_SPEED_INCREASE_PER_LEVEL = 0.002; |
|
const MAX_BALL_SPEED_INCREASE = 0.035; |
|
const MAX_BALL_SPEED = 5.00; |
|
const MULTIBALL_SCORE_INTERVAL = 75; |
|
const PADDLE_BASE_WIDTH = 70; |
|
const PADDLE_SHRINK_START_LEVEL = 11; |
|
const PADDLE_SCALE_STEP = 0.25; |
|
const PADDLE_MIN_SCALE = 0.50; |
|
const LEVEL_MIN_SPEED_INCREASE = 0.20; |
|
const MIN_SUPPORTED_VIEWPORT = 600; |
|
const MAX_PADDLE_BOUNCE_ANGLE = 65 * Math.PI / 180; |
|
|
|
// Logical ball speeds are tuned like the original frame-based game. |
|
// This multiplier converts them to a faster arcade pace while the fixed |
|
// timestep keeps that pace identical across Chrome, Edge, Firefox, Brave, |
|
// and different monitor refresh rates. |
|
const BALL_MOVEMENT_SCALE = 2.25; |
|
|
|
// Run gameplay at a fixed 60 updates per second. Rendering can happen at |
|
// whatever rate the browser/display supports without changing game speed. |
|
const FIXED_TIMESTEP = 1000 / 60; |
|
const MAX_CATCHUP_STEPS = 30; |
|
|
|
// TESTING ONLY: |
|
// 1 = Press S during gameplay to skip directly to the next level. |
|
// 0 = Disable level skipping for normal/public gameplay. |
|
const TESTING_SYSTEM = 0; |
|
let lastFrameTime = 0; |
|
let accumulator = 0; |
|
|
|
const brickGap = 2; |
|
const brickHeight = 12; |
|
const wallSize = 12; |
|
const BRICK_TOP = wallSize + HUD_HEIGHT; |
|
const BALL_START_Y = 260 + HUD_HEIGHT; |
|
let brickWidth = 25; |
|
|
|
// Level 1 always keeps the classic four solid color bands. |
|
const level1 = [ |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'RRRRRRRRRRRRRR', |
|
'RRRRRRRRRRRRRR', |
|
'OOOOOOOOOOOOOO', |
|
'OOOOOOOOOOOOOO', |
|
'GGGGGGGGGGGGGG', |
|
'GGGGGGGGGGGGGG', |
|
'YYYYYYYYYYYYYY', |
|
'YYYYYYYYYYYYYY' |
|
]; |
|
|
|
// After Level 1, these patterns repeat in order while their palettes rotate. |
|
const laterPatterns = [ |
|
[ |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'ABABABABABABAB', |
|
'BABABABABABABA', |
|
'CDCDCDCDCDCDCD', |
|
'DCDCDCDCDCDCDC', |
|
'ABABABABABABAB', |
|
'BABABABABABABA', |
|
'CDCDCDCDCDCDCD', |
|
'DCDCDCDCDCDCDC' |
|
], |
|
[ |
|
'.........AAAAAAAAAA.........', |
|
'.......AAAAAAAAAAAAAA.......', |
|
'.....AAAAAAAAAAAAAAAAAA.....', |
|
'.....AAAAAAAAAAAAAAAAAA.....', |
|
'....AAAA....CCCC....AAAA....', |
|
'....AADD....CCCC....DDAA....', |
|
'....AABBd...CCCC...dBBAA....', |
|
'....AABBDDccc..cccDDBBAA....', |
|
'....AABBDDcc....ccDDBBAA....', |
|
'.....DDBBdcc.cc.ccdBBDD.....', |
|
'.....DDBBddcccccccdBBDD.....', |
|
'......DDBB..c..c..BBDD......', |
|
'.......DDBB......BBDD.......', |
|
'........DDDDDDDDDDDD........' |
|
], |
|
[ |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'......AA......', |
|
'.....ABBA.....', |
|
'....ABCCBA....', |
|
'...ABCDDCBA...', |
|
'..ABCDDDDCBA..', |
|
'.ABCDDDDDDCBA.', |
|
'ABCDDDDDDDDCBA', |
|
'.ABCCCCCCCCBA.', |
|
'..AABBBBBBAA..' |
|
], |
|
[ |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'AA..BB..CC..DD', |
|
'AA..BB..CC..DD', |
|
'..CC..DD..AA..', |
|
'..CC..DD..AA..', |
|
'BB..AA..DD..CC', |
|
'BB..AA..DD..CC', |
|
'..DD..CC..BB..', |
|
'..DD..CC..BB..', |
|
'AABBCCDDAABBCC' |
|
], |
|
[ |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'..............', |
|
'A............A', |
|
'AA..........AA', |
|
'BBB........BBB', |
|
'BBBB......BBBB', |
|
'CCCCC....CCCCC', |
|
'CCCCCC..CCCCCC', |
|
'DDDDDDDDDDDDDD', |
|
'.DDDDDDDDDDDD.', |
|
'..AAAAAAAAAA..' |
|
], |
|
[ |
|
'............................', |
|
'............................', |
|
'............................', |
|
'............................', |
|
'............................', |
|
'AABBCCDDa..........aDDCCBBAA', |
|
'.AABBCCDDb........bDDCCBBAA.', |
|
'..AABBCCDDc......cDDCCBBAA..', |
|
'...AABBCCDDd....dDDCCBBAA...', |
|
'....AABBCCDDa..aDDCCBBAA....', |
|
'.....AABBCCDDbbDDCCBBAA.....', |
|
'......AABBCCDDDDCCBBAA......', |
|
'.......aABBCCDDCCBBAa.......', |
|
'........aABBCCDDBBAa........' |
|
] |
|
]; |
|
|
|
const palettes = [ |
|
{ A: '#ff3b30', B: '#ff9500', C: '#34c759', D: '#ffd60a' }, |
|
{ A: '#ff2d55', B: '#af52de', C: '#5ac8fa', D: '#30d158' }, |
|
{ A: '#00ffff', B: '#007aff', C: '#bf5af2', D: '#ff375f' }, |
|
{ A: '#ffffff', B: '#64d2ff', C: '#ffd60a', D: '#ff9f0a' }, |
|
{ A: '#30d158', B: '#00c7be', C: '#0a84ff', D: '#5e5ce6' } |
|
]; |
|
|
|
const classicColors = { |
|
R: 'red', |
|
O: 'orange', |
|
G: 'green', |
|
Y: 'yellow' |
|
}; |
|
|
|
const classicPoints = { R: 7, O: 5, G: 3, Y: 1 }; |
|
const laterPoints = { A: 7, B: 5, C: 3, D: 1 }; |
|
|
|
const bricks = []; |
|
const paddle = { |
|
x: 0, |
|
y: 480, |
|
width: PADDLE_BASE_WIDTH, |
|
height: brickHeight, |
|
dx: 0 |
|
}; |
|
const balls = []; |
|
|
|
let score = 0; |
|
let multiballProgress = 0; |
|
let lives = STARTING_LIVES; |
|
let level = 1; |
|
let gameOver = false; |
|
let audioContext = null; |
|
let currentSpeed = START_BALL_SPEED; |
|
let lostLifeThisLevel = false; |
|
let levelClearedPending = false; |
|
let levelBannerUntil = 0; |
|
let bonusBannerUntil = 0; |
|
let gameStarted = false; |
|
let screenSupported = true; |
|
let paddleScale = 1.0; |
|
|
|
function ensureAudio() { |
|
if (!audioContext) { |
|
const AudioCtx = window.AudioContext || window.webkitAudioContext; |
|
if (AudioCtx) audioContext = new AudioCtx(); |
|
} |
|
if (audioContext && audioContext.state === 'suspended') audioContext.resume(); |
|
} |
|
|
|
function playTone(frequency, duration = 0.05, volume = 0.05, type = 'square', slideTo = null) { |
|
if (!audioContext) return; |
|
|
|
const now = audioContext.currentTime; |
|
const oscillator = audioContext.createOscillator(); |
|
const gain = audioContext.createGain(); |
|
|
|
oscillator.type = type; |
|
oscillator.frequency.setValueAtTime(frequency, now); |
|
if (slideTo !== null) { |
|
oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, slideTo), now + duration); |
|
} |
|
|
|
gain.gain.setValueAtTime(volume, now); |
|
gain.gain.exponentialRampToValueAtTime(0.001, now + duration); |
|
oscillator.connect(gain); |
|
gain.connect(audioContext.destination); |
|
oscillator.start(now); |
|
oscillator.stop(now + duration); |
|
} |
|
|
|
function playBrickSound(pointValue) { |
|
const tones = { 7: 420, 5: 360, 3: 300, 1: 240 }; |
|
playTone(tones[pointValue] || 300, 0.045, 0.045, 'square'); |
|
} |
|
function playWallSound() { playTone(150, 0.025, 0.025, 'square'); } |
|
function playPaddleSound() { playTone(190, 0.04, 0.035, 'square'); } |
|
function playLifeLostSound() { playTone(220, 0.22, 0.055, 'sawtooth', 70); } |
|
function playMultiballSound() { |
|
playTone(440, 0.06, 0.04, 'square'); |
|
setTimeout(() => playTone(660, 0.08, 0.04, 'square'), 55); |
|
} |
|
function playExtraLifeSound() { |
|
playTone(520, 0.07, 0.04, 'square'); |
|
setTimeout(() => playTone(700, 0.07, 0.04, 'square'), 70); |
|
setTimeout(() => playTone(880, 0.10, 0.04, 'square'), 140); |
|
} |
|
function playLevelClearSound() { |
|
playTone(330, 0.06, 0.04, 'square'); |
|
setTimeout(() => playTone(440, 0.06, 0.04, 'square'), 55); |
|
setTimeout(() => playTone(660, 0.10, 0.04, 'square'), 110); |
|
} |
|
function playGameOverSound() { |
|
playTone(180, 0.18, 0.06, 'square', 120); |
|
setTimeout(() => playTone(120, 0.28, 0.06, 'square', 55), 150); |
|
} |
|
|
|
function getMinimumSpeedForLevel(levelNumber) { |
|
return Math.min(MAX_BALL_SPEED, START_BALL_SPEED + ((levelNumber - 1) * LEVEL_MIN_SPEED_INCREASE)); |
|
} |
|
|
|
function updateScreenSupport() { |
|
screenSupported = window.innerWidth >= MIN_SUPPORTED_VIEWPORT; |
|
phoneBlock.classList.toggle('hidden', screenSupported); |
|
|
|
if (!screenSupported) { |
|
instructions.classList.add('hidden'); |
|
if (document.pointerLockElement === canvas) document.exitPointerLock?.(); |
|
} else if (!gameStarted) { |
|
instructions.classList.remove('hidden'); |
|
} |
|
} |
|
|
|
function getLevelDefinition(levelNumber) { |
|
if (levelNumber === 1) { |
|
return { |
|
pattern: level1, |
|
colors: classicColors, |
|
points: classicPoints, |
|
halfGrid: false |
|
}; |
|
} |
|
|
|
const patternIndex = (levelNumber - 2) % laterPatterns.length; |
|
const paletteIndex = Math.floor((levelNumber - 2) / laterPatterns.length) % palettes.length; |
|
|
|
return { |
|
pattern: laterPatterns[patternIndex], |
|
colors: palettes[paletteIndex], |
|
points: laterPoints, |
|
halfGrid: laterPatterns[patternIndex].some(row => row.length > BRICK_COLUMNS) |
|
}; |
|
} |
|
|
|
function buildLevel(levelNumber) { |
|
bricks.length = 0; |
|
const definition = getLevelDefinition(levelNumber); |
|
|
|
if (!definition.halfGrid) { |
|
// Standard 14-column map. Each character is one full-width brick position. |
|
for (let row = 0; row < definition.pattern.length; row++) { |
|
const rowData = definition.pattern[row]; |
|
for (let col = 0; col < rowData.length; col++) { |
|
const code = rowData[col]; |
|
if (code === '.') continue; |
|
|
|
bricks.push({ |
|
row, |
|
col, |
|
halfCol: null, |
|
x: wallSize + (brickWidth + brickGap) * col, |
|
y: BRICK_TOP + (brickHeight + brickGap) * row, |
|
color: definition.colors[code], |
|
pointValue: definition.points[code] || 1, |
|
width: brickWidth, |
|
height: brickHeight |
|
}); |
|
} |
|
} |
|
return; |
|
} |
|
|
|
// Half-brick map: rows use 28 half-width cells. |
|
// Two matching uppercase characters (RR, AA, etc.) form one normal brick. |
|
// A lowercase character (r, a, etc.) creates one half-width brick. |
|
// A period is one empty half-cell, so patterns can offset bricks by half a brick. |
|
const halfCellWidth = (canvas.width - (wallSize * 2)) / (BRICK_COLUMNS * 2); |
|
|
|
for (let row = 0; row < definition.pattern.length; row++) { |
|
const rowData = definition.pattern[row]; |
|
let halfCol = 0; |
|
|
|
while (halfCol < rowData.length) { |
|
const rawCode = rowData[halfCol]; |
|
if (rawCode === '.') { |
|
halfCol++; |
|
continue; |
|
} |
|
|
|
const code = rawCode.toUpperCase(); |
|
const isForcedHalf = rawCode !== code; |
|
const hasMatchingPair = !isForcedHalf && rowData[halfCol + 1] === rawCode; |
|
const cellsWide = hasMatchingPair ? 2 : 1; |
|
const gapInset = brickGap / 2; |
|
const x = wallSize + (halfCellWidth * halfCol) + gapInset; |
|
const width = Math.max(2, (halfCellWidth * cellsWide) - brickGap); |
|
|
|
bricks.push({ |
|
row, |
|
col: null, |
|
halfCol, |
|
cellsWide, |
|
x, |
|
y: BRICK_TOP + (brickHeight + brickGap) * row, |
|
color: definition.colors[code], |
|
pointValue: definition.points[code] || 1, |
|
width, |
|
height: brickHeight |
|
}); |
|
|
|
halfCol += cellsWide; |
|
} |
|
} |
|
} |
|
|
|
function setPaddleScale(newScale) { |
|
const oldCenter = paddle.x + paddle.width / 2; |
|
paddleScale = Math.max(PADDLE_MIN_SCALE, Math.min(1.0, newScale)); |
|
paddle.width = PADDLE_BASE_WIDTH * paddleScale; |
|
paddle.x = oldCenter - paddle.width / 2; |
|
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width)); |
|
} |
|
|
|
function restorePaddleAfterMiss() { |
|
setPaddleScale(paddleScale + PADDLE_SCALE_STEP); |
|
} |
|
|
|
function shrinkPaddleAfterCleanClear(completedLevel) { |
|
if (completedLevel < PADDLE_SHRINK_START_LEVEL - 1) return; |
|
setPaddleScale(paddleScale - PADDLE_SCALE_STEP); |
|
} |
|
|
|
function createBall(x, y, speed = currentSpeed, dx = 0, dy = 0) { |
|
return { x, y, width: 5, height: 5, speed, dx, dy }; |
|
} |
|
|
|
function resetToSingleBall(speed = currentSpeed) { |
|
balls.length = 0; |
|
|
|
const ballWidth = 5; |
|
const ballHeight = 5; |
|
const ballX = paddle.x + (paddle.width / 2) - (ballWidth / 2); |
|
const ballY = paddle.y - ballHeight; |
|
|
|
balls.push(createBall(ballX, ballY, speed, 0, 0)); |
|
} |
|
|
|
function launchBall(ball) { |
|
const direction = Math.random() < 0.5 ? -1 : 1; |
|
const angle = (25 + Math.random() * 20) * Math.PI / 180; |
|
|
|
ball.dx = direction * ball.speed * Math.sin(angle); |
|
ball.dy = -Math.abs(ball.speed * Math.cos(angle)); |
|
} |
|
|
|
function positionWaitingBallsOnPaddle() { |
|
balls.forEach(ball => { |
|
if (ball.dx === 0 && ball.dy === 0) { |
|
ball.x = paddle.x + (paddle.width / 2) - (ball.width / 2); |
|
ball.y = paddle.y - ball.height; |
|
} |
|
}); |
|
} |
|
|
|
function spawnSecondBall(sourceBall) { |
|
if (balls.length >= MAX_BALLS) return; |
|
|
|
const newBall = createBall(sourceBall.x, sourceBall.y, sourceBall.speed); |
|
const sourceDirection = sourceBall.dx === 0 ? 1 : Math.sign(sourceBall.dx); |
|
const angle = (30 + Math.random() * 25) * Math.PI / 180; |
|
newBall.dx = -sourceDirection * newBall.speed * Math.sin(angle); |
|
newBall.dy = -Math.abs(newBall.speed * Math.cos(angle)); |
|
balls.push(newBall); |
|
playMultiballSound(); |
|
} |
|
|
|
function resizeGame() { |
|
const oldWidth = canvas.width || MIN_GAME_WIDTH; |
|
const newWidth = Math.max(MIN_GAME_WIDTH, Math.min(MAX_GAME_WIDTH, Math.floor(window.innerWidth * 0.95))); |
|
const scaleX = newWidth / oldWidth; |
|
|
|
canvas.width = newWidth; |
|
canvas.height = GAME_HEIGHT; |
|
|
|
paddle.x *= scaleX; |
|
balls.forEach(ball => ball.x *= scaleX); |
|
|
|
const usableWidth = canvas.width - (wallSize * 2); |
|
brickWidth = (usableWidth - (brickGap * (BRICK_COLUMNS - 1))) / BRICK_COLUMNS; |
|
|
|
const halfCellWidth = usableWidth / (BRICK_COLUMNS * 2); |
|
bricks.forEach(brick => { |
|
if (brick.halfCol !== null && brick.halfCol !== undefined) { |
|
const gapInset = brickGap / 2; |
|
brick.x = wallSize + (halfCellWidth * brick.halfCol) + gapInset; |
|
brick.width = Math.max(2, (halfCellWidth * (brick.cellsWide || 1)) - brickGap); |
|
} else { |
|
brick.x = wallSize + (brickWidth + brickGap) * brick.col; |
|
brick.width = brickWidth; |
|
} |
|
}); |
|
|
|
paddle.y = canvas.height - 20; |
|
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width)); |
|
|
|
positionWaitingBallsOnPaddle(); |
|
|
|
balls.forEach(ball => { |
|
ball.x = Math.max(wallSize, Math.min(ball.x, canvas.width - wallSize - ball.width)); |
|
}); |
|
} |
|
|
|
function collides(obj1, obj2) { |
|
return obj1.x < obj2.x + obj2.width && |
|
obj1.x + obj1.width > obj2.x && |
|
obj1.y < obj2.y + obj2.height && |
|
obj1.y + obj1.height > obj2.y; |
|
} |
|
|
|
function getBallSpeedIncreaseForLevel(levelNumber) { |
|
return Math.min( |
|
MAX_BALL_SPEED_INCREASE, |
|
BALL_SPEED_INCREASE + ((levelNumber - 1) * BALL_SPEED_INCREASE_PER_LEVEL) |
|
); |
|
} |
|
|
|
function increaseBallSpeed(ball) { |
|
const oldSpeed = ball.speed; |
|
const speedIncrease = getBallSpeedIncreaseForLevel(level); |
|
const newSpeed = Math.min(MAX_BALL_SPEED, oldSpeed + speedIncrease); |
|
if (newSpeed === oldSpeed) return; |
|
|
|
ball.speed = newSpeed; |
|
currentSpeed = Math.max(currentSpeed, newSpeed); |
|
|
|
const magnitude = Math.hypot(ball.dx, ball.dy); |
|
if (magnitude > 0) { |
|
const scale = newSpeed / magnitude; |
|
ball.dx *= scale; |
|
ball.dy *= scale; |
|
} |
|
} |
|
|
|
function bounceOffPaddle(ball) { |
|
const ballCenter = ball.x + ball.width / 2; |
|
const paddleCenter = paddle.x + paddle.width / 2; |
|
let hitPosition = (ballCenter - paddleCenter) / (paddle.width / 2); |
|
hitPosition = Math.max(-1, Math.min(1, hitPosition)); |
|
|
|
const bounceAngle = hitPosition * MAX_PADDLE_BOUNCE_ANGLE; |
|
ball.dx = ball.speed * Math.sin(bounceAngle); |
|
ball.dy = -Math.abs(ball.speed * Math.cos(bounceAngle)); |
|
ball.y = paddle.y - ball.height; |
|
} |
|
|
|
function handleBall(ball) { |
|
// Split fast movement into smaller steps so the ball cannot tunnel through |
|
// several closely stacked bricks before a collision is resolved. |
|
const totalMoveX = ball.dx * BALL_MOVEMENT_SCALE; |
|
const totalMoveY = ball.dy * BALL_MOVEMENT_SCALE; |
|
const maxMove = Math.max(Math.abs(totalMoveX), Math.abs(totalMoveY)); |
|
const steps = Math.max(1, Math.ceil(maxMove / 2)); |
|
const stepX = totalMoveX / steps; |
|
const stepY = totalMoveY / steps; |
|
let brickHitThisTick = false; |
|
|
|
for (let step = 0; step < steps; step++) { |
|
const previousX = ball.x; |
|
const previousY = ball.y; |
|
|
|
ball.x += stepX; |
|
ball.y += stepY; |
|
|
|
if (ball.x < wallSize) { |
|
ball.x = wallSize; |
|
ball.dx = Math.abs(ball.dx); |
|
playWallSound(); |
|
} else if (ball.x + ball.width > canvas.width - wallSize) { |
|
ball.x = canvas.width - wallSize - ball.width; |
|
ball.dx = -Math.abs(ball.dx); |
|
playWallSound(); |
|
} |
|
|
|
if (ball.y < wallSize) { |
|
ball.y = wallSize; |
|
ball.dy = Math.abs(ball.dy); |
|
playWallSound(); |
|
} |
|
|
|
if (ball.dy > 0 && collides(ball, paddle)) { |
|
bounceOffPaddle(ball); |
|
playPaddleSound(); |
|
break; |
|
} |
|
|
|
if (brickHitThisTick) continue; |
|
|
|
for (let i = 0; i < bricks.length; i++) { |
|
const brick = bricks[i]; |
|
if (!collides(ball, brick)) continue; |
|
|
|
// Work out which face was crossed using the ball's position immediately |
|
// before this small movement step, then place it back outside the brick. |
|
const cameFromBelow = previousY >= brick.y + brick.height; |
|
const cameFromAbove = previousY + ball.height <= brick.y; |
|
const cameFromRight = previousX >= brick.x + brick.width; |
|
const cameFromLeft = previousX + ball.width <= brick.x; |
|
|
|
bricks.splice(i, 1); |
|
score += brick.pointValue; |
|
multiballProgress += brick.pointValue; |
|
playBrickSound(brick.pointValue); |
|
|
|
if (cameFromBelow) { |
|
ball.y = brick.y + brick.height; |
|
ball.dy = Math.abs(ball.dy); |
|
} else if (cameFromAbove) { |
|
ball.y = brick.y - ball.height; |
|
ball.dy = -Math.abs(ball.dy); |
|
} else if (cameFromRight) { |
|
ball.x = brick.x + brick.width; |
|
ball.dx = Math.abs(ball.dx); |
|
} else if (cameFromLeft) { |
|
ball.x = brick.x - ball.width; |
|
ball.dx = -Math.abs(ball.dx); |
|
} else { |
|
// Fallback for a rare corner overlap: use the shallowest penetration. |
|
const overlapLeft = ball.x + ball.width - brick.x; |
|
const overlapRight = brick.x + brick.width - ball.x; |
|
const overlapTop = ball.y + ball.height - brick.y; |
|
const overlapBottom = brick.y + brick.height - ball.y; |
|
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); |
|
|
|
if (minOverlap === overlapTop) { |
|
ball.y = brick.y - ball.height; |
|
ball.dy = -Math.abs(ball.dy); |
|
} else if (minOverlap === overlapBottom) { |
|
ball.y = brick.y + brick.height; |
|
ball.dy = Math.abs(ball.dy); |
|
} else if (minOverlap === overlapLeft) { |
|
ball.x = brick.x - ball.width; |
|
ball.dx = -Math.abs(ball.dx); |
|
} else { |
|
ball.x = brick.x + brick.width; |
|
ball.dx = Math.abs(ball.dx); |
|
} |
|
} |
|
|
|
increaseBallSpeed(ball); |
|
|
|
while (multiballProgress >= MULTIBALL_SCORE_INTERVAL) { |
|
if (balls.length < MAX_BALLS) spawnSecondBall(ball); |
|
multiballProgress -= MULTIBALL_SCORE_INTERVAL; |
|
} |
|
|
|
if (bricks.length === 0) levelClearedPending = true; |
|
brickHitThisTick = true; |
|
break; |
|
} |
|
} |
|
} |
|
|
|
function skipToNextLevelForTesting() { |
|
if (!TESTING_SYSTEM || !gameStarted || !screenSupported || gameOver) return; |
|
|
|
const completedLevel = level; |
|
|
|
// Testing skips simulate a clean level clear for paddle-size progression |
|
// so Levels 11+ can be tested without playing through every earlier level. |
|
// Score and bonus lives are still not awarded. |
|
shrinkPaddleAfterCleanClear(completedLevel); |
|
|
|
levelClearedPending = false; |
|
level++; |
|
lostLifeThisLevel = false; |
|
multiballProgress = 0; |
|
|
|
currentSpeed = getMinimumSpeedForLevel(level); |
|
|
|
buildLevel(level); |
|
|
|
paddle.x = canvas.width / 2 - paddle.width / 2; |
|
paddle.dx = 0; |
|
resetToSingleBall(currentSpeed); |
|
|
|
levelBannerUntil = Date.now() + 1500; |
|
} |
|
|
|
function advanceLevel() { |
|
levelClearedPending = false; |
|
playLevelClearSound(); |
|
|
|
const completedLevel = level; |
|
const cleanClear = !lostLifeThisLevel; |
|
|
|
// A perfect clear means the player did not spend a life during this level. |
|
if (cleanClear) { |
|
lives++; |
|
bonusBannerUntil = Date.now() + 1800; |
|
playExtraLifeSound(); |
|
shrinkPaddleAfterCleanClear(completedLevel); |
|
} |
|
|
|
level++; |
|
lostLifeThisLevel = false; |
|
|
|
// Carry forward the speed earned in the previous level, but each new level |
|
// also establishes a slightly higher minimum speed floor. |
|
currentSpeed = Math.max(currentSpeed, getMinimumSpeedForLevel(level)); |
|
|
|
buildLevel(level); |
|
paddle.x = canvas.width / 2 - paddle.width / 2; |
|
paddle.dx = 0; |
|
resetToSingleBall(currentSpeed); |
|
levelBannerUntil = Date.now() + 1500; |
|
} |
|
|
|
const PIXEL_FONT = { |
|
'A': ['01110','10001','10001','11111','10001','10001','10001'], |
|
'E': ['11111','10000','10000','11110','10000','10000','11111'], |
|
'G': ['01111','10000','10000','10111','10001','10001','01110'], |
|
'L': ['10000','10000','10000','10000','10000','10000','11111'], |
|
'M': ['10001','11011','10101','10101','10001','10001','10001'], |
|
'O': ['01110','10001','10001','10001','10001','10001','01110'], |
|
'R': ['11110','10001','10001','11110','10100','10010','10001'], |
|
'V': ['10001','10001','10001','10001','10001','01010','00100'], |
|
' ': ['00000','00000','00000','00000','00000','00000','00000'] |
|
}; |
|
|
|
function drawPixelText(text, centerX, topY, scale) { |
|
const letterWidth = 5 * scale; |
|
const spacing = scale * 2; |
|
const totalWidth = text.length * letterWidth + (text.length - 1) * spacing; |
|
let startX = Math.floor(centerX - totalWidth / 2); |
|
|
|
context.fillStyle = 'white'; |
|
for (const char of text) { |
|
const pattern = PIXEL_FONT[char] || PIXEL_FONT[' ']; |
|
for (let row = 0; row < pattern.length; row++) { |
|
for (let col = 0; col < pattern[row].length; col++) { |
|
if (pattern[row][col] === '1') { |
|
context.fillRect(startX + col * scale, topY + row * scale, scale, scale); |
|
} |
|
} |
|
} |
|
startX += letterWidth + spacing; |
|
} |
|
} |
|
|
|
function restartGame() { |
|
score = 0; |
|
lives = STARTING_LIVES; |
|
level = 1; |
|
gameOver = false; |
|
multiballProgress = 0; |
|
currentSpeed = getMinimumSpeedForLevel(level); |
|
lostLifeThisLevel = false; |
|
levelClearedPending = false; |
|
levelBannerUntil = 0; |
|
bonusBannerUntil = 0; |
|
|
|
buildLevel(level); |
|
paddleScale = 1.0; |
|
paddle.width = PADDLE_BASE_WIDTH; |
|
paddle.x = canvas.width / 2 - paddle.width / 2; |
|
paddle.dx = 0; |
|
resetToSingleBall(currentSpeed); |
|
} |
|
|
|
function updateGame() { |
|
if (gameStarted && !gameOver && screenSupported) paddle.x += paddle.dx; |
|
|
|
if (paddle.x < wallSize) { |
|
paddle.x = wallSize; |
|
} else if (paddle.x + paddle.width > canvas.width - wallSize) { |
|
paddle.x = canvas.width - wallSize - paddle.width; |
|
} |
|
|
|
// Before launch, keep the waiting ball sitting on top of the paddle. |
|
positionWaitingBallsOnPaddle(); |
|
|
|
if (gameStarted && screenSupported && !gameOver) { |
|
for (let i = balls.length - 1; i >= 0; i--) { |
|
const ball = balls[i]; |
|
handleBall(ball); |
|
if (ball.y > canvas.height) balls.splice(i, 1); |
|
} |
|
|
|
if (levelClearedPending) { |
|
advanceLevel(); |
|
} else if (balls.length === 0) { |
|
lives--; |
|
lostLifeThisLevel = true; |
|
multiballProgress = 0; |
|
restorePaddleAfterMiss(); |
|
playLifeLostSound(); |
|
|
|
// A lost life resets extra-ball progress and restores 25% paddle width. |
|
// Ball speed also resets to this level's minimum speed. |
|
currentSpeed = getMinimumSpeedForLevel(level); |
|
|
|
if (lives <= 0) { |
|
lives = 0; |
|
gameOver = true; |
|
paddle.dx = 0; |
|
playGameOverSound(); |
|
} else { |
|
resetToSingleBall(currentSpeed); |
|
} |
|
} |
|
} |
|
} |
|
|
|
function drawGame() { |
|
context.clearRect(0, 0, canvas.width, canvas.height); |
|
|
|
context.fillStyle = 'lightgrey'; |
|
context.fillRect(0, 0, canvas.width, wallSize); |
|
context.fillRect(0, 0, wallSize, canvas.height); |
|
context.fillRect(canvas.width - wallSize, 0, wallSize, canvas.height); |
|
|
|
|
|
|
|
context.fillStyle = 'white'; |
|
context.font = 'bold 18px monospace'; |
|
context.textAlign = 'left'; |
|
context.fillText('BALLS ' + lives, wallSize + 10, 34); |
|
context.textAlign = 'center'; |
|
context.fillText('LEVEL ' + level, canvas.width / 2, 34); |
|
context.textAlign = 'right'; |
|
context.fillText('SCORE ' + score, canvas.width - wallSize - 10, 34); |
|
|
|
context.font = 'bold 12px monospace'; |
|
context.fillStyle = '#bdbdbd'; |
|
context.fillText('EXTRA BALL ' + multiballProgress + '/' + MULTIBALL_SCORE_INTERVAL, canvas.width - wallSize - 10, 52); |
|
|
|
if (TESTING_SYSTEM) { |
|
context.textAlign = 'left'; |
|
context.fillStyle = '#ff4d4d'; |
|
context.font = 'bold 12px monospace'; |
|
context.fillText('TEST MODE - S: SKIP LEVEL', wallSize + 10, 52); |
|
} |
|
|
|
balls.forEach(ball => { |
|
context.fillStyle = 'white'; |
|
context.fillRect(ball.x, ball.y, ball.width, ball.height); |
|
}); |
|
|
|
bricks.forEach(brick => { |
|
context.fillStyle = brick.color; |
|
context.fillRect(brick.x, brick.y, brick.width, brick.height); |
|
}); |
|
|
|
context.fillStyle = 'cyan'; |
|
context.fillRect(paddle.x, paddle.y, paddle.width, paddle.height); |
|
|
|
if (Date.now() < levelBannerUntil && !gameOver) { |
|
context.fillStyle = 'white'; |
|
context.font = 'bold 28px monospace'; |
|
context.textAlign = 'center'; |
|
context.fillText('LEVEL ' + level, canvas.width / 2, canvas.height / 2 - 12); |
|
context.font = 'bold 14px monospace'; |
|
context.fillText('SPEED ' + currentSpeed.toFixed(2), canvas.width / 2, canvas.height / 2 + 18); |
|
} |
|
|
|
if (Date.now() < bonusBannerUntil && !gameOver) { |
|
context.fillStyle = 'white'; |
|
context.font = 'bold 16px monospace'; |
|
context.textAlign = 'center'; |
|
context.fillText('PERFECT CLEAR +1 BALL', canvas.width / 2, canvas.height / 2 + 50); |
|
} |
|
|
|
if (gameOver) { |
|
const scale = Math.max(4, Math.min(10, Math.floor(canvas.width / 90))); |
|
drawPixelText('GAME OVER', canvas.width / 2, canvas.height / 2 - (7 * scale) / 2, scale); |
|
|
|
context.fillStyle = 'white'; |
|
context.font = 'bold 14px monospace'; |
|
context.textAlign = 'center'; |
|
context.fillText('PRESS SPACE OR TAP TO PLAY AGAIN', canvas.width / 2, canvas.height / 2 + 60); |
|
} |
|
} |
|
|
|
function loop(timestamp) { |
|
if (!lastFrameTime) lastFrameTime = timestamp; |
|
|
|
const frameDelta = timestamp - lastFrameTime; |
|
lastFrameTime = timestamp; |
|
accumulator += frameDelta; |
|
|
|
// Keep the simulation tied to real elapsed time, even when a browser |
|
// delivers requestAnimationFrame callbacks less frequently. A generous |
|
// safety cap prevents a pathological stall from locking the page. |
|
let steps = 0; |
|
while (accumulator >= FIXED_TIMESTEP && steps < MAX_CATCHUP_STEPS) { |
|
updateGame(); |
|
accumulator -= FIXED_TIMESTEP; |
|
steps++; |
|
} |
|
|
|
// If the browser was stalled for an extreme amount of time, discard only |
|
// the excess beyond our safety cap. Normal foreground frame delays are |
|
// never shortened, so Chrome, Edge and Firefox run at the same game speed. |
|
if (steps === MAX_CATCHUP_STEPS) { |
|
accumulator = 0; |
|
} |
|
|
|
drawGame(); |
|
requestAnimationFrame(loop); |
|
} |
|
|
|
// Do not "catch up" time spent in another tab or while the window is hidden. |
|
// Reset the clock only for an actual visibility transition rather than |
|
// clipping ordinary foreground frames. |
|
document.addEventListener('visibilitychange', function() { |
|
if (document.hidden) { |
|
lastFrameTime = 0; |
|
accumulator = 0; |
|
} else { |
|
lastFrameTime = performance.now(); |
|
accumulator = 0; |
|
} |
|
}); |
|
|
|
function movePaddleToClientX(clientX) { |
|
const rect = canvas.getBoundingClientRect(); |
|
const scaleX = canvas.width / rect.width; |
|
const canvasX = (clientX - rect.left) * scaleX; |
|
paddle.x = canvasX - paddle.width / 2; |
|
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width)); |
|
} |
|
|
|
function launchWaitingBall() { |
|
if (gameOver) { |
|
restartGame(); |
|
return; |
|
} |
|
|
|
const waitingBall = balls.find(ball => ball.dx === 0 && ball.dy === 0); |
|
if (waitingBall) launchBall(waitingBall); |
|
} |
|
|
|
document.addEventListener('keydown', function(e) { |
|
ensureAudio(); |
|
|
|
if (gameStarted && screenSupported && !gameOver && e.key === 'ArrowLeft') { |
|
paddle.dx = -3; |
|
e.preventDefault(); |
|
} else if (gameStarted && screenSupported && !gameOver && e.key === 'ArrowRight') { |
|
paddle.dx = 3; |
|
e.preventDefault(); |
|
} |
|
|
|
if (gameStarted && screenSupported && e.code === 'Space') { |
|
launchWaitingBall(); |
|
e.preventDefault(); |
|
} |
|
|
|
if (TESTING_SYSTEM && gameStarted && screenSupported && !gameOver && e.key.toLowerCase() === 's') { |
|
skipToNextLevelForTesting(); |
|
e.preventDefault(); |
|
} |
|
}); |
|
|
|
document.addEventListener('keyup', function(e) { |
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paddle.dx = 0; |
|
}); |
|
|
|
canvas.addEventListener('pointerdown', function(e) { |
|
if (!gameStarted || !screenSupported) return; |
|
ensureAudio(); |
|
|
|
// Mouse users get pointer lock so control continues even when the physical |
|
// mouse would otherwise leave the canvas. Press Esc to release it. |
|
if (e.pointerType === 'mouse' && document.pointerLockElement !== canvas) { |
|
canvas.requestPointerLock?.(); |
|
} else if (e.pointerType !== 'mouse') { |
|
canvas.setPointerCapture(e.pointerId); |
|
movePaddleToClientX(e.clientX); |
|
} |
|
|
|
if (gameOver || balls.some(ball => ball.dx === 0 && ball.dy === 0)) { |
|
launchWaitingBall(); |
|
} |
|
|
|
e.preventDefault(); |
|
}); |
|
|
|
document.addEventListener('mousemove', function(e) { |
|
if (gameStarted && screenSupported && document.pointerLockElement === canvas && !gameOver) { |
|
const rect = canvas.getBoundingClientRect(); |
|
const scaleX = canvas.width / rect.width; |
|
paddle.x += e.movementX * scaleX; |
|
paddle.x = Math.max(wallSize, Math.min(paddle.x, canvas.width - wallSize - paddle.width)); |
|
} |
|
}); |
|
|
|
canvas.addEventListener('pointermove', function(e) { |
|
if (!gameStarted || !screenSupported) return; |
|
if (document.pointerLockElement === canvas) return; |
|
|
|
if (e.pointerType === 'mouse' || e.buttons > 0 || e.pointerType === 'touch' || e.pointerType === 'pen') { |
|
movePaddleToClientX(e.clientX); |
|
e.preventDefault(); |
|
} |
|
}); |
|
|
|
canvas.addEventListener('contextmenu', e => e.preventDefault()); |
|
window.addEventListener('resize', function() { |
|
resizeGame(); |
|
updateScreenSupport(); |
|
}); |
|
|
|
startGameButton.addEventListener('click', function() { |
|
if (!screenSupported) return; |
|
ensureAudio(); |
|
gameStarted = true; |
|
instructions.classList.add('hidden'); |
|
}); |
|
|
|
document.addEventListener('visibilitychange', function() { |
|
// Throw away elapsed hidden-tab time so returning to the game never causes |
|
// a burst of catch-up movement. |
|
lastFrameTime = 0; |
|
accumulator = 0; |
|
}); |
|
|
|
if (testingControlNote) { |
|
testingControlNote.style.display = TESTING_SYSTEM ? 'block' : 'none'; |
|
} |
|
|
|
buildLevel(level); |
|
currentSpeed = getMinimumSpeedForLevel(level); |
|
resizeGame(); |
|
updateScreenSupport(); |
|
paddle.x = canvas.width / 2 - paddle.width / 2; |
|
resetToSingleBall(currentSpeed); |
|
requestAnimationFrame(loop); |
|
</script> |
|
</body> |
|
</html> |