arcadia / brick-blitz

Brick Blitz

game.js10.3 KB
1// Brick Blitz — an Arcadia seed game. Fork it and make it better!
2(() => {
3 const W = 800, H = 600;
4 const c = document.getElementById("c");
5 const ctx = c.getContext("2d");
6 let scale = 1;
7 function fit() {
8 scale = Math.min(innerWidth / W, innerHeight / H);
9 const dpr = window.devicePixelRatio || 1;
10 c.style.width = W * scale + "px";
11 c.style.height = H * scale + "px";
12 c.width = Math.round(W * scale * dpr);
13 c.height = Math.round(H * scale * dpr);
14 ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
15 }
16 addEventListener("resize", fit);
17 fit();
18
19 const COLORS = ["#ff4d6d", "#ff9f1c", "#ffd23f", "#3ddc97", "#4cc9f0", "#9d4edd"];
20 const best = () => Number(localStorage.getItem("brickblitz.best") || 0);
21
22 let state = "title"; // title | play | serve | over | won
23 let paddle, balls, bricks, drops, particles, score, lives, level, keys = {}, pointerX = null, shake = 0;
24
25 function reset() {
26 score = 0; lives = 3; level = 1;
27 buildLevel();
28 }
29
30 function buildLevel() {
31 paddle = { x: W / 2, y: H - 40, w: 110, h: 14, wideTimer: 0 };
32 balls = [];
33 drops = [];
34 particles = [];
35 bricks = [];
36 const rows = Math.min(4 + level, 9);
37 const cols = 11;
38 const bw = 64, bh = 22, gap = 6;
39 const ox = (W - (cols * bw + (cols - 1) * gap)) / 2;
40 for (let r = 0; r < rows; r++) {
41 for (let col = 0; col < cols; col++) {
42 // Patterns get more interesting each level.
43 if (level % 3 === 2 && (r + col) % 4 === 0) continue;
44 if (level % 3 === 0 && Math.abs(col - 5) + r < 2) continue;
45 const hp = r < level - 1 && r < 3 ? 2 : 1;
46 bricks.push({ x: ox + col * (bw + gap), y: 70 + r * (bh + gap), w: bw, h: bh, hp, color: COLORS[r % COLORS.length] });
47 }
48 }
49 serve();
50 }
51
52 function serve() {
53 state = "serve";
54 balls = [{ x: paddle.x, y: paddle.y - 12, vx: 0, vy: 0, r: 8, stuck: true }];
55 }
56
57 function launch() {
58 for (const b of balls) {
59 if (b.stuck) {
60 const speed = 380 + level * 25;
61 const a = -Math.PI / 2 + (Math.random() - 0.5) * 0.6;
62 b.vx = Math.cos(a) * speed;
63 b.vy = Math.sin(a) * speed;
64 b.stuck = false;
65 }
66 }
67 state = "play";
68 }
69
70 function burst(x, y, color, n = 10) {
71 for (let i = 0; i < n; i++) {
72 const a = Math.random() * Math.PI * 2, s = 60 + Math.random() * 220;
73 particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 0.6 + Math.random() * 0.4, color });
74 }
75 }
76
77 function press() {
78 if (state === "title" || state === "over" || state === "won") { reset(); return; }
79 if (state === "serve") launch();
80 }
81
82 addEventListener("keydown", (e) => {
83 keys[e.key] = true;
84 if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp") { press(); e.preventDefault(); }
85 });
86 addEventListener("keyup", (e) => (keys[e.key] = false));
87 function toLocal(e) {
88 const r = c.getBoundingClientRect();
89 return ((e.clientX - r.left) / r.width) * W;
90 }
91 c.addEventListener("pointerdown", (e) => { pointerX = toLocal(e); press(); c.focus(); });
92 addEventListener("pointermove", (e) => { if (e.pointerType === "mouse" || e.buttons) pointerX = toLocal(e); });
93
94 let paused = false;
95 if (window.Arcadia) { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); }
96
97 function update(dt) {
98 // Paddle
99 const speed = 620;
100 if (keys.ArrowLeft || keys.a) { paddle.x -= speed * dt; pointerX = null; }
101 if (keys.ArrowRight || keys.d) { paddle.x += speed * dt; pointerX = null; }
102 if (pointerX !== null) paddle.x += (pointerX - paddle.x) * Math.min(1, dt * 18);
103 if (paddle.wideTimer > 0) { paddle.wideTimer -= dt; paddle.w += (160 - paddle.w) * dt * 8; }
104 else paddle.w += (110 - paddle.w) * dt * 8;
105 paddle.x = Math.max(paddle.w / 2, Math.min(W - paddle.w / 2, paddle.x));
106
107 for (const p of particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.vy += 400 * dt; p.life -= dt; }
108 particles = particles.filter((p) => p.life > 0);
109 shake = Math.max(0, shake - dt * 30);
110
111 if (state === "serve") { balls[0].x = paddle.x; balls[0].y = paddle.y - 14; return; }
112 if (state !== "play") return;
113
114 // Balls (sub-stepped so fast balls don't tunnel through bricks)
115 for (const b of balls) {
116 const steps = Math.ceil((Math.hypot(b.vx, b.vy) * dt) / 6);
117 for (let s = 0; s < steps; s++) {
118 const sdt = dt / steps;
119 b.x += b.vx * sdt; b.y += b.vy * sdt;
120 if (b.x < b.r) { b.x = b.r; b.vx = Math.abs(b.vx); }
121 if (b.x > W - b.r) { b.x = W - b.r; b.vx = -Math.abs(b.vx); }
122 if (b.y < b.r) { b.y = b.r; b.vy = Math.abs(b.vy); }
123 // Paddle: angle depends on where the ball hits
124 if (b.vy > 0 && b.y + b.r >= paddle.y && b.y + b.r <= paddle.y + paddle.h + 10 && Math.abs(b.x - paddle.x) <= paddle.w / 2 + b.r) {
125 const hit = (b.x - paddle.x) / (paddle.w / 2);
126 const sp = Math.min(Math.hypot(b.vx, b.vy) * 1.01, 900);
127 const a = -Math.PI / 2 + hit * 1.05;
128 b.vx = Math.cos(a) * sp; b.vy = Math.sin(a) * sp;
129 b.y = paddle.y - b.r;
130 }
131 for (const br of bricks) {
132 if (br.hp <= 0) continue;
133 const nx = Math.max(br.x, Math.min(b.x, br.x + br.w));
134 const ny = Math.max(br.y, Math.min(b.y, br.y + br.h));
135 const dx = b.x - nx, dy = b.y - ny;
136 if (dx * dx + dy * dy <= b.r * b.r) {
137 if (Math.abs(dx) > Math.abs(dy)) b.vx = Math.sign(dx || -b.vx) * Math.abs(b.vx);
138 else b.vy = Math.sign(dy || -b.vy) * Math.abs(b.vy);
139 br.hp--;
140 score += 10 * level;
141 if (br.hp <= 0) {
142 burst(br.x + br.w / 2, br.y + br.h / 2, br.color, 14);
143 shake = 4;
144 if (Math.random() < 0.12) drops.push({ x: br.x + br.w / 2, y: br.y, kind: Math.random() < 0.5 ? "wide" : "multi" });
145 }
146 break;
147 }
148 }
149 }
150 }
151 balls = balls.filter((b) => b.y < H + 20);
152 if (!balls.length) {
153 lives--;
154 shake = 10;
155 if (lives <= 0) return gameOver();
156 serve();
157 }
158
159 for (const d of drops) {
160 d.y += 180 * dt;
161 if (d.y > paddle.y - 8 && d.y < paddle.y + paddle.h && Math.abs(d.x - paddle.x) < paddle.w / 2 + 12) {
162 d.taken = true;
163 score += 50;
164 if (d.kind === "wide") paddle.wideTimer = 12;
165 else {
166 const extra = [];
167 for (const b of balls.slice(0, 3)) {
168 for (const turn of [-0.4, 0.4]) {
169 const sp = Math.hypot(b.vx, b.vy), a = Math.atan2(b.vy, b.vx) + turn;
170 extra.push({ ...b, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp });
171 }
172 }
173 balls.push(...extra);
174 }
175 }
176 }
177 drops = drops.filter((d) => !d.taken && d.y < H + 20);
178
179 if (bricks.every((b) => b.hp <= 0)) {
180 score += 500 * level;
181 level++;
182 if (window.Arcadia) Arcadia.submitScore(score);
183 buildLevel();
184 }
185 }
186
187 function gameOver() {
188 state = "over";
189 if (score > best()) localStorage.setItem("brickblitz.best", String(score));
190 if (window.Arcadia) { Arcadia.submitScore(score); Arcadia.gameOver(); }
191 }
192
193 function roundRect(x, y, w, h, r) {
194 ctx.beginPath();
195 ctx.roundRect ? ctx.roundRect(x, y, w, h, r) : ctx.rect(x, y, w, h);
196 }
197
198 function draw() {
199 ctx.save();
200 if (shake) ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
201 const g = ctx.createLinearGradient(0, 0, 0, H);
202 g.addColorStop(0, "#1b1340"); g.addColorStop(1, "#0d0a1f");
203 ctx.fillStyle = g; ctx.fillRect(-20, -20, W + 40, H + 40);
204
205 for (const b of bricks) {
206 if (b.hp <= 0) continue;
207 ctx.fillStyle = b.color;
208 ctx.globalAlpha = b.hp > 1 ? 1 : 0.85;
209 roundRect(b.x, b.y, b.w, b.h, 5); ctx.fill();
210 if (b.hp > 1) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 2; ctx.stroke(); }
211 ctx.globalAlpha = 0.25; ctx.fillStyle = "#fff"; roundRect(b.x + 3, b.y + 3, b.w - 6, 5, 3); ctx.fill();
212 ctx.globalAlpha = 1;
213 }
214 for (const d of drops) {
215 ctx.fillStyle = d.kind === "wide" ? "#3ddc97" : "#ffd23f";
216 roundRect(d.x - 14, d.y - 9, 28, 18, 9); ctx.fill();
217 ctx.fillStyle = "#111"; ctx.font = "bold 12px system-ui"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
218 ctx.fillText(d.kind === "wide" ? "W" : "×3", d.x, d.y + 1);
219 }
220 ctx.shadowColor = "#4cc9f0"; ctx.shadowBlur = 16;
221 ctx.fillStyle = "#4cc9f0"; roundRect(paddle.x - paddle.w / 2, paddle.y, paddle.w, paddle.h, 7); ctx.fill();
222 ctx.shadowColor = "#fff";
223 ctx.fillStyle = "#fff";
224 for (const b of balls) { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill(); }
225 ctx.shadowBlur = 0;
226 for (const p of particles) { ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.color; ctx.fillRect(p.x - 2, p.y - 2, 4, 4); }
227 ctx.globalAlpha = 1;
228
229 ctx.fillStyle = "#fff"; ctx.font = "bold 18px system-ui"; ctx.textBaseline = "top";
230 ctx.textAlign = "left"; ctx.fillText(`SCORE ${score ?? 0}`, 16, 16);
231 ctx.textAlign = "center"; ctx.fillText(`LEVEL ${level ?? 1}`, W / 2, 16);
232 ctx.textAlign = "right"; ctx.fillText("♥".repeat(Math.max(0, lives ?? 3)), W - 16, 16);
233 ctx.restore();
234
235 const center = (title, sub, sub2) => {
236 ctx.fillStyle = "rgba(8,6,20,.72)"; ctx.fillRect(0, 0, W, H);
237 ctx.fillStyle = "#fff"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
238 ctx.font = "900 64px system-ui"; ctx.fillText(title, W / 2, H / 2 - 50);
239 ctx.font = "600 22px system-ui"; ctx.fillStyle = "#c9c3ff"; ctx.fillText(sub, W / 2, H / 2 + 20);
240 if (sub2) { ctx.font = "16px system-ui"; ctx.fillStyle = "#8f88c9"; ctx.fillText(sub2, W / 2, H / 2 + 56); }
241 };
242 if (state === "title") center("BRICK BLITZ", "Tap or press Space to start", `Move: mouse, touch or ← → · Best: ${best()}`);
243 if (state === "serve") { ctx.fillStyle = "#c9c3ff"; ctx.font = "600 18px system-ui"; ctx.textAlign = "center"; ctx.fillText("Tap / Space to launch", W / 2, H - 90); }
244 if (state === "over") center("GAME OVER", `Score ${score} · Best ${best()}`, "Tap or press Space to play again");
245 }
246
247 if (!paddle) { reset(); state = "title"; }
248 let last = performance.now();
249 function loop(now) {
250 const dt = Math.min(0.033, (now - last) / 1000);
251 last = now;
252 if (!paused) update(dt);
253 draw();
254 requestAnimationFrame(loop);
255 }
256 requestAnimationFrame(loop);
257})();
258