import React, { useEffect, useMemo, useRef, useState } from 'react'
import { TopBar } from './TopBar'
import { VNViewport } from './VNViewport'
import { DialogueBox } from './DialogueBox'
import { ChoiceMenu } from './ChoiceMenu'
import { BottomBar } from './BottomBar'
import { LoadingScreen } from './LoadingScreen'
import { ConfigModal } from './ConfigModal'
import { VNWebSocket } from '../services/ws'
import { ChatMessage, Choice } from '../types'
// Using direct PNG logo in VN header
const DEFAULT_CHOICES: Choice[] = [
{ id: 'greet', label: '"Hello?"' },
{ id: 'who', label: '"Who are you?"' },
{ id: 'silence', label: '(Stay silent)' },
]
export function VNApp() {
const [loading, setLoading] = useState(true)
const [loadingComplete, setLoadingComplete] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([
{ id: 'm1', role: 'assistant', content: 'A warm CRT glow fills the room. A figure turns towards you...' },
])
const [choices, setChoices] = useState<Choice[]>(DEFAULT_CHOICES)
const [status, setStatus] = useState('OFFLINE')
const [name, setName] = useState('???')
const [bg, setBg] = useState('/__gaogao__56242cbde8f18ac64522e410bad04e68_waifu2x_art_noise2.png')
const [money, setMoney] = useState(15000)
const [currentTime, setCurrentTime] = useState(new Date())
const [location, setLocation] = useState('Tokyo')
const [weather, setWeather] = useState('Sunny 22°C')
const [configModalOpen, setConfigModalOpen] = useState(false)
const [skipIntro, setSkipIntro] = useState(() => {
const saved = localStorage.getItem('skipIntro')
return saved === 'true'
})
const ws = useMemo(() => new VNWebSocket('ws://localhost:8080/ws'), [])
const lastId = useRef(2)
// Check if intro should be skipped on initial load
useEffect(() => {
if (skipIntro) {
setLoading(false)
setLoadingComplete(true)
// Set skip intro to true after first time loading screen is shown
localStorage.setItem('skipIntro', 'true')
}
}, [])
// Handle skip intro toggle
const handleSkipIntroChange = (skip: boolean) => {
setSkipIntro(skip)
localStorage.setItem('skipIntro', skip.toString())
}
// Handle loading screen completion
const handleLoadingComplete = () => {
setLoading(false)
setLoadingComplete(true)
// Set skip intro to true after first time loading screen is shown
if (!skipIntro) {
setSkipIntro(true)
localStorage.setItem('skipIntro', 'true')
}
}
useEffect(() => {
ws.on('open', () => setStatus('ONLINE'))
ws.on('close', () => setStatus('OFFLINE'))
ws.on('message', (data) => {
// Expecting { type: 'assistant'|'choices'|'bg'|'name'|'system', ... }
if (data?.type === 'assistant') {
pushMessage('assistant', data.content || '')
} else if (data?.type === 'choices') {
setChoices((data.items || []).map((it: any, idx: number) => ({ id: it.id ?? String(idx), label: it.label ?? String(it) })))
} else if (data?.type === 'bg') {
setBg(data.src || '/bg.jpg')
} else if (data?.type === 'name') {
setName(data.value || name)
} else if (data?.type === 'system') {
pushMessage('system', data.content || '')
}
})
ws.connect()
return () => ws.close()
}, [ws])
// Update clock every second
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date())
}, 1000)
return () => clearInterval(timer)
}, [])
function pushMessage(role: 'user' | 'assistant' | 'system', content: string) {
lastId.current += 1
setMessages((prev) => [...prev, { id: 'm' + lastId.current, role, content }])
}
function handleChoice(id: string) {
const choice = choices.find(c => c.id === id)
const content = choice?.label || id
pushMessage('user', content)
ws.send({ type: 'user_choice', id, label: content })
setChoices([])
setTimeout(() => {
pushMessage('assistant', `The figure nods: "${content}"...`)
setChoices(DEFAULT_CHOICES)
setName('Guide')
}, 600)
}
const lastAssistant = messages.slice().reverse().find(m => m.role !== 'user')
return (
<div>
{!loadingComplete && (
<LoadingScreen
onComplete={handleLoadingComplete}
/>
)}
<div className={`main-interface ${loadingComplete ? 'visible' : 'hidden'}`}>
<div className="screen" role="application">
<div className="left-pillar">
<div className="pillar-image">
Character A
</div>
<div className="pillar-bottom-section">
<div className="pillar-buttons">
<button className="pillar-btn" onClick={() => {}}>SAVE</button>
<button className="pillar-btn" onClick={() => {}}>LOAD</button>
<button className="pillar-btn" onClick={() => setConfigModalOpen(true)}>CONFIG</button>
<button className="pillar-btn" onClick={() => {}}>EXIT</button>
</div>
</div>
</div>
<div className="screen-content">
<div className="screen-header header-footer-border">
<div className="brand">
<img
src="/logo/crane-logo-transparent.png"
alt="Soryu"
height={24}
className="brand-mark"
onError={(e) => { const img = (e.currentTarget as HTMLImageElement); img.onerror = null; img.src = '/logo/crane-logo.png'; }}
/>
</div>
</div>
<VNViewport bgSrc={bg}>
<div className="ui-layer">
<div className="weather-display">
{location} - {weather}
</div>
</div>
</VNViewport>
<DialogueBox name={name} text={lastAssistant?.content || ''} />
<div className="text-input-area">
<input
type="text"
className="text-input"
placeholder="Type your response..."
onKeyPress={(e) => {
if (e.key === 'Enter') {
const target = e.target as HTMLInputElement;
if (target.value.trim()) {
pushMessage('user', target.value);
target.value = '';
}
}
}}
/>
</div>
<div className="screen-footer header-footer-border">
<div className="desktop-time">{new Date().toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })} {new Date().toLocaleTimeString('ja-JP', { hour12: false })}</div>
<div className="mobile-time-money">
<div className="mobile-clock">
{currentTime.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })} {currentTime.toLocaleTimeString('ja-JP', { hour12: false })}
</div>
<div className="mobile-money">
{money.toLocaleString()}
</div>
</div>
<div className="mobile-buttons">
<button className="mobile-btn" onClick={() => {}}>SAVE</button>
<button className="mobile-btn" onClick={() => {}}>LOAD</button>
<button className="mobile-btn" onClick={() => setConfigModalOpen(true)}>CONFIG</button>
<button className="mobile-btn" onClick={() => {}}>EXIT</button>
</div>
</div>
<BottomBar
onSkip={() => {}}
onAuto={() => {}}
onLog={() => {}}
location={location}
/>
</div>
<div className="right-pillar">
<div className="pillar-image">
Character B
</div>
<div className="pillar-bottom-section">
<div className="pillar-display digital-clock">
{currentTime.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })}
<br />
{currentTime.toLocaleTimeString('ja-JP', { hour12: false })}
</div>
<div className="pillar-display yen-counter">
{money.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
<ConfigModal
isOpen={configModalOpen}
onClose={() => setConfigModalOpen(false)}
skipIntro={skipIntro}
onSkipIntroChange={handleSkipIntroChange}
/>
</div>
)
}