This repository has no description
23 kB
481 lines
1// Punchcard Tower Defense: the punchcard itself becomes the battlefield.
2// Click "play" to overlay the game on your commit grid; the dots march the
3// track and their punchcard color is their strength. Build towers on the dark
4// tiles to stop them. Hit "exit" to return to the normal punchcard.
5
6// Tangled swaps profiles in via htmx, so the module only executes once. Re-bind
7// to whichever punchcard is on the page after each swap, tearing down the old
8// instance first so nothing (rAF loop, window listener, injected DOM) leaks.
9let tdTeardown = null;
10let tdGrid = null;
11function tdBoot() {
12 const grid = document.querySelector("[data-punchcard]");
13 if (grid === tdGrid) return;
14 if (tdTeardown) tdTeardown();
15 tdGrid = grid;
16 tdTeardown = grid ? run(grid) : null;
17}
18tdBoot();
19document.addEventListener("htmx:load", tdBoot);
20
21function run(grid) {
22 grid.dataset.tdOn = "1";
23 const parent = grid.parentElement || document.body;
24 const ac = new AbortController();
25 const sig = ac.signal;
26
27 // ---- read commit data off the real punchcard dots ----
28 const parseCount = (dot) => {
29 const m = dot && dot.title && dot.title.match(/(\d+)\s*commits?/i);
30 return m ? parseInt(m[1], 10) : 0;
31 };
32 const roster = [];
33 Array.from(grid.children).forEach((cell) => {
34 const dot = cell.firstElementChild;
35 const count = parseCount(dot);
36 if (count > 0) {
37 const month = parseInt(dot.title.slice(5, 7), 10) - 1;
38 roster.push({ dot, count, month, color: getComputedStyle(dot).backgroundColor });
39 }
40 });
41 if (!roster.length) return;
42
43 // ---- difficulty tunables (cranked up) ----
44 const START_GOLD = 120, SPAWN_GAP = 0.42, WAVE_GAP = 1.2;
45 const RAD = 6; // every enemy is the same size; color shows strength
46 const speedFor = (c) => Math.max(0.9, 1.9 - c * 0.05); // base tiles / second (brighter = slower)
47 const waveSpeed = (w) => 1 + 0.11 * w; // ...and everything speeds up each wave
48 const rewardFor = (c) => 1 + c;
49 const hpBase = (c) => 12 + c * 9; // health scales with commits (color)
50 const hpFor = (c, w) => Math.round(hpBase(c) * (1 + 0.14 * w)); // ...and ramps up hard each wave
51 // peon & rapid deal damage; frost deals none but chills (slows) so your killers get more shots in
52 const TOWERS = {
53 peon: { cost: 50, range: 2.0, cd: 0.35, dmg: 9, body: "#38bdf8", proj: "#7dd3fc", slow: 0, slowT: 0 },
54 frost: { cost: 70, range: 2.4, cd: 0.50, dmg: 0, body: "#67e8f9", proj: "#a5f3fc", slow: 0.4, slowT: 1.4 },
55 rapid: { cost: 90, range: 1.7, cd: 0.16, dmg: 4, body: "#a78bfa", proj: "#c4b5fd", slow: 0, slowT: 0 },
56 };
57 const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
58
59 // ---- waves (grouped by month) ----
60 const greenFor = (c) => (c >= 8 ? "#22c55e" : c >= 4 ? "#4ade80" : c >= 2 ? "#86efac" : "#bbf7d0");
61 const groupWaves = (list) => {
62 const bm = Array.from({ length: 12 }, () => []);
63 list.forEach((e) => bm[e.month].push(e));
64 return bm.map((l, m) => ({ m, list: l })).filter((w) => w.list.length);
65 };
66 let waves = groupWaves(roster);
67 let totalEnemies = roster.length;
68 let boss = false;
69
70 // The "final boss": a nod to Lewis (oyster.cafe) and his absurd commit count. We don't
71 // fetch anything, just conjure a swarm sized off the number, ~1 dot per 100 commits.
72 const BOSS_NAME = "Lewis";
73 const BOSS_COMMITS = 25241;
74 const bossRoster = () => {
75 const n = Math.round(BOSS_COMMITS / 100); // ~252 dots, each worth ~100 commits
76 const list = [];
77 for (let i = 0; i < n; i++) {
78 const count = 8 + ((i * 7) % 8); // all maxed-out (brightest); every day is a grind for Lewis
79 list.push({ count, month: i % 12, color: greenFor(count) });
80 }
81 return list;
82 };
83
84 // ---- board geometry (recomputed on enter, sized to the punchcard) ----
85 let COLS, ROWS, TILE, W, H, pts, segLen, cum, pathLen, blocked, buildable;
86 const keyOf = (c, r) => c + "," + r;
87
88 function serpentine(cols, rows) {
89 const lanes = [];
90 for (let r = 0; r < rows; r += 2) lanes.push(r);
91 const wp = [[-1, lanes[0]]];
92 for (let i = 0; i < lanes.length; i++) {
93 const ltr = i % 2 === 0, a = ltr ? 0 : cols - 1, b = ltr ? cols - 1 : 0;
94 wp.push([a, lanes[i]], [b, lanes[i]]);
95 if (i < lanes.length - 1) wp.push([b, lanes[i + 1]]);
96 }
97 const li = lanes.length - 1;
98 wp.push([li % 2 === 0 ? cols : -1, lanes[li]]);
99 return wp;
100 }
101
102 function layout() {
103 const cw = Math.max(160, grid.clientWidth);
104 const ch = Math.max(160, grid.clientHeight);
105 COLS = 8;
106 TILE = cw / COLS;
107 ROWS = Math.max(7, Math.min(20, Math.floor(ch / TILE)));
108 W = cw; H = ch; // exactly cover the punchcard, never taller than it
109 const wp = serpentine(COLS, ROWS);
110 pts = wp.map(([c, r]) => ({ x: c * TILE + TILE / 2, y: r * TILE + TILE / 2 }));
111 segLen = []; cum = [0];
112 for (let i = 0; i < pts.length - 1; i++) {
113 const L = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
114 segLen.push(L); cum.push(cum[i] + L);
115 }
116 pathLen = cum[cum.length - 1];
117 blocked = new Set();
118 for (let i = 0; i < wp.length - 1; i++) {
119 let [c, r] = wp[i]; const [c1, r1] = wp[i + 1];
120 const dc = Math.sign(c1 - c), dr = Math.sign(r1 - r);
121 blocked.add(keyOf(c, r));
122 while (c !== c1 || r !== r1) { c += dc; r += dr; blocked.add(keyOf(c, r)); }
123 }
124 buildable = [];
125 for (let r = 0; r < ROWS; r++)
126 for (let c = 0; c < COLS; c++)
127 if (!blocked.has(keyOf(c, r))) buildable.push([c, r]);
128 }
129
130 const pointAt = (d) => {
131 if (d <= 0) return { x: pts[0].x, y: pts[0].y };
132 if (d >= pathLen) return { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y };
133 let i = 0;
134 while (i < segLen.length && cum[i + 1] < d) i++;
135 const t = (d - cum[i]) / segLen[i];
136 return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t };
137 };
138
139 // ---- DOM ----
140 const el = (tag, style, text) => {
141 const n = document.createElement(tag);
142 if (style) n.style.cssText = style;
143 if (text != null) n.textContent = text;
144 return n;
145 };
146 const btn = (label) =>
147 el("button", "font-family:ui-monospace,Menlo,monospace;font-size:11px;padding:5px 8px;border-radius:6px;border:1px solid rgba(120,120,135,0.4);background:rgba(2,6,23,0.6);color:#cbd5e1;cursor:pointer;", label);
148
149 grid.style.position = "relative";
150
151 const canvas = el("canvas", "position:absolute;left:0;top:0;display:none;border-radius:6px;z-index:5;touch-action:none;cursor:crosshair;");
152 const hudBar = el("div", "position:absolute;left:0;right:0;top:0;display:none;padding:3px 54px 3px 5px;white-space:nowrap;overflow:hidden;font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#e2e8f0;background:linear-gradient(#0b1220ee,#0b122000);border-radius:6px 6px 0 0;z-index:6;pointer-events:none;");
153 const overlayMsg = el("div", "position:absolute;left:0;top:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:9px;text-align:center;padding:14px;border-radius:6px;background:rgba(2,6,23,0.86);color:#e2e8f0;z-index:7;");
154 grid.append(canvas, hudBar, overlayMsg);
155 // always-on-board fast-forward toggle (top-right) so speed is reachable during any wave
156 const ffBtn = el("button", "position:absolute;right:4px;top:3px;z-index:6;display:none;font-family:ui-monospace,Menlo,monospace;font-size:10px;padding:2px 6px;border-radius:6px;border:1px solid rgba(120,120,135,0.55);background:rgba(2,6,23,0.85);color:#e2e8f0;cursor:pointer;", "⏩ 1×");
157 grid.appendChild(ffBtn);
158 const ctx = canvas.getContext("2d");
159
160 // controls live below the punchcard; only the board overlays it
161 const ui = el("div", "font-family:ui-monospace,Menlo,monospace;margin-top:10px;");
162 const playBtn = btn("▶ play tower defense");
163 playBtn.style.borderColor = "#22c55e"; playBtn.style.color = "#4ade80";
164 ui.appendChild(playBtn);
165
166 const panel = el("div", "display:none;flex-direction:column;gap:6px;");
167 const shop = el("div", "display:flex;gap:5px;flex-wrap:wrap;");
168 const shopBtns = [];
169 [["peon", "Peon"], ["frost", "Frost"], ["rapid", "Rapid"]].forEach(([t, label]) => {
170 const b = btn(label + " " + TOWERS[t].cost);
171 b.dataset.t = t;
172 shop.appendChild(b); shopBtns.push(b);
173 });
174 const controls = el("div", "display:flex;gap:5px;flex-wrap:wrap;");
175 const pauseBtn = btn("⏸ pause");
176 const resetBtn = btn("↺ reset");
177 const exitBtn = btn("✕ exit");
178 controls.append(pauseBtn, resetBtn, exitBtn);
179 const tip = el("div", "font-size:9px;color:#64748b;", "build on dark tiles · peon & rapid damage, frost slows · ⏩ or press F to fast-forward");
180 panel.append(shop, controls, tip);
181 ui.appendChild(panel);
182 parent.insertBefore(ui, grid.nextSibling);
183
184 // ---- state ----
185 let active = false, gold, waveNum, enemies, towers, shots,
186 spawnQueue, spawnTimer, betweenTimer, state, paused, speed, selected, hover, killed, escaped;
187
188 function reset() {
189 boss = false;
190 waves = groupWaves(roster); totalEnemies = roster.length;
191 gold = START_GOLD; waveNum = 0;
192 enemies = []; towers = []; shots = []; spawnQueue = []; spawnTimer = 0; betweenTimer = null;
193 state = "idle"; paused = false; speed = 1; selected = "peon"; hover = null;
194 killed = 0; escaped = 0;
195 roster.forEach((e) => { e.dot.style.opacity = ""; });
196 showOverlay("start"); sync();
197 }
198
199 function enter() {
200 layout();
201 const dpr = Math.min(2, window.devicePixelRatio || 1);
202 canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
203 canvas.style.width = W + "px"; canvas.style.height = H + "px";
204 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
205 overlayMsg.style.width = W + "px"; overlayMsg.style.height = H + "px";
206 canvas.style.display = "block"; hudBar.style.display = "block"; panel.style.display = "flex";
207 ffBtn.style.display = "block"; playBtn.style.display = "none";
208 active = true;
209 reset();
210 }
211 function exit() {
212 active = false;
213 canvas.style.display = "none"; hudBar.style.display = "none"; overlayMsg.style.display = "none";
214 ffBtn.style.display = "none"; panel.style.display = "none"; playBtn.style.display = "inline-block";
215 roster.forEach((e) => { e.dot.style.opacity = ""; });
216 }
217
218 function dim(src, leaked) {
219 // keep the punchcard color & size; just fade to show it's been dealt with
220 if (src.dot) src.dot.style.opacity = leaked ? "0.12" : "0.32"; // boss dots have no home cell
221 }
222 function makeEnemy(src) {
223 // boss dots move at a flat brisk clip (they're all maxed out, so speedFor would crawl)
224 const spd = (boss ? 1.7 : speedFor(src.count)) * waveSpeed(waveNum);
225 const hp = boss ? 24 : hpFor(src.count, waveNum);
226 return { src, dist: 0, x: pts[0].x, y: pts[0].y, count: src.count,
227 spd, hp, maxHp: hp, reward: boss ? 2 : rewardFor(src.count), color: src.color, rad: RAD,
228 slowF: 1, slowT: 0, dead: false, leaked: false };
229 }
230 function beginWave() {
231 if (state !== "idle") return;
232 spawnQueue = waves[0].list.slice();
233 spawnTimer = 0.15; betweenTimer = null;
234 state = "running"; paused = false;
235 hideOverlay(); sync();
236 }
237 function startBoss() {
238 boss = true;
239 const list = bossRoster();
240 waves = groupWaves(list); totalEnemies = list.length;
241 waveNum = 0; enemies = []; shots = []; betweenTimer = null; killed = 0; escaped = 0;
242 spawnQueue = waves[0].list.slice(); spawnTimer = 0.2;
243 gold += 200; // reinforcements for the final stand (your towers carry over)
244 state = "running"; paused = false;
245 hideOverlay(); sync();
246 }
247
248 function updateShop() {
249 shopBtns.forEach((b) => {
250 const spec = TOWERS[b.dataset.t], is = b.dataset.t === selected;
251 b.style.borderColor = is ? "#22c55e" : "rgba(120,120,135,0.4)";
252 b.style.color = is ? "#4ade80" : gold < spec.cost ? "#64748b" : "#cbd5e1";
253 b.style.boxShadow = is ? "inset 0 0 0 1px #22c55e" : "none";
254 b.style.opacity = gold < spec.cost ? "0.6" : "1";
255 });
256 }
257 function sync() {
258 const shown = state === "idle" ? 0 : state === "won" ? waves.length : Math.min(waveNum + 1, waves.length);
259 let line = "💰" + gold + " 🌊" + shown + "/" + waves.length +
260 " " + (boss ? "BOSS" : MONTHS[waves[Math.min(waveNum, waves.length - 1)].m]) +
261 " <span style=\"color:#4ade80\">●</span>" + enemies.length +
262 " (" + Math.max(0, totalEnemies - killed - escaped) + " left)";
263 if (state === "running" && betweenTimer !== null) line += " ⏳next " + Math.ceil(betweenTimer) + "s";
264 hudBar.innerHTML = line;
265 pauseBtn.disabled = state !== "running";
266 pauseBtn.style.opacity = pauseBtn.disabled ? "0.4" : "1";
267 pauseBtn.textContent = paused ? "▶ resume" : "⏸ pause";
268 ffBtn.textContent = "⏩ " + speed + "×";
269 updateShop();
270 }
271 function showOverlay(kind) {
272 overlayMsg.style.display = "flex"; overlayMsg.innerHTML = "";
273 const h = el("div", "font-size:15px;font-weight:600;");
274 const p = el("div", "font-size:10px;line-height:1.5;color:#cbd5e1;max-width:195px;");
275 const row = el("div", "display:flex;gap:6px;flex-wrap:wrap;justify-content:center;");
276 const add = (label, fn, accent) => {
277 const b = btn(label);
278 if (accent) { b.style.borderColor = "#22c55e"; b.style.color = "#4ade80"; }
279 b.onclick = fn; row.appendChild(b);
280 };
281 if (kind === "start") {
282 h.textContent = "Tower defense";
283 p.innerHTML = "<b>" + totalEnemies + "</b> commit-days march the track over <b>" + waves.length +
284 "</b> waves, each faster than the last. Brighter dots pack more commits and take more hits. Let a single one reach the exit and it's over.";
285 add("⚔ line them up", beginWave, true);
286 } else if (kind === "won") {
287 h.textContent = "You won! 🎉"; h.style.color = "#4ade80";
288 p.innerHTML = "Are you ready to fight " + BOSS_NAME + ", the final boss?";
289 add("I think so. Wait.. more than 25k commits..?", startBoss, true);
290 add("No, but throw it at me anyway", startBoss);
291 } else if (kind === "bossWon") {
292 h.textContent = "Final boss down"; h.style.color = "#facc15";
293 p.innerHTML = "You held the line against " + BOSS_NAME + "'s onslaught. <b>" + BOSS_COMMITS.toLocaleString("en-US") +
294 "</b> commits and not one got through.";
295 add("↺ play again", reset, true);
296 } else {
297 h.textContent = "One slipped through 💥"; h.style.color = "#f87171";
298 p.innerHTML = "That's all it takes. You crushed <b>" + killed + "</b> before a commit reached the exit.";
299 add("↺ try again", reset, true);
300 }
301 overlayMsg.append(h, p, row);
302 }
303 function hideOverlay() { overlayMsg.style.display = "none"; overlayMsg.innerHTML = ""; }
304
305 // ---- simulation ----
306 function step(dt) {
307 const s = dt * speed;
308 if (spawnQueue.length) {
309 const gap = boss ? 0.18 : Math.max(0.22, SPAWN_GAP - waveNum * 0.025); // waves flood denser as they go
310 spawnTimer -= s;
311 while (spawnTimer <= 0 && spawnQueue.length) { enemies.push(makeEnemy(spawnQueue.shift())); spawnTimer += gap; }
312 }
313 for (const e of enemies) {
314 let m = 1;
315 if (e.slowT > 0) { m = e.slowF; e.slowT -= s; }
316 e.dist += e.spd * TILE * m * s;
317 if (e.dist >= pathLen) e.leaked = true;
318 else { const q = pointAt(e.dist); e.x = q.x; e.y = q.y; }
319 }
320 const gotThrough = enemies.find((e) => e.leaked);
321 if (gotThrough) { escaped++; dim(gotThrough.src, true); state = "lost"; showOverlay("lost"); sync(); return; }
322 for (const t of towers) {
323 t.cd -= s;
324 if (t.cd > 0) continue;
325 const spec = TOWERS[t.type], range = spec.range * TILE;
326 let best = null, bd = -1, rr = range * range;
327 for (const e of enemies) {
328 if (e.dead) continue;
329 if (spec.slow && e.slowT > 0) continue; // frost won't waste a shot re-chilling
330 const dx = e.x - t.x, dy = e.y - t.y;
331 if (dx * dx + dy * dy <= rr && e.dist > bd) { best = e; bd = e.dist; }
332 }
333 if (best) { shots.push({ x: t.x, y: t.y, target: best, color: spec.proj, dmg: spec.dmg, slow: spec.slow, slowT: spec.slowT, v: 340 }); t.cd = spec.cd; }
334 else t.cd = 0;
335 }
336 for (const p of shots) {
337 const e = p.target;
338 if (!e || e.dead) { p.done = true; continue; }
339 const dx = e.x - p.x, dy = e.y - p.y, d = Math.hypot(dx, dy) || 0.001, adv = p.v * s;
340 if (d <= adv + e.rad) {
341 if (p.slow > 0) { e.slowF = p.slow; e.slowT = Math.max(e.slowT, p.slowT); } // frost chills
342 if (p.dmg > 0) {
343 e.hp -= p.dmg;
344 if (e.hp <= 0 && !e.dead) { e.dead = true; gold += e.reward; killed++; dim(e.src, false); }
345 }
346 p.done = true;
347 } else { p.x += dx / d * adv; p.y += dy / d * adv; }
348 }
349 enemies = enemies.filter((e) => !e.dead);
350 shots = shots.filter((p) => !p.done);
351 if (state === "running" && !spawnQueue.length && !enemies.length) {
352 if (betweenTimer === null) {
353 // wave cleared, bank a small bonus, then auto-launch the next after a short breather
354 if (waveNum + 1 >= waves.length) { state = "won"; showOverlay(boss ? "bossWon" : "won"); }
355 else { betweenTimer = WAVE_GAP; gold += 8 + (waveNum + 1) * 3; }
356 } else {
357 betweenTimer -= s;
358 if (betweenTimer <= 0) {
359 betweenTimer = null;
360 waveNum++;
361 spawnQueue = waves[waveNum].list.slice();
362 spawnTimer = 0.1;
363 }
364 }
365 }
366 sync();
367 }
368
369 // ---- render ----
370 function road() {
371 ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
372 for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
373 ctx.stroke();
374 }
375 function draw() {
376 ctx.clearRect(0, 0, W, H);
377 ctx.fillStyle = "rgba(2,6,23,0.62)"; ctx.fillRect(0, 0, W, H); // scrim: mutes the real dots behind
378 ctx.lineJoin = "round"; ctx.lineCap = "round";
379 ctx.strokeStyle = "rgba(30,41,59,0.92)"; ctx.lineWidth = TILE * 0.66; road();
380 ctx.strokeStyle = "rgba(148,163,184,0.5)"; ctx.lineWidth = 2; ctx.setLineDash([4, 7]); road(); ctx.setLineDash([]);
381 ctx.fillStyle = "rgba(148,163,184,0.16)";
382 for (const [c, r] of buildable) {
383 if (towers.some((t) => t.tc === c && t.tr === r)) continue;
384 ctx.beginPath(); ctx.arc(c * TILE + TILE / 2, r * TILE + TILE / 2, 1.4, 0, 7); ctx.fill();
385 }
386 if (hover && state !== "won" && state !== "lost") {
387 const { c, r } = hover;
388 if (c >= 0 && c < COLS && r >= 0 && r < ROWS) {
389 const occ = blocked.has(keyOf(c, r)) || towers.some((t) => t.tc === c && t.tr === r);
390 const spec = TOWERS[selected], ok = !occ && gold >= spec.cost;
391 const cx = c * TILE + TILE / 2, cy = r * TILE + TILE / 2;
392 ctx.beginPath(); ctx.arc(cx, cy, spec.range * TILE, 0, 7);
393 ctx.fillStyle = ok ? "rgba(74,222,128,0.10)" : "rgba(248,113,113,0.10)";
394 ctx.strokeStyle = ok ? "rgba(74,222,128,0.5)" : "rgba(248,113,113,0.5)"; ctx.lineWidth = 1.5;
395 ctx.fill(); ctx.stroke();
396 ctx.fillStyle = ok ? "rgba(74,222,128,0.4)" : "rgba(248,113,113,0.4)";
397 ctx.fillRect(c * TILE + 4, r * TILE + 4, TILE - 8, TILE - 8);
398 }
399 }
400 for (const t of towers) {
401 const spec = TOWERS[t.type];
402 ctx.fillStyle = "rgba(15,23,42,0.95)"; ctx.fillRect(t.tc * TILE + 3, t.tr * TILE + 3, TILE - 6, TILE - 6);
403 ctx.strokeStyle = spec.body; ctx.lineWidth = 1.5; ctx.strokeRect(t.tc * TILE + 3, t.tr * TILE + 3, TILE - 6, TILE - 6);
404 ctx.beginPath(); ctx.arc(t.x, t.y, TILE * 0.22, 0, 7); ctx.fillStyle = spec.body; ctx.fill();
405 }
406 for (const p of shots) { ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, 7); ctx.fillStyle = p.color; ctx.fill(); }
407 for (const e of enemies) {
408 ctx.beginPath(); ctx.arc(e.x, e.y, e.rad, 0, 7); ctx.fillStyle = e.color; ctx.fill();
409 if (e.slowT > 0) { ctx.lineWidth = 1.5; ctx.strokeStyle = "#67e8f9"; ctx.stroke(); } // chilled
410 if (e.hp < e.maxHp) {
411 const bw = e.rad * 2.4, bx = e.x - bw / 2, by = e.y - e.rad - 5;
412 ctx.fillStyle = "#0f172a"; ctx.fillRect(bx, by, bw, 2.5);
413 ctx.fillStyle = "#22c55e"; ctx.fillRect(bx, by, bw * Math.max(0, e.hp / e.maxHp), 2.5);
414 }
415 }
416 ctx.fillStyle = "#64748b"; ctx.font = "700 9px ui-monospace,monospace";
417 ctx.fillText("IN", 3, pts[0].y + 3);
418 const last = pts[pts.length - 1];
419 ctx.fillText("OUT", Math.min(W - 22, last.x - 8), last.y - 6);
420 }
421
422 // ---- input ----
423 const toTile = (ev) => {
424 const b = canvas.getBoundingClientRect();
425 const x = (ev.clientX - b.left) * (W / b.width), y = (ev.clientY - b.top) * (H / b.height);
426 return { c: Math.floor(x / TILE), r: Math.floor(y / TILE) };
427 };
428 canvas.addEventListener("pointermove", (e) => { hover = toTile(e); }, { signal: sig });
429 canvas.addEventListener("pointerleave", () => { hover = null; }, { signal: sig });
430 canvas.addEventListener("pointerdown", (e) => {
431 e.preventDefault();
432 if (state === "won" || state === "lost") return;
433 const t = toTile(e); hover = t;
434 if (t.c < 0 || t.c >= COLS || t.r < 0 || t.r >= ROWS) return;
435 if (blocked.has(keyOf(t.c, t.r))) return;
436 if (towers.some((w) => w.tc === t.c && w.tr === t.r)) return;
437 const spec = TOWERS[selected];
438 if (gold < spec.cost) return;
439 gold -= spec.cost;
440 towers.push({ tc: t.c, tr: t.r, x: t.c * TILE + TILE / 2, y: t.r * TILE + TILE / 2, type: selected, cd: 0 });
441 sync();
442 }, { signal: sig });
443 shopBtns.forEach((b) => b.addEventListener("click", () => { selected = b.dataset.t; updateShop(); }, { signal: sig }));
444 playBtn.addEventListener("click", enter, { signal: sig });
445 exitBtn.addEventListener("click", exit, { signal: sig });
446 pauseBtn.addEventListener("click", () => { if (state === "running") { paused = !paused; sync(); } }, { signal: sig });
447 const bumpSpeed = () => { speed = speed >= 4 ? 1 : speed + 1; sync(); };
448 ffBtn.addEventListener("click", bumpSpeed, { signal: sig });
449 window.addEventListener("keydown", (e) => {
450 if (!active) return;
451 if (e.key === "f" || e.key === "F") bumpSpeed();
452 else if (e.key === " " && state === "running") { e.preventDefault(); paused = !paused; sync(); }
453 }, { signal: sig });
454 resetBtn.addEventListener("click", reset, { signal: sig });
455
456 // ---- loop ----
457 let last = null, raf = 0, alive = true;
458 function frame(t) {
459 if (!alive) return;
460 raf = requestAnimationFrame(frame);
461 if (!active) { last = t; return; }
462 if (last === null) last = t;
463 let dt = (t - last) / 1000; last = t;
464 if (dt > 0.05) dt = 0.05;
465 if (state === "running" && !paused) step(dt);
466 draw();
467 }
468 raf = requestAnimationFrame(frame);
469
470 // ---- teardown (called before re-binding on the next htmx swap) ----
471 return () => {
472 alive = false;
473 cancelAnimationFrame(raf);
474 ac.abort();
475 ui.remove();
476 canvas.remove(); hudBar.remove(); overlayMsg.remove(); ffBtn.remove();
477 grid.style.position = "";
478 delete grid.dataset.tdOn;
479 roster.forEach((e) => { e.dot.style.opacity = ""; });
480 };
481}