diff options
Diffstat (limited to 'frontend/src/components/VNApp.tsx')
| -rw-r--r-- | frontend/src/components/VNApp.tsx | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/frontend/src/components/VNApp.tsx b/frontend/src/components/VNApp.tsx new file mode 100644 index 0000000..0f73d2c --- /dev/null +++ b/frontend/src/components/VNApp.tsx @@ -0,0 +1,228 @@ +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> + ) +} |
