'use client' import { ExampleLayoutProps } from '../example-layout' const instanceId = Math.random().toString(36).substring(2, 9) const appJsCode = `import { useEffect, useState, useRef } from 'react'; import './styles.css'; import { createClient } from '@supabase/supabase-js'; import { Canvas, useFrame } from '@react-three/fiber'; import { Physics, useBox, usePlane } from '@react-three/cannon'; import { OrbitControls } from '@react-three/drei'; import { Vector3, MathUtils } from 'three'; // Initialize Supabase client const supabaseUrl = '${process.env.NEXT_PUBLIC_EXAMPLES_SUPABASE_URL}'; const supabaseKey = '${process.env.NEXT_PUBLIC_EXAMPLES_SUPABASE_ANON_KEY}'; const supabase = createClient(supabaseUrl, supabaseKey); // Channel name - using a unique ID to ensure both instances connect to the same channel const CHANNEL = 'platformer-3d-example-${instanceId}'; // Game constants const MOVE_SPEED = 5; const GROUND_MOVE_SPEED = 10; // Increased movement speed on ground const JUMP_FORCE = 8; // Strong jump force const PLAYER_SIZE = [1, 1, 1]; // Width, height, depth const GRAVITY = -12; // Stronger gravity for more responsive falling const BROADCAST_THROTTLE = 50; // ms between broadcasts (20 updates per second) const INTERPOLATION_SPEED = 15; // Higher values = faster interpolation const PROJECTILE_SPEED = 12; // Reduced speed for better collision detection const PROJECTILE_SIZE = 0.2; // Smaller projectile size const SHOOT_COOLDOWN = 500; // Cooldown between shots in ms const COLLISION_OFFSET = 0.05; // Small offset to prevent clipping const GROUND_CHECK_THRESHOLD = 0.3; // Threshold for ground detection // Character colors - vibrant neon colors const COLORS = [ '#ff00ff', // Neon pink '#00ffff', // Neon cyan '#ffff00', // Neon yellow '#00ff00', // Neon green '#ff9900', // Neon orange '#ff0099' // Neon magenta ]; // Spawn positions - safe places to teleport to const SPAWN_POSITIONS = [ [0, 1, 0], // Above ground [-3, 2, 0], // Above left platform [3, 3, 0], // Above right platform [0, 4, 0] // Above center platform ]; export default function App() { const [username, setUsername] = useState(''); const [isConnected, setIsConnected] = useState(false); const [players, setPlayers] = useState([]); const [localPlayer, setLocalPlayer] = useState(null); const [projectiles, setProjectiles] = useState([]); const [gameOverMessage, setGameOverMessage] = useState(''); const userId = useRef(Math.random().toString(36).substring(2, 15)); const channelRef = useRef(null); const keysPressed = useRef({}); const lastShootTime = useRef(0); const canShoot = useRef(true); const isGameOver = useRef(false); // Use a ref to track the current localPlayer state to avoid closure issues const localPlayerRef = useRef(null); // Keep the ref in sync with the state useEffect(() => { localPlayerRef.current = localPlayer; }, [localPlayer]); // Initialize game useEffect(() => { // Generate a random username const adjectives = ['Happy', 'Clever', 'Brave', 'Bright', 'Kind']; const nouns = ['Panda', 'Tiger', 'Eagle', 'Dolphin', 'Fox']; const randomName = \`\${adjectives[Math.floor(Math.random() * adjectives.length)]}\${ nouns[Math.floor(Math.random() * nouns.length)] }\${Math.floor(Math.random() * 100)}\`; setUsername(randomName); // Assign a random color const playerColor = COLORS[Math.floor(Math.random() * COLORS.length)]; // Create initial player state const initialPlayerState = { id: userId.current, username: randomName, position: [Math.random() * 8 - 4, 5, 0], // Higher Y position (5 instead of 3) color: playerColor, lastUpdated: Date.now(), isAlive: true, direction: [1, 0, 0] // Default direction (facing right) }; setLocalPlayer(initialPlayerState); localPlayerRef.current = initialPlayerState; // Also set the ref directly // Set up Supabase channel const channel = supabase.channel(CHANNEL, { config: { presence: { key: userId.current, }, }, }); channelRef.current = channel; // Handle presence for player list channel.on('presence', { event: 'sync' }, () => { const state = channel.presenceState(); const presenceList = []; // Convert presence state to array Object.keys(state).forEach(key => { const presences = state[key]; presenceList.push(...presences); }); // Update players list, but keep local player's state setPlayers(prevPlayers => { const updatedPlayers = presenceList.map(presence => { // If this is our local player, use our local state if (presence.id === userId.current) { return localPlayerRef.current || presence; } // For other players, find their previous state or use presence data const existingPlayer = prevPlayers.find(p => p.id === presence.id); if (existingPlayer) { // If player already exists, keep their interpolation data return { ...existingPlayer, // Update any new properties from presence ...presence, // But keep position and interpolation data position: existingPlayer.position, currentPosition: existingPlayer.currentPosition, targetPosition: existingPlayer.targetPosition, interpolating: existingPlayer.interpolating }; } else { // For new players, initialize interpolation data return { ...presence, // Add interpolation properties currentPosition: [...presence.position], targetPosition: [...presence.position], interpolating: false, isAlive: presence.isAlive !== false, // Default to alive if not specified direction: presence.direction || [1, 0, 0] // Default direction }; } }); return updatedPlayers; }); }); // Handle player position updates channel.on('broadcast', { event: 'player_update' }, (payload) => { const updatedPlayer = payload.payload; // Don't update our own player (we manage that locally) if (updatedPlayer.id === userId.current) return; // Update the player in our list setPlayers(prevPlayers => { return prevPlayers.map(player => { if (player.id === updatedPlayer.id) { // Only update if the incoming data is newer if (!player.lastUpdated || updatedPlayer.lastUpdated > player.lastUpdated) { // Calculate distance between current and new position const currentPos = player.position || [0, 0, 0]; const newPos = updatedPlayer.position; const distance = Math.sqrt( Math.pow(newPos[0] - currentPos[0], 2) + Math.pow(newPos[1] - currentPos[1], 2) + Math.pow(newPos[2] - currentPos[2], 2) ); // For large position changes (teleports), skip interpolation const skipInterpolation = distance > 10; return { ...player, // Update direction direction: updatedPlayer.direction || player.direction, // Update alive status isAlive: updatedPlayer.isAlive !== undefined ? updatedPlayer.isAlive : player.isAlive, // If skipping interpolation, directly set position ...(skipInterpolation ? { position: [...newPos], currentPosition: [...newPos], targetPosition: [...newPos], interpolating: false } : { // Keep the current position for interpolation currentPosition: player.currentPosition || player.position, // Set the new target position targetPosition: newPos, // Update the canonical position position: player.position, // Mark as interpolating interpolating: true }), // Update timestamp lastUpdated: updatedPlayer.lastUpdated }; } } return player; }); }); }); // Handle projectile creation channel.on('broadcast', { event: 'projectile_fired' }, (payload) => { const projectileData = payload.payload; // Don't process our own projectiles (we already added them) if (projectileData.ownerId === userId.current) return; // Add the projectile to our list setProjectiles(prev => { return [ ...prev, { ...projectileData, position: [...projectileData.position], createdAt: Date.now() } ]; }); }); // Handle player hit events channel.on('broadcast', { event: 'player_hit' }, (payload) => { const hitData = payload.payload; // If we were hit if (hitData.playerId === userId.current) { // Show game over message setGameOverMessage(\`You were eliminated by \${hitData.shooterName}!\`); isGameOver.current = true; // Update our local player state setLocalPlayer(prev => { const updated = { ...prev, isAlive: false }; localPlayerRef.current = updated; // Update ref too return updated; }); // After 3 seconds, respawn setTimeout(() => { respawnPlayer(); }, 3000); } // Update the hit player in our list setPlayers(prevPlayers => { return prevPlayers.map(player => { if (player.id === hitData.playerId) { return { ...player, isAlive: false }; } return player; }); }); }); // Subscribe to channel channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') { // Track presence await channel.track(initialPlayerState); setIsConnected(true); } }); // Set up keyboard event listeners window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); // Clean up return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); if (channel) { channel.unsubscribe(); } }; }, []); // Broadcast player position when it changes useEffect(() => { if (!localPlayer || !channelRef.current || !isConnected) return; // Broadcast our updated position channelRef.current.send({ type: 'broadcast', event: 'player_update', payload: localPlayer }); // Update our presence data channelRef.current.track(localPlayer); }, [localPlayer, isConnected]); // Periodic position broadcast to ensure consistency useEffect(() => { if (!localPlayer || !channelRef.current || !isConnected) return; // Broadcast position every 2 seconds regardless of movement const interval = setInterval(() => { // Update timestamp for the broadcast const updatedPlayer = { ...localPlayer, lastUpdated: Date.now() }; // Broadcast position channelRef.current.send({ type: 'broadcast', event: 'player_update', payload: updatedPlayer }); // Update presence data channelRef.current.track(updatedPlayer); }, 2000); return () => clearInterval(interval); }, [localPlayer, isConnected]); // Handle key down events const handleKeyDown = (e) => { // Prevent default behavior for arrow keys and WASD to disable scrolling if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyS', 'KeyA', 'KeyD'].includes(e.code)) { e.preventDefault(); } // Simply update the keysPressed object keysPressed.current[e.code] = true; // Shoot when Enter is pressed if (e.code === 'Enter' || e.code === 'NumpadEnter') { if (canShoot.current && !isGameOver.current && localPlayerRef.current?.isAlive !== false) { shootProjectile(); } } }; // Handle key up events const handleKeyUp = (e) => { keysPressed.current[e.code] = false; }; // Function to shoot a projectile const shootProjectile = () => { // Use the ref instead of the state const player = localPlayerRef.current; if (!player) { return; } const now = Date.now(); // Check cooldown if (now - lastShootTime.current < SHOOT_COOLDOWN) { return; } lastShootTime.current = now; canShoot.current = false; // Reset cooldown after delay setTimeout(() => { canShoot.current = true; }, SHOOT_COOLDOWN); // Create projectile data with better positioning const projectileData = { id: \`\${userId.current}-\${now}\`, ownerId: userId.current, ownerName: player.username, position: [ player.position[0] + player.direction[0] * 1.0, // Increased offset from player player.position[1] + 0.5, // Slightly above center player.position[2] + player.direction[2] * 1.0 ], direction: [...player.direction], color: player.color, createdAt: now }; try { // Add to local projectiles setProjectiles(prev => { const newProjectiles = [...prev, projectileData]; return newProjectiles; }); // Broadcast projectile creation channelRef.current.send({ type: 'broadcast', event: 'projectile_fired', payload: projectileData }); } catch (error) { console.error('Error creating projectile:', error); } }; // Function to handle player hit const handlePlayerHit = (playerId, shooterId, shooterName) => { // Don't process hits for dead players const hitPlayer = players.find(p => p.id === playerId); if (!hitPlayer || hitPlayer.isAlive === false) return; // Broadcast hit channelRef.current.send({ type: 'broadcast', event: 'player_hit', payload: { playerId, shooterId, shooterName } }); // If we hit someone else if (playerId !== userId.current && shooterId === userId.current) { // Show elimination message setGameOverMessage(\`You eliminated \${hitPlayer.username}!\`); // Clear message after 3 seconds setTimeout(() => { setGameOverMessage(''); }, 3000); } }; // Function to respawn player const respawnPlayer = () => { if (!localPlayerRef.current) return; // Get a random spawn position const spawnPos = SPAWN_POSITIONS[Math.floor(Math.random() * SPAWN_POSITIONS.length)]; // Update local player position and state setLocalPlayer(prev => { if (!prev) return prev; const updated = { ...prev, position: [...spawnPos], isAlive: true, lastUpdated: Date.now() }; // Update ref too localPlayerRef.current = updated; return updated; }); // Clear game over message setGameOverMessage(''); isGameOver.current = false; }; return (
{/* Toolbar */}
{players.map((user, index) => (
{user.username}
))}
{/* Main content */}
{/* Three-point lighting setup */} {/* Key light - main light source */} {/* Fill light - fills in shadows */} {/* Rim light - creates separation */} {/* Additional spot light for dramatic effect */} {/* Ground */} {/* Platforms */} {/* Players */} {players.map((player) => ( ))} {/* Projectiles - now managed by ProjectileManager */} {projectiles.length > 0 && ( )} {/* Interpolation manager - handles smooth transitions for remote players */} {/* Game over message */} {gameOverMessage && (
{gameOverMessage}
)}
); } // Projectile component - simplified version without physics function Projectile({ position, color }) { // Add ref to track mounting const ref = useRef(); return ( ); } // ProjectileManager component - handles all projectile updates in one place function ProjectileManager({ projectiles, setProjectiles, players, handlePlayerHit }) { // Store hit status for projectiles const hitProjectiles = useRef(new Set()); // Use useFrame for smooth animation that's separate from React's render cycle useFrame((state, delta) => { if (projectiles.length === 0) return; // First, let's handle movement separately from collision detection setProjectiles(prev => { // Skip processing if nothing to update if (prev.length === 0) return prev; // Move projectiles const updatedProjectiles = prev.map(projectile => { // Skip already hit projectiles if (hitProjectiles.current.has(projectile.id)) return projectile; // Calculate new position const newPosition = [ projectile.position[0] + projectile.direction[0] * PROJECTILE_SPEED * delta, projectile.position[1] - 0.02, // Reduced gravity effect projectile.position[2] + projectile.direction[2] * PROJECTILE_SPEED * delta ]; return { ...projectile, position: newPosition }; }); // Only update if something changed return updatedProjectiles; }); // Now handle collisions as a separate process // This prevents complex state updates and race conditions const currentProjectiles = projectiles.filter(p => !hitProjectiles.current.has(p.id)); const alivePlayers = players.filter(p => p.isAlive !== false); // Process collisions currentProjectiles.forEach(projectile => { // Skip already hit projectiles if (hitProjectiles.current.has(projectile.id)) return; // Skip projectiles from dead players const owner = players.find(p => p.id === projectile.ownerId); if (owner && owner.isAlive === false) { hitProjectiles.current.add(projectile.id); return; } // Check collision with each alive player for (const player of alivePlayers) { // Skip collision with projectile owner if (player.id === projectile.ownerId) continue; // Use a simpler and more generous collision detection // Increase collision radius for better hit detection const collisionRadius = PLAYER_SIZE[0] * 0.6 + PROJECTILE_SIZE * 1.5; // Calculate distance between projectile and player const dx = projectile.position[0] - player.position[0]; const dy = projectile.position[1] - player.position[1]; const dz = projectile.position[2] - player.position[2]; const distance = Math.sqrt(dx*dx + dy*dy + dz*dz); // If collision detected if (distance < collisionRadius) { // Mark this projectile as hit hitProjectiles.current.add(projectile.id); // Handle hit handlePlayerHit(player.id, projectile.ownerId, projectile.ownerName); // Break the loop since this projectile hit someone break; } } }); // Remove old projectiles and hit projectiles setProjectiles(prev => { const now = Date.now(); // Remove projectiles that are: // 1. Older than 5 seconds // 2. Have hit something return prev.filter(p => now - p.createdAt < 5000 && !hitProjectiles.current.has(p.id) ); }); }); // Render all projectiles return ( <> {projectiles.map((projectile) => ( ))} ); } // Interpolation manager component - must be inside Canvas function InterpolationManager({ players, setPlayers, localPlayerId }) { // Track last update time for each player const lastUpdateTimes = useRef({}); // Run on every frame useFrame((state, delta) => { // Skip if no players if (players.length === 0) return; const now = Date.now(); // Update players with interpolation setPlayers(prevPlayers => { // Check if any players need interpolation let needsUpdate = false; const updatedPlayers = prevPlayers.map(player => { // Skip local player if (player.id === localPlayerId) return player; // Skip if not interpolating if (!player.interpolating) return player; // Update last activity time lastUpdateTimes.current[player.id] = now; // Calculate interpolation step (faster for bigger distances) const distance = Math.sqrt( Math.pow(player.targetPosition[0] - player.currentPosition[0], 2) + Math.pow(player.targetPosition[1] - player.currentPosition[1], 2) + Math.pow(player.targetPosition[2] - player.currentPosition[2], 2) ); // Adaptive interpolation speed based on distance const adaptiveSpeed = INTERPOLATION_SPEED * (1 + Math.min(distance, 5)); const lerpAmount = Math.min(adaptiveSpeed * delta, 1); // Create new interpolated position const newPosition = [ MathUtils.lerp(player.currentPosition[0], player.targetPosition[0], lerpAmount), MathUtils.lerp(player.currentPosition[1], player.targetPosition[1], lerpAmount), MathUtils.lerp(player.currentPosition[2], player.targetPosition[2], lerpAmount) ]; // Check if we've reached the target const reachedTarget = Math.abs(newPosition[0] - player.targetPosition[0]) < 0.01 && Math.abs(newPosition[1] - player.targetPosition[1]) < 0.01 && Math.abs(newPosition[2] - player.targetPosition[2]) < 0.01; if (reachedTarget) { // We've reached the target, stop interpolating return { ...player, position: [...player.targetPosition], currentPosition: [...player.targetPosition], interpolating: false }; } else { // Still interpolating needsUpdate = true; return { ...player, position: newPosition, currentPosition: newPosition }; } }); // Only update state if something changed return needsUpdate ? updatedPlayers : prevPlayers; }); }); // This component doesn't render anything return null; } // Ground component function Ground(props) { const [ref] = usePlane(() => ({ rotation: [-Math.PI / 2, 0, 0], ...props, type: 'Static', material: { friction: 0.05, // Low friction for smooth movement restitution: 0.0 // No bounce } })); return ( ); } // Platform component function Platform({ position, size }) { // Add a tiny offset to the platform size to prevent clipping const adjustedSize = [ size[0] + COLLISION_OFFSET, size[1] + COLLISION_OFFSET, size[2] + COLLISION_OFFSET ]; const [ref] = useBox(() => ({ position, args: adjustedSize, // Use the adjusted size for physics type: 'Static', material: { friction: 0.05, // Low friction for smooth movement restitution: 0.0 // No bounce } })); return ( ); } // Player component function Player({ player, isLocal, keysPressed, setLocalPlayer }) { // Adjust the physics body size to be slightly smaller than visual representation const physicsSize = [ PLAYER_SIZE[0] - COLLISION_OFFSET, PLAYER_SIZE[1] - COLLISION_OFFSET, PLAYER_SIZE[2] - COLLISION_OFFSET ]; // Use physics for local player, but not for remote players const [ref, api] = useBox(() => ({ mass: isLocal ? 1 : 0, // No physics for remote players position: player.position, args: physicsSize, // Smaller collision box allowSleep: false, fixedRotation: true, type: isLocal ? 'Dynamic' : 'Kinematic', // Kinematic for remote players linearDamping: 0.1, // Reduced damping material: { friction: 0.05, // Low friction restitution: 0.1 // Low bounciness } })); const isJumping = useRef(false); const positionRef = useRef(player.position); const velocityRef = useRef([0, 0, 0]); const lastBroadcastRef = useRef(Date.now()); const lastPositionRef = useRef(player.position); const directionRef = useRef(player.direction || [1, 0, 0]); const isOnGroundRef = useRef(false); // For remote players, directly update position from props useEffect(() => { if (!isLocal) { api.position.set(...player.position); positionRef.current = player.position; } }, [player.position, isLocal, api.position]); // Subscribe to position changes useEffect(() => { const unsubscribe = api.position.subscribe((p) => { positionRef.current = p; // Only update local player position if (isLocal) { const now = Date.now(); // Update player direction based on movement if (keysPressed['ArrowLeft'] || keysPressed['KeyA']) { directionRef.current = [-1, 0, 0]; // Left } else if (keysPressed['ArrowRight'] || keysPressed['KeyD']) { directionRef.current = [1, 0, 0]; // Right } // Update position in state if it changed significantly if (now - lastBroadcastRef.current > BROADCAST_THROTTLE) { lastBroadcastRef.current = now; setLocalPlayer(prev => { if (!prev) return prev; return { ...prev, position: [p[0], p[1], p[2]], direction: directionRef.current, lastUpdated: now }; }); } } }); return () => { unsubscribe(); }; }, [api, isLocal, setLocalPlayer, keysPressed]); // Handle controls for local player useEffect(() => { if (!isLocal || player.isAlive === false) return; // Track velocity and ground state const velocityUnsubscribe = api.velocity.subscribe((velocity) => { velocityRef.current = velocity; // Simple ground detection isOnGroundRef.current = Math.abs(velocity[1]) < GROUND_CHECK_THRESHOLD; // Allow jumping again when on ground if (isOnGroundRef.current) { isJumping.current = false; } }); // Main game update loop const interval = setInterval(() => { const velocity = velocityRef.current || [0, 0, 0]; // Movement speed const moveSpeed = isOnGroundRef.current ? GROUND_MOVE_SPEED : MOVE_SPEED; // Left/Right movement if (keysPressed['ArrowLeft'] || keysPressed['KeyA']) { api.velocity.set(-moveSpeed, velocity[1], velocity[2]); directionRef.current = [-1, 0, 0]; } else if (keysPressed['ArrowRight'] || keysPressed['KeyD']) { api.velocity.set(moveSpeed, velocity[1], velocity[2]); directionRef.current = [1, 0, 0]; } else { // Apply friction api.velocity.set(velocity[0] * 0.8, velocity[1], velocity[2]); } // Jumping - only if on ground and not already jumping if ((keysPressed['ArrowUp'] || keysPressed['KeyW']) && isOnGroundRef.current && !isJumping.current) { api.velocity.set(velocity[0], JUMP_FORCE, velocity[2]); isJumping.current = true; } }, 10); return () => { clearInterval(interval); velocityUnsubscribe(); }; }, [api, isLocal, player.isAlive, keysPressed]); // Apply visual effects for dead players const playerColor = player.isAlive === false ? '#333333' : player.color; const playerOpacity = player.isAlive === false ? 0.3 : 1; return ( {/* Player cube */} ); }` const platformer3DFiles = { '/App.js': appJsCode, '/styles.css': `/* No custom CSS needed - using Tailwind */ /* Game-specific styles that can't be handled by Tailwind */ canvas { width: 100%; height: 100%; } .game-over-message { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 100; } `, } const layoutProps: ExampleLayoutProps = { appJsCode, files: platformer3DFiles, dependencies: { three: 'latest', '@react-three/fiber': 'latest', '@react-three/drei': 'latest', '@react-three/cannon': 'latest', }, title: '3D Platformer Example', description: 'An immersive 3D multiplayer platformer game where players navigate a physics-based environment, collect coins, and interact with each other. This advanced example demonstrates real-time position synchronization, 3D physics with jumping and collision detection, projectile shooting mechanics, and player presence with usernames. Built with Three.js and React Three Fiber, it showcases sophisticated game development concepts including interpolated movement, optimized network updates, and responsive controls in a 3D space.', } export default layoutProps