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([ { id: 'm1', role: 'assistant', content: 'A warm CRT glow fills the room. A figure turns towards you...' }, ]) const [choices, setChoices] = useState(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 (
{!loadingComplete && ( )}
Character A
Soryu { const img = (e.currentTarget as HTMLImageElement); img.onerror = null; img.src = '/logo/crane-logo.png'; }} />
{location} - {weather}
{ if (e.key === 'Enter') { const target = e.target as HTMLInputElement; if (target.value.trim()) { pushMessage('user', target.value); target.value = ''; } } }} />
{new Date().toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })} {new Date().toLocaleTimeString('ja-JP', { hour12: false })}
{currentTime.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })} {currentTime.toLocaleTimeString('ja-JP', { hour12: false })}
{money.toLocaleString()}
{}} onAuto={() => {}} onLog={() => {}} location={location} />
Character B
{currentTime.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })}
{currentTime.toLocaleTimeString('ja-JP', { hour12: false })}
{money.toLocaleString()}
setConfigModalOpen(false)} skipIntro={skipIntro} onSkipIntroChange={handleSkipIntroChange} />
) }