mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
feat: implement core pixel canvas with real-time updates
Next.js App Router + Upstash Redis BITFIELD for 2048x2048 canvas. - 32-color palette (5-bit encoding) from rplace.live - Batch pixel placement API (up to 256 pixels per request) - Stackable credit rate limiting via Redis Lua script - SSE real-time broadcasting via Redis sorted set - HTML5 Canvas frontend with zoom/pan and color picker
This commit is contained in:
1 parent
1c6b2edcff
commit
3beaa9d43f
26 files changed
+6951
No files matched your search
@@ -0,0 +1,8 @@
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+5902
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "rplace-init",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setPixels } from '@/lib/canvas-storage';
|
||||
import { getUserId } from '@/lib/get-user-id';
|
||||
import { checkAndDeductCredits } from '@/lib/rate-limiter';
|
||||
import { publishPixelUpdates } from '@/lib/sse-broadcaster';
|
||||
import {
|
||||
CANVAS_WIDTH,
|
||||
CANVAS_HEIGHT,
|
||||
MAX_COLORS,
|
||||
MAX_BATCH_SIZE,
|
||||
} from '@/lib/constants';
|
||||
|
||||
/**
|
||||
* POST /api/canvas/place — batch pixel placement.
|
||||
* Body: { pixels: [{x, y, color}] }
|
||||
* Returns: { ok, credits } or error with 400/429.
|
||||
*/
|
||||
export async function POST(request) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { pixels } = body;
|
||||
if (!Array.isArray(pixels) || pixels.length === 0) {
|
||||
return NextResponse.json({ error: 'pixels_required' }, { status: 400 });
|
||||
}
|
||||
if (pixels.length > MAX_BATCH_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: 'batch_too_large', max: MAX_BATCH_SIZE },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each pixel
|
||||
for (const p of pixels) {
|
||||
if (
|
||||
typeof p.x !== 'number' || typeof p.y !== 'number' ||
|
||||
typeof p.color !== 'number' ||
|
||||
p.x < 0 || p.x >= CANVAS_WIDTH ||
|
||||
p.y < 0 || p.y >= CANVAS_HEIGHT ||
|
||||
p.color < 0 || p.color >= MAX_COLORS ||
|
||||
!Number.isInteger(p.x) || !Number.isInteger(p.y) ||
|
||||
!Number.isInteger(p.color)
|
||||
) {
|
||||
return NextResponse.json({ error: 'invalid_pixel', pixel: p }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const userId = getUserId(request);
|
||||
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
|
||||
userId,
|
||||
pixels.length,
|
||||
);
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'rate_limited', remaining, retryAfter },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Write pixels to canvas
|
||||
await setPixels(pixels);
|
||||
|
||||
// Broadcast updates for SSE consumers
|
||||
await publishPixelUpdates(pixels);
|
||||
|
||||
return NextResponse.json({ ok: true, credits: remaining });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { gzipSync } from 'zlib';
|
||||
import { getFullCanvas } from '@/lib/canvas-storage';
|
||||
|
||||
/**
|
||||
* GET /api/canvas — returns the full canvas as gzipped binary.
|
||||
* Client decodes 5-bit packed pixels from the raw bytes.
|
||||
*/
|
||||
export async function GET() {
|
||||
const buffer = await getFullCanvas();
|
||||
const gzipped = gzipSync(buffer);
|
||||
|
||||
return new Response(gzipped, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Encoding': 'gzip',
|
||||
'Cache-Control': 'public, max-age=1, s-maxage=1, stale-while-revalidate=5',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getUpdatesSince } from '@/lib/sse-broadcaster';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** SSE polling interval in ms */
|
||||
const POLL_INTERVAL = 500;
|
||||
|
||||
/** Max SSE connection duration before client must reconnect (25s for Vercel) */
|
||||
const MAX_DURATION = 25000;
|
||||
|
||||
/**
|
||||
* GET /api/canvas/stream?since=<timestamp>
|
||||
* Server-Sent Events endpoint for real-time pixel updates.
|
||||
* Polls Redis sorted set and streams new entries to clients.
|
||||
*/
|
||||
export async function GET(request) {
|
||||
const url = new URL(request.url);
|
||||
let since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const startTime = Date.now();
|
||||
|
||||
const poll = async () => {
|
||||
// Check if client disconnected or max duration reached
|
||||
if (request.signal.aborted || Date.now() - startTime > MAX_DURATION) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updates = await getUpdatesSince(since + 1);
|
||||
if (updates.length > 0) {
|
||||
// Update the since cursor to latest timestamp
|
||||
since = updates[updates.length - 1].ts;
|
||||
const data = JSON.stringify(updates);
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${data}\n\n`),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Send error event, don't crash the stream
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: error\ndata: ${err.message}\n\n`),
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(poll, POLL_INTERVAL);
|
||||
};
|
||||
|
||||
// Send initial connection event
|
||||
controller.enqueue(encoder.encode(`data: {"connected":true}\n\n`));
|
||||
poll();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Zoom controls and coordinate display.
|
||||
*/
|
||||
export default function CanvasControls({
|
||||
zoom, onZoomIn, onZoomOut, onResetZoom, cursorPos,
|
||||
}) {
|
||||
return (
|
||||
<div className="canvas-controls">
|
||||
<div className="zoom-controls">
|
||||
<button onClick={onZoomOut} title="Zoom out">−</button>
|
||||
<span className="zoom-level">{zoom >= 1 ? `${zoom}x` : `1/${1 / zoom}x`}</span>
|
||||
<button onClick={onZoomIn} title="Zoom in">+</button>
|
||||
<button onClick={onResetZoom} title="Reset zoom">⟲</button>
|
||||
</div>
|
||||
<div className="coords">
|
||||
({cursorPos.x}, {cursorPos.y})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from '@/lib/constants';
|
||||
import { decodeCanvas, indicesToRgba } from '@/lib/canvas-decoder';
|
||||
|
||||
/**
|
||||
* Main canvas component with zoom/pan and pixel placement.
|
||||
*/
|
||||
export default function CanvasRenderer({
|
||||
selectedColor, credits, setCredits,
|
||||
zoom, setZoom, setCursorPos,
|
||||
}) {
|
||||
const canvasRef = useRef(null);
|
||||
const imageDataRef = useRef(null);
|
||||
const panRef = useRef({ x: 0, y: 0 });
|
||||
const draggingRef = useRef(false);
|
||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
/** Render the ImageData onto the visible canvas with current zoom/pan */
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const imageData = imageDataRef.current;
|
||||
if (!canvas || !imageData) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { width, height } = canvas;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Create offscreen canvas with the pixel data
|
||||
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
offCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(panRef.current.x, panRef.current.y);
|
||||
ctx.scale(zoom, zoom);
|
||||
ctx.drawImage(offscreen, 0, 0);
|
||||
ctx.restore();
|
||||
}, [zoom]);
|
||||
|
||||
/** Load full canvas from API on mount */
|
||||
useEffect(() => {
|
||||
async function loadCanvas() {
|
||||
try {
|
||||
const res = await fetch('/api/canvas');
|
||||
const buffer = await res.arrayBuffer();
|
||||
const indices = decodeCanvas(buffer);
|
||||
const rgba = indicesToRgba(indices);
|
||||
imageDataRef.current = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error('Failed to load canvas:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadCanvas();
|
||||
}, [render]);
|
||||
|
||||
/** Re-render when zoom changes */
|
||||
useEffect(() => { render(); }, [render]);
|
||||
|
||||
/** Resize canvas to fill viewport */
|
||||
useEffect(() => {
|
||||
function resize() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
render();
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
return () => window.removeEventListener('resize', resize);
|
||||
}, [render]);
|
||||
|
||||
/** Convert screen coords to canvas pixel coords */
|
||||
const screenToCanvas = useCallback((clientX, clientY) => {
|
||||
const x = Math.floor((clientX - panRef.current.x) / zoom);
|
||||
const y = Math.floor((clientY - panRef.current.y) / zoom);
|
||||
return { x, y };
|
||||
}, [zoom]);
|
||||
|
||||
/** Update a single pixel in the local ImageData */
|
||||
const updateLocalPixel = useCallback((x, y, colorIndex) => {
|
||||
if (!imageDataRef.current) return;
|
||||
const color = COLORS_RGBA[colorIndex];
|
||||
const offset = (y * CANVAS_WIDTH + x) * 4;
|
||||
imageDataRef.current.data[offset] = color[0];
|
||||
imageDataRef.current.data[offset + 1] = color[1];
|
||||
imageDataRef.current.data[offset + 2] = color[2];
|
||||
imageDataRef.current.data[offset + 3] = color[3];
|
||||
}, []);
|
||||
|
||||
/** Place a pixel via API */
|
||||
const placePixel = useCallback(async (x, y) => {
|
||||
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
|
||||
if (credits <= 0) return;
|
||||
|
||||
// Optimistic update
|
||||
updateLocalPixel(x, y, selectedColor);
|
||||
render();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/canvas/place', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pixels: [{ x, y, color: selectedColor }] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setCredits(data.credits);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to place pixel:', err);
|
||||
}
|
||||
}, [selectedColor, credits, setCredits, updateLocalPixel, render]);
|
||||
|
||||
/** Mouse handlers for pan and place */
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
if (e.button === 0) {
|
||||
draggingRef.current = false;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
const pos = screenToCanvas(e.clientX, e.clientY);
|
||||
setCursorPos({ x: Math.max(0, pos.x), y: Math.max(0, pos.y) });
|
||||
|
||||
if (e.buttons === 1) {
|
||||
const dx = e.clientX - lastMouseRef.current.x;
|
||||
const dy = e.clientY - lastMouseRef.current.y;
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||
draggingRef.current = true;
|
||||
}
|
||||
panRef.current.x += dx;
|
||||
panRef.current.y += dy;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
render();
|
||||
}
|
||||
}, [screenToCanvas, setCursorPos, render]);
|
||||
|
||||
const handleMouseUp = useCallback((e) => {
|
||||
if (e.button === 0 && !draggingRef.current) {
|
||||
const pos = screenToCanvas(e.clientX, e.clientY);
|
||||
placePixel(pos.x, pos.y);
|
||||
}
|
||||
draggingRef.current = false;
|
||||
}, [screenToCanvas, placePixel]);
|
||||
|
||||
/** Scroll wheel zoom centered on cursor */
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 2 : 0.5;
|
||||
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
|
||||
|
||||
// Zoom toward cursor position
|
||||
const cx = e.clientX;
|
||||
const cy = e.clientY;
|
||||
panRef.current.x = cx - (cx - panRef.current.x) * (newZoom / zoom);
|
||||
panRef.current.y = cy - (cy - panRef.current.y) * (newZoom / zoom);
|
||||
|
||||
setZoom(newZoom);
|
||||
}, [zoom, setZoom]);
|
||||
|
||||
/** Expose updateLocalPixel for SSE updates */
|
||||
useEffect(() => {
|
||||
window.__rplaceUpdatePixel = (x, y, color) => {
|
||||
updateLocalPixel(x, y, color);
|
||||
render();
|
||||
};
|
||||
return () => { delete window.__rplaceUpdatePixel; };
|
||||
}, [updateLocalPixel, render]);
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
{loading && <div className="loading">Loading canvas...</div>}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
style={{ cursor: draggingRef.current ? 'grabbing' : 'crosshair' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { COLORS } from '@/lib/constants';
|
||||
|
||||
/**
|
||||
* 32-color palette grid. Click to select a color.
|
||||
*/
|
||||
export default function ColorPicker({ selectedColor, onSelect }) {
|
||||
return (
|
||||
<div className="color-picker">
|
||||
{COLORS.map((hex, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`color-swatch ${i === selectedColor ? 'selected' : ''}`}
|
||||
style={{ backgroundColor: hex }}
|
||||
onClick={() => onSelect(i)}
|
||||
title={`Color ${i}: ${hex}`}
|
||||
aria-label={`Select color ${hex}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MAX_CREDITS } from '@/lib/constants';
|
||||
|
||||
/**
|
||||
* Displays credit count with client-side regeneration animation.
|
||||
*/
|
||||
export default function UserInfo({ credits, setCredits }) {
|
||||
// Animate credit regeneration client-side (1 per second)
|
||||
useEffect(() => {
|
||||
if (credits >= MAX_CREDITS) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCredits((c) => Math.min(c + 1, MAX_CREDITS));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [credits >= MAX_CREDITS, setCredits]);
|
||||
|
||||
return (
|
||||
<div className="user-info">
|
||||
<div className="credits">
|
||||
<span className="credits-label">Pixels</span>
|
||||
<span className="credits-value">{credits}</span>
|
||||
<span className="credits-max">/ {MAX_CREDITS}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,159 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Canvas */
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.canvas-container canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.5rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Color Picker */
|
||||
.color-picker {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
transform: scale(1.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.color-swatch.selected {
|
||||
border-color: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Canvas Controls */
|
||||
.canvas-controls {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.zoom-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-controls button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.coords {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* User Info */
|
||||
.user-info {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.credits {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.credits-label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.credits-value {
|
||||
font-weight: bold;
|
||||
color: #4ade80;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.credits-max {
|
||||
color: #555;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Shared canvas UI state: selected color, credits, coordinates.
|
||||
*/
|
||||
export function useCanvasState() {
|
||||
const [selectedColor, setSelectedColor] = useState(27); // black
|
||||
const [credits, setCredits] = useState(256);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoom((z) => Math.min(z * 2, 64));
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoom((z) => Math.max(z / 2, 0.25));
|
||||
}, []);
|
||||
|
||||
const resetZoom = useCallback(() => setZoom(1), []);
|
||||
|
||||
return {
|
||||
selectedColor, setSelectedColor,
|
||||
credits, setCredits,
|
||||
cursorPos, setCursorPos,
|
||||
zoom, setZoom, zoomIn, zoomOut, resetZoom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for SSE pixel updates.
|
||||
* Auto-reconnects when the stream closes (Vercel 25s limit).
|
||||
* @param {function} onBatch - callback receiving array of {pixels, ts}
|
||||
*/
|
||||
export function useSSEUpdates(onBatch) {
|
||||
const sinceRef = useRef(Date.now());
|
||||
const onBatchRef = useRef(onBatch);
|
||||
onBatchRef.current = onBatch;
|
||||
|
||||
useEffect(() => {
|
||||
let es;
|
||||
let reconnectTimer;
|
||||
|
||||
function connect() {
|
||||
es = new EventSource(`/api/canvas/stream?since=${sinceRef.current}`);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Skip connection confirmation
|
||||
if (data.connected) return;
|
||||
|
||||
// data is an array of {pixels, ts}
|
||||
if (Array.isArray(data)) {
|
||||
for (const batch of data) {
|
||||
if (batch.ts) sinceRef.current = batch.ts;
|
||||
onBatchRef.current(batch.pixels);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
// Reconnect after a short delay
|
||||
reconnectTimer = setTimeout(connect, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
es?.close();
|
||||
clearTimeout(reconnectTimer);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "rplace — Collaborative Pixel Canvas",
|
||||
description: "A Reddit r/place clone. Place pixels, create art together.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import CanvasRenderer from './components/canvas-renderer';
|
||||
import ColorPicker from './components/color-picker';
|
||||
import CanvasControls from './components/canvas-controls';
|
||||
import UserInfo from './components/user-info';
|
||||
import { useCanvasState } from './hooks/use-canvas-state';
|
||||
import { useSSEUpdates } from './hooks/use-sse-updates';
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
selectedColor, setSelectedColor,
|
||||
credits, setCredits,
|
||||
cursorPos, setCursorPos,
|
||||
zoom, setZoom, zoomIn, zoomOut, resetZoom,
|
||||
} = useCanvasState();
|
||||
|
||||
// Apply SSE pixel updates to the canvas
|
||||
useSSEUpdates((pixels) => {
|
||||
if (!window.__rplaceUpdatePixel) return;
|
||||
for (const { x, y, color } of pixels) {
|
||||
window.__rplaceUpdatePixel(x, y, color);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<CanvasRenderer
|
||||
selectedColor={selectedColor}
|
||||
credits={credits}
|
||||
setCredits={setCredits}
|
||||
zoom={zoom}
|
||||
setZoom={setZoom}
|
||||
setCursorPos={setCursorPos}
|
||||
/>
|
||||
<CanvasControls
|
||||
zoom={zoom}
|
||||
onZoomIn={zoomIn}
|
||||
onZoomOut={zoomOut}
|
||||
onResetZoom={resetZoom}
|
||||
cursorPos={cursorPos}
|
||||
/>
|
||||
<ColorPicker
|
||||
selectedColor={selectedColor}
|
||||
onSelect={setSelectedColor}
|
||||
/>
|
||||
<UserInfo credits={credits} setCredits={setCredits} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from './constants';
|
||||
|
||||
/**
|
||||
* Decode 5-bit packed canvas buffer into an array of color indices.
|
||||
* @param {ArrayBuffer} buffer - raw canvas bytes
|
||||
* @returns {Uint8Array} color index per pixel (length = CANVAS_WIDTH * CANVAS_HEIGHT)
|
||||
*/
|
||||
export function decodeCanvas(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const totalPixels = CANVAS_WIDTH * CANVAS_HEIGHT;
|
||||
const indices = new Uint8Array(totalPixels);
|
||||
|
||||
let bitPos = 0;
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const byteIndex = bitPos >> 3;
|
||||
const bitOffset = bitPos & 7;
|
||||
// Read 5 bits spanning at most 2 bytes
|
||||
const value =
|
||||
((bytes[byteIndex] << 8 | (bytes[byteIndex + 1] || 0)) >> (11 - bitOffset)) & 0x1f;
|
||||
indices[i] = value;
|
||||
bitPos += 5;
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert color indices array to RGBA ImageData pixels.
|
||||
* @param {Uint8Array} indices - color index per pixel
|
||||
* @returns {Uint8ClampedArray} RGBA data for ImageData
|
||||
*/
|
||||
export function indicesToRgba(indices) {
|
||||
const rgba = new Uint8ClampedArray(indices.length * 4);
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const color = COLORS_RGBA[indices[i]] || COLORS_RGBA[0];
|
||||
const offset = i * 4;
|
||||
rgba[offset] = color[0];
|
||||
rgba[offset + 1] = color[1];
|
||||
rgba[offset + 2] = color[2];
|
||||
rgba[offset + 3] = color[3];
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import redis from './redis-client.js';
|
||||
import {
|
||||
CANVAS_WIDTH,
|
||||
CANVAS_HEIGHT,
|
||||
TOTAL_PIXELS,
|
||||
BITS_PER_PIXEL,
|
||||
REDIS_CANVAS_KEY,
|
||||
} from './constants.js';
|
||||
|
||||
/** Total bytes needed for the canvas bitfield */
|
||||
const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
|
||||
|
||||
/**
|
||||
* Get the full canvas as a Buffer of raw bytes.
|
||||
* Each pixel is 5 bits at offset (y * CANVAS_WIDTH + x).
|
||||
* Returns a zero-filled buffer if canvas doesn't exist yet.
|
||||
*/
|
||||
export async function getFullCanvas() {
|
||||
const data = await redis.get(REDIS_CANVAS_KEY);
|
||||
if (!data) {
|
||||
return Buffer.alloc(CANVAS_BYTES);
|
||||
}
|
||||
// Upstash returns base64-encoded string for binary data
|
||||
if (typeof data === 'string') {
|
||||
return Buffer.from(data, 'base64');
|
||||
}
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple pixels in a single atomic BITFIELD command.
|
||||
* @param {Array<{x: number, y: number, color: number}>} pixels
|
||||
*/
|
||||
export async function setPixels(pixels) {
|
||||
if (!pixels.length) return;
|
||||
|
||||
// Build BITFIELD subcommands: SET u5 #offset value
|
||||
const commands = [];
|
||||
for (const { x, y, color } of pixels) {
|
||||
const offset = y * CANVAS_WIDTH + x;
|
||||
commands.push('SET', 'u5', `#${offset}`, color);
|
||||
}
|
||||
|
||||
await redis.bitfield(REDIS_CANVAS_KEY, commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single pixel's color index.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Promise<number>} color index (0-31)
|
||||
*/
|
||||
export async function getPixel(x, y) {
|
||||
const offset = y * CANVAS_WIDTH + x;
|
||||
const result = await redis.bitfield(REDIS_CANVAS_KEY, [
|
||||
'GET', 'u5', `#${offset}`,
|
||||
]);
|
||||
return result?.[0] ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Canvas dimensions (configurable) */
|
||||
export const CANVAS_WIDTH = 2048;
|
||||
export const CANVAS_HEIGHT = 2048;
|
||||
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
||||
|
||||
/** Color encoding */
|
||||
export const BITS_PER_PIXEL = 5;
|
||||
export const MAX_COLORS = 32;
|
||||
|
||||
/** Rate limiting — stackable credit system */
|
||||
export const MAX_BATCH_SIZE = 256;
|
||||
export const CREDIT_REGEN_RATE = 1; // credits per second
|
||||
export const MAX_CREDITS = 256;
|
||||
|
||||
/** Redis keys */
|
||||
export const REDIS_CANVAS_KEY = 'canvas';
|
||||
export const REDIS_UPDATES_KEY = 'canvas:updates';
|
||||
|
||||
/** 32-color palette from rplace.live (hex values) */
|
||||
export const COLORS = [
|
||||
'#6d001a', '#be0039', '#ff4500', '#ffa800', '#ffd635', '#fff8b8',
|
||||
'#00a368', '#00cc78', '#7eed56', '#00756f', '#009eaa', '#00ccc0',
|
||||
'#2450a4', '#3690ea', '#51e9f4', '#493ac1', '#6a5cff', '#94b3ff',
|
||||
'#811e9f', '#b44ac0', '#e4abff', '#de107f', '#ff3881', '#ff99aa',
|
||||
'#6d482f', '#9c6926', '#ffb470', '#000000', '#515252', '#898d90',
|
||||
'#d4d7d9', '#ffffff',
|
||||
];
|
||||
|
||||
/**
|
||||
* RGBA values for each color (pre-computed for canvas rendering).
|
||||
* Each entry is [r, g, b, 255].
|
||||
*/
|
||||
export const COLORS_RGBA = COLORS.map((hex) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255];
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Extract a user identifier from the request.
|
||||
* Uses IP address hash for anonymous users.
|
||||
* @param {Request} request
|
||||
* @returns {string} user id prefixed with "anon:" or "auth:"
|
||||
*/
|
||||
export function getUserId(request) {
|
||||
// Extract IP from Vercel/proxy headers
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const ip = forwarded ? forwarded.split(',')[0].trim() : '127.0.0.1';
|
||||
|
||||
// Simple hash for privacy (not crypto-grade, just for keying)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < ip.length; i++) {
|
||||
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
|
||||
}
|
||||
return `anon:${Math.abs(hash).toString(36)}`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import redis from './redis-client.js';
|
||||
import { MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
|
||||
|
||||
/**
|
||||
* Lua script for atomic check-and-deduct of stackable credits.
|
||||
* KEYS[1] = credits:{userId}
|
||||
* ARGV[1] = count (pixels to place)
|
||||
* ARGV[2] = now (unix seconds)
|
||||
* ARGV[3] = max credits
|
||||
* ARGV[4] = regen rate (credits per second)
|
||||
* Returns: [allowed (0/1), remaining, retryAfter]
|
||||
*/
|
||||
const CREDIT_SCRIPT = `
|
||||
local data = redis.call('HGETALL', KEYS[1])
|
||||
local lastUpdate = 0
|
||||
local credits = tonumber(ARGV[3])
|
||||
|
||||
if #data > 0 then
|
||||
for i = 1, #data, 2 do
|
||||
if data[i] == 'lu' then lastUpdate = tonumber(data[i+1]) end
|
||||
if data[i] == 'cr' then credits = tonumber(data[i+1]) end
|
||||
end
|
||||
end
|
||||
|
||||
local elapsed = tonumber(ARGV[2]) - lastUpdate
|
||||
local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4])))
|
||||
local count = tonumber(ARGV[1])
|
||||
|
||||
if accrued < count then
|
||||
return {0, accrued, count - accrued}
|
||||
end
|
||||
|
||||
local remaining = accrued - count
|
||||
redis.call('HSET', KEYS[1], 'lu', ARGV[2], 'cr', remaining)
|
||||
redis.call('EXPIRE', KEYS[1], 86400)
|
||||
return {1, remaining, 0}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Check and deduct credits for a user's pixel placement.
|
||||
* @param {string} userId
|
||||
* @param {number} count - number of pixels to place
|
||||
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
|
||||
*/
|
||||
export async function checkAndDeductCredits(userId, count) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const key = `credits:${userId}`;
|
||||
|
||||
const result = await redis.eval(
|
||||
CREDIT_SCRIPT,
|
||||
[key],
|
||||
[count, now, MAX_CREDITS, CREDIT_REGEN_RATE],
|
||||
);
|
||||
|
||||
return {
|
||||
allowed: result[0] === 1,
|
||||
remaining: result[1],
|
||||
retryAfter: result[2],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
/**
|
||||
* Singleton Upstash Redis client.
|
||||
* Reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from env.
|
||||
*/
|
||||
const redis = new Redis({
|
||||
url: process.env.UPSTASH_REDIS_REST_URL,
|
||||
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export default redis;
|
||||
@@ -0,0 +1,30 @@
|
||||
import redis from './redis-client.js';
|
||||
import { REDIS_UPDATES_KEY } from './constants.js';
|
||||
|
||||
/**
|
||||
* Publish a batch of pixel updates to Redis for SSE consumers.
|
||||
* Uses a sorted set with timestamp scores for ordered retrieval.
|
||||
* @param {Array<{x: number, y: number, color: number}>} pixels
|
||||
*/
|
||||
export async function publishPixelUpdates(pixels) {
|
||||
const now = Date.now();
|
||||
const entry = JSON.stringify({ pixels, ts: now });
|
||||
// Store with timestamp score, auto-expire old entries
|
||||
await redis.zadd(REDIS_UPDATES_KEY, { score: now, member: entry });
|
||||
// Trim entries older than 60 seconds
|
||||
await redis.zremrangebyscore(REDIS_UPDATES_KEY, 0, now - 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pixel updates since a given timestamp.
|
||||
* @param {number} since - timestamp in ms
|
||||
* @returns {Promise<Array<{pixels: Array, ts: number}>>}
|
||||
*/
|
||||
export async function getUpdatesSince(since) {
|
||||
const entries = await redis.zrangebyscore(
|
||||
REDIS_UPDATES_KEY,
|
||||
since,
|
||||
'+inf',
|
||||
);
|
||||
return entries.map((e) => (typeof e === 'string' ? JSON.parse(e) : e));
|
||||
}
|
||||
Reference in new issue
Block a user