1
2
3
4
5
6
7
8
9class Ball {
10 constructor(x = 400, y = 500, radius = 7, speed = 6) {
11 this.x = x;
12 this.y = y;
13 this.radius = radius;
14 this.baseSpeed = speed;
15 this.speed = speed;
16 this.vx = 0;
17 this.vy = -speed;
18 this.stuckToPaddle = true;
19 this.trail = [];
20 this.maxTrailLength = 10;
21 }
22
23 reset(paddleX, paddleY, paddleWidth) {
24 this.x = paddleX + paddleWidth / 2;
25 this.y = paddleY - this.radius - 2;
26 this.speed = this.baseSpeed;
27 this.vx = 0;
28 this.vy = -this.speed;
29 this.stuckToPaddle = true;
30 this.trail = [];
31 }
32
33 launch(angleOffset = 0) {
34 if (!this.stuckToPaddle) return;
35 this.stuckToPaddle = false;
36
37 const angle = (-Math.PI / 2) + angleOffset;
38 this.vx = this.speed * Math.cos(angle);
39 this.vy = this.speed * Math.sin(angle);
40 }
41
42 update(dt = 1) {
43 if (this.stuckToPaddle) return;
44
45
46 this.trail.unshift({ x: this.x, y: this.y });
47 if (this.trail.length > this.maxTrailLength) {
48 this.trail.pop();
49 }
50
51 this.x += this.vx * dt;
52 this.y += this.vy * dt;
53 }
54
55 setSpeed(newSpeed) {
56 const currentSpeed = Math.hypot(this.vx, this.vy) || 1;
57 this.speed = newSpeed;
58 this.vx = (this.vx / currentSpeed) * newSpeed;
59 this.vy = (this.vy / currentSpeed) * newSpeed;
60 }
61
62 getSpeed() {
63 return Math.hypot(this.vx, this.vy);
64 }
65}
66
67class Paddle {
68 constructor(canvasWidth = 800, canvasHeight = 600) {
69 this.width = 110;
70 this.height = 14;
71 this.x = (canvasWidth - this.width) / 2;
72 this.y = canvasHeight - 40;
73 this.speed = 9;
74 this.vx = 0;
75 this.targetX = this.x;
76 }
77
78 moveLeft() {
79 this.vx = -this.speed;
80 }
81
82 moveRight() {
83 this.vx = this.speed;
84 }
85
86 stop() {
87 this.vx = 0;
88 }
89
90 update(canvasWidth) {
91 this.x += this.vx;
92
93 if (this.x < 0) {
94 this.x = 0;
95 } else if (this.x + this.width > canvasWidth) {
96 this.x = canvasWidth - this.width;
97 }
98 }
99
100 setPosition(x, canvasWidth) {
101 this.x = Math.max(0, Math.min(canvasWidth - this.width, x - this.width / 2));
102 }
103}
104
105class Brick {
106 constructor(id, x, y, width, height, hp = 1, color = '#00f3ff', scoreValue = 10) {
107 this.id = id;
108 this.x = x;
109 this.y = y;
110 this.width = width;
111 this.height = height;
112 this.hp = hp;
113 this.maxHp = hp;
114 this.color = color;
115 this.scoreValue = scoreValue;
116 this.active = true;
117 }
118
119 hit() {
120 if (!this.active) return false;
121 this.hp -= 1;
122 if (this.hp <= 0) {
123 this.active = false;
124 return true;
125 }
126 return false;
127 }
128}
129
130
131
132
133
134class PhysicsEngine {
135 static checkWallCollision(ball, canvasWidth, canvasHeight) {
136 let bounced = false;
137 let outOfBounds = false;
138
139
140 if (ball.x - ball.radius <= 0) {
141 ball.x = ball.radius;
142 ball.vx = Math.abs(ball.vx);
143 bounced = true;
144 }
145
146 else if (ball.x + ball.radius >= canvasWidth) {
147 ball.x = canvasWidth - ball.radius;
148 ball.vx = -Math.abs(ball.vx);
149 bounced = true;
150 }
151
152
153 if (ball.y - ball.radius <= 0) {
154 ball.y = ball.radius;
155 ball.vy = Math.abs(ball.vy);
156 bounced = true;
157 }
158
159
160 if (ball.y - ball.radius >= canvasHeight) {
161 outOfBounds = true;
162 }
163
164 return { bounced, outOfBounds };
165 }
166
167 static checkPaddleCollision(ball, paddle) {
168 if (ball.stuckToPaddle) return false;
169
170
171 const closestX = Math.max(paddle.x, Math.min(ball.x, paddle.x + paddle.width));
172 const closestY = Math.max(paddle.y, Math.min(ball.y, paddle.y + paddle.height));
173
174 const distX = ball.x - closestX;
175 const distY = ball.y - closestY;
176 const distanceSq = distX * distX + distY * distY;
177
178 if (distanceSq <= ball.radius * ball.radius) {
179
180 if (ball.vy > 0 && ball.y <= paddle.y + paddle.height / 2 + ball.radius) {
181 ball.y = paddle.y - ball.radius;
182
183
184 const paddleCenter = paddle.x + paddle.width / 2;
185 const hitOffset = (ball.x - paddleCenter) / (paddle.width / 2);
186 const clampedOffset = Math.max(-0.9, Math.min(0.9, hitOffset));
187
188
189 const maxAngle = Math.PI * 0.36;
190 const bounceAngle = clampedOffset * maxAngle;
191
192 const currentSpeed = ball.getSpeed();
193 ball.vx = currentSpeed * Math.sin(bounceAngle);
194 ball.vy = -currentSpeed * Math.cos(bounceAngle);
195
196 return true;
197 }
198 }
199 return false;
200 }
201
202 static checkBrickCollision(ball, brick) {
203 if (!brick.active || ball.stuckToPaddle) return null;
204
205
206 const closestX = Math.max(brick.x, Math.min(ball.x, brick.x + brick.width));
207 const closestY = Math.max(brick.y, Math.min(ball.y, brick.y + brick.height));
208
209 const distX = ball.x - closestX;
210 const distY = ball.y - closestY;
211 const distanceSq = distX * distX + distY * distY;
212
213 if (distanceSq < ball.radius * ball.radius) {
214
215 const prevX = ball.x - ball.vx;
216 const prevY = ball.y - ball.vy;
217
218 const hitFromLeft = prevX <= brick.x;
219 const hitFromRight = prevX >= brick.x + brick.width;
220 const hitFromTop = prevY <= brick.y;
221 const hitFromBottom = prevY >= brick.y + brick.height;
222
223 if (hitFromLeft || hitFromRight) {
224 ball.vx = hitFromLeft ? -Math.abs(ball.vx) : Math.abs(ball.vx);
225 }
226 if (hitFromTop || hitFromBottom) {
227 ball.vy = hitFromTop ? -Math.abs(ball.vy) : Math.abs(ball.vy);
228 }
229 if (!hitFromLeft && !hitFromRight && !hitFromTop && !hitFromBottom) {
230
231 ball.vy = -ball.vy;
232 }
233
234 const destroyed = brick.hit();
235 return { destroyed, hpRemaining: brick.hp };
236 }
237
238 return null;
239 }
240}
241
242
243
244
245
246class LevelManager {
247 static get COLOR_PALETTE() {
248 return [
249 { name: 'pink', color: '#ff007f', hp: 3, score: 50 },
250 { name: 'orange', color: '#ff6600', hp: 2, score: 30 },
251 { name: 'yellow', color: '#ffe600', hp: 1, score: 20 },
252 { name: 'green', color: '#00ff66', hp: 1, score: 15 },
253 { name: 'cyan', color: '#00f3ff', hp: 1, score: 10 }
254 ];
255 }
256
257 static createLevel(levelNumber, canvasWidth = 800) {
258 const cols = 9;
259 const rows = Math.min(8, 4 + Math.floor(levelNumber / 2));
260 const padding = 8;
261 const marginTop = 70;
262 const marginLeft = 35;
263 const availableWidth = canvasWidth - marginLeft * 2;
264 const brickWidth = (availableWidth - (cols - 1) * padding) / cols;
265 const brickHeight = 22;
266
267 const bricks = [];
268 let idCounter = 1;
269
270 for (let r = 0; r < rows; r++) {
271 for (let c = 0; c < cols; c++) {
272
273 let shouldCreate = true;
274
275 if (levelNumber === 2) {
276
277 if ((r + c) % 2 === 1 && r > 1 && r < rows - 1) shouldCreate = false;
278 } else if (levelNumber === 3) {
279
280 if (r === 2 && (c === 2 || c === 6)) shouldCreate = false;
281 } else if (levelNumber > 3) {
282
283 if (r % 2 === 1 && (c === 0 || c === cols - 1)) shouldCreate = false;
284 }
285
286 if (shouldCreate) {
287 const paletteIndex = r % this.COLOR_PALETTE.length;
288 const config = this.COLOR_PALETTE[paletteIndex];
289
290
291 let hp = config.hp;
292 if (levelNumber >= 3 && r === 0) hp = Math.min(4, hp + 1);
293
294 const x = marginLeft + c * (brickWidth + padding);
295 const y = marginTop + r * (brickHeight + padding);
296
297 bricks.push(new Brick(idCounter++, x, y, brickWidth, brickHeight, hp, config.color, config.score));
298 }
299 }
300 }
301
302
303 const baseSpeed = 6 + (levelNumber - 1) * 0.75;
304
305 return { bricks, baseSpeed };
306 }
307}
308
309
310
311
312
313class ParticleSystem {
314 constructor() {
315 this.particles = [];
316 }
317
318 spawnBrickExplosion(x, y, width, height, color, count = 14) {
319 for (let i = 0; i < count; i++) {
320 const px = x + Math.random() * width;
321 const py = y + Math.random() * height;
322 const angle = Math.random() * Math.PI * 2;
323 const speed = 1.5 + Math.random() * 4.5;
324 this.particles.push({
325 x: px,
326 y: py,
327 vx: Math.cos(angle) * speed,
328 vy: Math.sin(angle) * speed,
329 size: 2 + Math.random() * 4,
330 color: color,
331 alpha: 1,
332 decay: 0.02 + Math.random() * 0.03,
333 gravity: 0.1
334 });
335 }
336 }
337
338 spawnPaddleSpark(x, y, count = 8) {
339 for (let i = 0; i < count; i++) {
340 const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.2;
341 const speed = 2 + Math.random() * 4;
342 this.particles.push({
343 x: x,
344 y: y,
345 vx: Math.cos(angle) * speed,
346 vy: Math.sin(angle) * speed,
347 size: 2 + Math.random() * 3,
348 color: '#00f3ff',
349 alpha: 1,
350 decay: 0.04 + Math.random() * 0.04,
351 gravity: 0.05
352 });
353 }
354 }
355
356 update() {
357 for (let i = this.particles.length - 1; i >= 0; i--) {
358 const p = this.particles[i];
359 p.x += p.vx;
360 p.y += p.vy;
361 p.vy += p.gravity;
362 p.alpha -= p.decay;
363 if (p.alpha <= 0) {
364 this.particles.splice(i, 1);
365 }
366 }
367 }
368
369 draw(ctx) {
370 ctx.save();
371 for (const p of this.particles) {
372 ctx.globalAlpha = Math.max(0, p.alpha);
373 ctx.fillStyle = p.color;
374 ctx.shadowColor = p.color;
375 ctx.shadowBlur = 8;
376 ctx.beginPath();
377 ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
378 ctx.fill();
379 }
380 ctx.restore();
381 }
382
383 clear() {
384 this.particles = [];
385 }
386}
387
388
389
390
391
392class SoundSynth {
393 constructor() {
394 this.ctx = null;
395 this.muted = false;
396 if (typeof window !== 'undefined') {
397 const savedMute = localStorage.getItem('breakout_muted');
398 if (savedMute !== null) {
399 this.muted = savedMute === 'true';
400 }
401 }
402 }
403
404 init() {
405 if (!this.ctx && typeof window !== 'undefined') {
406 const AudioCtx = window.AudioContext || window.webkitAudioContext;
407 if (AudioCtx) {
408 this.ctx = new AudioCtx();
409 }
410 }
411 if (this.ctx && this.ctx.state === 'suspended') {
412 this.ctx.resume();
413 }
414 }
415
416 toggleMute() {
417 this.muted = !this.muted;
418 if (typeof window !== 'undefined') {
419 localStorage.setItem('breakout_muted', this.muted);
420 }
421 return this.muted;
422 }
423
424 playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) {
425 if (this.muted) return;
426 this.init();
427 if (!this.ctx) return;
428
429 try {
430 const osc = this.ctx.createOscillator();
431 const gain = this.ctx.createGain();
432
433 osc.type = type;
434 osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
435
436 gain.gain.setValueAtTime(startVol, this.ctx.currentTime);
437 gain.gain.exponentialRampToValueAtTime(endVol, this.ctx.currentTime + duration);
438
439 osc.connect(gain);
440 gain.connect(this.ctx.destination);
441
442 osc.start();
443 osc.stop(this.ctx.currentTime + duration);
444 } catch (e) {
445
446 }
447 }
448
449 playPaddleHit() {
450 if (this.muted) return;
451 this.init();
452 if (!this.ctx) return;
453 try {
454 const osc = this.ctx.createOscillator();
455 const gain = this.ctx.createGain();
456 osc.type = 'triangle';
457 osc.frequency.setValueAtTime(320, this.ctx.currentTime);
458 osc.frequency.exponentialRampToValueAtTime(540, this.ctx.currentTime + 0.08);
459
460 gain.gain.setValueAtTime(0.25, this.ctx.currentTime);
461 gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.08);
462
463 osc.connect(gain);
464 gain.connect(this.ctx.destination);
465
466 osc.start();
467 osc.stop(this.ctx.currentTime + 0.08);
468 } catch (e) {}
469 }
470
471 playWallHit() {
472 this.playTone(220, 'square', 0.05, 0.15);
473 }
474
475 playBrickHit() {
476 this.playTone(600, 'sine', 0.06, 0.2);
477 }
478
479 playBrickBreak() {
480 if (this.muted) return;
481 this.init();
482 if (!this.ctx) return;
483 try {
484 const osc = this.ctx.createOscillator();
485 const gain = this.ctx.createGain();
486 osc.type = 'sawtooth';
487 osc.frequency.setValueAtTime(800, this.ctx.currentTime);
488 osc.frequency.exponentialRampToValueAtTime(200, this.ctx.currentTime + 0.12);
489
490 gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
491 gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.12);
492
493 osc.connect(gain);
494 gain.connect(this.ctx.destination);
495
496 osc.start();
497 osc.stop(this.ctx.currentTime + 0.12);
498 } catch (e) {}
499 }
500
501 playLifeLost() {
502 if (this.muted) return;
503 this.init();
504 if (!this.ctx) return;
505 try {
506 const osc = this.ctx.createOscillator();
507 const gain = this.ctx.createGain();
508 osc.type = 'sawtooth';
509 osc.frequency.setValueAtTime(350, this.ctx.currentTime);
510 osc.frequency.linearRampToValueAtTime(100, this.ctx.currentTime + 0.35);
511
512 gain.gain.setValueAtTime(0.35, this.ctx.currentTime);
513 gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.35);
514
515 osc.connect(gain);
516 gain.connect(this.ctx.destination);
517
518 osc.start();
519 osc.stop(this.ctx.currentTime + 0.35);
520 } catch (e) {}
521 }
522
523 playLevelUp() {
524 if (this.muted) return;
525 const notes = [440, 554.37, 659.25, 880];
526 notes.forEach((freq, i) => {
527 setTimeout(() => {
528 this.playTone(freq, 'triangle', 0.15, 0.25);
529 }, i * 90);
530 });
531 }
532
533 playGameOver() {
534 if (this.muted) return;
535 const notes = [400, 350, 300, 220];
536 notes.forEach((freq, i) => {
537 setTimeout(() => {
538 this.playTone(freq, 'sawtooth', 0.25, 0.3);
539 }, i * 140);
540 });
541 }
542}
543
544
545
546
547
548const GameState = {
549 START: 'START',
550 PLAYING: 'PLAYING',
551 PAUSED: 'PAUSED',
552 LEVEL_COMPLETE: 'LEVEL_COMPLETE',
553 GAME_OVER: 'GAME_OVER'
554};
555
556class GameController {
557 constructor() {
558 this.score = 0;
559 this.highScore = this.loadHighScore();
560 this.lives = 3;
561 this.level = 1;
562 this.state = GameState.START;
563
564 this.canvasWidth = 800;
565 this.canvasHeight = 600;
566
567 this.paddle = new Paddle(this.canvasWidth, this.canvasHeight);
568 this.ball = new Ball(this.canvasWidth / 2, this.paddle.y - 10);
569 this.particles = new ParticleSystem();
570 this.sound = new SoundSynth();
571
572 this.bricks = [];
573 this.loadLevel(this.level);
574 }
575
576 loadHighScore() {
577 if (typeof window !== 'undefined' && window.localStorage) {
578 const val = parseInt(localStorage.getItem('breakout_highscore'), 10);
579 return isNaN(val) ? 0 : val;
580 }
581 return 0;
582 }
583
584 saveHighScore() {
585 if (this.score > this.highScore) {
586 this.highScore = this.score;
587 if (typeof window !== 'undefined' && window.localStorage) {
588 localStorage.setItem('breakout_highscore', this.highScore.toString());
589 }
590 return true;
591 }
592 return false;
593 }
594
595 loadLevel(lvl) {
596 this.level = lvl;
597 const levelData = LevelManager.createLevel(this.level, this.canvasWidth);
598 this.bricks = levelData.bricks;
599 this.ball.baseSpeed = levelData.baseSpeed;
600 this.ball.reset(this.paddle.x, this.paddle.y, this.paddle.width);
601 }
602
603 startNewGame() {
604 this.score = 0;
605 this.lives = 3;
606 this.level = 1;
607 this.loadLevel(1);
608 this.state = GameState.PLAYING;
609 this.particles.clear();
610 }
611
612 launchBall() {
613 if (this.state === GameState.PLAYING && this.ball.stuckToPaddle) {
614 this.ball.launch((Math.random() - 0.5) * 0.2);
615 }
616 }
617
618 togglePause() {
619 if (this.state === GameState.PLAYING) {
620 this.state = GameState.PAUSED;
621 } else if (this.state === GameState.PAUSED) {
622 this.state = GameState.PLAYING;
623 }
624 }
625
626 update() {
627 if (this.state !== GameState.PLAYING) return;
628
629 this.paddle.update(this.canvasWidth);
630
631 if (this.ball.stuckToPaddle) {
632 this.ball.x = this.paddle.x + this.paddle.width / 2;
633 this.ball.y = this.paddle.y - this.ball.radius - 2;
634 } else {
635 this.ball.update();
636
637
638 const wallResult = PhysicsEngine.checkWallCollision(this.ball, this.canvasWidth, this.canvasHeight);
639 if (wallResult.bounced) {
640 this.sound.playWallHit();
641 }
642 if (wallResult.outOfBounds) {
643 this.lives -= 1;
644 this.sound.playLifeLost();
645
646 if (this.lives <= 0) {
647 this.state = GameState.GAME_OVER;
648 this.saveHighScore();
649 this.sound.playGameOver();
650 } else {
651 this.ball.reset(this.paddle.x, this.paddle.y, this.paddle.width);
652 }
653 return;
654 }
655
656
657 if (PhysicsEngine.checkPaddleCollision(this.ball, this.paddle)) {
658 this.sound.playPaddleHit();
659 this.particles.spawnPaddleSpark(this.ball.x, this.paddle.y);
660 }
661
662
663 let remainingActive = 0;
664 for (const brick of this.bricks) {
665 if (brick.active) {
666 const hitResult = PhysicsEngine.checkBrickCollision(this.ball, brick);
667 if (hitResult) {
668 if (hitResult.destroyed) {
669 this.score += brick.scoreValue;
670 this.saveHighScore();
671 this.sound.playBrickBreak();
672 this.particles.spawnBrickExplosion(brick.x, brick.y, brick.width, brick.height, brick.color);
673 } else {
674 this.sound.playBrickHit();
675 }
676 }
677 if (brick.active) {
678 remainingActive++;
679 }
680 }
681 }
682
683
684 if (remainingActive === 0) {
685 this.state = GameState.LEVEL_COMPLETE;
686 this.sound.playLevelUp();
687 setTimeout(() => {
688 if (this.state === GameState.LEVEL_COMPLETE) {
689 this.loadLevel(this.level + 1);
690 this.state = GameState.PLAYING;
691 }
692 }, 1800);
693 }
694 }
695
696 this.particles.update();
697 }
698}
699
700
701if (typeof module !== 'undefined' && module.exports) {
702 module.exports = {
703 Ball,
704 Paddle,
705 Brick,
706 PhysicsEngine,
707 LevelManager,
708 ParticleSystem,
709 SoundSynth,
710 GameState,
711 GameController
712 };
713}
714
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.