Files
breakpilot-lehrer/studio-v2/components/geo-lernwelt/UnityViewer.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { LearningNode } from '@/app/geo-lernwelt/types'
interface UnityViewerProps {
aoiId: string
manifestUrl?: string
learningNodes: LearningNode[]
geoServiceUrl: string
}
interface UnityInstance {
SendMessage: (objectName: string, methodName: string, value?: string | number) => void
}
export default function UnityViewer({
aoiId,
manifestUrl,
learningNodes,
geoServiceUrl,
}: UnityViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [unityInstance, setUnityInstance] = useState<UnityInstance | null>(null)
const [loadingProgress, setLoadingProgress] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<LearningNode | null>(null)
const [showNodePanel, setShowNodePanel] = useState(false)
// Placeholder mode when Unity build is not available
const [placeholderMode, setPlaceholderMode] = useState(true)
useEffect(() => {
// Check if Unity build exists
// For now, always use placeholder mode since Unity build needs to be created separately
setPlaceholderMode(true)
setIsLoading(false)
}, [])
// Unity message handler (for when Unity build exists)
useEffect(() => {
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).handleUnityMessage = (message: string) => {
try {
const data = JSON.parse(message)
switch (data.type) {
case 'nodeSelected':
const node = learningNodes.find((n) => n.id === data.nodeId)
if (node) {
setSelectedNode(node)
setShowNodePanel(true)
}
break
case 'terrainLoaded':
console.log('Unity terrain loaded')
break
case 'error':
setError(data.message)
break
}
} catch (e) {
console.error('Error parsing Unity message:', e)
}
}
}
return () => {
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).handleUnityMessage
}
}
}, [learningNodes])
const sendToUnity = useCallback(
(objectName: string, methodName: string, value?: string | number) => {
if (unityInstance) {
unityInstance.SendMessage(objectName, methodName, value)
}
},
[unityInstance]
)
// Send AOI data to Unity when ready
useEffect(() => {
if (unityInstance && manifestUrl) {
sendToUnity('TerrainManager', 'LoadManifest', manifestUrl)
// Send learning nodes
const nodesJson = JSON.stringify(learningNodes)
sendToUnity('LearningNodeManager', 'LoadNodes', nodesJson)
}
}, [unityInstance, manifestUrl, learningNodes, sendToUnity])
const handleNodeClick = (node: LearningNode) => {
setSelectedNode(node)
setShowNodePanel(true)
if (unityInstance) {
sendToUnity('CameraController', 'FocusOnNode', node.id)
}
}
const handleCloseNodePanel = () => {
setShowNodePanel(false)
setSelectedNode(null)
}
// Placeholder 3D view (when Unity is not available)
const PlaceholderView = () => (
<div className="w-full h-full bg-gradient-to-b from-sky-400 to-sky-200 relative overflow-hidden">
{/* Sky */}
<div className="absolute inset-0 bg-gradient-to-b from-sky-500 via-sky-400 to-sky-300" />
{/* Sun */}
<div className="absolute top-8 right-12 w-16 h-16 bg-yellow-300 rounded-full shadow-lg shadow-yellow-400/50" />
{/* Clouds */}
<div className="absolute top-12 left-8 w-24 h-8 bg-white/80 rounded-full blur-sm" />
<div className="absolute top-20 left-20 w-16 h-6 bg-white/70 rounded-full blur-sm" />
<div className="absolute top-16 right-32 w-20 h-7 bg-white/75 rounded-full blur-sm" />
{/* Mountains */}
<svg
className="absolute bottom-0 left-0 w-full h-1/2"
viewBox="0 0 1200 400"
preserveAspectRatio="none"
>
{/* Background mountains */}
<path d="M0,400 L200,150 L400,300 L600,100 L800,250 L1000,80 L1200,200 L1200,400 Z" fill="#4ade80" fillOpacity="0.5" />
{/* Foreground mountains */}
<path d="M0,400 L150,200 L300,350 L500,150 L700,300 L900,120 L1100,280 L1200,180 L1200,400 Z" fill="#22c55e" />
{/* Ground */}
<path d="M0,400 L0,350 Q300,320 600,350 Q900,380 1200,340 L1200,400 Z" fill="#15803d" />
</svg>
{/* Learning Nodes as Markers */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-full h-full">
{learningNodes.map((node, idx) => {
// Position nodes across the view
const positions = [
{ left: '20%', top: '55%' },
{ left: '40%', top: '45%' },
{ left: '60%', top: '50%' },
{ left: '75%', top: '55%' },
{ left: '30%', top: '60%' },
]
const pos = positions[idx % positions.length]
return (
<button
key={node.id}
onClick={() => handleNodeClick(node)}
style={{ left: pos.left, top: pos.top }}
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
>
<div className="relative">
{/* Marker pin */}
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold shadow-lg group-hover:bg-blue-600 transition-colors animate-bounce">
{idx + 1}
</div>
{/* Pulse effect */}
<div className="absolute inset-0 w-8 h-8 bg-blue-500 rounded-full animate-ping opacity-25" />
{/* Label */}
<div className="absolute top-10 left-1/2 transform -translate-x-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
{node.title}
</div>
</div>
</button>
)
})}
</div>
</div>
{/* 3D View Notice */}
<div className="absolute bottom-4 left-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
<p className="font-medium">Vorschau-Modus</p>
<p className="text-white/70 text-xs">
Unity WebGL-Build wird fuer die volle 3D-Ansicht benoetigt
</p>
</div>
{/* Controls hint */}
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
<p className="text-white/70">Klicke auf die Marker um Lernstationen anzuzeigen</p>
</div>
</div>
)
return (
<div className="relative w-full h-full bg-slate-900">
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 bg-slate-900 flex flex-col items-center justify-center z-10">
<div className="w-64 mb-4">
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${loadingProgress}%` }}
/>
</div>
</div>
<p className="text-white/60 text-sm">Lade 3D-Lernwelt... {loadingProgress}%</p>
</div>
)}
{/* Error display */}
{error && (
<div className="absolute inset-0 bg-slate-900 flex items-center justify-center z-10">
<div className="text-center p-8">
<div className="text-red-400 text-6xl mb-4"></div>
<p className="text-white text-lg mb-2">Fehler beim Laden</p>
<p className="text-white/60 text-sm max-w-md">{error}</p>
<button
onClick={() => {
setError(null)
setIsLoading(true)
setLoadingProgress(0)
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
>
Erneut versuchen
</button>
</div>
</div>
)}
{/* Unity Canvas or Placeholder */}
{placeholderMode ? (
<PlaceholderView />
) : (
<canvas
ref={canvasRef}
id="unity-canvas"
className="w-full h-full"
tabIndex={-1}
/>
)}
{/* Learning Node Panel */}
{showNodePanel && selectedNode && (
<div className="absolute top-4 right-4 w-80 bg-white/95 dark:bg-slate-800/95 backdrop-blur-lg rounded-2xl shadow-2xl overflow-hidden z-20">
{/* Header */}
<div className="bg-gradient-to-r from-blue-500 to-purple-500 p-4 text-white">
<div className="flex items-center justify-between">
<span className="text-sm opacity-80">Station {learningNodes.indexOf(selectedNode) + 1}</span>
<button
onClick={handleCloseNodePanel}
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center"
>
</button>
</div>
<h3 className="font-semibold text-lg mt-1">{selectedNode.title}</h3>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Question */}
<div>
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Aufgabe</h4>
<p className="text-slate-800 dark:text-white">{selectedNode.question}</p>
</div>
{/* Hints */}
{selectedNode.hints.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Hinweise</h4>
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-300 space-y-1">
{selectedNode.hints.map((hint, idx) => (
<li key={idx}>{hint}</li>
))}
</ul>
</div>
)}
{/* Answer (collapsible) */}
<details className="bg-green-50 dark:bg-green-900/20 rounded-lg">
<summary className="p-3 cursor-pointer text-green-700 dark:text-green-400 font-medium text-sm">
Loesung anzeigen
</summary>
<div className="px-3 pb-3">
<p className="text-green-800 dark:text-green-300 text-sm">{selectedNode.answer}</p>
{selectedNode.explanation && (
<p className="text-green-600 dark:text-green-400 text-xs mt-2 italic">
{selectedNode.explanation}
</p>
)}
</div>
</details>
{/* Points */}
<div className="flex items-center justify-between pt-2 border-t border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">Punkte</span>
<span className="font-bold text-blue-500">{selectedNode.points}</span>
</div>
</div>
</div>
)}
{/* Node List (minimized) */}
{!showNodePanel && learningNodes.length > 0 && (
<div className="absolute top-4 right-4 bg-black/70 rounded-xl p-3 z-20 max-h-64 overflow-y-auto">
<h4 className="text-white text-sm font-medium mb-2">Lernstationen</h4>
<div className="space-y-1">
{learningNodes.map((node, idx) => (
<button
key={node.id}
onClick={() => handleNodeClick(node)}
className="w-full text-left px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white text-sm flex items-center gap-2"
>
<span className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center text-xs">
{idx + 1}
</span>
<span className="truncate">{node.title}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}