summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-11-15 18:00:09 +0000
committersoryu <soryu@soryu.co>2025-11-15 18:00:09 +0000
commit3e7b2beca1136a42700a7e1aebfe4c0fb2861a00 (patch)
tree6c896c31820681e360e50a73839fc2284c043dea /frontend/src/components
downloadsoryu-3e7b2beca1136a42700a7e1aebfe4c0fb2861a00.tar.gz
soryu-3e7b2beca1136a42700a7e1aebfe4c0fb2861a00.zip
Initial commit
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/BottomBar.tsx19
-rw-r--r--frontend/src/components/ChoiceMenu.tsx20
-rw-r--r--frontend/src/components/CityscapeBackground.tsx310
-rw-r--r--frontend/src/components/ConfigModal.tsx44
-rw-r--r--frontend/src/components/DialogueBox.tsx15
-rw-r--r--frontend/src/components/HeartLogo.tsx270
-rw-r--r--frontend/src/components/LandingPage.tsx219
-rw-r--r--frontend/src/components/LoadingScreen.tsx129
-rw-r--r--frontend/src/components/OrigamiDragonLogo.tsx180
-rw-r--r--frontend/src/components/TopBar.tsx24
-rw-r--r--frontend/src/components/VNApp.tsx228
-rw-r--r--frontend/src/components/VNInterface.tsx208
-rw-r--r--frontend/src/components/VNViewport.tsx22
13 files changed, 1688 insertions, 0 deletions
diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx
new file mode 100644
index 0000000..b9f19ed
--- /dev/null
+++ b/frontend/src/components/BottomBar.tsx
@@ -0,0 +1,19 @@
+import React from 'react'
+
+type Props = {
+ onSkip?: () => void
+ onAuto?: () => void
+ onLog?: () => void
+ location?: string
+}
+
+export const BottomBar: React.FC<Props> = ({ onSkip, onAuto, onLog, location = 'Tokyo' }) => {
+ return (
+ <div className="bottombar">
+ <button className="mini-btn" onClick={onSkip}>SKIP</button>
+ <button className="mini-btn" onClick={onAuto}>AUTO</button>
+ <button className="mini-btn" onClick={onLog}>LOG</button>
+ <div className="spacer" />
+ </div>
+ )
+}
diff --git a/frontend/src/components/ChoiceMenu.tsx b/frontend/src/components/ChoiceMenu.tsx
new file mode 100644
index 0000000..0de86f6
--- /dev/null
+++ b/frontend/src/components/ChoiceMenu.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import { Choice } from '../types'
+
+type Props = {
+ choices: Choice[]
+ onSelect: (id: string) => void
+}
+
+export const ChoiceMenu: React.FC<Props> = ({ choices, onSelect }) => {
+ if (!choices.length) return null
+ return (
+ <div className="choice-menu">
+ {choices.map((c) => (
+ <button key={c.id} className="choice-item" onClick={() => onSelect(c.id)}>
+ {c.label}
+ </button>
+ ))}
+ </div>
+ )
+}
diff --git a/frontend/src/components/CityscapeBackground.tsx b/frontend/src/components/CityscapeBackground.tsx
new file mode 100644
index 0000000..82a679d
--- /dev/null
+++ b/frontend/src/components/CityscapeBackground.tsx
@@ -0,0 +1,310 @@
+import React, { useRef, useEffect } from 'react'
+import * as THREE from 'three'
+
+interface CityscapeBackgroundProps {
+ className?: string
+}
+
+export function CityscapeBackground({ className }: CityscapeBackgroundProps) {
+ const canvasRef = useRef<HTMLCanvasElement>(null)
+ const sceneRef = useRef<THREE.Scene>()
+ const rendererRef = useRef<THREE.WebGLRenderer>()
+ const cameraRef = useRef<THREE.PerspectiveCamera>()
+ const animationIdRef = useRef<number>()
+ const speedLinesRef = useRef<THREE.LineSegments>()
+ const mouseRef = useRef(new THREE.Vector2())
+ const startTimeRef = useRef<number>(Date.now())
+
+ useEffect(() => {
+ if (!canvasRef.current) {
+ console.log('CityscapeBackground: Canvas ref not available')
+ return
+ }
+
+ console.log('FuturistBackground: Initializing 3D futurist scene')
+
+ // Scene setup with futurist art movement atmosphere
+ const scene = new THREE.Scene()
+ scene.background = new THREE.Color(0x000000) // Black background
+ scene.fog = new THREE.Fog(0x000000, 30, 120) // Black fog for depth
+
+ // Camera setup for isometric view
+ const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200)
+ camera.position.set(30, 25, 30)
+ camera.lookAt(0, 0, 0)
+
+ // Renderer setup
+ const renderer = new THREE.WebGLRenderer({
+ canvas: canvasRef.current,
+ antialias: false, // Disable for better performance
+ alpha: false // Disable alpha for opaque background
+ })
+ renderer.setSize(window.innerWidth, window.innerHeight)
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)) // Limit pixel ratio for performance
+
+ // Store references
+ sceneRef.current = scene
+ rendererRef.current = renderer
+ cameraRef.current = camera
+
+ // Futurist art movement lighting - dramatic contrasts inspired by paintings
+ const ambientLight = new THREE.AmbientLight(0x1a1a1a, 0.4) // Warm grey ambient
+ scene.add(ambientLight)
+
+ // Bold primary light - warm like industrial sunlight
+ const primaryLight = new THREE.DirectionalLight(0xffaa33, 1.8) // Golden orange
+ primaryLight.position.set(-25, 35, 20)
+ scene.add(primaryLight)
+
+ // Dynamic red light for energy and motion
+ const dynamicLight = new THREE.DirectionalLight(0xdd2222, 1.2) // Bold red
+ dynamicLight.position.set(20, 10, -15)
+ scene.add(dynamicLight)
+
+ // Contrasting blue for depth and drama
+ const contrastLight = new THREE.PointLight(0x2244bb, 0.8, 50) // Deep blue
+ contrastLight.position.set(-30, 5, 0)
+ scene.add(contrastLight)
+
+ // Yellow accent for vibrant highlights
+ const accentLight = new THREE.PointLight(0xdddd22, 0.7, 40) // Yellow
+ accentLight.position.set(25, 20, -5)
+ scene.add(accentLight)
+
+ // Green industrial light
+ const industrialLight = new THREE.PointLight(0x22aa22, 0.6, 45) // Industrial green
+ industrialLight.position.set(0, -5, 30)
+ scene.add(industrialLight)
+
+ // Create speed lines
+ createSpeedLines()
+
+ console.log('FuturistBackground: Dynamic futurist art created, starting animation')
+
+ // Reset start time
+ startTimeRef.current = Date.now()
+
+ // Animation loop
+ const animate = () => {
+ animationIdRef.current = requestAnimationFrame(animate)
+
+ const time = Date.now() * 0.001 // Convert to seconds for smoother animation
+
+ // Animate speed lines
+ animateSpeedLines(time)
+
+ // Apply interactive effects
+ applyFuturistInteractions()
+
+ // Dramatic camera movement inspired by futurist dynamism
+ const cameraSpeed = time * 0.2
+ camera.position.x = 20 + Math.sin(cameraSpeed * 1.3) * 12 + Math.cos(cameraSpeed * 0.7) * 6
+ camera.position.y = 15 + Math.cos(cameraSpeed * 0.9) * 8 + Math.sin(cameraSpeed * 1.1) * 4
+ camera.position.z = 25 + Math.sin(cameraSpeed * 0.6) * 15 + Math.cos(cameraSpeed * 1.4) * 8
+
+ // Dynamic look-at point for more dramatic perspective shifts
+ const lookAtTarget = new THREE.Vector3(
+ Math.sin(cameraSpeed * 0.8) * 5,
+ Math.cos(cameraSpeed * 0.5) * 3,
+ Math.sin(cameraSpeed * 1.2) * 8
+ )
+ camera.lookAt(lookAtTarget)
+
+ renderer.render(scene, camera)
+ }
+
+ animate()
+
+ // Mouse tracking for interactive effects
+ const handleMouseMove = (event: MouseEvent) => {
+ // Convert screen coordinates to normalized device coordinates (-1 to +1)
+ mouseRef.current.set(
+ (event.clientX / window.innerWidth) * 2 - 1,
+ -(event.clientY / window.innerHeight) * 2 + 1
+ )
+ }
+
+ // Handle window resize
+ const handleResize = () => {
+ if (camera && renderer) {
+ camera.aspect = window.innerWidth / window.innerHeight
+ camera.updateProjectionMatrix()
+ renderer.setSize(window.innerWidth, window.innerHeight)
+ }
+ }
+
+ window.addEventListener('mousemove', handleMouseMove)
+ window.addEventListener('resize', handleResize)
+
+ // Cleanup
+ return () => {
+ if (animationIdRef.current) {
+ cancelAnimationFrame(animationIdRef.current)
+ }
+ window.removeEventListener('mousemove', handleMouseMove)
+ window.removeEventListener('resize', handleResize)
+
+ // Dispose of Three.js objects
+ scene.clear()
+ renderer.dispose()
+ }
+ }, [])
+
+ const applyFuturistInteractions = () => {
+ const camera = cameraRef.current!
+ const raycaster = new THREE.Raycaster()
+
+ // Convert mouse coordinates to world position
+ raycaster.setFromCamera(mouseRef.current, camera)
+
+ // Interactive speed line distortion
+ if (speedLinesRef.current) {
+ const positions = speedLinesRef.current.geometry.attributes.position.array as Float32Array
+
+ for (let i = 0; i < positions.length; i += 6) { // Step by 6 for line pairs
+ const linePos = new THREE.Vector3(positions[i], positions[i + 1], positions[i + 2])
+ const mouseWorldPos = new THREE.Vector3()
+ raycaster.ray.at(linePos.z, mouseWorldPos)
+
+ const distance = linePos.distanceTo(mouseWorldPos)
+ const distortRadius = 15
+
+ if (distance < distortRadius) {
+ const distortStrength = (distortRadius - distance) / distortRadius
+
+ // Create dramatic wave distortion around mouse
+ const waveX = Math.sin(Date.now() * 0.02 + i) * distortStrength * 2.0
+ const waveY = Math.cos(Date.now() * 0.025 + i) * distortStrength * 1.5
+
+ positions[i] += waveX
+ positions[i + 1] += waveY
+ positions[i + 3] += waveX * 0.8 // End point follows with slight lag
+ positions[i + 4] += waveY * 0.8
+ }
+ }
+
+ speedLinesRef.current.geometry.attributes.position.needsUpdate = true
+ }
+ }
+
+
+ const createSpeedLines = () => {
+ const scene = sceneRef.current!
+
+ // Create sharp, dramatic speed lines
+ const motionVertices = []
+ const motionColors = []
+
+ // High-contrast speed colors
+ const speedColors = [
+ { r: 1.0, g: 1.0, b: 1.0 }, // Brilliant white
+ { r: 1.0, g: 0.1, b: 0.1 }, // Sharp red
+ { r: 1.0, g: 0.8, b: 0.0 }, // Electric yellow
+ { r: 0.0, g: 1.0, b: 1.0 }, // Cyan
+ { r: 1.0, g: 0.0, b: 1.0 }, // Magenta
+ ]
+
+ // Create 300 sharp speed lines for intense motion
+ for (let i = 0; i < 300; i++) {
+ // Start point - spread across space
+ const startX = (Math.random() - 0.5) * 100
+ const startY = (Math.random() - 0.5) * 40
+ const startZ = Math.random() * -200
+
+ // End point - long streaks suggesting extreme speed
+ const endX = startX + (Math.random() - 0.5) * 20
+ const endY = startY + (Math.random() - 0.5) * 8
+ const endZ = startZ + 20 + Math.random() * 40 // Longer streaks
+
+ motionVertices.push(startX, startY, startZ)
+ motionVertices.push(endX, endY, endZ)
+
+ // High-contrast colors for sharp appearance
+ const colorIndex = Math.floor(Math.random() * speedColors.length)
+ const color = speedColors[colorIndex]
+
+ motionColors.push(color.r, color.g, color.b)
+ motionColors.push(color.r, color.g, color.b)
+ }
+
+ const motionGeometry = new THREE.BufferGeometry()
+ motionGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(motionVertices), 3))
+ motionGeometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(motionColors), 3))
+
+ const motionMaterial = new THREE.LineBasicMaterial({
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.9,
+ linewidth: 2
+ })
+
+ const speedLines = new THREE.LineSegments(motionGeometry, motionMaterial)
+ speedLinesRef.current = speedLines
+ scene.add(speedLines)
+ }
+
+
+
+
+
+
+ const animateSpeedLines = (time: number) => {
+ if (!speedLinesRef.current) return
+
+ const positions = speedLinesRef.current.geometry.attributes.position.array as Float32Array
+
+ for (let i = 0; i < positions.length; i += 6) { // Step by 6 since we have line pairs
+ // Extreme speed effect - lines blazing toward camera
+ positions[i + 2] += 6.0 + Math.sin(time * 2 + i) * 3.0 // High variable speed
+ positions[i + 5] += 6.0 + Math.sin(time * 2 + i) * 3.0 // End point matches
+
+ // Intense lateral movement for speed blur
+ const lateralMotion = Math.sin(time * 1.5 + i * 0.15) * 0.8
+ positions[i] += lateralMotion
+ positions[i + 3] += lateralMotion
+
+ // Sharp vertical oscillation for dynamic energy
+ const verticalMotion = Math.cos(time * 2.2 + i * 0.12) * 0.6
+ positions[i + 1] += verticalMotion
+ positions[i + 4] += verticalMotion
+
+ // Reset lines that have blazed past camera
+ if (positions[i + 2] > 100) {
+ positions[i + 2] = -250 - Math.random() * 100
+ positions[i + 5] = positions[i + 2] + 20 + Math.random() * 40
+
+ positions[i] = (Math.random() - 0.5) * 100
+ positions[i + 1] = (Math.random() - 0.5) * 40
+ positions[i + 3] = positions[i] + (Math.random() - 0.5) * 20
+ positions[i + 4] = positions[i + 1] + (Math.random() - 0.5) * 8
+ }
+ }
+
+ speedLinesRef.current.geometry.attributes.position.needsUpdate = true
+
+ // Rapid rotation for intense motion blur
+ speedLinesRef.current.rotation.x += 0.008
+ speedLinesRef.current.rotation.y += 0.012
+ speedLinesRef.current.rotation.z += 0.025
+ }
+
+
+
+ return (
+ <canvas
+ ref={canvasRef}
+ className={className}
+ style={{
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ zIndex: 0,
+ pointerEvents: 'auto',
+ opacity: 1,
+ visibility: 'visible',
+ }}
+ />
+ )
+} \ No newline at end of file
diff --git a/frontend/src/components/ConfigModal.tsx b/frontend/src/components/ConfigModal.tsx
new file mode 100644
index 0000000..e7b1f9f
--- /dev/null
+++ b/frontend/src/components/ConfigModal.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+type Props = {
+ isOpen: boolean
+ onClose: () => void
+ skipIntro: boolean
+ onSkipIntroChange: (skip: boolean) => void
+}
+
+export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => {
+ if (!isOpen) return null
+
+ return (
+ <div className="modal-overlay" onClick={onClose}>
+ <div className="config-modal" onClick={e => e.stopPropagation()}>
+ <div className="modal-header">
+ <h2>Configuration</h2>
+ <button className="close-btn" onClick={onClose}>×</button>
+ </div>
+
+ <div className="modal-content">
+ <div className="config-option">
+ <label className="config-label">
+ <input
+ type="checkbox"
+ checked={skipIntro}
+ onChange={e => onSkipIntroChange(e.target.checked)}
+ className="config-checkbox"
+ />
+ <span className="config-text">Skip Intro</span>
+ </label>
+ <div className="config-description">
+ Skip the loading screen animation on startup
+ </div>
+ </div>
+ </div>
+
+ <div className="modal-footer">
+ <button className="modal-btn" onClick={onClose}>Close</button>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/frontend/src/components/DialogueBox.tsx b/frontend/src/components/DialogueBox.tsx
new file mode 100644
index 0000000..ad5975a
--- /dev/null
+++ b/frontend/src/components/DialogueBox.tsx
@@ -0,0 +1,15 @@
+import React from 'react'
+
+type Props = {
+ name?: string
+ text: string
+}
+
+export const DialogueBox: React.FC<Props> = ({ name = 'You', text }) => {
+ return (
+ <div className="dialogue vn-text-box">
+ <div className="nameplate">{name}</div>
+ <div className="dialogue-text">{text}</div>
+ </div>
+ )
+}
diff --git a/frontend/src/components/HeartLogo.tsx b/frontend/src/components/HeartLogo.tsx
new file mode 100644
index 0000000..2d407f7
--- /dev/null
+++ b/frontend/src/components/HeartLogo.tsx
@@ -0,0 +1,270 @@
+import React, { useState, useEffect } from 'react'
+
+interface HeartLogoProps {
+ size?: 'small' | 'medium' | 'large' | 'header' | 'header-no-rays'
+ className?: string
+ onClick?: () => void
+}
+
+export function HeartLogo({ size = 'small', className = '', onClick }: HeartLogoProps) {
+ const [animationOffset, setAnimationOffset] = useState(0)
+ const [isAnimating, setIsAnimating] = useState(false)
+
+ // Click handler to toggle animation
+ const handleClick = () => {
+ setIsAnimating(!isAnimating)
+ if (onClick) {
+ onClick()
+ }
+ }
+
+ // Animation for header beams
+ useEffect(() => {
+ if (size !== 'header-no-rays' || !isAnimating) return
+
+ let animationId: number
+ let startTime = Date.now()
+
+ const animate = () => {
+ const elapsed = Date.now() - startTime
+ const rotationSpeed = 0.02 // Slow rotation speed
+ const offset = (elapsed * rotationSpeed) % 360
+ setAnimationOffset(offset)
+ animationId = requestAnimationFrame(animate)
+ }
+
+ animationId = requestAnimationFrame(animate)
+
+ return () => {
+ if (animationId) {
+ cancelAnimationFrame(animationId)
+ }
+ }
+ }, [size, isAnimating])
+ if (size === 'header') {
+ // Header version with rays filling the entire header rectangle
+ return (
+ <div className={`heart-logo ${size} ${className}`}>
+ <div className="heart-logo-rectangle">
+ <div className="heart-logo-content">
+ <div className="heart-logo-rays">
+ <svg viewBox="0 0 100 100" className="heart-logo-rays-svg" preserveAspectRatio="none">
+ {/* Header rays matching loading screen exactly */}
+ <g transform="translate(50, 50)">
+ {[...Array(16)].map((_, i) => {
+ const angle = (i * 22.5);
+ const rayWidth = 2; // Much thinner rays
+
+ // Create triangular ray path - thin rays extending to header edges
+ const startAngle = angle - rayWidth;
+ const endAngle = angle + rayWidth;
+
+ const innerRadius = 0; // Start from center (heart center)
+ const outerRadius = 200; // Extend far beyond to reach all header edges
+
+ const x1 = Math.cos(startAngle * Math.PI / 180) * innerRadius;
+ const y1 = Math.sin(startAngle * Math.PI / 180) * innerRadius;
+ const x2 = Math.cos(endAngle * Math.PI / 180) * innerRadius;
+ const y2 = Math.sin(endAngle * Math.PI / 180) * innerRadius;
+ const x3 = Math.cos(startAngle * Math.PI / 180) * outerRadius;
+ const y3 = Math.sin(startAngle * Math.PI / 180) * outerRadius;
+ const x4 = Math.cos(endAngle * Math.PI / 180) * outerRadius;
+ const y4 = Math.sin(endAngle * Math.PI / 180) * outerRadius;
+
+ return (
+ <polygon
+ key={i}
+ points={`${x1},${y1} ${x2},${y2} ${x4},${y4} ${x3},${y3}`}
+ fill={i % 2 === 0 ? "#8B0000" : "#000000"}
+ opacity="0.9"
+ />
+ );
+ })}
+ </g>
+ </svg>
+ </div>
+ <div className="heart-logo-outline">
+ <svg viewBox="0 0 100 100" className="heart-logo-svg">
+ <path d="M50,85 C50,85 10,60 10,35 C10,18 20,8 35,8 C42,8 50,13 50,25 C50,13 58,8 65,8 C80,8 90,18 90,35 C90,60 50,85 50,85 Z"
+ fill="#8B0000"
+ stroke="#8B0000"
+ strokeWidth="2"/>
+ </svg>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ }
+
+ if (size === 'header-no-rays') {
+ // Header version with small red beams - compact sunlight effect
+ return (
+ <div className={`heart-logo ${size} ${className}`} onClick={handleClick} style={{ cursor: 'pointer' }}>
+ <div className="heart-logo-content">
+ <div className="heart-logo-rays">
+ <svg viewBox="-200 -67 400 134" className="heart-logo-rays-svg" preserveAspectRatio="xMidYMid slice">
+ {/* Red beams extending across entire header rectangle - optimized for wide screens */}
+ <g>
+ {[...Array(32)].map((_, i) => {
+ const angle = (i * 11.25) + animationOffset; // 32 rays, 11.25 degrees apart, animated
+ const rayWidth = 2; // Much narrower rays to show background between them
+
+ // Create triangular ray path
+ const startAngle = angle - rayWidth;
+ const endAngle = angle + rayWidth;
+
+ const innerRadius = 0; // Start from heart center
+
+ // Calculate intersection with rectangle bounds for wide header
+ const getIntersectionWithRect = (angleInDegrees: number) => {
+ const rad = angleInDegrees * Math.PI / 180;
+ const dx = Math.cos(rad);
+ const dy = Math.sin(rad);
+
+ // Header rectangle bounds (wide format ~3:1 ratio)
+ const rectBoundsX = 200; // Full width
+ const rectBoundsY = 67; // Height to match header proportions
+
+ // Calculate intersection with each edge
+ let t = Infinity;
+
+ // Right edge (x = rectBoundsX)
+ if (dx > 0) {
+ t = Math.min(t, rectBoundsX / dx);
+ }
+ // Left edge (x = -rectBoundsX)
+ if (dx < 0) {
+ t = Math.min(t, -rectBoundsX / dx);
+ }
+ // Top edge (y = -rectBoundsY)
+ if (dy < 0) {
+ t = Math.min(t, -rectBoundsY / dy);
+ }
+ // Bottom edge (y = rectBoundsY)
+ if (dy > 0) {
+ t = Math.min(t, rectBoundsY / dy);
+ }
+
+ return { x: dx * t, y: dy * t };
+ };
+
+ const x1 = Math.cos(startAngle * Math.PI / 180) * innerRadius;
+ const y1 = Math.sin(startAngle * Math.PI / 180) * innerRadius;
+ const x2 = Math.cos(endAngle * Math.PI / 180) * innerRadius;
+ const y2 = Math.sin(endAngle * Math.PI / 180) * innerRadius;
+
+ const edge1 = getIntersectionWithRect(startAngle);
+ const edge2 = getIntersectionWithRect(endAngle);
+
+ return (
+ <polygon
+ key={i}
+ points={`${x1},${y1} ${x2},${y2} ${edge2.x},${edge2.y} ${edge1.x},${edge1.y}`}
+ fill={i % 2 === 0 ? "#DC2626" : "#B91C1C"}
+ opacity="0.6"
+ />
+ );
+ })}
+ </g>
+ </svg>
+ </div>
+ <div className="heart-logo-outline">
+ <svg viewBox="0 0 100 100" className="heart-logo-svg">
+ <path d="M50,85 C50,85 10,60 10,35 C10,18 20,8 35,8 C42,8 50,13 50,25 C50,13 58,8 65,8 C80,8 90,18 90,35 C90,60 50,85 50,85 Z"
+ fill="#8B0000"
+ stroke="#8B0000"
+ strokeWidth="2"/>
+ </svg>
+ </div>
+ </div>
+ </div>
+ )
+ }
+
+ // Original square version for other sizes
+ return (
+ <div className={`heart-logo ${size} ${className}`}>
+ <div className="heart-logo-rectangle">
+ <div className="heart-logo-content">
+ <div className="heart-logo-rays">
+ <svg viewBox="-100 -67 200 134" className="heart-logo-rays-svg">
+ {/* Rising Sun flag style rays - triangular rays extending to rectangle edges */}
+ <g>
+ {[...Array(16)].map((_, i) => {
+ const angle = (i * 22.5);
+ const rayWidth = 11.25; // Half the angle between rays for triangular shape
+
+ // Create triangular ray path
+ const startAngle = angle - rayWidth;
+ const endAngle = angle + rayWidth;
+
+ const innerRadius = 0; // Start from center (heart center)
+
+ // Calculate intersection with rectangle bounds
+ // Rectangle aspect ratio 120:80 = 3:2, so use different bounds for x and y
+ const getIntersectionWithRect = (angleInDegrees: number) => {
+ const rad = angleInDegrees * Math.PI / 180;
+ const dx = Math.cos(rad);
+ const dy = Math.sin(rad);
+
+ // Rectangle bounds matching the 120x80 aspect ratio (3:2)
+ const rectBoundsX = 100; // Full width
+ const rectBoundsY = 67; // 2/3 of width to maintain 3:2 ratio
+
+ // Calculate intersection with each edge
+ let t = Infinity;
+
+ // Right edge (x = rectBoundsX)
+ if (dx > 0) {
+ t = Math.min(t, rectBoundsX / dx);
+ }
+ // Left edge (x = -rectBoundsX)
+ if (dx < 0) {
+ t = Math.min(t, -rectBoundsX / dx);
+ }
+ // Top edge (y = -rectBoundsY)
+ if (dy < 0) {
+ t = Math.min(t, -rectBoundsY / dy);
+ }
+ // Bottom edge (y = rectBoundsY)
+ if (dy > 0) {
+ t = Math.min(t, rectBoundsY / dy);
+ }
+
+ return { x: dx * t, y: dy * t };
+ };
+
+ const x1 = Math.cos(startAngle * Math.PI / 180) * innerRadius;
+ const y1 = Math.sin(startAngle * Math.PI / 180) * innerRadius;
+ const x2 = Math.cos(endAngle * Math.PI / 180) * innerRadius;
+ const y2 = Math.sin(endAngle * Math.PI / 180) * innerRadius;
+
+ const edge1 = getIntersectionWithRect(startAngle);
+ const edge2 = getIntersectionWithRect(endAngle);
+
+ return (
+ <polygon
+ key={i}
+ points={`${x1},${y1} ${x2},${y2} ${edge2.x},${edge2.y} ${edge1.x},${edge1.y}`}
+ fill={i % 2 === 0 ? "#8B0000" : "#000000"}
+ opacity="0.9"
+ />
+ );
+ })}
+ </g>
+ </svg>
+ </div>
+ <div className="heart-logo-outline">
+ <svg viewBox="0 0 100 100" className="heart-logo-svg">
+ <path d="M50,85 C50,85 10,60 10,35 C10,18 20,8 35,8 C42,8 50,13 50,25 C50,13 58,8 65,8 C80,8 90,18 90,35 C90,60 50,85 50,85 Z"
+ fill="#8B0000"
+ stroke="#8B0000"
+ strokeWidth="2"/>
+ </svg>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx
new file mode 100644
index 0000000..f5dc55c
--- /dev/null
+++ b/frontend/src/components/LandingPage.tsx
@@ -0,0 +1,219 @@
+import React, { useState, useEffect } from 'react'
+import { LoadingScreen } from './LoadingScreen'
+import { HeartLogo } from './HeartLogo'
+// Using direct PNG logo on landing header
+
+interface LandingPageProps {
+ onLogin: () => void
+}
+
+export function LandingPage({ onLogin }: LandingPageProps) {
+ const [loading, setLoading] = useState(false)
+ const [showLanding, setShowLanding] = useState(false)
+ const [isStandby, setIsStandby] = useState(true) // false = LIVE, true = STDBY
+ const [asciiArt, setAsciiArt] = useState('')
+ const [isGlitching, setIsGlitching] = useState(false)
+ // Note: Removed VN preview and ASCII features to focus on an Art Deco/Nouveau landing.
+
+ // Auto-fade in landing page content after component mounts
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setShowLanding(true)
+ }, 500) // Delay before fading in content
+
+ return () => clearTimeout(timer)
+ }, [])
+
+ // Removed VN dialogue preview / typing effect.
+
+ // Load ASCII art for overlay in hero (right/bottom)
+ useEffect(() => {
+ fetch('/ascii/ascii1.txt')
+ .then((res) => res.text())
+ .then((txt) => setAsciiArt(txt.replace(/\n+$/, '')))
+ .catch(() => setAsciiArt(''))
+ }, [])
+
+ // Occasionally trigger a brief glitch on the word "futurist"
+ useEffect(() => {
+ let armTimer: number | undefined
+ let activeTimer: number | undefined
+ let cancelled = false
+
+ const arm = () => {
+ // Random delay between glitches: 0.8s–2s (slightly punchier)
+ const delay = 800 + Math.random() * 1200
+ armTimer = window.setTimeout(() => {
+ setIsGlitching(true)
+ // Glitch duration: 150ms–350ms (snappier)
+ const dur = 150 + Math.random() * 200
+ activeTimer = window.setTimeout(() => {
+ setIsGlitching(false)
+ if (!cancelled) arm()
+ }, dur)
+ }, delay)
+ }
+
+ arm()
+
+ return () => {
+ cancelled = true
+ if (armTimer) clearTimeout(armTimer)
+ if (activeTimer) clearTimeout(activeTimer)
+ }
+ }, [])
+
+
+ // Handle loading screen completion
+ const handleLoadingComplete = () => {
+ // Call the original onLogin immediately to transition to VN page
+ // Don't reset loading states as we're leaving the landing page
+ onLogin()
+ }
+
+ // Handle login button click
+ const handleLogin = () => {
+ setLoading(true)
+ }
+
+ // Removed ASCII art effects and rendering.
+
+ return (
+ <div>
+ {loading && (
+ <LoadingScreen onComplete={handleLoadingComplete} />
+ )}
+
+ {/* Floating Header Bar - Hidden during loading */}
+ {!loading && (
+ <div className={`floating-header ${showLanding ? 'fade-in' : 'hidden'}`}>
+ <div className="header-content">
+ <div className="brand">
+ <img
+ src="/logo/crane-logo-transparent.png"
+ alt="Soryu"
+ height={40}
+ className="brand-mark"
+ onError={(e) => { const img = (e.currentTarget as HTMLImageElement); img.onerror = null; img.src = '/logo/crane-logo.png'; }}
+ />
+ </div>
+ <div className="header-center">
+ <HeartLogo size="header-no-rays" className="header-heart" />
+ </div>
+
+ <div className="system-info">
+ <div className="info-item">
+ <span className="info-label">System:</span>
+ <span
+ className="info-value live-status clickable"
+ onClick={() => setIsStandby(!isStandby)}
+ title="Click to toggle between LIVE and STANDBY"
+ >
+ <span className={`status-dot ${isStandby ? 'standby' : 'live'}`}></span>
+ {isStandby ? 'STDBY' : 'LIVE'}
+ </span>
+ </div>
+ <div className="info-item">
+ <span className="info-label">Version:</span>
+ <span className="info-value">v1.0.0</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className={`modern-landing-page manga-style ${showLanding && !loading ? 'fade-in' : 'hidden'}`}>
+ {/* Retro-futuristic page background */}
+ <div className="rf-page-bg" aria-hidden="true">
+ <div className="rf-page-speedlines layer-a"></div>
+ <div className="rf-page-speedlines layer-b"></div>
+ </div>
+ {/* Taisho Magazine Cover Backdrop */}
+ <div className="taisho-cover">
+ <div className="cover-backdrop" aria-hidden="true"></div>
+
+ {/* Cover Content Grid */}
+ <div className="cover-content">
+ {/* Vertical Masthead (magazine-style) */}
+ <div className="masthead">
+ <div className="masthead-vertical">
+ <span className="jp">そりゅう</span>
+ <span className="en">SORYU</span>
+ </div>
+ <div className="issue-badge">かはいい Vol.01</div>
+ </div>
+
+ {/* Central Hero - full-frame retro-futuristic */}
+ <div className="hero">
+ <div className="hero-frame taisho-modern-frame">
+ <div className="hero-inner hero-fill">
+ {/* Retro-futuristic racing hero content */}
+ <div className="rf-hero" aria-hidden="true">
+ <div className="rf-speedlines layer-a"></div>
+ <div className="rf-speedlines layer-b"></div>
+ <div className="rf-hero-content">
+ <div className="rf-badge">Engineering • Systems • para bellum</div>
+ <h2 className="rf-headline">
+ Mission driven{' '}
+ <span
+ className={`glitch-word ${isGlitching ? 'is-glitching' : ''}`}
+ data-text="futurist"
+ >
+ futurist
+ </span>{' '}
+ technology
+ </h2>
+ <p className="rf-tagline">Avant-garde. Aesthetic Engineering. A Race of Steel</p>
+ <div className="rf-stats">
+ <div className="rf-stat"><span className="label">Velocity</span><span className="value">0–603 km/h</span></div>
+ <div className="rf-stat"><span className="label">Energy</span><span className="value">32 MJ</span></div>
+ <div className="rf-stat"><span className="label">Flow</span><span className="value">MAX</span></div>
+ </div>
+ </div>
+ <div className="rf-accent-diagonal"></div>
+ {/* ASCII overlay (transparent, gradient text) */}
+ {asciiArt && (
+ <div className="rf-ascii-overlay" aria-hidden="true">
+ {asciiArt.split('\n').map((line, i) => (
+ <div key={i} className="ascii-line">
+ {Array.from(line).map((ch, j) => (
+ <span
+ key={`${i}-${j}`}
+ className="ascii-char"
+ style={{
+ '--delay': `${(i * 0.08 + j * 0.01)}s`,
+ } as React.CSSProperties}
+ >
+ {ch}
+ </span>
+ ))}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* CTA */}
+ <div className="cta-area">
+ <button className="taisho-cta" onClick={handleLogin}>
+ <span className="cta-icon">▶</span>
+ <span className="cta-text">Enter</span>
+ </button>
+ </div>
+ {/* Visitor Counter */}
+ <div className="visit-counter" aria-label="visitor counter">
+ <img
+ src="https://count.getloli.com/get/@soryu-landing?theme=booru-jaypee&darkmode=0"
+ alt="visit counter"
+ referrerPolicy="no-referrer-when-downgrade"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/frontend/src/components/LoadingScreen.tsx b/frontend/src/components/LoadingScreen.tsx
new file mode 100644
index 0000000..5f33d00
--- /dev/null
+++ b/frontend/src/components/LoadingScreen.tsx
@@ -0,0 +1,129 @@
+import React, { useEffect, useState, useRef } from 'react'
+
+type Props = {
+ onComplete: () => void
+}
+
+export const LoadingScreen: React.FC<Props> = ({ onComplete }) => {
+ const [fadeOut, setFadeOut] = useState(false)
+ const [mounted, setMounted] = useState(true)
+ const [subtitle, setSubtitle] = useState('')
+ const [animatedText, setAnimatedText] = useState('')
+ const subtitleLoaded = useRef(false)
+
+ useEffect(() => {
+ console.log('Loading screen mounted - starting timer...')
+
+ // Set fixed subtitle and animate it
+ if (!subtitleLoaded.current) {
+ subtitleLoaded.current = true
+
+ const fixedSubtitle = 'Whisper of the Heart'
+ setSubtitle(fixedSubtitle)
+
+ // Animate text character by character
+ let currentIndex = 0
+ const animateText = () => {
+ if (currentIndex <= fixedSubtitle.length) {
+ setAnimatedText(fixedSubtitle.slice(0, currentIndex))
+ currentIndex++
+ setTimeout(animateText, 50) // 50ms delay between characters
+ }
+ }
+
+ // Start animation after heart appears (1000ms = 0.2s heart delay + 0.8s heart animation)
+ setTimeout(animateText, 1000)
+ }
+
+ // Start fade after 4 seconds (longer to show ray spinning)
+ const fadeTimer = setTimeout(() => {
+ console.log('4 seconds passed - Starting fade out...')
+ setFadeOut(true)
+ }, 4000)
+
+ // Complete after fade finishes (4s show + 1s fade = 5s total)
+ const completeTimer = setTimeout(() => {
+ console.log('5 seconds passed - Loading screen completing...')
+ setMounted(false)
+ onComplete()
+ }, 5000)
+
+ return () => {
+ console.log('Cleaning up timers...')
+ clearTimeout(fadeTimer)
+ clearTimeout(completeTimer)
+ }
+ }, []) // Empty dependency array - run once on mount
+
+ const handleClick = () => {
+ console.log('Loading screen clicked, completing early...')
+ setFadeOut(true)
+ setTimeout(() => {
+ setMounted(false)
+ onComplete()
+ }, 1000)
+ }
+
+ console.log('LoadingScreen render - fadeOut:', fadeOut, 'mounted:', mounted)
+
+ if (!mounted) return null
+
+ return (
+ <div className={`loading-screen ${fadeOut ? 'fade-out' : ''}`} onClick={handleClick}>
+ <div className="loading-logo">
+ <div className="heart-container">
+ <div className="sun-rays loading-animate-rays">
+ <svg viewBox="0 0 100 100" className="rays-svg">
+ {/* Rising Sun flag style rays - triangular rays extending to edges */}
+ <g transform="translate(50, 50)">
+ {[...Array(16)].map((_, i) => {
+ const angle = (i * 22.5);
+ const nextAngle = ((i + 1) * 22.5);
+ const rayWidth = 11.25; // Half the angle between rays for triangular shape
+
+ // Create triangular ray path
+ const startAngle = angle - rayWidth;
+ const endAngle = angle + rayWidth;
+
+ const innerRadius = 0; // Start from center (heart center)
+ const outerRadius = 70; // Extend to screen edge
+
+ const x1 = Math.cos(startAngle * Math.PI / 180) * innerRadius;
+ const y1 = Math.sin(startAngle * Math.PI / 180) * innerRadius;
+ const x2 = Math.cos(endAngle * Math.PI / 180) * innerRadius;
+ const y2 = Math.sin(endAngle * Math.PI / 180) * innerRadius;
+ const x3 = Math.cos(startAngle * Math.PI / 180) * outerRadius;
+ const y3 = Math.sin(startAngle * Math.PI / 180) * outerRadius;
+ const x4 = Math.cos(endAngle * Math.PI / 180) * outerRadius;
+ const y4 = Math.sin(endAngle * Math.PI / 180) * outerRadius;
+
+ return (
+ <polygon
+ key={i}
+ points={`${x1},${y1} ${x2},${y2} ${x4},${y4} ${x3},${y3}`}
+ fill={i % 2 === 0 ? "#8B0000" : "#000000"}
+ opacity="0.9"
+ />
+ );
+ })}
+ </g>
+ </svg>
+ </div>
+ <div className="heart-outline loading-animate-heart">
+ <svg viewBox="0 0 100 100" className="heart-svg">
+ <path d="M50,85 C50,85 10,60 10,35 C10,18 20,8 35,8 C42,8 50,13 50,25 C50,13 58,8 65,8 C80,8 90,18 90,35 C90,60 50,85 50,85 Z"
+ fill="#8B0000"
+ stroke="#8B0000"
+ strokeWidth="2"/>
+ </svg>
+ </div>
+ <div className="logo-text loading-animate-text">soryu</div>
+ <div className="loading-subtitle loading-animate-subtitle">{animatedText}</div>
+ <div className="loading-dots loading-animate-text">
+ <span>.</span><span>.</span><span>.</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/frontend/src/components/OrigamiDragonLogo.tsx b/frontend/src/components/OrigamiDragonLogo.tsx
new file mode 100644
index 0000000..6a7a5fb
--- /dev/null
+++ b/frontend/src/components/OrigamiDragonLogo.tsx
@@ -0,0 +1,180 @@
+import React from 'react'
+
+type Variant = 'mark' | 'ribbonS' | 'badge' | 'crane' | 'craneOutline'
+
+type Props = {
+ variant?: Variant
+ height?: number
+ color?: string
+ className?: string
+ title?: string
+ strokeWidth?: number
+}
+
+/**
+ * Origami-style dragon logo for small header placement.
+ * - Uses currentColor by default so it adapts to surrounding text color.
+ * - Keep height around 20–24px in compact headers.
+ */
+export const OrigamiDragonLogo: React.FC<Props> = ({
+ variant = 'crane',
+ height = 20,
+ color = 'currentColor',
+ className = '',
+ title = 'Soryu logo',
+ strokeWidth = 12,
+}) => {
+ if (variant === 'craneOutline') {
+ // Outline rendition inspired by provided licensed crane icon
+ return (
+ <svg
+ className={className}
+ role="img"
+ aria-label={title}
+ width={height}
+ height={height}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{ display: 'inline-block', verticalAlign: 'middle', color }}
+ >
+ <title>{title}</title>
+ <g fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round">
+ {/* Left tail → left wing base → body pivot */}
+ <path d="M10 60 L36 76 L18 42 L42 58" />
+ {/* Left wing upstroke */}
+ <path d="M42 58 L26 12" />
+ {/* Left wing inner crease to pivot */}
+ <path d="M26 12 L50 62" />
+ {/* Body lower crease */}
+ <path d="M30 76 L50 62" />
+ {/* Center mast (rear triangle) */}
+ <path d="M50 62 L58 16 L64 70" />
+ {/* Right wing outer edge */}
+ <path d="M50 62 L72 30 L66 78" />
+ {/* Right wing inner crease */}
+ <path d="M72 30 L60 52" />
+ {/* Neck and head */}
+ <path d="M66 78 L82 54 L90 40 L96 56" />
+ {/* Neck inner crease */}
+ <path d="M82 54 L72 44" />
+ </g>
+ </svg>
+ )
+ }
+ if (variant === 'crane') {
+ // Origami crane silhouette with a dragon head
+ return (
+ <svg
+ className={className}
+ role="img"
+ aria-label={title}
+ width={height}
+ height={height}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{ display: 'inline-block', verticalAlign: 'middle', color }}
+ >
+ <title>{title}</title>
+ {/* Left/up wing */}
+ <polygon points="46,48 22,24 40,52" fill="currentColor" opacity="0.95" />
+ {/* Right/down wing */}
+ <polygon points="52,52 78,76 58,54" fill="currentColor" opacity="0.78" />
+ {/* Body (diamond) */}
+ <polygon points="44,52 52,46 58,52 50,60" fill="currentColor" opacity="0.88" />
+ {/* Tail */}
+ <polygon points="44,60 34,70 46,64" fill="currentColor" opacity="0.7" />
+ {/* Neck */}
+ <polygon points="58,46 76,40 74,44 56,50" fill="currentColor" opacity="0.9" />
+ {/* Dragon head (top plane) */}
+ <polygon points="76,40 90,38 82,46" fill="currentColor" opacity="0.95" />
+ {/* Dragon jaw plane */}
+ <polygon points="74,44 90,46 82,50" fill="currentColor" opacity="0.8" />
+ {/* Small horn */}
+ <polygon points="86,36 92,38 88,42" fill="currentColor" opacity="0.85" />
+ </svg>
+ )
+ }
+ if (variant === 'mark') {
+ // Angular origami dragon head/neck, faceted into simple planes
+ return (
+ <svg
+ className={className}
+ role="img"
+ aria-label={title}
+ width={height}
+ height={height}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{ display: 'inline-block', verticalAlign: 'middle', color }}
+ >
+ <title>{title}</title>
+ {/* Upper head */}
+ <polygon points="60,22 86,16 73,34 58,30" fill="currentColor" opacity="0.95" />
+ {/* Snout / jaw plane */}
+ <polygon points="58,30 73,34 90,30 71,45" fill="currentColor" opacity="0.8" />
+ {/* Neck planes */}
+ <polygon points="58,30 71,45 56,60 40,66 30,72 22,64 46,48" fill="currentColor" opacity="0.9" />
+ <polygon points="22,64 30,72 18,74 26,60" fill="currentColor" opacity="0.65" />
+ {/* Small ear/horn suggestion */}
+ <polygon points="78,18 86,16 82,24" fill="currentColor" opacity="0.7" />
+ </svg>
+ )
+ }
+
+ if (variant === 'ribbonS') {
+ // Folded ribbon forming an angular "S" silhouette
+ return (
+ <svg
+ className={className}
+ role="img"
+ aria-label={title}
+ width={height}
+ height={height}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{ display: 'inline-block', verticalAlign: 'middle', color }}
+ >
+ <title>{title}</title>
+ {/* Top fold */}
+ <polygon points="22,32 62,18 70,26 30,40" fill="currentColor" opacity="0.95" />
+ {/* Middle fold (turning back) */}
+ <polygon points="30,40 70,26 60,46 20,60" fill="currentColor" opacity="0.8" />
+ {/* Lower fold */}
+ <polygon points="22,64 60,46 68,54 28,70" fill="currentColor" opacity="0.9" />
+ </svg>
+ )
+ }
+
+ // badge
+ return (
+ <svg
+ className={className}
+ role="img"
+ aria-label={title}
+ width={height}
+ height={height}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{ display: 'inline-block', verticalAlign: 'middle', color }}
+ >
+ <title>{title}</title>
+ {/* Hex container */}
+ <polygon
+ points="50,6 83,25 83,75 50,94 17,75 17,25"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="8"
+ strokeLinejoin="round"
+ opacity="0.9"
+ />
+ {/* Mini mark inside */}
+ <g transform="translate(0,2) scale(0.85) translate(10,6)">
+ <polygon points="60,22 86,16 73,34 58,30" fill="currentColor" opacity="0.95" />
+ <polygon points="58,30 73,34 90,30 71,45" fill="currentColor" opacity="0.8" />
+ <polygon points="58,30 71,45 56,60 40,66 30,72 22,64 46,48" fill="currentColor" opacity="0.9" />
+ </g>
+ </svg>
+ )
+}
+
+export default OrigamiDragonLogo
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx
new file mode 100644
index 0000000..89d7206
--- /dev/null
+++ b/frontend/src/components/TopBar.tsx
@@ -0,0 +1,24 @@
+import React from 'react'
+
+type Props = {
+ title?: string
+ status?: string
+}
+
+export const TopBar: React.FC<Props> = ({ title = 'PC-98 VISUAL NOVEL', status = 'IDLE' }) => {
+ return (
+ <div className="topbar window-border">
+ <div className="topbar-left">
+ <span className="led led-green" />
+ <span className="menu-item">FILE</span>
+ <span className="menu-item">SAVE</span>
+ <span className="menu-item">LOAD</span>
+ <span className="menu-item">CONFIG</span>
+ </div>
+ <div className="topbar-title">{title}</div>
+ <div className="topbar-right">
+ <span className="status">{status}</span>
+ </div>
+ </div>
+ )
+}
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>
+ )
+}
diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx
new file mode 100644
index 0000000..be71d27
--- /dev/null
+++ b/frontend/src/components/VNInterface.tsx
@@ -0,0 +1,208 @@
+import React, { useEffect } from 'react'
+import { useStore } from '@nanostores/react'
+import {
+ isStandbyStore,
+ currentTimeStore,
+ weatherStore,
+ showChoicesStore,
+ showSettingsModalStore,
+ isVisibleStore,
+ yenBalanceStore,
+ toggleStandby,
+ toggleShowChoices,
+ updateTime
+} from '../stores'
+
+interface VNInterfaceProps {
+ onLogout: () => void
+}
+
+export function VNInterface({ onLogout }: VNInterfaceProps) {
+ const isStandby = useStore(isStandbyStore)
+ const currentTime = useStore(currentTimeStore)
+ const weather = useStore(weatherStore)
+ const showChoices = useStore(showChoicesStore)
+ const showSettingsModal = useStore(showSettingsModalStore)
+ const isVisible = useStore(isVisibleStore)
+ const yenBalance = useStore(yenBalanceStore)
+
+ // Fade in effect on mount
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ isVisibleStore.set(true)
+ }, 100)
+ return () => clearTimeout(timer)
+ }, [])
+
+ // Update clock every second (Japan Time)
+ useEffect(() => {
+ const timer = setInterval(() => {
+ const now = new Date()
+ // Convert to Japan Time (UTC+9)
+ const japanTime = new Date(now.getTime() + (now.getTimezoneOffset() * 60000) + (9 * 3600000))
+ updateTime()
+ }, 1000)
+ return () => clearInterval(timer)
+ }, [])
+
+ return (
+ <div className={`vn-interface ${isVisible ? 'fade-in' : 'fade-out'}`}>
+ {/* Background */}
+ <div className="vn-background">
+ <img
+ src="/__gaogao__56242cbde8f18ac64522e410bad04e68_waifu2x_art_noise2.png"
+ alt="Background image"
+ className="background-image"
+ />
+ </div>
+
+ {/* Combined Info Panel (Top Right) */}
+ <div className="floating-info-panel">
+ <div className="info-panel-content">
+ {/* Weather Section */}
+ <div className="weather-section">
+ <div className="weather-icon">🌤️</div>
+ <div className="weather-details">
+ <div className="weather-location">Tokyo</div>
+ <div className="weather-temp">22°C Sunny</div>
+ </div>
+ </div>
+
+ {/* Time Section */}
+ <div className="time-section">
+ <div className="japan-date">{currentTime.toLocaleDateString('ja-JP', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ weekday: 'short'
+ })}</div>
+ <div className="japan-time">{currentTime.toLocaleTimeString('ja-JP', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit'
+ })}</div>
+ </div>
+
+ {/* Status Section */}
+ <div className="status-section">
+ <div className="status-item">
+ <span className="info-label">Balance:</span>
+ <span className="info-value yen-balance">¥{yenBalance.toLocaleString()}</span>
+ </div>
+ <div className="status-item">
+ <span className="info-label">System:</span>
+ <span
+ className="info-value live-status clickable"
+ onClick={toggleStandby}
+ title="Click to toggle between LIVE and STANDBY"
+ >
+ <span className={`status-dot ${isStandby ? 'standby' : 'live'}`}></span>
+ {isStandby ? 'STDBY' : 'LIVE'}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Main VN Viewport */}
+ <div className="vn-viewport">
+ <div className="vn-content">
+ </div>
+ </div>
+
+ {/* Dialogue Panel (Bottom) */}
+ <div className="floating-dialogue-panel">
+ <div className="dialogue-content">
+ <div className="dialogue-speaker">???</div>
+ <div className="dialogue-text">
+ A warm CRT glow fills the room. A figure turns towards you...
+ </div>
+ </div>
+ </div>
+
+ {/* Input/Choice Panel (Bottom) */}
+ <div className="floating-input-panel">
+ <div className="input-content">
+ {!showChoices ? (
+ // Text Input Mode
+ <input
+ type="text"
+ className="vn-text-input"
+ placeholder="Type your response..."
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ const target = e.target as HTMLInputElement;
+ if (target.value.trim()) {
+ console.log('User input:', target.value);
+ target.value = '';
+ }
+ }
+ }}
+ />
+ ) : (
+ // Choice Options Mode
+ <div className="choice-buttons">
+ <button className="choice-btn" onClick={() => console.log('Choice: Hello?')}>"Hello?"</button>
+ <button className="choice-btn" onClick={() => console.log('Choice: Who are you?')}>"Who are you?"</button>
+ <button className="choice-btn" onClick={() => console.log('Choice: Stay silent')}>(Stay silent)</button>
+ </div>
+ )}
+
+ {/* Toggle Button */}
+ <button
+ className="toggle-input-btn"
+ onClick={toggleShowChoices}
+ title={showChoices ? "Switch to text input" : "Switch to choice options"}
+ >
+ {showChoices ? "⎀" : "≡"}
+ </button>
+ </div>
+ </div>
+
+ {/* Floating Settings Button */}
+ <button className="floating-logout-btn" onClick={() => showSettingsModalStore.set(true)}>
+ <span className="btn-icon">⚙</span>
+ <span className="btn-text">Settings</span>
+ </button>
+
+ {/* Settings Modal */}
+ {showSettingsModal && (
+ <div className="modal-overlay" onClick={() => showSettingsModalStore.set(false)}>
+ <div className="settings-modal" onClick={(e) => e.stopPropagation()}>
+ <div className="modal-header">
+ <h2 className="modal-title">Settings</h2>
+ <button className="modal-close-btn" onClick={() => showSettingsModalStore.set(false)}>
+ ×
+ </button>
+ </div>
+ <div className="modal-content">
+ <div className="settings-section">
+ <h3>Display Options</h3>
+ <div className="setting-item">
+ <label>
+ <input type="checkbox" defaultChecked /> Enable animations
+ </label>
+ </div>
+ </div>
+ <div className="settings-section">
+ <h3>Audio</h3>
+ <div className="setting-item">
+ <label>Master Volume</label>
+ <input type="range" min="0" max="100" defaultValue="75" />
+ </div>
+ </div>
+ </div>
+ <div className="modal-footer">
+ <button className="modal-btn secondary" onClick={() => showSettingsModalStore.set(false)}>
+ Cancel
+ </button>
+ <button className="modal-btn logout" onClick={() => { showSettingsModalStore.set(false); onLogout(); }}>
+ Logout
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/frontend/src/components/VNViewport.tsx b/frontend/src/components/VNViewport.tsx
new file mode 100644
index 0000000..fe01264
--- /dev/null
+++ b/frontend/src/components/VNViewport.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+
+type Props = {
+ bgSrc?: string
+ children?: React.ReactNode
+}
+
+export const VNViewport: React.FC<Props> = ({ bgSrc = '/bg.jpg', children }) => {
+ return (
+ <div className="viewport">
+ <div className="viewport-inner">
+ <div className="bg">
+ {bgSrc && <img src={bgSrc} alt="Background" />}
+ </div>
+ {children}
+ </div>
+ <div className="scanlines" aria-hidden="true" />
+ <div className="crt-glow" aria-hidden="true" />
+ <div className="dither-pattern" aria-hidden="true" />
+ </div>
+ )
+}