mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
8afacb78ed
Points now render as actual large solid circles (base 8-20, attenuation 800, clamp up to 64px) instead of tiny dots with glow halos. Fragment shader produces solid fill with depth shading and crisp Tron-style rim edge -- no fuzzy glow. Remove EffectComposer/Bloom entirely for clean digital rendering.
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useMemo, useRef, useEffect, Suspense } from "react";
|
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
import {
|
|
OrthographicCamera,
|
|
PerspectiveCamera,
|
|
OrbitControls,
|
|
PointerLockControls,
|
|
} from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import type { MapTrack } from "./types";
|
|
import { TrackCloud, WORLD_SCALE } from "./TrackCloud";
|
|
import { TrackTooltip } from "./TrackTooltip";
|
|
|
|
interface VibeUniverseProps {
|
|
tracks: MapTrack[];
|
|
highlightedIds: Set<string>;
|
|
selectedTrackId: string | null;
|
|
onTrackClick: (trackId: string) => void;
|
|
onBackgroundClick: () => void;
|
|
}
|
|
|
|
function useIsMobile(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
return window.innerWidth < 768;
|
|
}
|
|
|
|
function FlyMovement({ speed = 30 }: { speed?: number }) {
|
|
const { camera } = useThree();
|
|
const keys = useRef<Set<string>>(new Set());
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
keys.current.add(e.code);
|
|
};
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
keys.current.delete(e.code);
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
window.addEventListener("keyup", handleKeyUp);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
window.removeEventListener("keyup", handleKeyUp);
|
|
};
|
|
}, []);
|
|
|
|
useFrame((_, delta) => {
|
|
const velocity = new THREE.Vector3();
|
|
const boost = keys.current.has("KeyR") ? 3 : 1;
|
|
const actualSpeed = speed * boost;
|
|
|
|
if (keys.current.has("KeyW") || keys.current.has("ArrowUp")) velocity.z -= 1;
|
|
if (keys.current.has("KeyS") || keys.current.has("ArrowDown")) velocity.z += 1;
|
|
if (keys.current.has("KeyA") || keys.current.has("ArrowLeft")) velocity.x -= 1;
|
|
if (keys.current.has("KeyD") || keys.current.has("ArrowRight")) velocity.x += 1;
|
|
if (keys.current.has("Space")) velocity.y += 1;
|
|
if (keys.current.has("ShiftLeft") || keys.current.has("ShiftRight")) velocity.y -= 1;
|
|
|
|
if (velocity.length() > 0) {
|
|
velocity.normalize().multiplyScalar(actualSpeed * delta);
|
|
velocity.applyQuaternion(camera.quaternion);
|
|
camera.position.add(velocity);
|
|
}
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
function TronGrid({ worldCenter }: { worldCenter: readonly [number, number, number] }) {
|
|
const material = useMemo(() => {
|
|
const halfSize = WORLD_SCALE * 2.0;
|
|
return new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
uCenter: { value: new THREE.Vector3(worldCenter[0], worldCenter[1], 0) },
|
|
uGridSpacing: { value: WORLD_SCALE * 0.06 },
|
|
uHalfSize: { value: halfSize },
|
|
uColorA: { value: new THREE.Color(168 / 255, 85 / 255, 247 / 255) },
|
|
uColorB: { value: new THREE.Color(252 / 255, 162 / 255, 0) },
|
|
},
|
|
vertexShader: `
|
|
varying vec3 vWorldPos;
|
|
void main() {
|
|
vec4 worldPos = modelMatrix * vec4(position, 1.0);
|
|
vWorldPos = worldPos.xyz;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform vec3 uCenter;
|
|
uniform float uGridSpacing;
|
|
uniform float uHalfSize;
|
|
uniform vec3 uColorA;
|
|
uniform vec3 uColorB;
|
|
varying vec3 vWorldPos;
|
|
void main() {
|
|
vec2 cXY = vWorldPos.xy / uGridSpacing;
|
|
vec2 gXY = abs(fract(cXY - 0.5) - 0.5) / fwidth(cXY);
|
|
float lXY = min(gXY.x, gXY.y);
|
|
|
|
vec2 cXZ = vWorldPos.xz / uGridSpacing;
|
|
vec2 gXZ = abs(fract(cXZ - 0.5) - 0.5) / fwidth(cXZ);
|
|
float lXZ = min(gXZ.x, gXZ.y);
|
|
|
|
vec2 cYZ = vWorldPos.yz / uGridSpacing;
|
|
vec2 gYZ = abs(fract(cYZ - 0.5) - 0.5) / fwidth(cYZ);
|
|
float lYZ = min(gYZ.x, gYZ.y);
|
|
|
|
float line = min(lXY, min(lXZ, lYZ));
|
|
float alpha = 1.0 - min(line, 1.0);
|
|
|
|
float t = smoothstep(-uHalfSize, uHalfSize, vWorldPos.y - uCenter.y);
|
|
vec3 color = mix(uColorA, uColorB, t);
|
|
|
|
float dist = length(vWorldPos - uCenter) / (uHalfSize * 1.2);
|
|
alpha *= smoothstep(1.0, 0.3, dist);
|
|
alpha *= 0.08;
|
|
|
|
gl_FragColor = vec4(color, alpha);
|
|
}
|
|
`,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
side: THREE.BackSide,
|
|
});
|
|
}, [worldCenter]);
|
|
|
|
const boxSize = WORLD_SCALE * 4;
|
|
|
|
return (
|
|
<mesh
|
|
position={[worldCenter[0], worldCenter[1], 0]}
|
|
material={material}
|
|
>
|
|
<boxGeometry args={[boxSize, boxSize, boxSize]} />
|
|
</mesh>
|
|
);
|
|
}
|
|
|
|
function SceneContent({
|
|
tracks,
|
|
highlightedIds,
|
|
selectedTrackId,
|
|
is3D,
|
|
isMobile: _isMobile,
|
|
isLocked,
|
|
onLockChange,
|
|
onTrackClick,
|
|
onBackgroundClick: _onBackgroundClick,
|
|
}: VibeUniverseProps & {
|
|
is3D: boolean;
|
|
isMobile: boolean;
|
|
isLocked: boolean;
|
|
onLockChange: (locked: boolean) => void;
|
|
}) {
|
|
const [hoveredTrack, setHoveredTrack] = useState<MapTrack | null>(null);
|
|
const [hoverPosition, setHoverPosition] = useState<THREE.Vector3 | null>(null);
|
|
|
|
const handleTrackHover = useCallback((track: MapTrack | null, point: THREE.Vector3 | null) => {
|
|
setHoveredTrack(track);
|
|
setHoverPosition(point);
|
|
}, []);
|
|
|
|
const { center, span } = useMemo(() => {
|
|
if (tracks.length === 0) {
|
|
return { center: [0.5, 0.5] as const, span: 1 };
|
|
}
|
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
for (const t of tracks) {
|
|
if (t.x < minX) minX = t.x;
|
|
if (t.x > maxX) maxX = t.x;
|
|
if (t.y < minY) minY = t.y;
|
|
if (t.y > maxY) maxY = t.y;
|
|
}
|
|
return {
|
|
center: [(minX + maxX) / 2, (minY + maxY) / 2] as const,
|
|
span: Math.max(maxX - minX, maxY - minY) || 1,
|
|
};
|
|
}, [tracks]);
|
|
|
|
const worldCenter = useMemo(
|
|
() => [center[0] * WORLD_SCALE, center[1] * WORLD_SCALE, 0] as const,
|
|
[center]
|
|
);
|
|
|
|
const handleLock = useCallback(() => onLockChange(true), [onLockChange]);
|
|
const handleUnlock = useCallback(() => onLockChange(false), [onLockChange]);
|
|
|
|
// 2D zoom: fit all tracks in view with padding
|
|
const orthoZoom = useMemo(() => {
|
|
if (typeof window === "undefined") return 2;
|
|
const viewportMin = Math.min(window.innerWidth, window.innerHeight);
|
|
const worldSpan = span * WORLD_SCALE;
|
|
return viewportMin / (worldSpan * 1.3);
|
|
}, [span]);
|
|
|
|
return (
|
|
<>
|
|
{is3D ? (
|
|
<>
|
|
<PerspectiveCamera
|
|
makeDefault
|
|
position={[worldCenter[0], worldCenter[1], WORLD_SCALE * span * 0.6]}
|
|
fov={60}
|
|
near={0.1}
|
|
far={WORLD_SCALE * 5}
|
|
/>
|
|
<PointerLockControls onLock={handleLock} onUnlock={handleUnlock} />
|
|
<FlyMovement speed={WORLD_SCALE * 0.08} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<OrthographicCamera
|
|
makeDefault
|
|
position={[worldCenter[0], worldCenter[1], 100]}
|
|
zoom={orthoZoom}
|
|
near={0.1}
|
|
far={WORLD_SCALE * 5}
|
|
/>
|
|
<OrbitControls
|
|
enableRotate={false}
|
|
enableDamping
|
|
dampingFactor={0.12}
|
|
target={[worldCenter[0], worldCenter[1], 0]}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Tron-style background grid */}
|
|
<TronGrid worldCenter={worldCenter} />
|
|
|
|
<TrackCloud
|
|
tracks={tracks}
|
|
highlightedIds={highlightedIds}
|
|
selectedTrackId={selectedTrackId}
|
|
onTrackClick={onTrackClick}
|
|
onTrackHover={handleTrackHover}
|
|
/>
|
|
|
|
{hoveredTrack && hoverPosition && !isLocked && (
|
|
<TrackTooltip track={hoveredTrack} position={hoverPosition} />
|
|
)}
|
|
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function VibeUniverse({
|
|
tracks,
|
|
highlightedIds,
|
|
selectedTrackId,
|
|
onTrackClick,
|
|
onBackgroundClick,
|
|
}: VibeUniverseProps) {
|
|
const [is3D, setIs3D] = useState(false);
|
|
const [isLocked, setIsLocked] = useState(false);
|
|
const isMobile = useIsMobile();
|
|
|
|
return (
|
|
<div className="w-full h-full relative">
|
|
<Canvas
|
|
dpr={[1, 1.5]}
|
|
gl={{
|
|
antialias: true,
|
|
toneMapping: THREE.NoToneMapping,
|
|
outputColorSpace: THREE.SRGBColorSpace,
|
|
powerPreference: "high-performance",
|
|
}}
|
|
style={{ background: "#050508" }}
|
|
onPointerMissed={onBackgroundClick}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<SceneContent
|
|
tracks={tracks}
|
|
highlightedIds={highlightedIds}
|
|
selectedTrackId={selectedTrackId}
|
|
is3D={is3D}
|
|
isMobile={isMobile}
|
|
isLocked={isLocked}
|
|
onLockChange={setIsLocked}
|
|
onTrackClick={onTrackClick}
|
|
onBackgroundClick={onBackgroundClick}
|
|
/>
|
|
</Suspense>
|
|
</Canvas>
|
|
|
|
{/* 2D / 3D toggle */}
|
|
<div className="absolute top-4 right-4 z-10 flex gap-2">
|
|
<button
|
|
onClick={() => setIs3D(!is3D)}
|
|
className="px-3 py-1.5 rounded-lg backdrop-blur-md border text-xs font-medium transition-colors bg-white/10 border-white/10 text-white/70 hover:text-white hover:bg-white/15"
|
|
>
|
|
{is3D ? "2D" : "3D"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 3D mode instructions */}
|
|
{is3D && !isLocked && (
|
|
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
|
|
<div className="text-center pointer-events-auto">
|
|
<p className="text-white/60 text-sm mb-1">Click anywhere to explore</p>
|
|
<p className="text-white/30 text-xs">WASD to move -- Mouse to look -- R for boost -- ESC to exit</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Track count */}
|
|
<div className="absolute bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-[max(0.75rem,env(safe-area-inset-left))] z-10 text-white/15 text-[10px] tracking-widest uppercase font-medium">
|
|
{tracks.length} tracks
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|