1
2
3
4
5
6
7
8'use strict';
9
10const Breakout = (() => {
11 const NEON_COLORS = ['#ff2d95', '#ff7a00', '#ffe600', '#39ff14', '#00e5ff', '#b026ff'];
12
13
14 const MAX_BOUNCE_ANGLE = Math.PI / 3;
15
16 function clamp(v, lo, hi) {
17 return Math.max(lo, Math.min(hi, v));
18 }
19
20 function rowColor(row) {
21 return NEON_COLORS[row % NEON_COLORS.length];
22 }
23
24
25 function stepBall(ball, dt) {
26 ball.x += ball.vx * dt;
27 ball.y += ball.vy * dt;
28 return ball;
29 }
30
31
32
33 function collideWalls(ball, width, height) {
34 const events = [];
35 if (ball.x - ball.r < 0) {
36 ball.x = ball.r;
37 ball.vx = Math.abs(ball.vx);
38 events.push('left');
39 } else if (ball.x + ball.r > width) {
40 ball.x = width - ball.r;
41 ball.vx = -Math.abs(ball.vx);
42 events.push('right');
43 }
44 if (ball.y - ball.r < 0) {
45 ball.y = ball.r;
46 ball.vy = Math.abs(ball.vy);
47 events.push('top');
48 } else if (ball.y - ball.r > height) {
49 events.push('bottom');
50 }
51 return events;
52 }
53
54 function circleRectOverlap(cx, cy, r, rect) {
55 const nx = clamp(cx, rect.x, rect.x + rect.w);
56 const ny = clamp(cy, rect.y, rect.y + rect.h);
57 const dx = cx - nx;
58 const dy = cy - ny;
59 return dx * dx + dy * dy <= r * r;
60 }
61
62
63
64
65 function collidePaddle(ball, paddle) {
66 if (ball.vy <= 0) return false;
67 if (!circleRectOverlap(ball.x, ball.y, ball.r, paddle)) return false;
68 const speed = Math.hypot(ball.vx, ball.vy);
69 const hit = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
70 const angle = hit * MAX_BOUNCE_ANGLE;
71 ball.vx = speed * Math.sin(angle);
72 ball.vy = -speed * Math.cos(angle);
73 ball.y = paddle.y - ball.r;
74 return true;
75 }
76
77
78
79 function collideBrick(ball, brick) {
80 if (!brick.alive) return false;
81 if (!circleRectOverlap(ball.x, ball.y, ball.r, brick)) return false;
82 const overlapLeft = ball.x + ball.r - brick.x;
83 const overlapRight = brick.x + brick.w - (ball.x - ball.r);
84 const overlapTop = ball.y + ball.r - brick.y;
85 const overlapBottom = brick.y + brick.h - (ball.y - ball.r);
86 if (Math.min(overlapLeft, overlapRight) < Math.min(overlapTop, overlapBottom)) {
87 ball.vx = overlapLeft < overlapRight ? -Math.abs(ball.vx) : Math.abs(ball.vx);
88 } else {
89 ball.vy = overlapTop < overlapBottom ? -Math.abs(ball.vy) : Math.abs(ball.vy);
90 }
91 return true;
92 }
93
94 function hitBrick(brick) {
95 if (!brick.alive) return { destroyed: false, points: 0 };
96 brick.hp -= 1;
97 if (brick.hp <= 0) {
98 brick.alive = false;
99 return { destroyed: true, points: brick.points };
100 }
101 return { destroyed: false, points: 0 };
102 }
103
104 function movePaddle(x, dir, speed, dt, width, paddleW) {
105 return clamp(x + dir * speed * dt, 0, width - paddleW);
106 }
107
108 function ballSpeedForLevel(base, level, perLevel = 0.14) {
109 return base * (1 + (level - 1) * perLevel);
110 }
111
112 function levelRows(level, maxRows = 8) {
113 return Math.min(3 + level, maxRows);
114 }
115
116 function buildLevel(level, opts = {}) {
117 const width = opts.width !== undefined ? opts.width : 800;
118 const cols = opts.cols !== undefined ? opts.cols : 10;
119 const rows = opts.rows !== undefined ? opts.rows : levelRows(level);
120 const gap = opts.gap !== undefined ? opts.gap : 6;
121 const margin = opts.margin !== undefined ? opts.margin : 40;
122 const top = opts.top !== undefined ? opts.top : 70;
123 const brickH = opts.brickH !== undefined ? opts.brickH : 22;
124 const brickW = (width - margin * 2 - gap * (cols - 1)) / cols;
125 const bricks = [];
126 for (let r = 0; r < rows; r++) {
127 for (let c = 0; c < cols; c++) {
128
129 const hp = r === 0 && level >= 3 ? 2 : 1;
130 bricks.push({
131 x: margin + c * (brickW + gap),
132 y: top + r * (brickH + gap),
133 w: brickW,
134 h: brickH,
135 row: r,
136 col: c,
137 hp,
138 maxHp: hp,
139 points: (rows - r) * 10,
140 alive: true,
141 });
142 }
143 }
144 return bricks;
145 }
146
147
148 function launchVelocity(speed, rand = Math.random) {
149 const angle = -Math.PI / 2 + (rand() - 0.5) * (Math.PI / 3);
150 return { vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed };
151 }
152
153 function spawnParticles(x, y, color, count = 14, rand = Math.random) {
154 const parts = [];
155 for (let i = 0; i < count; i++) {
156 const angle = rand() * Math.PI * 2;
157 const speed = 60 + rand() * 240;
158 const life = 0.35 + rand() * 0.45;
159 parts.push({
160 x,
161 y,
162 vx: Math.cos(angle) * speed,
163 vy: Math.sin(angle) * speed - 50,
164 life,
165 maxLife: life,
166 size: 1.5 + rand() * 2.5,
167 color,
168 });
169 }
170 return parts;
171 }
172
173
174 function updateParticles(parts, dt, gravity = 520) {
175 const alive = [];
176 for (const p of parts) {
177 p.life -= dt;
178 if (p.life <= 0) continue;
179 p.x += p.vx * dt;
180 p.y += p.vy * dt;
181 p.vy += gravity * dt;
182 p.vx *= Math.max(0, 1 - 1.2 * dt);
183 alive.push(p);
184 }
185 return alive;
186 }
187
188 return {
189 NEON_COLORS,
190 MAX_BOUNCE_ANGLE,
191 clamp,
192 rowColor,
193 stepBall,
194 collideWalls,
195 circleRectOverlap,
196 collidePaddle,
197 collideBrick,
198 hitBrick,
199 movePaddle,
200 ballSpeedForLevel,
201 levelRows,
202 buildLevel,
203 launchVelocity,
204 spawnParticles,
205 updateParticles,
206 };
207})();
208
209
210
211
212
213function initGame() {
214 const canvas = document.getElementById('game');
215 const ctx = canvas.getContext('2d');
216 const W = canvas.width;
217 const H = canvas.height;
218
219 const scoreEl = document.getElementById('score');
220 const highEl = document.getElementById('high');
221 const levelEl = document.getElementById('level');
222 const livesEl = document.getElementById('lives');
223 const overlay = document.getElementById('overlay');
224 const overlayTitle = document.getElementById('overlayTitle');
225 const overlayMsg = document.getElementById('overlayMsg');
226 const pauseBtn = document.getElementById('pauseBtn');
227 const muteBtn = document.getElementById('muteBtn');
228
229 const HS_KEY = 'neon-breakout-highscore';
230 const MUTE_KEY = 'neon-breakout-muted';
231
232 const BASE_SPEED = 340;
233 const PADDLE_SPEED = 540;
234 const START_LIVES = 3;
235
236 const paddle = { x: W / 2 - 55, y: H - 40, w: 110, h: 14 };
237 const ball = { x: W / 2, y: paddle.y - 10, r: 8, vx: 0, vy: 0 };
238
239 const state = {
240 mode: 'ready',
241 score: 0,
242 high: Number(localStorage.getItem(HS_KEY)) || 0,
243 lives: START_LIVES,
244 level: 1,
245 bricks: [],
246 particles: [],
247 trail: [],
248 keys: { left: false, right: false },
249 };
250
251
252
253 const audio = { ctx: null, muted: localStorage.getItem(MUTE_KEY) === '1' };
254
255 function ensureAudio() {
256 if (!audio.ctx) {
257 try {
258 audio.ctx = new (window.AudioContext || window.webkitAudioContext)();
259 } catch (e) {
260 audio.ctx = null;
261 }
262 }
263 if (audio.ctx && audio.ctx.state === 'suspended') audio.ctx.resume();
264 }
265
266 function beep(freq, dur = 0.07, type = 'square', vol = 0.12, slideTo = null) {
267 if (audio.muted || !audio.ctx) return;
268 const t = audio.ctx.currentTime;
269 const osc = audio.ctx.createOscillator();
270 const gain = audio.ctx.createGain();
271 osc.type = type;
272 osc.frequency.setValueAtTime(freq, t);
273 if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
274 gain.gain.setValueAtTime(vol, t);
275 gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
276 osc.connect(gain).connect(audio.ctx.destination);
277 osc.start(t);
278 osc.stop(t + dur + 0.02);
279 }
280
281 const sfx = {
282 wall: () => beep(220, 0.05, 'triangle', 0.08),
283 paddle: () => beep(330, 0.07, 'square', 0.12),
284 brick: (row) => beep(460 + row * 70, 0.06, 'square', 0.12),
285 loseLife: () => beep(280, 0.5, 'sawtooth', 0.15, 60),
286 levelUp: () => {
287 beep(523, 0.09);
288 setTimeout(() => beep(659, 0.09), 90);
289 setTimeout(() => beep(784, 0.14), 180);
290 },
291 gameOver: () => {
292 beep(392, 0.15, 'sawtooth', 0.15);
293 setTimeout(() => beep(311, 0.15, 'sawtooth', 0.15), 160);
294 setTimeout(() => beep(233, 0.4, 'sawtooth', 0.15, 60), 320);
295 },
296 };
297
298 function toggleMute() {
299 audio.muted = !audio.muted;
300 localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0');
301 muteBtn.textContent = audio.muted ? '\u{1F507} Muted' : '\u{1F50A} Sound';
302 muteBtn.classList.toggle('off', audio.muted);
303 }
304
305
306
307 function showOverlay(title, msg) {
308 overlayTitle.textContent = title;
309 overlayMsg.textContent = msg;
310 overlay.classList.remove('hidden');
311 }
312
313 function hideOverlay() {
314 overlay.classList.add('hidden');
315 }
316
317 function updateHud() {
318 scoreEl.textContent = state.score;
319 highEl.textContent = state.high;
320 levelEl.textContent = state.level;
321 livesEl.textContent = '♥'.repeat(Math.max(0, state.lives));
322 pauseBtn.textContent = state.mode === 'paused' ? '▶ Resume' : '❚❚ Pause';
323 }
324
325
326
327 function resetBall() {
328 ball.x = paddle.x + paddle.w / 2;
329 ball.y = paddle.y - ball.r - 2;
330 ball.vx = 0;
331 ball.vy = 0;
332 }
333
334 function startLevel(level) {
335 state.level = level;
336 paddle.w = Math.max(70, 110 - (level - 1) * 6);
337 paddle.x = Breakout.clamp(paddle.x, 0, W - paddle.w);
338 state.bricks = Breakout.buildLevel(level, { width: W });
339 state.particles = [];
340 state.trail = [];
341 resetBall();
342 state.mode = 'ready';
343 updateHud();
344 }
345
346 function launch() {
347 if (state.mode !== 'ready') return;
348 ensureAudio();
349 const speed = Breakout.ballSpeedForLevel(BASE_SPEED, state.level);
350 const v = Breakout.launchVelocity(speed);
351 ball.vx = v.vx;
352 ball.vy = v.vy;
353 state.mode = 'playing';
354 hideOverlay();
355 updateHud();
356 }
357
358 function togglePause() {
359 if (state.mode === 'playing') {
360 state.mode = 'paused';
361 showOverlay('PAUSED', 'Press P to resume');
362 } else if (state.mode === 'paused') {
363 state.mode = 'playing';
364 hideOverlay();
365 }
366 updateHud();
367 }
368
369 function saveHigh() {
370 localStorage.setItem(HS_KEY, String(state.high));
371 }
372
373 function loseLife() {
374 state.lives -= 1;
375 state.particles.push(
376 ...Breakout.spawnParticles(Breakout.clamp(ball.x, 0, W), H - 6, '#ff2d95', 24)
377 );
378 if (state.lives <= 0) {
379 state.mode = 'gameover';
380 sfx.gameOver();
381 showOverlay('GAME OVER', `Score ${state.score} · Press SPACE to restart`);
382 } else {
383 sfx.loseLife();
384 resetBall();
385 state.mode = 'ready';
386 showOverlay(
387 'BALL LOST',
388 `${state.lives} ${state.lives === 1 ? 'life' : 'lives'} left · Press SPACE to serve`
389 );
390 }
391 updateHud();
392 }
393
394 function levelClear() {
395 sfx.levelUp();
396 const next = state.level + 1;
397 startLevel(next);
398 showOverlay(`LEVEL ${next}`, 'Speed up! Press SPACE to serve');
399 }
400
401 function restart() {
402 state.score = 0;
403 state.lives = START_LIVES;
404 startLevel(1);
405 showOverlay('NEON BREAKOUT', 'Press SPACE to launch');
406 }
407
408
409
410 function update(dt) {
411 const dir = (state.keys.right ? 1 : 0) - (state.keys.left ? 1 : 0);
412 paddle.x = Breakout.movePaddle(paddle.x, dir, PADDLE_SPEED, dt, W, paddle.w);
413
414 if (state.mode === 'ready') {
415 ball.x = paddle.x + paddle.w / 2;
416 ball.y = paddle.y - ball.r - 2;
417 }
418
419 if (state.mode === 'playing') {
420
421 const speed = Math.hypot(ball.vx, ball.vy);
422 const steps = Breakout.clamp(Math.ceil((speed * dt) / (ball.r * 0.6)), 1, 10);
423 const sdt = dt / steps;
424
425 for (let i = 0; i < steps; i++) {
426 Breakout.stepBall(ball, sdt);
427
428 const walls = Breakout.collideWalls(ball, W, H);
429 if (walls.includes('bottom')) {
430 state.particles = Breakout.updateParticles(state.particles, dt);
431 loseLife();
432 return;
433 }
434 if (walls.length) sfx.wall();
435
436 if (Breakout.collidePaddle(ball, paddle)) {
437 sfx.paddle();
438 state.particles.push(...Breakout.spawnParticles(ball.x, paddle.y, '#00f0ff', 6));
439 }
440
441 for (const brick of state.bricks) {
442 if (!brick.alive) continue;
443 if (Breakout.collideBrick(ball, brick)) {
444 const res = Breakout.hitBrick(brick);
445 const color = Breakout.rowColor(brick.row);
446 state.particles.push(
447 ...Breakout.spawnParticles(ball.x, ball.y, color, res.destroyed ? 16 : 8)
448 );
449 sfx.brick(brick.row);
450 if (res.destroyed) {
451 state.score += res.points;
452 if (state.score > state.high) {
453 state.high = state.score;
454 saveHigh();
455 }
456 }
457 updateHud();
458 break;
459 }
460 }
461 }
462
463 if (state.bricks.every((b) => !b.alive)) {
464 state.particles = Breakout.updateParticles(state.particles, dt);
465 levelClear();
466 return;
467 }
468
469 state.trail.push({ x: ball.x, y: ball.y });
470 if (state.trail.length > 10) state.trail.shift();
471 }
472
473 state.particles = Breakout.updateParticles(state.particles, dt);
474 }
475
476
477
478 function drawGrid() {
479 ctx.strokeStyle = 'rgba(0, 240, 255, 0.05)';
480 ctx.lineWidth = 1;
481 ctx.beginPath();
482 for (let x = 40; x < W; x += 40) {
483 ctx.moveTo(x, 0);
484 ctx.lineTo(x, H);
485 }
486 for (let y = 40; y < H; y += 40) {
487 ctx.moveTo(0, y);
488 ctx.lineTo(W, y);
489 }
490 ctx.stroke();
491 }
492
493 function draw() {
494 ctx.clearRect(0, 0, W, H);
495 drawGrid();
496
497 for (const brick of state.bricks) {
498 if (!brick.alive) continue;
499 const color = Breakout.rowColor(brick.row);
500 ctx.save();
501 ctx.shadowColor = color;
502 ctx.shadowBlur = 14;
503 ctx.globalAlpha = brick.hp < brick.maxHp ? 0.55 : 1;
504 ctx.fillStyle = color;
505 ctx.fillRect(brick.x, brick.y, brick.w, brick.h);
506 ctx.shadowBlur = 0;
507 ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
508 ctx.fillRect(brick.x, brick.y, brick.w, 3);
509 ctx.restore();
510 }
511
512 for (let i = 0; i < state.trail.length; i++) {
513 const t = state.trail[i];
514 const f = (i + 1) / state.trail.length;
515 ctx.fillStyle = `rgba(0, 240, 255, ${f * 0.22})`;
516 ctx.beginPath();
517 ctx.arc(t.x, t.y, ball.r * (0.35 + 0.55 * f), 0, Math.PI * 2);
518 ctx.fill();
519 }
520
521 ctx.save();
522 ctx.shadowColor = '#00f0ff';
523 ctx.shadowBlur = 18;
524 ctx.fillStyle = '#eaffff';
525 ctx.beginPath();
526 ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
527 ctx.fill();
528 ctx.restore();
529
530 ctx.save();
531 ctx.shadowColor = '#00f0ff';
532 ctx.shadowBlur = 16;
533 const grad = ctx.createLinearGradient(paddle.x, 0, paddle.x + paddle.w, 0);
534 grad.addColorStop(0, '#b026ff');
535 grad.addColorStop(0.5, '#00f0ff');
536 grad.addColorStop(1, '#b026ff');
537 ctx.fillStyle = grad;
538 ctx.fillRect(paddle.x, paddle.y, paddle.w, paddle.h);
539 ctx.restore();
540
541 for (const p of state.particles) {
542 ctx.globalAlpha = Math.max(0, p.life / p.maxLife);
543 ctx.fillStyle = p.color;
544 ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
545 }
546 ctx.globalAlpha = 1;
547 }
548
549
550
551 window.addEventListener('keydown', (e) => {
552 if (e.code === 'ArrowLeft' || e.code === 'KeyA') {
553 state.keys.left = true;
554 e.preventDefault();
555 } else if (e.code === 'ArrowRight' || e.code === 'KeyD') {
556 state.keys.right = true;
557 e.preventDefault();
558 } else if (e.code === 'Space') {
559 e.preventDefault();
560 ensureAudio();
561 if (state.mode === 'gameover') restart();
562 else if (state.mode === 'ready') launch();
563 else if (state.mode === 'paused') togglePause();
564 } else if (e.code === 'KeyP' || e.code === 'Escape') {
565 togglePause();
566 } else if (e.code === 'KeyM') {
567 toggleMute();
568 }
569 });
570
571 window.addEventListener('keyup', (e) => {
572 if (e.code === 'ArrowLeft' || e.code === 'KeyA') state.keys.left = false;
573 if (e.code === 'ArrowRight' || e.code === 'KeyD') state.keys.right = false;
574 });
575
576 function pointerX(clientX) {
577 const rect = canvas.getBoundingClientRect();
578 return (clientX - rect.left) * (canvas.width / rect.width);
579 }
580
581 canvas.addEventListener('mousemove', (e) => {
582 if (state.mode === 'paused' || state.mode === 'gameover') return;
583 paddle.x = Breakout.clamp(pointerX(e.clientX) - paddle.w / 2, 0, W - paddle.w);
584 });
585
586 canvas.addEventListener(
587 'touchmove',
588 (e) => {
589 if (state.mode === 'paused' || state.mode === 'gameover') return;
590 e.preventDefault();
591 paddle.x = Breakout.clamp(pointerX(e.touches[0].clientX) - paddle.w / 2, 0, W - paddle.w);
592 },
593 { passive: false }
594 );
595
596 function primaryAction() {
597 ensureAudio();
598 if (state.mode === 'gameover') restart();
599 else if (state.mode === 'ready') launch();
600 else if (state.mode === 'paused') togglePause();
601 }
602
603 canvas.addEventListener('click', primaryAction);
604 overlay.addEventListener('click', primaryAction);
605
606 pauseBtn.addEventListener('click', (e) => {
607 e.currentTarget.blur();
608 togglePause();
609 });
610 muteBtn.addEventListener('click', (e) => {
611 e.currentTarget.blur();
612 toggleMute();
613 });
614
615 document.addEventListener('visibilitychange', () => {
616 if (document.hidden && state.mode === 'playing') togglePause();
617 });
618
619
620
621 muteBtn.textContent = audio.muted ? '\u{1F507} Muted' : '\u{1F50A} Sound';
622 muteBtn.classList.toggle('off', audio.muted);
623 startLevel(1);
624 showOverlay('NEON BREAKOUT', 'Press SPACE to launch');
625
626 let last = performance.now();
627 function frame(now) {
628 const dt = Math.min((now - last) / 1000, 0.033);
629 last = now;
630 if (state.mode === 'ready' || state.mode === 'playing') update(dt);
631 draw();
632 requestAnimationFrame(frame);
633 }
634 requestAnimationFrame(frame);
635}
636
637if (typeof window !== 'undefined') {
638 window.Breakout = Breakout;
639 if (document.getElementById('game')) initGame();
640}
641if (typeof module !== 'undefined' && module.exports) {
642 module.exports = Breakout;
643}
644
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.