This repository has no description
10 kB
313 lines
1// Super Mario Bros. World 1-1, rendered onto the punchcard.
2//
3// The punchcard is a grid of small dots (one per day of the year). We treat it
4// as a low-res dot-matrix display and side-scroll World 1-1 across it: ground,
5// bricks, ? blocks, pipes, Goombas, hills, bushes, clouds, a flagpole and a
6// castle, with Mario auto-running and hopping over the obstacles. It loops.
7//
8// Textures are base64: every sprite is an array of strings, and each character
9// is one pixel whose value is its index in the base64 alphabet (A=0, B=1, ...).
10// That index selects an entry from PAL below. Index 0 (`A`) is transparent.
11
12const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
13
14// Palette. Index lines up with the base64 alphabet so a texture char maps
15// straight to a colour: A->sky, D->ground, M->mario-red, and so on.
16const PAL = [
17 null, // 0 A transparent
18 "#5c94fc", // 1 B sky
19 "#ffffff", // 2 C white (clouds, flag)
20 "#c84c0c", // 3 D ground
21 "#8a3b08", // 4 E ground / seam dark
22 "#e39d5b", // 5 F ground top light / block edge
23 "#b8560f", // 6 G brick
24 "#fac000", // 7 H ? block yellow
25 "#7a3b08", // 8 I shadow / mortar
26 "#00a800", // 9 J pipe green
27 "#5fdd5f", // 10 K pipe light
28 "#006000", // 11 L pipe dark
29 "#e21b0c", // 12 M mario red
30 "#ffa060", // 13 N mario skin
31 "#2038ec", // 14 O mario overalls blue
32 "#6a2a00", // 15 P brown (mario hair, goomba feet)
33 "#b06a3c", // 16 Q goomba tan
34 "#000000", // 17 R black
35 "#3ca03c", // 18 S hill / bush green
36 "#78d060", // 19 T hill light
37 "#b0b0b0", // 20 U castle grey
38 "#707070", // 21 V castle dark
39];
40const SKY = PAL[1];
41
42// Decode a base64 texture (array of rows) into a {w,h,d} sprite of palette ids.
43const spr = (rows) => ({
44 w: rows[0].length,
45 h: rows.length,
46 d: rows.map((r) => Array.prototype.map.call(r, (c) => B64.indexOf(c))),
47});
48
49const HILL = spr(["AASAA", "ASTSA", "SSSSS"]);
50const BUSH = spr(["ASSSA", "SSSSS"]);
51const CLOUD = spr(["ACCCA", "CCCCC"]);
52
53// Mario's three fixed top rows (hat, face, body); the fourth row is his legs,
54// swapped per animation frame below.
55const MTOP = ["AMM", "PNN", "MMM"];
56const LEGS = ["OAO", "AOO", "OAO", "OOA"]; // running cycle
57const LEGS_AIR = "OOA"; // tucked while jumping
58
59// --- Level layout (one looping period, measured in dot-columns) --------------
60const P = 72; // period width
61const SCROLL = 5.5; // dot-columns per second
62
63const CLOUDS = [
64 { x: 8, f: 0.18 },
65 { x: 26, f: 0.1 },
66 { x: 42, f: 0.24 },
67 { x: 62, f: 0.14 },
68];
69const HILLS = [3, 46];
70const BUSHES = [14, 58];
71const QSINGLE = [{ x: 10, u: 4 }]; // lone ? block
72const HIGHQ = { x: 21, u: 8 }; // high ? block
73const RUN = [
74 // the classic brick / ? / brick / ? / brick row, 4 tiles up
75 { x: 18, t: "b" },
76 { x: 19, t: "q" },
77 { x: 20, t: "b" },
78 { x: 21, t: "q" },
79 { x: 22, t: "b" },
80];
81const PIPES = [
82 { x: 28, h: 2 },
83 { x: 34, h: 3 },
84 { x: 48, h: 4 },
85 { x: 56, h: 2 },
86];
87const PITS = [[44, 45]]; // inclusive column ranges with no ground
88const GOOMBAS = [24, 40, 52]; // spawn columns; they walk left
89const STAIRS = [
90 { x: 61, h: 1 },
91 { x: 62, h: 2 },
92 { x: 63, h: 3 },
93 { x: 64, h: 4 },
94];
95const FLAGX = 68;
96const CASTLEX = 70;
97
98// Scheduled jumps (column ranges + peak height) that carry Mario over each
99// obstacle. Between them he runs along the ground.
100const JUMPS = [
101 { a: 25, b: 31, p: 4 }, // pipe @28
102 { a: 31.5, b: 38, p: 5 }, // pipe @34
103 { a: 42.5, b: 52, p: 7 }, // pit @44-45 + pipe @48
104 { a: 53.5, b: 59, p: 4 }, // pipe @56
105 { a: 60, b: 65, p: 5 }, // staircase
106];
107
108const isPit = (local) => PITS.some(([a, b]) => local >= a && local <= b);
109const jumpY = (local) => {
110 let y = 0;
111 for (const j of JUMPS) {
112 if (local > j.a && local < j.b) {
113 const u = (local - j.a) / (j.b - j.a);
114 const h = 4 * j.p * u * (1 - u); // parabola, peak at the middle
115 if (h > y) y = h;
116 }
117 }
118 return y;
119};
120
121// --- Renderer ----------------------------------------------------------------
122const run = (card) => {
123 const dots = Array.from(card.children, (c) => c.firstElementChild).filter(Boolean);
124 const count = dots.length;
125 if (!count) return () => {};
126
127 const cols = matchMedia("(min-width: 768px)").matches ? 14 : 28;
128 const rows = Math.max(1, Math.ceil(count / cols));
129 const W = cols;
130 const G = Math.max(2, Math.round(rows * 0.16)); // ground thickness
131 const horizon = rows - G; // first ground row; sky is above
132 const MSC = Math.max(2, Math.round(W * 0.32)); // Mario's fixed screen column
133 const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
134
135 // Fill the cells so the picture reads as a solid dot-matrix screen.
136 for (const d of dots) {
137 d.style.transition = "none";
138 d.style.transform = "scale(2)";
139 d.style.border = "none";
140 d.style.willChange = "background-color";
141 }
142
143 const buf = new Uint8Array(W * rows);
144 const last = new Array(count).fill(null);
145
146 const setPx = (x, y, idx) => {
147 if (idx > 0 && x >= 0 && x < W && y >= 0 && y < rows) buf[y * W + x] = idx;
148 };
149 const blit = (s, x, y) => {
150 for (let ry = 0; ry < s.h; ry++) {
151 const row = s.d[ry];
152 for (let cx = 0; cx < s.w; cx++) setPx(x + cx, y + ry, row[cx]);
153 }
154 };
155 const blitStr = (str, x, y) => {
156 for (let k = 0; k < str.length; k++) setPx(x + k, y, B64.indexOf(str[k]));
157 };
158
159 // Draw one instance of a level feature per visible period.
160 let camCol = 0;
161 const eachBase = (cb) => {
162 const start = Math.floor((camCol - 8) / P) * P;
163 for (let b = start; b <= camCol + W + 8; b += P) cb(b);
164 };
165 const at = (lx, draw) =>
166 eachBase((b) => {
167 const sx = b + lx - camCol;
168 if (sx > -8 && sx < W + 2) draw(Math.round(sx));
169 });
170
171 const drawPipe = (sx, h) => {
172 const top = horizon - h;
173 for (let y = top; y <= horizon - 1; y++) {
174 setPx(sx, y, 10);
175 setPx(sx + 1, y, 9);
176 setPx(sx + 2, y, 11);
177 }
178 setPx(sx + 1, top, 10); // brighten the cap lip
179 };
180 const drawStep = (sx, h) => {
181 for (let y = horizon - h; y <= horizon - 1; y++) setPx(sx, y, y === horizon - h ? 5 : 3);
182 };
183 const drawFlag = (sx) => {
184 const top = horizon - 9;
185 for (let y = top; y <= horizon - 1; y++) setPx(sx, y, 20);
186 setPx(sx, top - 1, 2); // ball
187 setPx(sx - 1, top, 18);
188 setPx(sx - 2, top + 1, 18);
189 setPx(sx - 1, top + 1, 18);
190 setPx(sx - 1, top + 2, 18);
191 };
192 const drawCastle = (sx) => {
193 const top = horizon - 4;
194 for (let ry = 0; ry < 4; ry++) {
195 for (let cx = 0; cx < 5; cx++) {
196 if (ry === 0 && cx % 2 === 1) continue; // crenellations
197 setPx(sx + cx, top + ry, 20);
198 }
199 }
200 setPx(sx + 2, horizon - 1, 17); // door
201 setPx(sx + 2, horizon - 2, 17);
202 setPx(sx + 1, top + 1, 17); // windows
203 setPx(sx + 3, top + 1, 17);
204 };
205
206 let marioY = 0; // set each frame, read by the Goomba stomp check
207 const drawGoomba = (sx, t) => {
208 if ((sx === MSC || sx === MSC + 1) && marioY <= 1) {
209 blitStr("PPP", sx, horizon - 1); // squished flat
210 return;
211 }
212 blitStr("QQQ", sx, horizon - 2);
213 blitStr(Math.floor(t / 200) % 2 ? "APA" : "PAP", sx, horizon - 1); // waddle
214 };
215
216 const render = (t) => {
217 const camX = (t / 1000) * SCROLL;
218 camCol = Math.floor(camX);
219 buf.fill(1); // sky
220
221 for (const c of CLOUDS) at(c.x, (sx) => blit(CLOUD, sx, Math.round(horizon * c.f)));
222 for (const hx of HILLS) at(hx, (sx) => blit(HILL, sx, horizon - HILL.h));
223 for (const bx of BUSHES) at(bx, (sx) => blit(BUSH, sx, horizon - BUSH.h));
224
225 // Ground, per screen column, with a pit here and there.
226 for (let x = 0; x < W; x++) {
227 const local = ((camCol + x) % P + P) % P;
228 if (isPit(local)) continue;
229 const seam = local % 4 === 0;
230 setPx(x, horizon, seam ? 4 : 5);
231 for (let y = horizon + 1; y < rows; y++) setPx(x, y, (local + y) % 4 === 0 ? 4 : 3);
232 }
233
234 const blink = Math.floor(t / 350) % 4 === 0;
235 for (const p of PIPES) at(p.x, (sx) => drawPipe(sx, p.h));
236 for (const q of QSINGLE) at(q.x, (sx) => setPx(sx, horizon - q.u, blink ? 5 : 7));
237 at(HIGHQ.x, (sx) => setPx(sx, horizon - HIGHQ.u, blink ? 5 : 7));
238 for (const r of RUN) at(r.x, (sx) => setPx(sx, horizon - 4, r.t === "q" ? (blink ? 5 : 7) : 6));
239 for (const s of STAIRS) at(s.x, (sx) => drawStep(sx, s.h));
240 at(FLAGX, drawFlag);
241 at(CASTLEX, drawCastle);
242
243 // Goombas walk left; a full period of travel loops seamlessly.
244 const phase = ((t / 1000) * 3) % P;
245 eachBase((b) => {
246 for (const s of GOOMBAS) {
247 const sx = Math.round(b + s - phase - camCol);
248 if (sx > -3 && sx < W + 1) drawGoomba(sx, t);
249 }
250 });
251
252 // Mario, pinned to screen column MSC, hopping the level as it scrolls by.
253 const local = ((camCol + MSC) % P + P) % P;
254 marioY = Math.min(horizon - 4, Math.round(jumpY(local)));
255 const legs = marioY > 0 ? LEGS_AIR : LEGS[Math.floor(t / 90) % LEGS.length];
256 const topY = horizon - 1 - marioY - 3;
257 const body = [MTOP[0], MTOP[1], MTOP[2], legs];
258 for (let r = 0; r < 4; r++) blitStr(body[r], MSC, topY + r);
259
260 // Commit only the dots that changed.
261 for (let i = 0; i < count; i++) {
262 const hex = PAL[buf[(i / W | 0) * W + (i % W)]] || SKY;
263 if (hex !== last[i]) {
264 dots[i].style.backgroundColor = hex;
265 last[i] = hex;
266 }
267 }
268 };
269
270 if (reduce) {
271 render(2600); // one static frame, no animation
272 return () => {};
273 }
274
275 let raf = 0;
276 let running = false;
277 const loop = (t) => {
278 render(t);
279 raf = requestAnimationFrame(loop);
280 };
281 const start = () => {
282 if (running) return;
283 running = true;
284 raf = requestAnimationFrame(loop);
285 };
286 const stop = () => {
287 running = false;
288 cancelAnimationFrame(raf);
289 };
290
291 // Only animate while the punchcard is on screen.
292 const io = new IntersectionObserver((es) => (es.some((e) => e.isIntersecting) ? start() : stop()));
293 io.observe(card);
294 start();
295
296 return () => {
297 stop();
298 io.disconnect();
299 };
300};
301
302// Boot, and re-bind across htmx navigations (matching the reference effect).
303let target = null;
304let teardown = null;
305const boot = () => {
306 const el = document.querySelector("[data-punchcard]");
307 if (el === target) return;
308 if (teardown) teardown();
309 target = el;
310 teardown = el ? run(el) : null;
311};
312boot();
313document.addEventListener("htmx:load", boot);