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 state.trail.length = 0;
333 }
334
335 function startLevel(level) {
336 state.level = level;
337 paddle.w = Math.max(70, 110 - (level - 1) * 6);
338 paddle.x = Breakout.clamp(paddle.x, 0, W - paddle.w);
339 state.bricks = Breakout.buildLevel(level, { width: W });
340 state.particles = [];
341 state.trail = [];
342 resetBall();
343 state.mode = 'ready';
344 updateHud();
345 }
346
347 function launch() {
348 if (state.mode !== 'ready') return;
349 ensureAudio();
350 const speed = Breakout.ballSpeedForLevel(BASE_SPEED, state.level);
351 const v = Breakout.launchVelocity(speed);
352 ball.vx = v.vx;
353 ball.vy = v.vy;
354 state.mode = 'playing';
355 hideOverlay();
356 updateHud();
357 }
358
359 function togglePause() {
360 if (state.mode === 'playing') {
361 state.mode = 'paused';
362 showOverlay('PAUSED', 'Press P to resume');
363 } else if (state.mode === 'paused') {
364 state.mode = 'playing';
365 hideOverlay();
366 }
367 updateHud();
368 }
369
370 function saveHigh() {
371 localStorage.setItem(HS_KEY, String(state.high));
372 }
373
374 function loseLife() {
375 state.lives -= 1;
376 state.particles.push(
377 ...Breakout.spawnParticles(Breakout.clamp(ball.x, 0, W), H - 6, '#ff2d95', 24)
378 );
379 if (state.lives <= 0) {
380 state.mode = 'gameover';
381 sfx.gameOver();
382 showOverlay('GAME OVER', `Score ${state.score} · Press SPACE to restart`);
383 } else {
384 sfx.loseLife();
385 resetBall();
386 state.mode = 'ready';
387 showOverlay(
388 'BALL LOST',
389 `${state.lives} ${state.lives === 1 ? 'life' : 'lives'} left · Press SPACE to serve`
390 );
391 }
392 updateHud();
393 }
394
395 function levelClear() {
396 sfx.levelUp();
397 const next = state.level + 1;
398 startLevel(next);
399 showOverlay(`LEVEL ${next}`, 'Speed up! Press SPACE to serve');
400 }
401
402 function restart() {
403 state.score = 0;
404 state.lives = START_LIVES;
405 startLevel(1);
406 showOverlay('NEON BREAKOUT', 'Press SPACE to launch');
407 }
408
409
410
411 function update(dt) {
412 const dir = (state.keys.right ? 1 : 0) - (state.keys.left ? 1 : 0);
413 paddle.x = Breakout.movePaddle(paddle.x, dir, PADDLE_SPEED, dt, W, paddle.w);
414
415 if (state.mode === 'ready') {
416 ball.x = paddle.x + paddle.w / 2;
417 ball.y = paddle.y - ball.r - 2;
418 }
419
420 if (state.mode === 'playing') {
421
422 const speed = Math.hypot(ball.vx, ball.vy);
423 const steps = Breakout.clamp(Math.ceil((speed * dt) / (ball.r * 0.6)), 1, 10);
424 const sdt = dt / steps;
425
426 for (let i = 0; i < steps; i++) {
427 Breakout.stepBall(ball, sdt);
428
429 const walls = Breakout.collideWalls(ball, W, H);
430 if (walls.includes('bottom')) {
431 state.particles = Breakout.updateParticles(state.particles, dt);
432 loseLife();
433 return;
434 }
435 if (walls.length) sfx.wall();
436
437 if (Breakout.collidePaddle(ball, paddle)) {
438 sfx.paddle();
439 state.particles.push(...Breakout.spawnParticles(ball.x, paddle.y, '#00f0ff', 6));
440 }
441
442 for (const brick of state.bricks) {
443 if (!brick.alive) continue;
444 if (Breakout.collideBrick(ball, brick)) {
445 const res = Breakout.hitBrick(brick);
446 const color = Breakout.rowColor(brick.row);
447 state.particles.push(
448 ...Breakout.spawnParticles(ball.x, ball.y, color, res.destroyed ? 16 : 8)
449 );
450 sfx.brick(brick.row);
451 if (res.destroyed) {
452 state.score += res.points;
453 if (state.score > state.high) {
454 state.high = state.score;
455 saveHigh();
456 }
457 }
458 updateHud();
459 break;
460 }
461 }
462 }
463
464 if (state.bricks.every((b) => !b.alive)) {
465 state.particles = Breakout.updateParticles(state.particles, dt);
466 levelClear();
467 return;
468 }
469
470 state.trail.push({ x: ball.x, y: ball.y });
471 if (state.trail.length > 10) state.trail.shift();
472 }
473
474 state.particles = Breakout.updateParticles(state.particles, dt);
475 }
476
477
478
479 function drawGrid() {
480 ctx.strokeStyle = 'rgba(0, 240, 255, 0.05)';
481 ctx.lineWidth = 1;
482 ctx.beginPath();
483 for (let x = 40; x < W; x += 40) {
484 ctx.moveTo(x, 0);
485 ctx.lineTo(x, H);
486 }
487 for (let y = 40; y < H; y += 40) {
488 ctx.moveTo(0, y);
489 ctx.lineTo(W, y);
490 }
491 ctx.stroke();
492 }
493
494 function draw() {
495 ctx.clearRect(0, 0, W, H);
496 drawGrid();
497
498 for (const brick of state.bricks) {
499 if (!brick.alive) continue;
500 const color = Breakout.rowColor(brick.row);
501 ctx.save();
502 ctx.shadowColor = color;
503 ctx.shadowBlur = 14;
504 ctx.globalAlpha = brick.hp < brick.maxHp ? 0.55 : 1;
505 ctx.fillStyle = color;
506 ctx.fillRect(brick.x, brick.y, brick.w, brick.h);
507 ctx.shadowBlur = 0;
508 ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
509 ctx.fillRect(brick.x, brick.y, brick.w, 3);
510 ctx.restore();
511 }
512
513 for (let i = 0; i < state.trail.length; i++) {
514 const t = state.trail[i];
515 const f = (i + 1) / state.trail.length;
516 ctx.fillStyle = `rgba(0, 240, 255, ${f * 0.22})`;
517 ctx.beginPath();
518 ctx.arc(t.x, t.y, ball.r * (0.35 + 0.55 * f), 0, Math.PI * 2);
519 ctx.fill();
520 }
521
522 ctx.save();
523 ctx.shadowColor = '#00f0ff';
524 ctx.shadowBlur = 18;
525 ctx.fillStyle = '#eaffff';
526 ctx.beginPath();
527 ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
528 ctx.fill();
529 ctx.restore();
530
531 ctx.save();
532 ctx.shadowColor = '#00f0ff';
533 ctx.shadowBlur = 16;
534 const grad = ctx.createLinearGradient(paddle.x, 0, paddle.x + paddle.w, 0);
535 grad.addColorStop(0, '#b026ff');
536 grad.addColorStop(0.5, '#00f0ff');
537 grad.addColorStop(1, '#b026ff');
538 ctx.fillStyle = grad;
539 ctx.fillRect(paddle.x, paddle.y, paddle.w, paddle.h);
540 ctx.restore();
541
542 for (const p of state.particles) {
543 ctx.globalAlpha = Math.max(0, p.life / p.maxLife);
544 ctx.fillStyle = p.color;
545 ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
546 }
547 ctx.globalAlpha = 1;
548 }
549
550
551
552 window.addEventListener('keydown', (e) => {
553 if (e.code === 'ArrowLeft' || e.code === 'KeyA') {
554 state.keys.left = true;
555 e.preventDefault();
556 } else if (e.code === 'ArrowRight' || e.code === 'KeyD') {
557 state.keys.right = true;
558 e.preventDefault();
559 } else if (e.code === 'Space') {
560 e.preventDefault();
561 ensureAudio();
562 if (state.mode === 'gameover') restart();
563 else if (state.mode === 'ready') launch();
564 else if (state.mode === 'paused') togglePause();
565 } else if (e.code === 'KeyP' || e.code === 'Escape') {
566 togglePause();
567 } else if (e.code === 'KeyM') {
568 toggleMute();
569 }
570 });
571
572 window.addEventListener('keyup', (e) => {
573 if (e.code === 'ArrowLeft' || e.code === 'KeyA') state.keys.left = false;
574 if (e.code === 'ArrowRight' || e.code === 'KeyD') state.keys.right = false;
575 });
576
577 function pointerX(clientX) {
578 const rect = canvas.getBoundingClientRect();
579 return (clientX - rect.left) * (canvas.width / rect.width);
580 }
581
582 canvas.addEventListener('mousemove', (e) => {
583 if (state.mode === 'paused' || state.mode === 'gameover') return;
584 paddle.x = Breakout.clamp(pointerX(e.clientX) - paddle.w / 2, 0, W - paddle.w);
585 });
586
587 canvas.addEventListener(
588 'touchmove',
589 (e) => {
590 if (state.mode === 'paused' || state.mode === 'gameover') return;
591 e.preventDefault();
592 paddle.x = Breakout.clamp(pointerX(e.touches[0].clientX) - paddle.w / 2, 0, W - paddle.w);
593 },
594 { passive: false }
595 );
596
597 function primaryAction() {
598 ensureAudio();
599 if (state.mode === 'gameover') restart();
600 else if (state.mode === 'ready') launch();
601 else if (state.mode === 'paused') togglePause();
602 }
603
604 canvas.addEventListener('click', primaryAction);
605 overlay.addEventListener('click', primaryAction);
606
607 pauseBtn.addEventListener('click', (e) => {
608 e.currentTarget.blur();
609 togglePause();
610 });
611 muteBtn.addEventListener('click', (e) => {
612 e.currentTarget.blur();
613 toggleMute();
614 });
615
616 document.addEventListener('visibilitychange', () => {
617 if (document.hidden && state.mode === 'playing') togglePause();
618 });
619
620
621
622 muteBtn.textContent = audio.muted ? '\u{1F507} Muted' : '\u{1F50A} Sound';
623 muteBtn.classList.toggle('off', audio.muted);
624 startLevel(1);
625 showOverlay('NEON BREAKOUT', 'Press SPACE to launch');
626
627 let last = performance.now();
628 function frame(now) {
629 const dt = Math.min((now - last) / 1000, 0.033);
630 last = now;
631 if (state.mode === 'ready' || state.mode === 'playing') update(dt);
632 draw();
633 requestAnimationFrame(frame);
634 }
635 requestAnimationFrame(frame);
636}
637
638if (typeof window !== 'undefined') {
639 window.Breakout = Breakout;
640 if (document.getElementById('game')) initGame();
641}
642if (typeof module !== 'undefined' && module.exports) {
643 module.exports = Breakout;
644}
645
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.