Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/pages: add punchcard TD game

Signed-off-by: Jens Rømer Hesselbjerg <jh.roemer@gmail.com>

authored by

Jens Rømer Hesselbjerg and committed by
Tangled
(Jul 14, 2026, 3:48 PM +0300) 506dc233 411051cd

+482
+481
appview/pages/profile-fx/punchcard-td.js
··· 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. 9 + let tdTeardown = null; 10 + let tdGrid = null; 11 + function 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 + } 18 + tdBoot(); 19 + document.addEventListener("htmx:load", tdBoot); 20 + 21 + function 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 + " &nbsp;<span style=\"color:#4ade80\">●</span>" + enemies.length + 262 + " &nbsp;(" + Math.max(0, totalEnemies - killed - escaped) + " left)"; 263 + if (state === "running" && betweenTimer !== null) line += " &nbsp;⏳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 + }
+1
appview/state/profile.go
··· 59 59 "did:plc:oxdlsmnvpk2riyyuvq5jtdkd": "/static/profile-fx/wilb.me.js", 60 60 "did:plc:lrphxvv25aibthe7xoc2eeyy": "/static/profile-fx/kandake.js", 61 61 "did:plc:vzl336yxfrftoc23ygcqklzr": "/static/profile-fx/gdorsi.bsky.social.js", 62 + "did:plc:laqygfbyvnkyuhsuaxmp6ez3": "/static/profile-fx/punchcard-td.js", 62 63 } 63 64 64 65 func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {