mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-14 12:17:05 +00:00
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:
1 parent
7793163e0b
commit
75441d650a
3 files changed
+120
-21
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user