Monorepo for Aesthetic.Computer aesthetic.computer

dumduel: pan camera by dragging, tap to move, pixel-perfect figures

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

+75 -25
+75 -25
system/public/aesthetic.computer/disks/dumduel.mjs
··· 31 31 let roundWinner = null; 32 32 let ping = 0; 33 33 34 + // Camera pan 35 + let camX = 0, camY = 0; 36 + let panning = false; 37 + let panStartX = 0, panStartY = 0; 38 + let panCamStartX = 0, panCamStartY = 0; 39 + let touchStartX = 0, touchStartY = 0; 40 + let wasDrag = false; 41 + const DRAG_THRESHOLD = 5; 42 + const SNAP_STRENGTH = 0.15; 43 + const SNAP_MARGIN = 10; 44 + 34 45 function applySnapshot(s) { 35 46 snap = s; 36 47 phase = s.phase; ··· 176 187 } 177 188 localWasMoving = isMoving; 178 189 } 190 + 191 + // Elastic camera snap-back 192 + if (!panning) { 193 + const minX = -SNAP_MARGIN; 194 + const minY = -SNAP_MARGIN; 195 + const maxX = Math.max(ARENA_W - sw + SNAP_MARGIN, minX); 196 + const maxY = Math.max(ARENA_H - sh + SNAP_MARGIN, minY); 197 + if (camX < minX) camX += (minX - camX) * SNAP_STRENGTH; 198 + else if (camX > maxX) camX += (maxX - camX) * SNAP_STRENGTH; 199 + if (camY < minY) camY += (minY - camY) * SNAP_STRENGTH; 200 + else if (camY > maxY) camY += (maxY - camY) * SNAP_STRENGTH; 201 + } 179 202 } 180 203 181 204 function act({ event: e, screen }) { 182 205 sw = screen.width; 183 206 sh = screen.height; 184 207 185 - if (e.is("touch") && (phase === "fight" || phase === "countdown")) { 186 - const ox = Math.floor(sw / 2 - ARENA_W / 2); 187 - const oy = Math.floor(sh / 2 - ARENA_H / 2); 188 - const tx = Math.max(6, Math.min(ARENA_W - 6, e.x - ox)); 189 - const ty = Math.max(6, Math.min(ARENA_H - 6, e.y - oy)); 208 + if (e.is("touch")) { 209 + touchStartX = e.x; 210 + touchStartY = e.y; 211 + wasDrag = false; 212 + panning = true; 213 + panStartX = e.x; 214 + panStartY = e.y; 215 + panCamStartX = camX; 216 + panCamStartY = camY; 217 + } 190 218 191 - inputSeq++; 192 - localTargetX = tx; 193 - localTargetY = ty; 194 - pendingInputs.push({ seq: inputSeq, targetX: tx, targetY: ty }); 219 + if (e.is("draw") && panning) { 220 + const dx = e.x - touchStartX; 221 + const dy = e.y - touchStartY; 222 + if (dx * dx + dy * dy > DRAG_THRESHOLD * DRAG_THRESHOLD) { 223 + wasDrag = true; 224 + } 225 + camX = panCamStartX - (e.x - panStartX); 226 + camY = panCamStartY - (e.y - panStartY); 227 + } 195 228 196 - // Send to server via UDP 197 - udpChannel?.send("duel:input", { seq: inputSeq, targetX: tx, targetY: ty }); 229 + if (e.is("lift")) { 230 + panning = false; 231 + // If it was a short tap (not a drag), send move input 232 + if (!wasDrag && (phase === "fight" || phase === "countdown")) { 233 + const ox = Math.floor(sw / 2 - ARENA_W / 2) - camX; 234 + const oy = Math.floor(sh / 2 - ARENA_H / 2) - camY; 235 + const tx = Math.max(6, Math.min(ARENA_W - 6, e.x - ox)); 236 + const ty = Math.max(6, Math.min(ARENA_H - 6, e.y - oy)); 237 + 238 + inputSeq++; 239 + localTargetX = tx; 240 + localTargetY = ty; 241 + pendingInputs.push({ seq: inputSeq, targetX: tx, targetY: ty }); 242 + udpChannel?.send("duel:input", { seq: inputSeq, targetX: tx, targetY: ty }); 243 + } 198 244 } 199 245 } 200 246 ··· 203 249 sh = screen.height; 204 250 wipe(240, 238, 232); 205 251 206 - const ox = Math.floor(sw / 2 - ARENA_W / 2); 207 - const oy = Math.floor(sh / 2 - ARENA_H / 2); 252 + const ox = Math.round(sw / 2 - ARENA_W / 2 - camX); 253 + const oy = Math.round(sh / 2 - ARENA_H / 2 - camY); 208 254 209 255 // Arena 210 256 ink(252, 250, 245).box(ox, oy, ARENA_W, ARENA_H); ··· 256 302 const alpha = Math.max(40, 255 - (b.age || 0) * 1.2); 257 303 if (b.owner === myHandle) ink(50, 120, 200, alpha); 258 304 else ink(200, 70, 60, alpha); 259 - circle(ox + Math.floor(b.x), oy + Math.floor(b.y), BULLET_R, true); 305 + circle(ox + Math.round(b.x), oy + Math.round(b.y), BULLET_R, true); 260 306 } 261 307 262 308 // Target indicator ··· 264 310 const meAlive = players.find((p) => p.handle === myHandle)?.alive; 265 311 if (meAlive) { 266 312 ink(50, 120, 200, 60).circle( 267 - ox + Math.floor(localTargetX), 268 - oy + Math.floor(localTargetY), 313 + ox + Math.round(localTargetX), 314 + oy + Math.round(localTargetY), 269 315 3, false, 270 316 ); 271 317 } ··· 286 332 const lx = (isMe(p.handle) ? localX : p.x); 287 333 const ly = (isMe(p.handle) ? localY : p.y); 288 334 ink(...col, 150).write(fullLabel, { 289 - x: ox + Math.floor(lx) - Math.floor(fullLabel.length * 2), 290 - y: oy + Math.floor(ly) + 9, 335 + x: ox + Math.round(lx) - Math.round(fullLabel.length * 2), 336 + y: oy + Math.round(ly) + 9, 291 337 }, undefined, undefined, false, "MatrixChunky8"); 292 338 } 293 339 ··· 333 379 } 334 380 335 381 function drawFigure(ink, circle, box, line, ox, oy, fig, col, fc) { 336 - const fx = ox + Math.floor(fig.x); 337 - const fy = oy + Math.floor(fig.y); 382 + const fx = ox + Math.round(fig.x); 383 + const fy = oy + Math.round(fig.y); 338 384 339 385 if (!fig.alive) { 340 386 ink(col[0], col[1], col[2], 60); ··· 346 392 const dx = (fig.targetX || fig.x) - fig.x; 347 393 const dy = (fig.targetY || fig.y) - fig.y; 348 394 const moving = dx * dx + dy * dy > 4; 349 - const legSwing = moving ? Math.sin(fc * 0.3) * 3 : 0; 395 + const swing = moving ? Math.round(Math.sin(fc * 0.3) * 3) : 0; 350 396 351 397 ink(...col); 352 - line(fx, fy + 1, fx - 4 + legSwing, fy + 6); 353 - line(fx, fy + 1, fx + 4 - legSwing, fy + 6); 354 - line(fx - 1, fy - 1, fx - 5 - legSwing * 0.5, fy + 2); 355 - line(fx + 1, fy - 1, fx + 5 + legSwing * 0.5, fy + 2); 398 + // Legs 399 + line(fx, fy + 1, fx - 4 + swing, fy + 6); 400 + line(fx, fy + 1, fx + 4 - swing, fy + 6); 401 + // Arms 402 + line(fx - 1, fy - 1, fx - 5 - Math.round(swing * 0.5), fy + 2); 403 + line(fx + 1, fy - 1, fx + 5 + Math.round(swing * 0.5), fy + 2); 404 + // Head 356 405 circle(fx, fy - 2, 3, true); 406 + // Eye 357 407 ink(255, 255, 255).box(fx, fy - 3, 1, 1); 358 408 } 359 409