summaryrefslogtreecommitdiff
path: root/frontend/src/components/VNApp.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/VNApp.tsx')
-rw-r--r--frontend/src/components/VNApp.tsx228
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>
+ )
+}