feat: add touch support, cooldown bar, and cap batch size

- Cap MAX_BATCH_SIZE to 32 (was 256) to limit burst grief damage
- Add touch handlers: pinch-zoom, drag-pan, long-press to place pixel
- Add credit bar visualization with fill animation and color states
  (yellow while regenerating, green when full)
This commit is contained in:
tiennm99 committed 2026-04-16 16:52:00 +07:00
1 parent 7793163e0b
commit 75441d650a
3 files changed
+120 -21

No files matched your search

+83 -18
View File
@@ -12,21 +12,22 @@
let lastMouse = { x: 0, y: 0 };
let loading = $state(true);
// Cached offscreen canvas — avoids re-allocation every render
// Touch state
let lastTouchDist = 0;
let touchStartTime = 0;
let touchMoved = false;
// Cached offscreen canvas
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const offCtx = offscreen.getContext('2d');
/** Render ImageData onto visible canvas with current zoom/pan */
function render() {
if (!canvasEl || !imageData) return;
const ctx = canvasEl.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
offCtx.putImageData(imageData, 0, 0);
ctx.save();
ctx.translate(pan.x, pan.y);
ctx.scale(zoom, zoom);
@@ -34,7 +35,6 @@
ctx.restore();
}
/** Convert screen coords to canvas pixel coords */
function screenToCanvas(clientX, clientY) {
return {
x: Math.floor((clientX - pan.x) / zoom),
@@ -42,7 +42,6 @@
};
}
/** Update a single pixel locally */
function updatePixel(x, y, colorIndex) {
if (!imageData) return;
const color = COLORS_RGBA[colorIndex];
@@ -53,12 +52,10 @@
imageData.data[offset + 3] = color[3];
}
/** Place a pixel via API */
async function placePixel(x, y) {
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
if (credits <= 0) return;
// Optimistic update
updatePixel(x, y, selectedColor);
render();
@@ -69,15 +66,12 @@
body: JSON.stringify({ pixels: [{ x, y, color: selectedColor }] }),
});
const data = await res.json();
if (data.ok) {
onCreditsChange(data.credits);
}
if (data.ok) onCreditsChange(data.credits);
} catch (err) {
console.error('Failed to place pixel:', err);
}
}
/** Apply pixel updates from WebSocket */
export function applyUpdates(pixels) {
for (const { x, y, color } of pixels) {
updatePixel(x, y, color);
@@ -85,6 +79,8 @@
render();
}
// --- Mouse handlers ---
function handleMouseDown(e) {
if (e.button === 0) {
dragging = false;
@@ -122,20 +118,87 @@
e.preventDefault();
const factor = e.deltaY < 0 ? 2 : 0.5;
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
const cx = e.clientX;
const cy = e.clientY;
pan.x = cx - (cx - pan.x) * (newZoom / zoom);
pan.y = cy - (cy - pan.y) * (newZoom / zoom);
onZoomChange(newZoom);
}
// --- Touch handlers (pinch-zoom, drag-pan, long-press to place) ---
function getTouchDist(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getTouchCenter(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
}
function handleTouchStart(e) {
e.preventDefault();
if (e.touches.length === 1) {
touchStartTime = Date.now();
touchMoved = false;
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
} else if (e.touches.length === 2) {
lastTouchDist = getTouchDist(e.touches);
lastMouse = getTouchCenter(e.touches);
}
}
function handleTouchMove(e) {
e.preventDefault();
if (e.touches.length === 1) {
const dx = e.touches[0].clientX - lastMouse.x;
const dy = e.touches[0].clientY - lastMouse.y;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) touchMoved = true;
pan.x += dx;
pan.y += dy;
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
render();
} else if (e.touches.length === 2) {
touchMoved = true;
const dist = getTouchDist(e.touches);
const center = getTouchCenter(e.touches);
const scale = dist / lastTouchDist;
const newZoom = Math.max(0.25, Math.min(64, zoom * scale));
// Zoom toward pinch center
pan.x = center.x - (center.x - pan.x) * (newZoom / zoom);
pan.y = center.y - (center.y - pan.y) * (newZoom / zoom);
// Pan with pinch movement
pan.x += center.x - lastMouse.x;
pan.y += center.y - lastMouse.y;
lastTouchDist = dist;
lastMouse = center;
onZoomChange(newZoom);
}
}
function handleTouchEnd(e) {
// Long-press to place pixel (>300ms, no movement, single touch)
if (!touchMoved && e.changedTouches.length === 1) {
const elapsed = Date.now() - touchStartTime;
if (elapsed > 300) {
const t = e.changedTouches[0];
const pos = screenToCanvas(t.clientX, t.clientY);
placePixel(pos.x, pos.y);
}
}
}
// Re-render when zoom changes
$effect(() => { zoom; render(); });
onMount(async () => {
// Size canvas to viewport
function resize() {
canvasEl.width = window.innerWidth;
canvasEl.height = window.innerHeight;
@@ -144,7 +207,6 @@
resize();
window.addEventListener('resize', resize);
// Load full canvas
try {
const res = await fetch('/api/canvas');
const buffer = await res.arrayBuffer();
@@ -172,7 +234,10 @@
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onwheel={handleWheel}
style="cursor: {dragging ? 'grabbing' : 'crosshair'}"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
style="cursor: {dragging ? 'grabbing' : 'crosshair'}; touch-action: none"
></canvas>
</div>
+36 -2
View File
@@ -2,14 +2,24 @@
import { MAX_CREDITS } from '../../lib/constants.js';
let { credits } = $props();
let fillPercent = $derived(Math.round((credits / MAX_CREDITS) * 100));
let isFull = $derived(credits >= MAX_CREDITS);
</script>
<div class="user-info">
<div class="credits">
<span class="label">Pixels</span>
<span class="value">{credits}</span>
<span class="value" class:full={isFull}>{credits}</span>
<span class="max">/ {MAX_CREDITS}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
class:full={isFull}
style="width: {fillPercent}%"
></div>
</div>
</div>
<style>
@@ -22,6 +32,7 @@
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
min-width: 120px;
}
.credits {
@@ -29,18 +40,41 @@
align-items: baseline;
gap: 4px;
font-size: 0.9rem;
margin-bottom: 6px;
}
.label { color: #888; }
.value {
font-weight: bold;
color: #4ade80;
color: #fbbf24;
font-variant-numeric: tabular-nums;
transition: color 0.3s;
}
.value.full { color: #4ade80; }
.max {
color: #555;
font-size: 0.8rem;
}
.bar-track {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #fbbf24;
border-radius: 2px;
transition: width 0.3s ease, background-color 0.3s;
}
.bar-fill.full {
background: #4ade80;
}
</style>
+1 -1
View File
@@ -8,7 +8,7 @@ export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
/** Rate limiting — stackable credit system */
export const MAX_BATCH_SIZE = 256;
export const MAX_BATCH_SIZE = 32;
export const CREDIT_REGEN_RATE = 1; // credits per second
export const MAX_CREDITS = 256;