Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

table: add chips, elastic pan, clamp cards to table, clean HUD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+229 -56
+229 -56
system/public/aesthetic.computer/disks/table.mjs
··· 11 11 const CARD_W = 28; 12 12 const CARD_H = 38; 13 13 14 + // Chip constants 15 + const CHIP_R = 6; 16 + const CHIP_COLOR = [210, 60, 60]; 17 + const CHIP_EDGE = [180, 45, 45]; 18 + 14 19 // Table size (fixed geography, larger than screen) 15 20 const TABLE_W = 480; 16 21 const TABLE_H = 320; ··· 30 35 let tableCards = []; 31 36 let deck = []; // remaining card indices 32 37 let deckSeed = 0; 38 + 39 + // Chips on the table: { id, x, y, dragger: handle|null } 40 + let tableChips = []; 41 + let chipIdCounter = 0; 33 42 34 43 // Deck position on table (fixed) 35 44 const DECK_X = Math.floor(TABLE_W / 2 - CARD_W / 2); 36 45 const DECK_Y = Math.floor(TABLE_H / 2 - CARD_H / 2); 37 46 47 + // Chip pile position (to the right of deck) 48 + const CHIP_PILE_X = DECK_X + CARD_W + 20; 49 + const CHIP_PILE_Y = DECK_Y + Math.floor(CARD_H / 2); 50 + 38 51 // Camera (viewport offset into table) 39 52 let camX = 0, camY = 0; 40 53 let sw = 0, sh = 0; 41 54 42 - // Local drag state 43 - let dragging = null; // index into tableCards 55 + // Local drag state — "card" or "chip" + index 56 + let dragType = null; // "card" | "chip" | null 57 + let dragging = null; // index into tableCards or tableChips 44 58 let dragOffX = 0, dragOffY = 0; 45 59 let hoverIdx = -1; // index into tableCards 60 + let hoverChipIdx = -1; // index into tableChips 46 61 47 62 // Pan state 48 63 let panning = false; 49 64 let panStartX = 0, panStartY = 0; 50 65 let panCamStartX = 0, panCamStartY = 0; 66 + 67 + // Elastic snap constants 68 + const SNAP_STRENGTH = 0.15; 69 + const SNAP_MARGIN = 10; // pixels of allowed overscroll before snap 51 70 52 71 // Deck hit zone (table space, slightly larger than card for easy drop) 53 72 const DECK_PAD = 6; ··· 56 75 ty >= DECK_Y - DECK_PAD && ty < DECK_Y + CARD_H + DECK_PAD; 57 76 } 58 77 78 + // Chip pile hit zone 79 + const CPILE_PAD = 4; 80 + function overChipPile(tx, ty) { 81 + const dx = tx - CHIP_PILE_X; 82 + const dy = ty - CHIP_PILE_Y; 83 + return dx * dx + dy * dy <= (CHIP_R + CPILE_PAD) * (CHIP_R + CPILE_PAD); 84 + } 85 + 86 + // Find topmost chip at position 87 + function chipAtPos(tx, ty) { 88 + for (let i = tableChips.length - 1; i >= 0; i--) { 89 + const ch = tableChips[i]; 90 + const dx = tx - ch.x; 91 + const dy = ty - ch.y; 92 + if (dx * dx + dy * dy <= CHIP_R * CHIP_R) return i; 93 + } 94 + return -1; 95 + } 96 + 59 97 // Seeded shuffle 60 98 function seededShuffle(arr, seed) { 61 99 const a = [...arr]; ··· 72 110 deckSeed = seed; 73 111 deck = seededShuffle(Array.from({ length: 52 }, (_, i) => i), seed); 74 112 tableCards = []; 113 + tableChips = []; 114 + dragType = null; 75 115 dragging = null; 76 116 hoverIdx = -1; 117 + hoverChipIdx = -1; 77 118 } 78 119 79 120 // Convert screen coords to table coords ··· 117 158 118 159 // UDP for low-latency drag position sync 119 160 udpChannel = udp((type, content) => { 161 + const d = typeof content === "string" ? JSON.parse(content) : content; 162 + if (d.handle === myHandle) return; 163 + 120 164 if (type === "table:drag") { 121 - const d = typeof content === "string" ? JSON.parse(content) : content; 122 - if (d.handle === myHandle) return; 123 - // Find card on table and update its position 124 165 const tc = tableCards.find((c) => c.idx === d.cardIdx); 125 - if (tc) { 126 - tc.x = d.x; 127 - tc.y = d.y; 128 - tc.dragger = d.handle; 129 - } 166 + if (tc) { tc.x = d.x; tc.y = d.y; tc.dragger = d.handle; } 167 + } 168 + 169 + if (type === "table:chipdrag") { 170 + const ch = tableChips.find((c) => c.id === d.chipId); 171 + if (ch) { ch.x = d.x; ch.y = d.y; ch.dragger = d.handle; } 130 172 } 131 173 }); 132 174 ··· 138 180 } 139 181 140 182 if (type === "left") { 141 - // Release any card they were dragging 142 - for (const c of tableCards) { 143 - if (players[id] && c.dragger === players[id].handle) { 144 - c.dragger = null; 145 - } 183 + // Release any card/chip they were dragging 184 + const h = players[id]?.handle; 185 + if (h) { 186 + for (const c of tableCards) { if (c.dragger === h) c.dragger = null; } 187 + for (const c of tableChips) { if (c.dragger === h) c.dragger = null; } 146 188 } 147 189 delete players[id]; 148 190 return; ··· 160 202 cards: tableCards.map((c) => ({ 161 203 idx: c.idx, x: c.x, y: c.y, faceUp: c.faceUp, dragger: c.dragger, 162 204 })), 205 + chips: tableChips.map((c) => ({ id: c.id, x: c.x, y: c.y, dragger: c.dragger })), 163 206 }); 164 207 } 165 208 166 209 if (type === "table:state") { 167 210 if (!players[id]) players[id] = { handle: msg.handle }; 168 211 // Sync table state from existing player 169 - if (msg.cards && msg.cards.length > 0 && tableCards.length === 0) { 170 - // Only accept if we have no cards yet (fresh join) 212 + if ((msg.cards?.length > 0 || msg.chips?.length > 0) && tableCards.length === 0) { 171 213 doShuffle(msg.seed); 172 - // Remove cards that are on the table from the deck 173 - for (const c of msg.cards) { 214 + for (const c of (msg.cards || [])) { 174 215 const di = deck.indexOf(c.idx); 175 216 if (di >= 0) deck.splice(di, 1); 176 217 tableCards.push({ 177 218 idx: c.idx, x: c.x, y: c.y, 178 219 faceUp: c.faceUp, dragger: c.dragger, 179 220 }); 221 + } 222 + for (const ch of (msg.chips || [])) { 223 + tableChips.push({ id: ch.id, x: ch.x, y: ch.y, dragger: ch.dragger }); 224 + if (ch.id >= chipIdCounter) chipIdCounter = ch.id + 1; 180 225 } 181 226 } 182 227 } ··· 226 271 const tc = tableCards.find((c) => c.idx === msg.cardIdx); 227 272 if (tc) tc.faceUp = !tc.faceUp; 228 273 } 274 + 275 + // Chip events 276 + if (type === "table:chipspawn") { 277 + tableChips.push({ id: msg.chipId, x: msg.x, y: msg.y, dragger: null }); 278 + } 279 + 280 + if (type === "table:chipgrab") { 281 + const ch = tableChips.find((c) => c.id === msg.chipId); 282 + if (ch) ch.dragger = msg.handle; 283 + } 284 + 285 + if (type === "table:chipdrop") { 286 + const ch = tableChips.find((c) => c.id === msg.chipId); 287 + if (ch) { ch.x = msg.x; ch.y = msg.y; ch.dragger = null; } 288 + } 289 + 290 + if (type === "table:chipreturn") { 291 + const ci = tableChips.findIndex((c) => c.id === msg.chipId); 292 + if (ci >= 0) tableChips.splice(ci, 1); 293 + } 229 294 }); 230 295 231 296 wipe(245, 240, 230); ··· 236 301 237 302 // Send drag position via UDP every 2 frames 238 303 if (dragging !== null && frameCount % 2 === 0 && udpChannel?.connected) { 239 - const c = tableCards[dragging]; 240 - if (c) { 241 - udpChannel.send("table:drag", { 242 - handle: myHandle, 243 - cardIdx: c.idx, 244 - x: c.x, 245 - y: c.y, 246 - }); 304 + if (dragType === "card") { 305 + const c = tableCards[dragging]; 306 + if (c) { 307 + udpChannel.send("table:drag", { 308 + handle: myHandle, cardIdx: c.idx, x: c.x, y: c.y, 309 + }); 310 + } 311 + } else if (dragType === "chip") { 312 + const ch = tableChips[dragging]; 313 + if (ch) { 314 + udpChannel.send("table:chipdrag", { 315 + handle: myHandle, chipId: ch.id, x: ch.x, y: ch.y, 316 + }); 317 + } 247 318 } 248 319 } 320 + 321 + // Elastic snap-back when not panning 322 + if (!panning) { 323 + const minX = -SNAP_MARGIN; 324 + const minY = -SNAP_MARGIN; 325 + const maxX = Math.max(TABLE_W - sw + SNAP_MARGIN, minX); 326 + const maxY = Math.max(TABLE_H - sh + SNAP_MARGIN, minY); 327 + 328 + if (camX < minX) camX += (minX - camX) * SNAP_STRENGTH; 329 + else if (camX > maxX) camX += (maxX - camX) * SNAP_STRENGTH; 330 + 331 + if (camY < minY) camY += (minY - camY) * SNAP_STRENGTH; 332 + else if (camY > maxY) camY += (maxY - camY) * SNAP_STRENGTH; 333 + } 249 334 } 250 335 251 336 function act({ event: e, screen }) { ··· 256 341 if (e.is("move")) { 257 342 const { tx, ty } = toTable(e.x, e.y); 258 343 hoverIdx = cardAtPos(tx, ty); 344 + hoverChipIdx = chipAtPos(tx, ty); 259 345 } 260 346 261 - // Touch: tap deck to draw, or grab a card 347 + // Touch: tap deck/chip-pile to spawn, or grab a card/chip 262 348 if (e.is("touch")) { 263 349 const { tx, ty } = toTable(e.x, e.y); 264 350 265 351 // Tap the deck to pull a card off 266 352 if (overDeck(tx, ty) && deck.length > 0) { 267 353 const cardIdx = deck.pop(); 268 - // Place just below the deck 269 354 const x = DECK_X + (Math.random() - 0.5) * 30; 270 355 const y = DECK_Y + CARD_H + 8; 271 356 tableCards.push({ idx: cardIdx, x, y, faceUp: true, dragger: null }); ··· 273 358 return; 274 359 } 275 360 361 + // Tap the chip pile to spawn a chip 362 + if (overChipPile(tx, ty)) { 363 + const id = chipIdCounter++; 364 + const x = CHIP_PILE_X + (Math.random() - 0.5) * 16; 365 + const y = CHIP_PILE_Y + CHIP_R + 10; 366 + tableChips.push({ id, x, y, dragger: null }); 367 + server?.send("table:chipspawn", { handle: myHandle, chipId: id, x, y }); 368 + return; 369 + } 370 + 371 + // Try to grab a chip (chips are on top of cards visually) 372 + const ci = chipAtPos(tx, ty); 373 + if (ci >= 0) { 374 + const ch = tableChips[ci]; 375 + if (!ch.dragger || ch.dragger === myHandle) { 376 + // Bring chip to top 377 + const chip = tableChips.splice(ci, 1)[0]; 378 + tableChips.push(chip); 379 + dragging = tableChips.length - 1; 380 + dragType = "chip"; 381 + chip.dragger = myHandle; 382 + dragOffX = tx - chip.x; 383 + dragOffY = ty - chip.y; 384 + server?.send("table:chipgrab", { handle: myHandle, chipId: chip.id }); 385 + } 386 + return; 387 + } 388 + 276 389 // Try to grab a card on the table 277 390 const idx = cardAtPos(tx, ty); 278 391 if (idx >= 0) { 279 392 const c = tableCards[idx]; 280 393 if (!c.dragger || c.dragger === myHandle) { 281 394 dragging = bringToTop(idx); 395 + dragType = "card"; 282 396 const nc = tableCards[dragging]; 283 397 nc.dragger = myHandle; 284 398 dragOffX = tx - nc.x; 285 399 dragOffY = ty - nc.y; 286 400 server?.send("table:grab", { handle: myHandle, cardIdx: nc.idx }); 287 401 } 288 - } else if (!overDeck(tx, ty)) { 402 + } else { 289 403 // Start panning (dragging empty space) 290 404 panning = true; 291 405 panStartX = e.x; ··· 295 409 } 296 410 } 297 411 298 - // Drag: move a card or pan the view 412 + // Drag: move a card/chip or pan the view 299 413 if (e.is("draw")) { 300 - if (dragging !== null) { 414 + if (dragging !== null && dragType === "card") { 301 415 const { tx, ty } = toTable(e.x, e.y); 302 416 const c = tableCards[dragging]; 303 417 if (c) { 304 - c.x = tx - dragOffX; 305 - c.y = ty - dragOffY; 418 + c.x = Math.max(0, Math.min(TABLE_W - CARD_W, tx - dragOffX)); 419 + c.y = Math.max(0, Math.min(TABLE_H - CARD_H, ty - dragOffY)); 420 + } 421 + } else if (dragging !== null && dragType === "chip") { 422 + const { tx, ty } = toTable(e.x, e.y); 423 + const ch = tableChips[dragging]; 424 + if (ch) { 425 + ch.x = Math.max(CHIP_R, Math.min(TABLE_W - CHIP_R, tx - dragOffX)); 426 + ch.y = Math.max(CHIP_R, Math.min(TABLE_H - CHIP_R, ty - dragOffY)); 306 427 } 307 428 } else if (panning) { 308 429 camX = panCamStartX - (e.x - panStartX); ··· 310 431 } 311 432 } 312 433 313 - // Lift: drop card, return to deck, or stop panning 434 + // Lift: drop card/chip, return to deck/pile, or stop panning 314 435 if (e.is("lift")) { 315 436 panning = false; 316 - if (dragging !== null) { 437 + if (dragging !== null && dragType === "card") { 317 438 const c = tableCards[dragging]; 318 439 if (c) { 319 - // Check if dropped on the deck — return it 320 440 const cx = c.x + CARD_W / 2; 321 441 const cy = c.y + CARD_H / 2; 322 442 if (overDeck(cx, cy)) { 323 - // Return card to deck and reshuffle 324 443 const cardIdx = c.idx; 325 444 tableCards.splice(dragging, 1); 326 445 const seed = Date.now(); ··· 334 453 }); 335 454 } 336 455 } 337 - dragging = null; 338 - hoverIdx = -1; 456 + } else if (dragging !== null && dragType === "chip") { 457 + const ch = tableChips[dragging]; 458 + if (ch) { 459 + // Drop on chip pile to remove 460 + if (overChipPile(ch.x, ch.y)) { 461 + const chipId = ch.id; 462 + tableChips.splice(dragging, 1); 463 + server?.send("table:chipreturn", { handle: myHandle, chipId }); 464 + } else { 465 + ch.dragger = null; 466 + server?.send("table:chipdrop", { 467 + handle: myHandle, chipId: ch.id, x: ch.x, y: ch.y, 468 + }); 469 + } 470 + } 339 471 } 472 + dragType = null; 473 + dragging = null; 474 + hoverIdx = -1; 475 + hoverChipIdx = -1; 340 476 } 341 477 342 478 // Press f to flip hovered card ··· 447 583 } 448 584 } 449 585 586 + // -- Chip pile (table space) -- 587 + const cps = toScreen(CHIP_PILE_X, CHIP_PILE_Y); 588 + ink(170, 50, 50).circle(cps.sx, cps.sy, CHIP_R + 1, true); 589 + ink(200, 60, 60).circle(cps.sx, cps.sy, CHIP_R, true); 590 + ink(230, 80, 80).circle(cps.sx, cps.sy, CHIP_R - 2, false); 591 + 592 + // -- Chips on table -- 593 + for (let i = 0; i < tableChips.length; i++) { 594 + const ch = tableChips[i]; 595 + const s = toScreen(ch.x, ch.y); 596 + const isHover = i === hoverChipIdx && dragging === null; 597 + const isDrag = dragType === "chip" && i === dragging; 598 + const isRemote = ch.dragger && ch.dragger !== myHandle; 599 + 600 + // Shadow when dragged 601 + if (isDrag || isRemote) { 602 + ink(0, 0, 0, 30).circle(s.sx + 1, s.sy + 1, CHIP_R, true); 603 + } 604 + 605 + // Chip body 606 + ink(...CHIP_EDGE).circle(s.sx, s.sy, CHIP_R, true); 607 + ink(...CHIP_COLOR).circle(s.sx, s.sy, CHIP_R - 1, true); 608 + 609 + // Highlight ring 610 + if (isDrag) { 611 + ink(120, 160, 200).circle(s.sx, s.sy, CHIP_R, false); 612 + } else if (isRemote) { 613 + ink(200, 140, 100).circle(s.sx, s.sy, CHIP_R, false); 614 + } else if (isHover) { 615 + ink(255, 220, 200).circle(s.sx, s.sy, CHIP_R, false); 616 + } 617 + 618 + // Handle label 619 + if (ch.dragger) { 620 + ink(255, 255, 255, 200).write(ch.dragger, { 621 + x: s.sx - ch.dragger.length * 3, 622 + y: s.sy - CHIP_R - 9, 623 + }); 624 + } 625 + } 626 + 450 627 // -- HUD (screen space, on top) -- 451 628 452 - // Players list (top right) 453 - const pList = [myHandle]; 454 - for (const p of Object.values(players)) pList.push(p.handle); 629 + // Players list (top right) — just handles, no duplicates 630 + const handles = new Set([myHandle]); 631 + for (const p of Object.values(players)) handles.add(p.handle); 455 632 let py = 4; 456 - for (let i = 0; i < pList.length; i++) { 457 - const h = pList[i]; 458 - const label = i === 0 ? h + " (you)" : h; 459 - ink(220, 230, 215).write(label, { x: sw - label.length * 6 - 4, y: py }); 633 + for (const h of handles) { 634 + ink(220, 230, 215).write(h, { x: sw - h.length * 6 - 4, y: py }); 460 635 py += 10; 461 636 } 462 637 ··· 482 657 } 483 658 } 484 659 660 + // Chips on minimap 661 + for (const ch of tableChips) { 662 + const mx = mmX + Math.floor(ch.x * mmScale); 663 + const my = mmY + Math.floor(ch.y * mmScale); 664 + ink(210, 60, 60).box(mx, my, 2, 2); 665 + } 666 + 485 667 // Deck on minimap 486 668 if (deck.length > 0) { 487 669 const mdx = mmX + Math.floor(DECK_X * mmScale); ··· 496 678 const vh = Math.floor(sh * mmScale); 497 679 ink(255, 255, 255, 100).box(vx, vy, vw, vh, "outline"); 498 680 499 - // Hint 500 - const hint = dragging !== null 501 - ? "drop on deck to return" 502 - : hoverIdx >= 0 503 - ? "drag to move / f to flip" 504 - : deck.length > 0 505 - ? "tap deck to draw" 506 - : "all cards on table"; 507 - ink(180, 195, 175).write(hint, { x: 4, y: 4 }); 508 681 } 509 682 510 683 function meta() {