Meta Description" name="description" />
import React, { useState, useEffect, useRef, useMemo, Suspense } from 'react';
import Head from 'next/head';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import {
Text,
Box,
Sphere,
useTexture,
PerspectiveCamera,
Environment,
Float,
Stars,
Trail
} from '@react-three/drei';
import * as THREE from 'three';
import { create } from 'zustand';
import { useDrag } from '@use-gesture/react';
import { Volume2, VolumeX, Play, RotateCcw, Trophy } from 'lucide-react';
// --- Game Constants ---
const LANE_WIDTH = 2.5;
const JUMP_FORCE = 0.15;
const GRAVITY = 0.008;
const BASE_SPEED = 0.15;
const MAX_SPEED = 0.4;
const SPEED_INCREMENT = 0.0001;
const SEGMENT_LENGTH = 20;
const VISIBLE_SEGMENTS = 8;
// --- Types ---
type GameState = 'MENU' | 'PLAYING' | 'GAME_OVER';
interface GameStore {
status: GameState;
score: number;
coins: number;
speed: number;
distance: number;
lane: number; // -1 (Left), 0 (Center), 1 (Right)
isJumping: boolean;
isSliding: boolean;
muted: boolean;
actions: {
start: () => void;
restart: () => void;
gameOver: () => void;
setLane: (lane: number) => void;
setJumping: (jumping: boolean) => void;
setSliding: (sliding: boolean) => void;
addScore: (amount: number) => void;
addCoin: () => void;
increaseSpeed: () => void;
toggleMute: () => void;
};
}
// --- Store ---
const useGameStore = create<GameStore>((set, get) => ({
status: 'MENU',
score: 0,
coins: 0,
speed: BASE_SPEED,
distance: 0,
lane: 0,
isJumping: false,
isSliding: false,
muted: false,
actions: {
start: () => set({ status: 'PLAYING', score: 0, coins: 0, speed: BASE_SPEED, distance: 0, lane: 0 }),
restart: () => set({ status: 'PLAYING', score: 0, coins: 0, speed: BASE_SPEED, distance: 0, lane: 0 }),
gameOver: () => set({ status: 'GAME_OVER' }),
setLane: (lane) => set({ lane: Math.max(-1, Math.min(1, lane)) }),
setJumping: (isJumping) => set({ isJumping }),
setSliding: (isSliding) => set({ isSliding }),
addScore: (amount) => set((state) => ({ score: state.score + amount })),
addCoin: () => set((state) => ({ coins: state.coins + 1, score: state.score + 50 })),
increaseSpeed: () => set((state) => ({ speed: Math.min(MAX_SPEED, state.speed + SPEED_INCREMENT) })),
toggleMute: () => set((state) => ({ muted: !state.muted })),
},
}));
// --- Audio Manager (Procedural) ---
class AudioManager {
ctx: AudioContext | null = null;
masterGain: GainNode | null = null;
init() {
if (typeof window !== 'undefined' && !this.ctx) {
this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.masterGain.gain.value = 0.3;
}
}
playTone(freq: number, type: OscillatorType, duration: number, startTime = 0) {
if (!this.ctx || !this.masterGain || useGameStore.getState().muted) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime + startTime);
gain.gain.setValueAtTime(0.5, this.ctx.currentTime + startTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + startTime + duration);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(this.ctx.currentTime + startTime);
osc.stop(this.ctx.currentTime + startTime + duration);
}
playJump() {
this.playTone(400, 'sine', 0.2);
this.playTone(600, 'sine', 0.2, 0.1);
}
playCoin() {
this.playTone(1200, 'square', 0.1);
this.playTone(1800, 'square', 0.1, 0.05);
}
playCrash() {
this.playTone(100, 'sawtooth', 0.4);
this.playTone(50, 'sawtooth', 0.4, 0.1);
}
playBGM() {
// Simple beat loop logic would go here, but omitted to keep complexity manageable.
// We'll rely on game loop to trigger beats if needed, or just keep it SFX focused.
}
}
const audioManager = new AudioManager();
// --- Components ---
// The Player Character
const Player = () => {
const meshRef = useRef<THREE.Group>(null);
const { lane, isJumping, isSliding, actions, status, speed } = useGameStore();
const [posY, setPosY] = useState(0);
const [velY, setVelY] = useState(0);
const [posZ, setPosZ] = useState(0);
// Refs for physics loop
const stateRef = useRef({ lane, isJumping, isSliding, posY, velY, posZ, speed, status });
useEffect(() => {
stateRef.current = { lane, isJumping, isSliding, posY, velY, posZ, speed, status };
}, [lane, isJumping, isSliding, posY, velY, posZ, speed, status]);
// Jump logic
useEffect(() => {
if (isJumping && stateRef.current.posY <= 0.01) {
setVelY(JUMP_FORCE);
audioManager.playJump();
setTimeout(() => actions.setJumping(false), 500); // Reset jump flag after animation intent
}
}, [isJumping, actions]);
useFrame((state, delta) => {
if (status !== 'PLAYING') return;
// Physics: Lane LERP
const targetX = lane * LANE_WIDTH;
if (meshRef.current) {
meshRef.current.position.x = THREE.MathUtils.lerp(meshRef.current.position.x, targetX, 0.2);
// Physics: Gravity
let newPosY = posY + velY;
let newVelY = velY - GRAVITY;
if (newPosY <= 0) {
newPosY = 0;
newVelY = 0;
}
setPosY(newPosY);
setVelY(newVelY);
meshRef.current.position.y = newPosY;
// Continuous forward movement
const moveZ = speed * (delta * 60); // normalize speed to 60fps
const newPosZ = posZ - moveZ;
setPosZ(newPosZ);
meshRef.current.position.z = newPosZ;
// Update Global Game Distance for generation
useGameStore.setState({ distance: Math.abs(newPosZ) });
actions.increaseSpeed();
// Camera follow
state.camera.position.z = newPosZ + 6;
state.camera.position.x = THREE.MathUtils.lerp(state.camera.position.x, targetX * 0.5, 0.1);
}
});
// Expose player position to global scope for collision checks (hacky but effective for simple React prototyping)
useEffect(() => {
(window as any).playerZ = posZ;
(window as any).playerX = meshRef.current?.position.x || 0;
(window as any).playerY = posY;
(window as any).isSliding = isSliding;
}, [posZ, posY, isSliding]);
return (
<group ref={meshRef} position={[0, 0, 0]}>
<Trail
width={1}
length={4}
color={new THREE.Color("#00ffff")}
attenuation={(t) => t * t}
>
<group>
{/* Body */}
<Box
args={[0.8, isSliding ? 0.5 : 1.5, 0.8]}
position={[0, isSliding ? 0.25 : 0.75, 0]}
castShadow
>
<meshStandardMaterial color="#ff0055" emissive="#ff0055" emissiveIntensity={0.5} />
</Box>
{/* Head */}
{!isSliding && (
<Box args={[0.5, 0.5, 0.5]} position={[0, 1.7, 0]}>
<meshStandardMaterial color="#ffffff" />
</Box>
)}
{/* Jetpack Glint */}
<Box args={[0.4, 0.6, 0.2]} position={[0, 1, 0.4]}>
<meshStandardMaterial color="cyan" emissive="cyan" emissiveIntensity={0.8} />
</Box>
</group>
</Trail>
</group>
);
};
// --- World Generation ---
// Types for generated objects
type ObstacleType = 'BARRIER_LOW' | 'BARRIER_HIGH' | 'TRAIN' | 'COIN';
interface GameObj {
id: string;
type: ObstacleType;
lane: number;
z: number;
active: boolean;
}
const Segment = ({ zOffset, objects, onCollect }: { zOffset: number, objects: GameObj[], onCollect: (id: string) => void }) => {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
// Simple Collision Detection
const pZ = (window as any).playerZ || 0;
const pX = (window as any).playerX || 0;
const pY = (window as any).playerY || 0;
const pSliding = (window as any).isSliding || false;
objects.forEach(obj => {
if (!obj.active) return;
const objZ = zOffset + obj.z;
// Basic Z intersection
if (Math.abs(objZ - pZ) < 1.0) {
const objX = obj.lane * LANE_WIDTH;
// Basic X intersection
if (Math.abs(objX - pX) < 1.0) {
if (obj.type === 'COIN') {
onCollect(obj.id);
} else {
// Obstacle logic
let collision = false;
if (obj.type === 'TRAIN') collision = true; // Train hits everything
if (obj.type === 'BARRIER_HIGH') {
// Must slide under. If not sliding, crash.
if (!pSliding) collision = true;
}
if (obj.type === 'BARRIER_LOW') {
// Must jump over. If Y < 1.0 (approx), crash.
if (pY < 1.0) collision = true;
}
if (collision) {
useGameStore.getState().actions.gameOver();
audioManager.playCrash();
}
}
}
}
});
});
return (
<group position={[0, 0, zOffset]} ref={groupRef}>
{/* Floor */}
<Box args={[LANE_WIDTH * 3 + 2, 0.2, SEGMENT_LENGTH]} position={[0, -0.1, SEGMENT_LENGTH / 2]} receiveShadow>
<meshStandardMaterial color="#1a1a2e" roughness={0.8} />
</Box>
{/* Lane Markers */}
{[-1, 1].map((offset) => (
<Box key={offset} args={[0.1, 0.05, SEGMENT_LENGTH]} position={[offset * (LANE_WIDTH / 2), 0.01, SEGMENT_LENGTH / 2]}>
<meshBasicMaterial color="#2a2a4e" />
</Box>
))}
{objects.map(obj => {
if (!obj.active) return null;
const x = obj.lane * LANE_WIDTH;
if (obj.type === 'COIN') {
return (
<Float key={obj.id} speed={5} rotationIntensity={1} floatIntensity={1}>
<Sphere args={[0.3, 16, 16]} position={[x, 1, obj.z]}>
<meshStandardMaterial color="#ffd700" emissive="#ffd700" emissiveIntensity={0.6} />
</Sphere>
</Float>
);
}
if (obj.type === 'TRAIN') {
return (
<group key={obj.id} position={[x, 2, obj.z]}>
<Box args={[2.2, 4, 8]} castShadow>
<meshStandardMaterial color="#444" />
</Box>
<Box args={[2.3, 0.5, 8.1]} position={[0, -1.5, 0]}>
<meshStandardMaterial color="#ff4400" emissive="#ff4400" emissiveIntensity={0.8} />
</Box>
</group>
);
}
if (obj.type === 'BARRIER_LOW') {
return (
<Box key={obj.id} args={[2, 1, 0.5]} position={[x, 0.5, obj.z]} castShadow>
<meshStandardMaterial color="#ff0000" />
<Box args={[2.1, 0.1, 0.1]} position={[0, 0.4, 0]}>
<meshStandardMaterial color="yellow" emissive="yellow" />
</Box>
</Box>
);
}
if (obj.type === 'BARRIER_HIGH') {
return (
<group key={obj.id} position={[x, 0, obj.z]}>
{/* Legs */}
<Box args={[0.2, 3, 0.2]} position={[-0.8, 1.5, 0]}><meshStandardMaterial color="#888"/></Box>
<Box args={[0.2, 3, 0.2]} position={[0.8, 1.5, 0]}><meshStandardMaterial color="#888"/></Box>
{/* Top */}
<Box args={[2, 1.2, 0.5]} position={[0, 2.5, 0]}>
<meshStandardMaterial color="#ff0000" />
</Box>
</group>
);
}
return null;
})}
</group>
);
};
const WorldManager = () => {
const playerDist = useGameStore(s => s.distance);
const { addCoin } = useGameStore(s => s.actions);
// State to hold active segments
const [segments, setSegments] = useState<{ index: number; objects: GameObj[] }[]>([]);
// Initialize
useEffect(() => {
// Create initial safe zone
const initialSegments = [];
for (let i = 0; i < 3; i++) {
initialSegments.push({ index: -i, objects: [] });
}
setSegments(initialSegments);
}, []);
// Update segments loop
useFrame(() => {
const currentSegIndex = Math.floor(playerDist / SEGMENT_LENGTH);
// We want segments from currentSegIndex - 1 to currentSegIndex + VISIBLE_SEGMENTS
setSegments(prev => {
const neededIndices = [];
for (let i = -1; i < VISIBLE_SEGMENTS; i++) {
neededIndices.push(currentSegIndex + i);
}
// Filter out too old
const kept = prev.filter(s => s.index >= currentSegIndex - 1);
// Add new
const newestIndex = kept.length > 0 ? kept[kept.length - 1].index : currentSegIndex - 2;
const toAdd = [];
for (let i = 1; i <= VISIBLE_SEGMENTS + 1; i++) {
const targetIdx = currentSegIndex + i;
if (!kept.find(s => s.index === targetIdx) && targetIdx > newestIndex) {
toAdd.push(generateSegment(targetIdx));
}
}
if (toAdd.length === 0) return prev;
return [...kept, ...toAdd].sort((a,b) => b.index - a.index); // Sort for fun, though map key handles order
});
});
const handleCollect = (id: string) => {
audioManager.playCoin();
addCoin();
setSegments(prev => prev.map(seg => ({
...seg,
objects: seg.objects.map(obj => obj.id === id ? { ...obj, active: false } : obj)
})));
};
return (
<group>
{segments.map(seg => (
<Segment
key={seg.index}
zOffset={-(seg.index * SEGMENT_LENGTH)}
objects={seg.objects}
onCollect={handleCollect}
/>
))}
{/* City Background - simplified as static distant meshes moving with player to fake infinity */}
<BackgroundCity playerDist={playerDist} />
</group>
);
};
const BackgroundCity = ({ playerDist }: { playerDist: number }) => {
const buildings = useMemo(() => {
const arr = [];
for(let i=0; i<40; i++) {
arr.push({
x: (Math.random() > 0.5 ? 1 : -1) * (15 + Math.random() * 30),
z: Math.random() * 200 - 100,
height: 10 + Math.random() * 40,
width: 5 + Math.random() * 10
});
}
return arr;
}, []);
// Move buildings to create parallax/endless feel relative to camera roughly
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
if (groupRef.current) {
groupRef.current.position.z = -playerDist;
}
});
return (
<group ref={groupRef}>
{buildings.map((b, i) => (
<Box key={i} args={[b.width, b.height, b.width]} position={[b.x, b.height/2 - 5, b.z - 50]}>
<meshStandardMaterial color={`hsl(${Math.random() * 60 + 200}, 50%, 20%)`} emissive="#111" />
</Box>
))}
</group>
)
}
// Generator Logic
let objIdCounter = 0;
function generateSegment(index: number) {
const objects: GameObj[] = [];
// Empty start segment
if (index < 2) return { index, objects };
// 1. Coins
const coinPattern = Math.random();
if (coinPattern > 0.3) {
const lane = Math.floor(Math.random() * 3) - 1;
for(let z=2; z<SEGMENT_LENGTH-2; z+=2) {
objects.push({ id: `c-${index}-${objIdCounter++}`, type: 'COIN', lane, z, active: true });
}
}
// 2. Obstacles
const numObstacles = Math.floor(Math.random() * 2) + 1;
for(let i=0; i<numObstacles; i++) {
const z = 5 + Math.random() * (SEGMENT_LENGTH - 10);
const lane = Math.floor(Math.random() * 3) - 1;
const typeRnd = Math.random();
let type: ObstacleType = 'BARRIER_LOW';
if (typeRnd > 0.8) type = 'TRAIN';
else if (typeRnd > 0.5) type = 'BARRIER_HIGH';
else type = 'BARRIER_LOW';
// Don't overlap too much (simple check omitted for brevity, just purely random)
objects.push({ id: `o-${index}-${objIdCounter++}`, type, lane, z, active: true });
}
return { index, objects };
}
// --- UI & Controls Overlay ---
const UI = () => {
const { status, score, coins, actions, muted } = useGameStore();
const { start, restart, toggleMute, setLane, setJumping, setSliding } = actions;
// Keyboard Controls
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (status !== 'PLAYING') return;
switch(e.key) {
case 'ArrowLeft':
case 'a':
setLane(useGameStore.getState().lane - 1);
break;
case 'ArrowRight':
case 'd':
setLane(useGameStore.getState().lane + 1);
break;
case 'ArrowUp':
case 'w':
case ' ':
setJumping(true);
break;
case 'ArrowDown':
case 's':
setSliding(true);
setTimeout(() => setSliding(false), 800);
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [status, setLane, setJumping, setSliding]);
// Touch Controls
const bind = useDrag(({ swipe: [swipeX, swipeY] }) => {
if (status !== 'PLAYING') return;
if (swipeX === -1) setLane(useGameStore.getState().lane - 1);
if (swipeX === 1) setLane(useGameStore.getState().lane + 1);
if (swipeY === -1) setJumping(true);
if (swipeY === 1) {
setSliding(true);
setTimeout(() => setSliding(false), 800);
}
});
return (
<div {...bind()} className="absolute inset-0 pointer-events-auto select-none overflow-hidden touch-none">
{/* HUD */}
{status === 'PLAYING' && (
<div className="absolute top-4 left-4 right-4 flex justify-between text-white font-bold text-xl drop-shadow-md">
<div className="flex flex-col">
<span>SCORE: {Math.floor(score)}</span>
<span className="text-yellow-400 text-sm">COINS: {coins}</span>
</div>
<button onClick={toggleMute} className="p-2 bg-black/30 rounded-full hover:bg-black/50">
{muted ? <VolumeX size={24} /> : <Volume2 size={24} />}
</button>
</div>
)}
{/* Menus */}
{status === 'MENU' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-50">
<div className="text-center space-y-6">
<h1 className="text-6xl font-black text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-600 drop-shadow-[0_0_15px_rgba(0,255,255,0.5)]">
NEON RUNNER
</h1>
<p className="text-gray-300">Swipe or Arrows to Move • Up to Jump • Down to Slide</p>
<button
onClick={() => { audioManager.init(); start(); }}
className="px-8 py-4 bg-cyan-600 hover:bg-cyan-500 text-white font-bold rounded-lg shadow-lg transform transition hover:scale-105 flex items-center gap-2 mx-auto"
>
<Play size={24} /> START GAME
</button>
</div>
</div>
)}
{status === 'GAME_OVER' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/90 z-50">
<div className="text-center space-y-6 p-8 border-2 border-red-500 rounded-2xl bg-black/50 backdrop-blur">
<h2 className="text-5xl font-bold text-red-500">CRASHED!</h2>
<div className="text-2xl text-white space-y-2">
<p className="flex items-center justify-center gap-2"><Trophy className="text-yellow-500"/> Score: {Math.floor(score)}</p>
<p className="text-yellow-400 text-lg">Coins: {coins}</p>
</div>
<button
onClick={restart}
className="px-8 py-4 bg-red-600 hover:bg-red-500 text-white font-bold rounded-lg shadow-lg transform transition hover:scale-105 flex items-center gap-2 mx-auto"
>
<RotateCcw size={24} /> TRY AGAIN
</button>
</div>
</div>
)}
{/* Mobile Control Hints (Fade out) */}
{status === 'PLAYING' && score < 100 && (
<div className="absolute bottom-20 w-full text-center text-white/30 animate-pulse">
Swipe to control
</div>
)}
</div>
);
};
export default function GamePage() {
return (
<div className="w-full h-screen bg-[#050510] relative overflow-hidden">
<Head>
<title>Neon Runner 3D</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
</Head>
<Canvas shadows dpr={[1, 2]}>
<PerspectiveCamera makeDefault position={[0, 5, 10]} fov={50} />
<ambientLight intensity={0.5} color="#4040a0" />
<directionalLight
position={[10, 20, 5]}
intensity={1.5}
castShadow
shadow-mapSize={[1024, 1024]}
/>
<Suspense fallback={null}>
<Environment preset="city" />
<Stars radius={100} depth={50} count={5000} factor={4} saturation={0} fade speed={1} />
<group>
<Player />
<WorldManager />
</group>
</Suspense>
<fog attach="fog" args={['#050510', 10, 60]} />
</Canvas>
<UI />
</div>
);
}
1
1
22KB
22KB
57.0ms
208.0ms
58.0ms