- Fix pixel data storage to include user information (userId, username, timestamp) - Enhance zoom controls to center properly without drift - Improve mobile modal centering with flexbox layout - Add dynamic backend URL detection for network access - Fix CORS configuration for development mode - Add mobile-optimized touch targets and safe area support 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
298 lines
No EOL
10 KiB
TypeScript
298 lines
No EOL
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useEffect } from 'react';
|
|
import { VirtualCanvas } from '../components/canvas/VirtualCanvas';
|
|
import { CooldownTimer } from '../components/ui/CooldownTimer';
|
|
import { PixelConfirmModal } from '../components/ui/PixelConfirmModal';
|
|
import { StatsOverlay } from '../components/ui/StatsOverlay';
|
|
import { CoordinateDisplay } from '../components/ui/CoordinateDisplay';
|
|
import { UsernameModal } from '../components/ui/UsernameModal';
|
|
import { SettingsButton } from '../components/ui/SettingsButton';
|
|
import { ZoomControls } from '../components/ui/ZoomControls';
|
|
import { useWebSocket } from '../hooks/useWebSocket';
|
|
import { useCanvasStore } from '../store/canvasStore';
|
|
import { ErrorBoundary } from '../components/ErrorBoundary';
|
|
import type { PixelPlacedMessage, ChunkDataMessage } from '@gaplace/shared';
|
|
|
|
export default function HomePage() {
|
|
const [selectedColor, setSelectedColor] = useState('#FF0000');
|
|
const [pendingPixel, setPendingPixel] = useState<{ x: number; y: number } | null>(null);
|
|
const [isCooldownActive, setIsCooldownActive] = useState(false);
|
|
const [onlineUsers, setOnlineUsers] = useState(1);
|
|
const [totalPixels, setTotalPixels] = useState(0);
|
|
const [hoverCoords, setHoverCoords] = useState<{ x: number; y: number; pixelInfo: { color: string; userId?: string; username?: string } | null } | null>(null);
|
|
const [username, setUsername] = useState('');
|
|
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
|
|
|
// Generate userId once and keep it stable, store in localStorage
|
|
const [userId] = useState(() => {
|
|
if (typeof window !== 'undefined') {
|
|
let storedUserId = localStorage.getItem('gaplace-user-id');
|
|
if (!storedUserId) {
|
|
storedUserId = 'guest-' + Math.random().toString(36).substr(2, 9);
|
|
localStorage.setItem('gaplace-user-id', storedUserId);
|
|
}
|
|
return storedUserId;
|
|
}
|
|
return 'guest-' + Math.random().toString(36).substr(2, 9);
|
|
});
|
|
|
|
// Load username from localStorage and show modal if none exists
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const storedUsername = localStorage.getItem('gaplace-username');
|
|
if (storedUsername) {
|
|
setUsername(storedUsername);
|
|
} else {
|
|
setShowUsernameModal(true);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Canvas store
|
|
const { setPixel, loadChunk: loadChunkToStore, viewport, setZoom, setViewport } = useCanvasStore();
|
|
|
|
const handlePixelPlaced = useCallback((message: PixelPlacedMessage & { username?: string }) => {
|
|
console.log('Pixel placed:', message);
|
|
setPixel(message.x, message.y, message.color, message.userId, message.username);
|
|
}, [setPixel]);
|
|
|
|
const handleChunkData = useCallback((message: ChunkDataMessage) => {
|
|
console.log('Chunk data received:', message);
|
|
loadChunkToStore(message.chunkX, message.chunkY, message.pixels);
|
|
}, [loadChunkToStore]);
|
|
|
|
const handleUserList = useCallback((users: string[]) => {
|
|
setOnlineUsers(users.length);
|
|
}, []);
|
|
|
|
const handleCanvasStats = useCallback((stats: { totalPixels?: number; activeUsers?: number; lastActivity?: number }) => {
|
|
setTotalPixels(stats.totalPixels || 0);
|
|
}, []);
|
|
|
|
const { isConnected, placePixel, loadChunk, moveCursor } = useWebSocket({
|
|
canvasId: 'main',
|
|
userId,
|
|
username,
|
|
onPixelPlaced: handlePixelPlaced,
|
|
onChunkData: handleChunkData,
|
|
onUserList: handleUserList,
|
|
onCanvasStats: handleCanvasStats,
|
|
});
|
|
|
|
const handlePixelClick = useCallback((x: number, y: number) => {
|
|
if (isCooldownActive) return;
|
|
setPendingPixel({ x, y });
|
|
}, [isCooldownActive]);
|
|
|
|
const handleConfirmPixel = useCallback(() => {
|
|
if (!pendingPixel) return;
|
|
|
|
// Immediately place pixel locally for instant feedback
|
|
setPixel(pendingPixel.x, pendingPixel.y, selectedColor, userId, username);
|
|
|
|
// Send to server
|
|
placePixel(pendingPixel.x, pendingPixel.y, selectedColor);
|
|
setPendingPixel(null);
|
|
setIsCooldownActive(true);
|
|
}, [pendingPixel, selectedColor, placePixel, setPixel, userId, username]);
|
|
|
|
const handleCancelPixel = useCallback(() => {
|
|
setPendingPixel(null);
|
|
}, []);
|
|
|
|
const handleCooldownComplete = useCallback(() => {
|
|
setIsCooldownActive(false);
|
|
}, []);
|
|
|
|
const handleCursorMove = useCallback((x: number, y: number) => {
|
|
moveCursor(x, y, 'pixel');
|
|
}, [moveCursor]);
|
|
|
|
const handleChunkNeeded = useCallback((chunkX: number, chunkY: number) => {
|
|
loadChunk(chunkX, chunkY);
|
|
}, [loadChunk]);
|
|
|
|
const handleHoverChange = useCallback((x: number, y: number, pixelInfo: { color: string; userId?: string } | null) => {
|
|
setHoverCoords({ x, y, pixelInfo });
|
|
}, []);
|
|
|
|
const handleUsernameChange = useCallback((newUsername: string) => {
|
|
setUsername(newUsername);
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('gaplace-username', newUsername);
|
|
}
|
|
}, []);
|
|
|
|
const handleZoomIn = useCallback(() => {
|
|
const newZoom = Math.min(viewport.zoom * 1.2, 5.0);
|
|
|
|
// Zoom towards center of viewport (canvas center)
|
|
if (typeof window !== 'undefined') {
|
|
const screenCenterX = window.innerWidth / 2;
|
|
const screenCenterY = window.innerHeight / 2;
|
|
|
|
// Calculate what canvas coordinate is currently at screen center
|
|
const BASE_PIXEL_SIZE = 32;
|
|
const currentPixelSize = BASE_PIXEL_SIZE * viewport.zoom;
|
|
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
|
|
|
|
const canvasCenterX = (screenCenterX + viewport.x) / currentPixelSize;
|
|
const canvasCenterY = (screenCenterY + viewport.y) / currentPixelSize;
|
|
|
|
// Calculate new viewport to keep same canvas point at screen center
|
|
const newViewportX = canvasCenterX * newPixelSize - screenCenterX;
|
|
const newViewportY = canvasCenterY * newPixelSize - screenCenterY;
|
|
|
|
setViewport({
|
|
zoom: newZoom,
|
|
x: newViewportX,
|
|
y: newViewportY,
|
|
});
|
|
} else {
|
|
setZoom(newZoom);
|
|
}
|
|
}, [setZoom, setViewport, viewport]);
|
|
|
|
const handleZoomOut = useCallback(() => {
|
|
const newZoom = Math.max(viewport.zoom / 1.2, 0.1);
|
|
|
|
// Zoom towards center of viewport (canvas center)
|
|
if (typeof window !== 'undefined') {
|
|
const screenCenterX = window.innerWidth / 2;
|
|
const screenCenterY = window.innerHeight / 2;
|
|
|
|
// Calculate what canvas coordinate is currently at screen center
|
|
const BASE_PIXEL_SIZE = 32;
|
|
const currentPixelSize = BASE_PIXEL_SIZE * viewport.zoom;
|
|
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
|
|
|
|
const canvasCenterX = (screenCenterX + viewport.x) / currentPixelSize;
|
|
const canvasCenterY = (screenCenterY + viewport.y) / currentPixelSize;
|
|
|
|
// Calculate new viewport to keep same canvas point at screen center
|
|
const newViewportX = canvasCenterX * newPixelSize - screenCenterX;
|
|
const newViewportY = canvasCenterY * newPixelSize - screenCenterY;
|
|
|
|
setViewport({
|
|
zoom: newZoom,
|
|
x: newViewportX,
|
|
y: newViewportY,
|
|
});
|
|
} else {
|
|
setZoom(newZoom);
|
|
}
|
|
}, [setZoom, setViewport, viewport]);
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<div className="relative w-full h-screen overflow-hidden">
|
|
{/* Fullscreen Canvas */}
|
|
<ErrorBoundary fallback={
|
|
<div className="w-full h-full bg-gray-900 flex items-center justify-center">
|
|
<div className="text-white text-center">
|
|
<div className="text-4xl mb-4">🎨</div>
|
|
<div>Canvas failed to load</div>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
|
>
|
|
Reload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}>
|
|
<VirtualCanvas
|
|
onPixelClick={handlePixelClick}
|
|
onCursorMove={handleCursorMove}
|
|
onChunkNeeded={handleChunkNeeded}
|
|
onHoverChange={handleHoverChange}
|
|
selectedColor={selectedColor}
|
|
/>
|
|
</ErrorBoundary>
|
|
|
|
{/* Overlay UI Components */}
|
|
<ErrorBoundary>
|
|
<StatsOverlay
|
|
onlineUsers={onlineUsers}
|
|
totalPixels={totalPixels}
|
|
zoom={viewport.zoom}
|
|
/>
|
|
</ErrorBoundary>
|
|
|
|
{/* Zoom Controls */}
|
|
<ErrorBoundary>
|
|
<ZoomControls
|
|
zoom={viewport.zoom}
|
|
onZoomIn={handleZoomIn}
|
|
onZoomOut={handleZoomOut}
|
|
/>
|
|
</ErrorBoundary>
|
|
|
|
{/* Username Settings Button - Only show when no stats are visible */}
|
|
{username && (
|
|
<ErrorBoundary>
|
|
<SettingsButton
|
|
username={username}
|
|
onOpenSettings={() => setShowUsernameModal(true)}
|
|
/>
|
|
</ErrorBoundary>
|
|
)}
|
|
|
|
<ErrorBoundary>
|
|
<CooldownTimer
|
|
isActive={isCooldownActive}
|
|
duration={10}
|
|
onComplete={handleCooldownComplete}
|
|
/>
|
|
</ErrorBoundary>
|
|
|
|
<ErrorBoundary>
|
|
<PixelConfirmModal
|
|
isOpen={!!pendingPixel}
|
|
x={pendingPixel?.x || 0}
|
|
y={pendingPixel?.y || 0}
|
|
color={selectedColor}
|
|
onColorChange={setSelectedColor}
|
|
onConfirm={handleConfirmPixel}
|
|
onCancel={handleCancelPixel}
|
|
/>
|
|
</ErrorBoundary>
|
|
|
|
{/* Coordinate Display */}
|
|
{hoverCoords && (
|
|
<ErrorBoundary>
|
|
<CoordinateDisplay
|
|
x={hoverCoords.x}
|
|
y={hoverCoords.y}
|
|
pixelColor={hoverCoords.pixelInfo?.color || null}
|
|
pixelOwner={hoverCoords.pixelInfo?.username || hoverCoords.pixelInfo?.userId || null}
|
|
zoom={viewport.zoom}
|
|
/>
|
|
</ErrorBoundary>
|
|
)}
|
|
|
|
{/* Username Modal */}
|
|
<ErrorBoundary>
|
|
<UsernameModal
|
|
isOpen={showUsernameModal}
|
|
currentUsername={username}
|
|
onUsernameChange={handleUsernameChange}
|
|
onClose={() => setShowUsernameModal(false)}
|
|
/>
|
|
</ErrorBoundary>
|
|
|
|
{/* Connection Status */}
|
|
{!isConnected && (
|
|
<ErrorBoundary>
|
|
<div className="fixed top-4 sm:top-6 left-1/2 transform -translate-x-1/2 z-50">
|
|
<div className="bg-red-500/90 backdrop-blur-md rounded-xl px-3 sm:px-4 py-2 text-white text-xs sm:text-sm font-medium">
|
|
Connecting...
|
|
</div>
|
|
</div>
|
|
</ErrorBoundary>
|
|
)}
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
} |