diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 19 | ||||
| -rw-r--r-- | frontend/src/components/BottomBar.tsx | 19 | ||||
| -rw-r--r-- | frontend/src/components/ChoiceMenu.tsx | 20 | ||||
| -rw-r--r-- | frontend/src/components/CityscapeBackground.tsx | 310 | ||||
| -rw-r--r-- | frontend/src/components/ConfigModal.tsx | 44 | ||||
| -rw-r--r-- | frontend/src/components/DialogueBox.tsx | 15 | ||||
| -rw-r--r-- | frontend/src/components/HeartLogo.tsx | 270 | ||||
| -rw-r--r-- | frontend/src/components/LandingPage.tsx | 219 | ||||
| -rw-r--r-- | frontend/src/components/LoadingScreen.tsx | 129 | ||||
| -rw-r--r-- | frontend/src/components/OrigamiDragonLogo.tsx | 180 | ||||
| -rw-r--r-- | frontend/src/components/TopBar.tsx | 24 | ||||
| -rw-r--r-- | frontend/src/components/VNApp.tsx | 228 | ||||
| -rw-r--r-- | frontend/src/components/VNInterface.tsx | 208 | ||||
| -rw-r--r-- | frontend/src/components/VNViewport.tsx | 22 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 10 | ||||
| -rw-r--r-- | frontend/src/services/ws.ts | 69 | ||||
| -rw-r--r-- | frontend/src/stores/index.ts | 94 | ||||
| -rw-r--r-- | frontend/src/styles/pc98.css | 4353 | ||||
| -rw-r--r-- | frontend/src/types.ts | 11 |
19 files changed, 6244 insertions, 0 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0a0e008 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { useStore } from '@nanostores/react' +import { LandingPage } from './components/LandingPage' +import { VNInterface } from './components/VNInterface' +import { isLoggedInStore, login, logout } from './stores' + +export default function App() { + const isLoggedIn = useStore(isLoggedInStore) + + return ( + <> + {isLoggedIn ? ( + <VNInterface onLogout={logout} /> + ) : ( + <LandingPage onLogin={login} /> + )} + </> + ) +} 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> + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ca5a10c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles/pc98.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + <React.StrictMode> + <App /> + </React.StrictMode> +) diff --git a/frontend/src/services/ws.ts b/frontend/src/services/ws.ts new file mode 100644 index 0000000..7d10512 --- /dev/null +++ b/frontend/src/services/ws.ts @@ -0,0 +1,69 @@ +// Minimal WS client with auto-reconnect for Rust backend interoperability. +// Point URL to your Rust server: ws://localhost:8080/ws (example) + +type Listener = (data: any) => void + +export class VNWebSocket { + private url: string + private ws: WebSocket | null = null + private reconnectDelay = 1000 + private maxReconnectDelay = 8000 + private shouldReconnect = true + private listeners: Record<string, Listener[]> = {} + + constructor(url: string) { + this.url = url + } + + on(event: 'open' | 'close' | 'error' | 'message', cb: Listener) { + if (!this.listeners[event]) this.listeners[event] = [] + this.listeners[event].push(cb) + } + + private emit(event: string, data?: any) { + ;(this.listeners[event] || []).forEach(cb => cb(data)) + } + + connect() { + this.ws = new WebSocket(this.url) + + this.ws.addEventListener('open', () => { + this.emit('open') + this.reconnectDelay = 1000 + }) + + this.ws.addEventListener('message', (evt) => { + try { + const parsed = JSON.parse(evt.data) + this.emit('message', parsed) + } catch { + this.emit('message', evt.data) + } + }) + + this.ws.addEventListener('close', () => { + this.emit('close') + if (this.shouldReconnect) { + setTimeout(() => this.connect(), this.reconnectDelay) + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay) + } + }) + + this.ws.addEventListener('error', (e) => { + this.emit('error', e) + this.ws?.close() + }) + } + + send(data: any) { + const payload = typeof data === 'string' ? data : JSON.stringify(data) + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(payload) + } + } + + close() { + this.shouldReconnect = false + this.ws?.close() + } +} diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..58f461c --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,94 @@ +import { atom } from 'nanostores' +import { ChatMessage, Choice } from '../types' + +// Authentication state +export const isLoggedInStore = atom<boolean>(false) + +// VN Interface state +export const isStandbyStore = atom<boolean>(false) +export const currentTimeStore = atom<Date>(new Date()) +export const weatherStore = atom<string>('Tokyo - Sunny 22°C') +export const showChoicesStore = atom<boolean>(false) +export const showSettingsModalStore = atom<boolean>(false) +export const isVisibleStore = atom<boolean>(false) +export const yenBalanceStore = atom<number>(15000) + +// VN Page state +export const loadingStore = atom<boolean>(true) +export const loadingCompleteStore = atom<boolean>(false) +export const messagesStore = atom<ChatMessage[]>([ + { id: 'm1', role: 'assistant', content: 'A warm CRT glow fills the room. A figure turns towards you...' }, +]) +export const choicesStore = atom<Choice[]>([ + { id: 'greet', label: '"Hello?"' }, + { id: 'who', label: '"Who are you?"' }, + { id: 'silence', label: '(Stay silent)' }, +]) +export const statusStore = atom<string>('OFFLINE') +export const nameStore = atom<string>('???') +export const backgroundStore = atom<string>('/__gaogao__56242cbde8f18ac64522e410bad04e68_waifu2x_art_noise2.png') +export const locationStore = atom<string>('Tokyo') +export const configModalOpenStore = atom<boolean>(false) +export const skipIntroStore = atom<boolean>( + (() => { + const saved = localStorage.getItem('skipIntro') + return saved === 'true' + })() +) + +// Actions +export const login = () => { + isLoggedInStore.set(true) +} + +export const logout = () => { + isLoggedInStore.set(false) +} + +export const toggleStandby = () => { + isStandbyStore.set(!isStandbyStore.get()) +} + +export const toggleShowChoices = () => { + showChoicesStore.set(!showChoicesStore.get()) +} + +export const updateTime = () => { + currentTimeStore.set(new Date()) +} + +export const addMessage = (message: ChatMessage) => { + messagesStore.set([...messagesStore.get(), message]) +} + +export const setChoices = (choices: Choice[]) => { + choicesStore.set(choices) +} + +export const clearChoices = () => { + choicesStore.set([]) +} + +export const setBackground = (src: string) => { + backgroundStore.set(src) +} + +export const setName = (name: string) => { + nameStore.set(name) +} + +export const setStatus = (status: string) => { + statusStore.set(status) +} + +export const setSkipIntro = (skip: boolean) => { + skipIntroStore.set(skip) + localStorage.setItem('skipIntro', skip.toString()) +} + +export const setLoadingComplete = (complete: boolean) => { + loadingCompleteStore.set(complete) + if (complete) { + loadingStore.set(false) + } +}
\ No newline at end of file diff --git a/frontend/src/styles/pc98.css b/frontend/src/styles/pc98.css new file mode 100644 index 0000000..63e1996 --- /dev/null +++ b/frontend/src/styles/pc98.css @@ -0,0 +1,4353 @@ +@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Dela+Gothic+One&family=Coral+Pixels&family=Notable&family=Noto+Serif+JP:wght@300;400;700;900&display=swap'); + +:root { + --bg: #000033; + --bg-gradient: linear-gradient(180deg, #000033 0%, #000066 100%); + --fg: #ffffff; + --accent: #ff6699; + --accent2: #66ccff; + --accent3: #ffcc66; + --text-box-bg: rgba(0, 0, 51, 0.9); + --text-box-border: #66ccff; + --interface-border: #000000; + --name-bg: #ff6699; + --name-fg: #ffffff; + --choice-bg: rgba(0, 0, 51, 0.8); + --choice-border: #66ccff; + --scanline-opacity: 0.1; + --shadow: rgba(0, 0, 0, 0.7); + --text-shadow: rgba(0, 0, 0, 0.8); +} + +* { box-sizing: border-box; } +html, body, #root { height: 100%; } + +body { + margin: 0; + background: #000000; + color: var(--fg); + font-family: 'MS Gothic', 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', 'メイリオ', Meiryo, 'Courier New', monospace; + font-weight: 400; + font-size: 16px; +} + +/* Remove app-root since we're using fixed positioning */ + +/* Main interface container with fixed pillar borders */ +.screen { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + background: var(--bg); + overflow: hidden; +} + +/* Left pillar border with character image */ +.left-pillar { + width: 200px; + height: 100vh; + background: linear-gradient(180deg, #000000, #000000, #000000); + border-right: 4px solid var(--interface-border); + position: relative; + flex-shrink: 0; + box-shadow: inset -4px 0 8px rgba(0,0,0,0.2); +} + +/* Right pillar border with character image */ +.right-pillar { + width: 200px; + height: 100vh; + background: linear-gradient(180deg, #000000, #000000, #000000); + border-left: 4px solid var(--interface-border); + position: relative; + flex-shrink: 0; + box-shadow: inset 4px 0 8px rgba(0,0,0,0.2); +} + +/* Character images */ +.pillar-image { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 160px; + height: 240px; + background: linear-gradient(180deg, #000000, #000000); + border: 3px solid var(--interface-border); + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: 'MS Gothic', monospace; + font-size: 11px; + color: #555; + box-shadow: + inset 0 0 15px rgba(0,0,0,0.2), + 0 4px 8px rgba(0,0,0,0.3); + text-align: center; + line-height: 1.3; +} + +/* Add character silhouette */ +.pillar-image::before { + content: ''; + width: 80px; + height: 120px; + background: linear-gradient(180deg, + #999 0%, + #777 20%, + #666 40%, + #555 60%, + #777 80%, + #999 100%); + border-radius: 40px 40px 20px 20px; + margin-bottom: 8px; + box-shadow: inset 0 0 10px rgba(0,0,0,0.4); +} + +.left-pillar .pillar-image { + background: linear-gradient(135deg, #000000, #000000, #000000); +} + +.right-pillar .pillar-image { + background: linear-gradient(225deg, #000000, #000000, #000000); +} + +/* Bottom corner UI elements */ +.pillar-bottom-section { + position: absolute; + bottom: 20px; + width: 180px; + left: 10px; + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 3px solid var(--interface-border); + border-radius: 6px; + padding: 12px; + box-shadow: + inset 0 0 8px rgba(0,0,0,0.2), + 0 3px 6px rgba(0,0,0,0.3); +} + +.pillar-bottom-section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +/* Button grid in left pillar */ +.pillar-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + position: relative; + z-index: 1; +} + +.pillar-btn { + appearance: none; + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px outset #000000; + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 10px; + font-weight: bold; + padding: 8px 4px; + text-align: center; + cursor: pointer; + box-shadow: inset 0 0 4px rgba(255,255,255,0.3); + text-shadow: 1px 1px 0 rgba(0,0,0,0.5); +} + +.pillar-btn:hover { + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px inset #000000; +} + +.pillar-btn:active { + background: linear-gradient(180deg, #000000, #000000, #000000); + box-shadow: inset 2px 2px 4px rgba(0,0,0,0.3); +} + +/* Digital display in right pillar */ +.pillar-display { + background: #000000; + border: 3px solid #ffffff; + border-radius: 4px; + padding: 8px; + font-family: 'Courier New', monospace; + color: #00ff00; + text-shadow: 0 0 8px #00ff00; + font-size: 14px; + font-weight: bold; + letter-spacing: 1px; + text-align: center; + box-shadow: 0 0 4px rgba(0,255,0,0.3); +} + +.digital-clock { + margin-bottom: 8px; + font-size: 16px; +} + +.yen-counter { + font-size: 12px; + color: #ffff00; + text-shadow: 0 0 6px #ffff00; +} + +.yen-counter::before { + content: '¥ '; + color: #ffffff; + text-shadow: 0 0 4px #ffffff; +} + +/* Main content area - much wider */ +.screen-content { + flex: 1; + display: grid; + grid-template-rows: auto 1fr auto auto auto; + gap: 0; + background: #000000; + position: relative; + min-width: 0; + max-width: none; + height: 100vh; +} + +/* Header and footer styling */ +.header-footer-border { + position: relative; + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px solid var(--interface-border); +} + +.header-footer-border::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; +} + +.screen-header { + height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: linear-gradient(180deg, #000000, #000000); + border-bottom: 2px solid var(--interface-border); + color: #ffffff; + font-family: 'Sylfaen', serif; + font-weight: bold; + font-size: 14px; +} + +/* Header brand: origami dragon mark + word */ +.screen-header .brand { + display: inline-flex; + align-items: center; + gap: 8px; + color: #ffffff; /* white lineart + text */ +} + +/* (brand text removed) */ + +.screen-header .brand-mark { + transition: transform 150ms ease-out; + opacity: 1; + will-change: transform, filter; +} + +.screen-header .brand:hover .brand-mark { + animation: logo-glitch 700ms steps(8, end) infinite; + opacity: 1; +} + +/* (brand text removed) */ + +/* (brand text animation removed) */ + +.screen-footer { + height: 24px; + display: flex; + align-items: center; + padding: 0 16px; + background: linear-gradient(180deg, #000000, #000000); + border-top: 2px solid var(--interface-border); + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 12px; +} + +.vn-text-box { + background: var(--text-box-bg); + border: 2px solid var(--text-box-border); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); +} + +.topbar { + display: none; +} + +.viewport { + position: relative; + overflow: hidden; + display: grid; + align-items: stretch; + height: 100%; +} +.viewport-inner { position: relative; inset: 0; } +.bg { position: absolute; inset: 0; } +.bg img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 25%; + image-rendering: pixelated; +} + +/* PC-98 color palette overlay */ +.bg::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 45deg, + rgba(255, 0, 255, 0.05) 0%, + rgba(0, 255, 255, 0.03) 25%, + rgba(255, 255, 0, 0.02) 50%, + rgba(255, 0, 0, 0.03) 75%, + rgba(0, 0, 255, 0.05) 100% + ); + mix-blend-mode: overlay; + pointer-events: none; +} + +.ui-layer { position: relative; inset: 0; display: contents; } + +.scanlines { + pointer-events: none; + position: absolute; + inset: 0; + mix-blend-mode: multiply; +} +.crt-glow { + pointer-events: none; + position: absolute; + inset: -2%; + background: radial-gradient(ellipse at center, rgba(255,102,153,0.08), rgba(0,0,0,0) 60%); + filter: blur(4px); +} + +/* PC-98 characteristic dithering pattern */ +.dither-pattern { + pointer-events: none; + position: absolute; + inset: 0; + opacity: 0.08; + background-image: + radial-gradient(circle at 1px 1px, rgba(255,255,255,0.8) 0.5px, transparent 0), + radial-gradient(circle at 3px 3px, rgba(0,0,0,0.3) 0.5px, transparent 0); + background-size: 6px 6px, 4px 4px; + mix-blend-mode: overlay; +} + +/* Additional PC-98 color quantization effect */ +.viewport::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + mix-blend-mode: color-burn; + pointer-events: none; + z-index: 1; +} + +/* Add retro CRT curvature effect */ +.screen::before { + content: ''; + pointer-events: none; + position: absolute; + inset: -5px; + background: radial-gradient( + ellipse at center, + transparent 50%, + rgba(0,0,0,0.1) 80%, + rgba(0,0,0,0.3) 100% + ); + border-radius: 8px; +} + +.dialogue { + position: absolute; + bottom: 120px; + left: 16px; + right: 16px; + height: 140px; + padding: 20px 24px 20px 24px; + display: grid; + align-items: start; + font-size: clamp(14px, 2.4vmin, 18px); + line-height: 1.6; + background: var(--text-box-bg); + border: 3px solid var(--text-box-border); + border-radius: 8px; + box-shadow: 0 4px 20px var(--shadow); + z-index: 10; +} +.nameplate { + position: absolute; + top: -16px; + left: 24px; + padding: 4px 16px; + background: var(--name-bg); + color: var(--name-fg); + font-size: clamp(12px, 1.8vmin, 14px); + font-weight: bold; + border: 2px solid var(--text-box-border); + border-radius: 4px; + text-shadow: 1px 1px 2px var(--text-shadow); +} +.dialogue-text { + white-space: pre-wrap; + color: var(--fg); + text-shadow: 1px 1px 2px var(--text-shadow); + font-family: 'MS Gothic', monospace; + font-weight: 400; + letter-spacing: 0.5px; +} + +/* Text input area */ +.text-input-area { + position: absolute; + bottom: 65px; + left: 16px; + right: 16px; + z-index: 5; +} + +.text-input { + width: 100%; + height: 44px; + padding: 12px 16px; + background: var(--text-box-bg); + border: 3px solid var(--text-box-border); + border-radius: 6px; + color: var(--fg); + font-family: 'MS Gothic', monospace; + font-size: clamp(13px, 2.0vmin, 16px); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + box-shadow: 0 2px 8px var(--shadow); + text-shadow: 1px 1px 2px var(--text-shadow); +} + +.text-input::placeholder { + color: rgba(255, 255, 255, 0.6); + font-style: italic; +} + +.text-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 8px rgba(255, 102, 153, 0.4); +} +.choice-item { + appearance: none; + border: 2px solid var(--choice-border); + background: var(--choice-bg); + color: var(--fg); + padding: 12px 20px; + text-align: left; + font-family: 'MS Gothic', monospace; + font-size: clamp(13px, 2.0vmin, 16px); + font-weight: normal; + letter-spacing: 0.5px; + border-radius: 6px; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + text-shadow: 1px 1px 2px var(--text-shadow); + transition: all 0.2s ease; +} +.choice-item:hover { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; + cursor: pointer; + transform: translateX(4px); + box-shadow: 0 2px 12px rgba(255, 102, 153, 0.4); +} +.choice-item:active { + transform: translateX(2px); + box-shadow: 0 1px 6px rgba(255, 102, 153, 0.6); +} + +.bottombar { + position: absolute; + bottom: 26px; + right: 24px; + display: flex; + align-items: center; + gap: 8px; + background: transparent; + border: none; + height: auto; + padding: 0; +} +.mini-btn { + appearance: none; + border: 2px solid var(--choice-border); + background: var(--choice-bg); + color: var(--fg); + font-family: 'MS Gothic', monospace; + font-size: clamp(10px, 1.6vmin, 12px); + font-weight: normal; + padding: 6px 12px; + min-width: 50px; + border-radius: 4px; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + text-shadow: 1px 1px 2px var(--text-shadow); +} +.mini-btn:hover { + background: var(--accent2); + border-color: var(--accent2); + cursor: pointer; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 204, 255, 0.3); +} +.mini-btn:active { + transform: translateY(0); + box-shadow: none; +} +.spacer { display: none; } +.location-display { + font-family: 'MS Gothic', monospace; + font-size: clamp(9px, 1.2vmin, 10px); + color: var(--accent3); + font-weight: bold; + text-shadow: 1px 1px 2px var(--text-shadow); +} + +/* Weather display in viewport */ +.weather-display { + position: absolute; + top: 18px; + right: 16px; + padding: 8px 12px; + background: rgba(0, 0, 51, 0.8); + border: 2px solid var(--interface-border); + border-radius: 4px; + color: var(--accent2); + font-family: 'MS Gothic', monospace; + font-size: clamp(11px, 1.6vmin, 13px); + font-weight: bold; + text-shadow: 1px 1px 2px var(--text-shadow); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + z-index: 20; +} + +/* Add PC-98 style text selection */ +::selection { + background: var(--accent); + color: #000000; +} + +/* Enhance button focus states */ +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* Add subtle animation to LEDs */ +.led { + animation: led-pulse 2s ease-in-out infinite alternate; +} + +@keyframes led-pulse { + from { opacity: 0.8; } + to { opacity: 1; } +} + +/* Add PC-98 style loading animation */ +.loading { + position: relative; +} + +.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid var(--accent); + border-radius: 50%; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Modern Landing Page Styles */ +.modern-landing-page { + position: fixed; + top: 120px; + left: 0; + width: 100vw; + height: calc(100vh - 120px); + background: linear-gradient(180deg, #0a0a0f 0%, #0c0d16 100%); + overflow: hidden; + opacity: 0; + transition: opacity 1s ease-in; +} + +.modern-landing-page.manga-style { background: none; } + +/* Retro-futuristic full-page speedlines */ +.rf-page-bg { position: absolute; inset: 0; z-index: 0; overflow: hidden; } +.rf-page-speedlines { position: absolute; inset: -40% -40%; opacity: 0.12; mix-blend-mode: screen; } +.rf-page-speedlines.layer-a { background: repeating-linear-gradient(80deg, #66ccff22 0 6px, transparent 6px 16px); animation: rf-page-move 12s linear infinite; } +.rf-page-speedlines.layer-b { background: repeating-linear-gradient(100deg, #ff669922 0 8px, transparent 8px 22px); animation: rf-page-move 18s linear infinite; } +@keyframes rf-page-move { from { transform: translateX(0); } to { transform: translateX(-240px); } } + +.modern-landing-page.fade-in { + opacity: 1; +} + +.modern-landing-page.hidden { + opacity: 0; + pointer-events: none; +} + +/* Floating Header Bar */ +.floating-header { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100vw; + height: 120px; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(15px); + border-bottom: 3px solid rgba(255, 255, 255, 0.8); + z-index: 100; + display: flex; + align-items: center; + padding: 20px 40px; + opacity: 0; + transition: opacity 1s ease-in; +} + +.floating-header.fade-in { + opacity: 1; +} + +.floating-header.hidden { + opacity: 0; + pointer-events: none; +} + +/* Light mode overrides for landing header */ +/* (Reverted site-wide light overrides; counter handles its own appearance) */ + +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +/* Brand in the floating header (top-left) */ +.floating-header .brand { + position: absolute; + top: 40px; + left: 40px; + display: inline-flex; + align-items: center; + gap: 8px; + color: #ffffff; /* white lineart + text */ + z-index: 21; +} + +/* (brand text removed) */ + +.floating-header .brand-mark { + transition: transform 150ms ease-out; + opacity: 1; + will-change: transform, filter; +} + +.floating-header .brand:hover .brand-mark { + animation: logo-glitch 700ms steps(8, end) infinite; + opacity: 1; +} + +/* Glitch effect for logo mark on hover */ +@keyframes logo-glitch { + 0% { transform: translate(0, 0); filter: none; } + 10% { transform: translate(-1px, 0.5px); filter: drop-shadow(-1px 0 red) drop-shadow(1px 0 cyan); } + 20% { transform: translate(1px, -0.5px); filter: drop-shadow(-1px 0 red) drop-shadow(1px 0 cyan); } + 30% { transform: translate(0.5px, 1px); filter: drop-shadow(-2px 0 magenta) drop-shadow(2px 0 cyan); } + 40% { transform: translate(-0.5px, -1px); filter: drop-shadow(-1px 0 blue) drop-shadow(1px 0 red); } + 50% { transform: translate(0, 0); filter: none; } + 60% { transform: translate(1px, 0.5px); filter: drop-shadow(-1px 0 magenta) drop-shadow(1px 0 lime); } + 70% { transform: translate(-1px, -0.5px); filter: drop-shadow(-2px 0 red) drop-shadow(2px 0 cyan); } + 80% { transform: translate(0.5px, -1px); filter: drop-shadow(-1px 0 magenta) drop-shadow(1px 0 cyan); } + 90% { transform: translate(-0.5px, 1px); filter: drop-shadow(-1px 0 red) drop-shadow(1px 0 cyan); } + 100% { transform: translate(0, 0); filter: none; } +} + +/* Respect reduced motion preferences */ +@media (prefers-reduced-motion: reduce) { + .screen-header .brand:hover .brand-mark, + .floating-header .brand:hover .brand-mark { + animation: none; + filter: none; + } +} + +/* (brand text removed) */ + +.header-center { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.heart-logo.header-no-rays { + width: 100%; + height: 100%; + position: relative; +} + +.heart-logo.header-no-rays .heart-logo-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.heart-logo.header-no-rays .heart-logo-rays { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; +} + +.heart-logo.header-no-rays .heart-logo-rays-svg { + width: 100%; + height: 100%; +} + +.heart-logo.header-no-rays .heart-logo-outline { + position: relative; + z-index: 2; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; +} + +.heart-logo.header-no-rays .heart-logo-svg { + width: 100%; + height: 100%; +} + +/* Video Background */ +.video-background, .ascii-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + z-index: 1; +} + +.ascii-background { + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; +} + +.ascii-art { + font-family: 'Courier New', 'MS Gothic', monospace; + font-size: clamp(6px, 1.2vmin, 12px); + line-height: 1.1; + text-align: center; + margin: 0; + padding: 20px; + position: relative; +} + +.ascii-line { + display: block; + white-space: pre; +} + +.ascii-char { + display: inline-block; + color: transparent; + background: linear-gradient( + var(--gradient-direction, 45deg), + #ff6b6b, + #4ecdc4, + #45b7d1, + #96ceb4, + #feca57, + #ff9ff3, + #54a0ff, + #5f27cd + ); + background-size: 600% 600%; + background-clip: text; + -webkit-background-clip: text; + animation: gradient-shift 4s ease-in-out infinite; + animation-delay: var(--delay, 0s); + transition: transform 0.05s ease-out; + transform-origin: center; + position: relative; +} + +.ascii-char::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 1ch; + height: 1.1em; + transform: translate(-50%, -50%); + pointer-events: none; +} + +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 25% { + background-position: 100% 0%; + } + 50% { + background-position: 100% 100%; + } + 75% { + background-position: 0% 100%; + } + 100% { + background-position: 0% 50%; + } +} + +/* Large Static Text */ +.large-text { + position: absolute; + left: 50%; + top: 20%; + transform: translate(-50%, -50%); + font-family: 'Inter', 'SF Pro Display', system-ui, sans-serif; + font-size: clamp(72px, 10vw, 180px); + font-weight: 700; + color: #ffffff; + white-space: nowrap; + z-index: 3; + pointer-events: none; + line-height: 1.1; + text-align: center; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 50%, #e0e0e0 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0 4px 20px rgba(255, 255, 255, 0.3); + filter: drop-shadow(0 0 30px rgba(255, 255, 255, 0.2)); + overflow: hidden; +} + +/* Subtitle Text */ +.subtitle-text { + position: absolute; + left: 50%; + top: 40%; + transform: translate(-50%, -50%); + font-family: 'Inter', 'SF Pro Display', system-ui, sans-serif; + font-size: clamp(16px, 2.5vmin, 24px); + font-weight: 400; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + z-index: 3; + pointer-events: none; + text-align: center; + letter-spacing: 0.02em; + opacity: 0.9; + animation: subtleFadeIn 2s ease-out 0.5s both; +} + +@keyframes subtleFadeIn { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 20px)); + } + to { + opacity: 0.9; + transform: translate(-50%, -50%); + } +} + +/* Manga Panel Layout */ +.manga-background { + position: absolute; + inset: 0; + overflow: hidden; + z-index: 1; +} + +/* Animated speed lines radiating from center */ +.speed-lines { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 10px, + rgba(0, 0, 0, 0.03) 10px, + rgba(0, 0, 0, 0.03) 11px + ); + animation: speed-lines-move 0.5s linear infinite; +} + +@keyframes speed-lines-move { + from { transform: translateY(0); } + to { transform: translateY(11px); } +} + +/* Halftone dot overlay for manga texture */ +.halftone-overlay { + position: absolute; + inset: 0; + background-image: + radial-gradient(circle, rgba(0, 0, 0, 0.15) 1px, transparent 1px); + background-size: 4px 4px; + opacity: 0.2; + pointer-events: none; +} + +/* Focus lines emanating from center */ +.focus-lines { + position: absolute; + inset: 0; + background: + repeating-conic-gradient( + from 0deg at 50% 50%, + transparent 0deg, + transparent 5deg, + rgba(0, 0, 0, 0.05) 5deg, + rgba(0, 0, 0, 0.05) 6deg + ); + animation: focus-pulse 3s ease-in-out infinite; +} + +@keyframes focus-pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.6; } +} + +/* Main manga panel container */ +.manga-panel-container { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: 1.2fr 0.8fr; + grid-template-rows: 1fr auto; + gap: 20px; + padding: 40px; + height: 100%; + max-width: 1600px; + margin: 0 auto; +} + +/* Individual manga panel base styles */ +.manga-panel { + position: relative; + background: #ffffff; + border: 4px solid #000000; + box-shadow: + inset 0 0 20px rgba(0, 0, 0, 0.1), + 8px 8px 0px rgba(0, 0, 0, 0.3), + 12px 12px 0px rgba(0, 0, 0, 0.15); + overflow: hidden; + transform: rotate(-0.5deg); + transition: transform 0.3s ease; +} + +.manga-panel:hover { + transform: rotate(0deg) scale(1.02); + z-index: 10; +} + +/* Panel borders with variable thickness */ +.panel-border { + position: absolute; + inset: 0; + border: 6px solid #000000; + pointer-events: none; + z-index: 10; +} + +.panel-border::before { + content: ''; + position: absolute; + inset: 6px; + border: 2px solid #000000; +} + +.panel-border::after { + content: ''; + position: absolute; + inset: 12px; + border: 1px solid rgba(0, 0, 0, 0.3); +} + +.panel-content { + position: relative; + padding: 40px; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 5; +} + +/* Hero Panel - Main focus area */ +.manga-panel-hero { + grid-column: 1; + grid-row: 1; + transform: rotate(0.8deg); + background: radial-gradient(ellipse at top left, #ffffff 0%, #f8f8f8 100%); +} + +.manga-title { + font-family: 'Dela Gothic One', 'Noto Sans JP', sans-serif; + text-align: center; + margin: 0; + position: relative; + z-index: 6; +} + +.title-kanji { + display: block; + font-size: clamp(80px, 12vw, 150px); + font-weight: 900; + color: #000000; + text-shadow: + 4px 4px 0px #ffffff, + 8px 8px 0px rgba(0, 0, 0, 0.3); + line-height: 0.9; + letter-spacing: 0.05em; + margin-bottom: 10px; +} + +.title-romaji { + display: block; + font-family: 'Impact', 'Arial Black', sans-serif; + font-size: clamp(28px, 4vw, 48px); + font-weight: 900; + color: #000000; + letter-spacing: 0.3em; + text-transform: uppercase; + border-top: 4px solid #000000; + border-bottom: 4px solid #000000; + padding: 8px 0; + background: linear-gradient(90deg, transparent 0%, #fff 20%, #fff 80%, transparent 100%); +} + +/* Speech Bubble */ +.speech-bubble { + position: absolute; + bottom: 60px; + right: 60px; + background: #ffffff; + border: 3px solid #000000; + border-radius: 20px; + padding: 20px 30px; + max-width: 300px; + box-shadow: 4px 4px 0px rgba(0, 0, 0, 0.2); + z-index: 7; +} + +.bubble-text { + font-family: 'Noto Sans JP', sans-serif; + font-size: clamp(14px, 1.5vw, 18px); + font-weight: 600; + color: #000000; + margin: 0; + line-height: 1.6; + letter-spacing: 0.02em; +} + +.bubble-tail { + position: absolute; + bottom: -15px; + right: 40px; + width: 0; + height: 0; + border-left: 15px solid transparent; + border-right: 15px solid transparent; + border-top: 20px solid #000000; +} + +.bubble-tail::before { + content: ''; + position: absolute; + bottom: 3px; + left: -12px; + width: 0; + height: 0; + border-left: 12px solid transparent; + border-right: 12px solid transparent; + border-top: 17px solid #ffffff; +} + +/* Action Lines - radiating from center */ +.action-lines { + position: absolute; + inset: 0; + z-index: 1; +} + +.action-line { + position: absolute; + top: 50%; + left: 50%; + width: 200%; + height: 3px; + background: linear-gradient(90deg, + transparent 0%, + rgba(0, 0, 0, 0.3) 50%, + transparent 100% + ); + transform-origin: 0% 50%; + transform: rotate(var(--angle)) translateX(-50%); + animation: action-line-pulse 2s ease-in-out infinite; + animation-delay: var(--delay); +} + +@keyframes action-line-pulse { + 0%, 100% { opacity: 0.3; width: 180%; } + 50% { opacity: 0.7; width: 220%; } +} + +/* Side Panel with ASCII Art */ +.manga-panel-side { + grid-column: 2; + grid-row: 1; + transform: rotate(-1.2deg); + background: linear-gradient(135deg, #fafafa 0%, #f0f0f0 100%); +} + +.ascii-art-manga { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.ascii-art-manga .ascii-art { + font-family: 'Courier New', monospace; + font-size: clamp(6px, 0.8vw, 10px); + line-height: 1.1; + color: #000000; + filter: drop-shadow(2px 2px 0px rgba(0, 0, 0, 0.1)); +} + +.ascii-char { + display: inline-block; + transition: transform 0.05s ease-out; + transform-origin: center; +} + +/* Manga SFX Text */ +.sfx-text { + position: absolute; + font-family: 'Dela Gothic One', 'Noto Sans JP', sans-serif; + font-weight: 900; + color: #000000; + text-shadow: + 2px 2px 0px #ffffff, + 4px 4px 0px rgba(0, 0, 0, 0.3); + z-index: 8; + pointer-events: none; +} + +.sfx-1 { + bottom: 20px; + right: 20px; + font-size: clamp(24px, 3vw, 36px); + opacity: 0.6; + animation: sfx-throb 1.5s ease-in-out infinite; +} + +@keyframes sfx-throb { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +/* Bottom Panel with CTA */ +.manga-panel-bottom { + grid-column: 1 / 3; + grid-row: 2; + transform: rotate(0.3deg); + min-height: 200px; + background: linear-gradient(to right, #ffffff 0%, #fafafa 50%, #ffffff 100%); +} + +.manga-panel-bottom .panel-content { + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 40px; +} + +/* Narrative Box */ +.narrative-box { + flex: 1; + background: #000000; + color: #ffffff; + padding: 20px 30px; + border: 3px solid #000000; + box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.1); + position: relative; +} + +.narrative-box::before { + content: ''; + position: absolute; + top: -8px; + left: 20px; + width: 40px; + height: 3px; + background: #ffffff; +} + +.narrative-text { + font-family: 'Noto Serif JP', serif; + font-size: clamp(16px, 2vw, 22px); + font-style: italic; + margin: 0; + line-height: 1.7; + letter-spacing: 0.05em; +} + +/* Manga CTA Button */ +.manga-cta { + position: relative; + background: #000000; + color: #ffffff; + border: 4px solid #000000; + font-family: 'Impact', 'Arial Black', sans-serif; + font-size: clamp(18px, 2.5vw, 28px); + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 20px 50px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: + 4px 4px 0px rgba(0, 0, 0, 0.3), + 8px 8px 0px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 15px; + z-index: 6; +} + +.manga-cta::before { + content: ''; + position: absolute; + inset: -8px; + border: 2px solid #000000; + pointer-events: none; +} + +.manga-cta:hover { + transform: translate(2px, 2px); + box-shadow: + 2px 2px 0px rgba(0, 0, 0, 0.3), + 4px 4px 0px rgba(0, 0, 0, 0.15); +} + +.manga-cta:active { + transform: translate(4px, 4px); + box-shadow: + 0px 0px 0px rgba(0, 0, 0, 0.3); +} + +.cta-kaomoji { + font-size: clamp(24px, 3vw, 32px); + animation: kaomoji-bounce 0.6s ease-in-out infinite; +} + +@keyframes kaomoji-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} + +.cta-text { + font-size: clamp(18px, 2.5vw, 28px); + letter-spacing: 0.15em; +} + +/* Button impact lines */ +.button-impact-lines { + position: absolute; + inset: -20px; + pointer-events: none; + z-index: -1; +} + +/* Impact burst effect */ +.impact-burst { + position: absolute; + top: 50%; + left: 50%; + width: 100px; + height: 100px; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 5; +} + +.burst-line { + position: absolute; + top: 50%; + left: 50%; + width: 60px; + height: 4px; + background: linear-gradient(90deg, #000000 0%, transparent 100%); + transform-origin: 0% 50%; + transform: rotate(var(--burst-angle)) translateX(-30px); + opacity: 0.4; + animation: burst-expand 1.5s ease-out infinite; +} + +@keyframes burst-expand { + 0% { width: 40px; opacity: 0.6; } + 100% { width: 80px; opacity: 0; } +} + +/* Floating manga effects */ +.manga-effects { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 20; +} + +.effect-star { + position: absolute; + font-size: clamp(30px, 4vw, 50px); + color: #000000; + opacity: 0.15; + animation: star-twinkle 2s ease-in-out infinite; +} + +.effect-star-1 { + top: 10%; + right: 15%; + animation-delay: 0s; +} + +.effect-star-2 { + top: 40%; + left: 10%; + animation-delay: 0.7s; +} + +.effect-star-3 { + bottom: 20%; + right: 20%; + animation-delay: 1.4s; +} + +@keyframes star-twinkle { + 0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.15; } + 50% { transform: scale(1.3) rotate(90deg); opacity: 0.3; } +} + +.sfx-floating { + top: 15%; + left: 50%; + transform: translateX(-50%); + font-size: clamp(40px, 6vw, 70px); + opacity: 0.1; + animation: menacing-float 4s ease-in-out infinite; +} + +@keyframes menacing-float { + 0%, 100% { transform: translateX(-50%) translateY(0) rotate(-2deg); } + 50% { transform: translateX(-50%) translateY(-20px) rotate(2deg); } +} + +/* ========== VN Preview Styles ========== */ + +/* VN Preview Content Container */ +.vn-preview-content { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + height: 100%; + padding: 20px; + gap: 15px; +} + +/* Character Name Badge */ +.vn-character-name { + background: #000000; + color: #ffffff; + padding: 8px 24px; + border: 3px solid #000000; + font-family: 'Noto Serif JP', serif; + font-size: clamp(16px, 2vw, 20px); + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + box-shadow: + 0 4px 8px rgba(0, 0, 0, 0.3), + inset 0 0 20px rgba(255, 255, 255, 0.1); + position: relative; + z-index: 10; + animation: name-badge-glow 2s ease-in-out infinite; +} + +@keyframes name-badge-glow { + 0%, 100% { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3), inset 0 0 20px rgba(255, 255, 255, 0.1); } + 50% { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), inset 0 0 30px rgba(255, 255, 255, 0.2); } +} + +/* Character Visual Area */ +.vn-character-visual { + flex: 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + min-height: 200px; +} + +.character-silhouette { + width: clamp(150px, 20vw, 250px); + height: clamp(200px, 30vw, 350px); + background: linear-gradient(180deg, #1a1a1a 0%, #000000 100%); + border: 3px solid #000000; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + box-shadow: + 0 8px 16px rgba(0, 0, 0, 0.4), + inset 0 0 40px rgba(255, 255, 255, 0.05); + animation: silhouette-breathe 3s ease-in-out infinite; +} + +@keyframes silhouette-breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.02); } +} + +.silhouette-glow { + position: absolute; + inset: -10px; + background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.1) 0%, transparent 70%); + border-radius: 8px; + animation: glow-pulse 2.5s ease-in-out infinite; + pointer-events: none; +} + +@keyframes glow-pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.6; } +} + +.character-placeholder { + font-size: clamp(60px, 10vw, 100px); + color: #333333; + font-weight: 900; + text-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); + animation: placeholder-float 3s ease-in-out infinite; +} + +@keyframes placeholder-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +/* VN Textbox */ +.vn-textbox { + width: 100%; + background: #000000; + border: 4px solid #000000; + box-shadow: + 0 -4px 12px rgba(0, 0, 0, 0.3), + inset 0 0 30px rgba(255, 255, 255, 0.1); + position: relative; + z-index: 10; + min-height: 100px; +} + +.textbox-border { + position: absolute; + inset: -4px; + border: 2px solid #333333; + pointer-events: none; +} + +.textbox-content { + padding: 20px 25px; + position: relative; +} + +.dialogue-text { + font-family: 'Noto Serif JP', serif; + font-size: clamp(14px, 1.8vw, 18px); + line-height: 1.8; + color: #ffffff; + margin: 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + min-height: 40px; +} + +/* Typing Cursor */ +.typing-cursor { + display: inline-block; + margin-left: 2px; + animation: cursor-blink 0.8s steps(2) infinite; +} + +@keyframes cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Continue Indicator */ +.continue-indicator { + position: absolute; + bottom: 10px; + right: 15px; + font-size: 20px; + color: #ffffff; + animation: indicator-bounce 1s ease-in-out infinite; +} + +@keyframes indicator-bounce { + 0%, 100% { transform: translateY(0); opacity: 0.7; } + 50% { transform: translateY(-5px); opacity: 1; } +} + +/* ========== End VN Preview Styles ========== */ + + +.video-background video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.background-gif, .background-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +.video-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(45deg, #1a1a2e 0%, #16213e 50%, #0a0a0f 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +/* ================== Taisho Magazine Cover Layout ================== */ +.taisho-cover { + position: absolute; + inset: 0; + overflow: hidden; + color: #1a0f08; +} + +.taisho-cover, .taisho-cover * { + box-sizing: border-box; +} + +.taisho-cover { + --paper: #f7f1e1; + --ink: #1a0f08; + --ink-20: rgba(26, 15, 8, 0.12); + --crimson: #a83a32; + --teal: #2f6b6d; + --mustard: #c9a876; + --indigo: #2b3a67; + --shadow: rgba(26, 15, 8, 0.25); +} + +.taisho-cover .cover-backdrop { display: none; } + +.taisho-cover .paper-tone { + position: absolute; + inset: 0; + background: + radial-gradient(120% 120% at 100% 0%, rgba(0,0,0,0.04), transparent 60%), + radial-gradient(120% 120% at 0% 100%, rgba(0,0,0,0.05), transparent 60%), + var(--paper); +} + +/* Ichimatsu (checker) pattern replacing circular waves */ +.taisho-cover .pattern-ichimatsu { + position: absolute; + inset: 0; + opacity: 0.18; + background: + linear-gradient(45deg, var(--ink-20) 25%, transparent 25%, transparent 75%, var(--ink-20) 75%, var(--ink-20)) 0 0/22px 22px, + linear-gradient(45deg, var(--ink-20) 25%, transparent 25%, transparent 75%, var(--ink-20) 75%, var(--ink-20)) 11px 11px/22px 22px; + pointer-events: none; + mix-blend-mode: multiply; +} + +.taisho-cover .halftone-overlay { + position: absolute; + inset: 0; + background: repeating-linear-gradient(135deg, rgba(26, 15, 8, 0.08) 0 1px, transparent 1px 6px); + opacity: 0.25; + pointer-events: none; +} + +.taisho-cover .cover-content { + position: relative; + z-index: 1; + height: 100%; + display: grid; + grid-template-columns: 1fr 220px; /* hero left, masthead right */ + grid-template-rows: 1fr auto; /* CTA sits below hero */ + grid-template-areas: + "hero masthead" + "hero cta"; + gap: 28px; + padding: 40px 48px; +} + +/* Masthead */ +.masthead { grid-area: masthead; display: flex; flex-direction: column; align-items: center; gap: 18px; } +.masthead-vertical { + writing-mode: vertical-rl; + text-orientation: mixed; + font-family: 'DotGothic16', system-ui, sans-serif; + background: linear-gradient(180deg, var(--ink) 0%, #000 100%); + color: #fff; + border: 6px double #fff; + box-shadow: 0 12px 30px var(--shadow); + padding: 24px 10px; + letter-spacing: 4px; +} +.masthead-vertical .jp { font-size: 42px; line-height: 1; } +.masthead-vertical .en { font-size: 12px; margin-top: 10px; opacity: 0.8; letter-spacing: 2px; } +.issue-badge { + background: var(--mustard); + color: #1a0f08; + font-family: 'Noto Serif JP', serif; + font-weight: 700; + padding: 6px 10px; + border: 2px solid var(--ink); + box-shadow: 2px 2px 0 rgba(26,15,8,0.25); +} + +/* Hero */ +.hero { grid-area: hero; display: flex; align-items: center; justify-content: center; } +.hero-frame { + position: relative; + width: 100%; + height: 100%; + min-height: 420px; + background: #fff; + /* No panel borders; keep only drop shadow */ + box-shadow: 0 22px 40px var(--shadow); + overflow: hidden; +} +.hero-pattern.asanoha { + position: absolute; inset: 0; opacity: 0.15; + background: + repeating-conic-gradient(from 0deg, var(--indigo) 0 6deg, transparent 6deg 12deg), + repeating-conic-gradient(from 30deg, var(--teal) 0 6deg, transparent 6deg 12deg); + mix-blend-mode: multiply; +} + +/* Seigaiha (wave) pattern overlay */ +.pattern-seigaiha { + position: absolute; + left: 0; right: 0; bottom: 0; + height: 38%; + pointer-events: none; + opacity: 0.18; + mix-blend-mode: multiply; + -webkit-mask-image: linear-gradient(to top, rgba(0,0,0,1), rgba(0,0,0,0)); + mask-image: linear-gradient(to top, rgba(0,0,0,1), rgba(0,0,0,0)); +} +.pattern-seigaiha path { + stroke: rgba(26, 15, 8, 0.22); + stroke-width: 1.2; + fill: none; + vector-effect: non-scaling-stroke; +} +.hero-inner { position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column; } +.hero-textbox { margin-top: auto; } + +/* Full-bleed hero fill */ +.hero-inner.hero-fill { position: absolute; inset: 0; padding: 0; display: block; } + +/* Art Deco/Nouveau hero content */ +.deco-hero-content { + display: grid; + grid-template-rows: auto auto auto auto auto auto; /* tighten rows; rf block is self-contained */ + align-items: start; + justify-items: center; + padding: 40px 28px; + color: var(--ink); +} +.deco-headline { + font-family: 'Notable', 'Dela Gothic One', sans-serif; + font-weight: 700; + letter-spacing: 0.2em; + font-size: clamp(28px, 4vw, 48px); + margin: 0; + text-transform: uppercase; +} +.deco-divider { + width: min(280px, 60%); + height: 10px; + margin: 14px 0 10px 0; + background: + linear-gradient(90deg, transparent 0%, var(--mustard) 20%, var(--mustard) 80%, transparent 100%); + position: relative; +} +.deco-divider::before, +.deco-divider::after { + content: ''; + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border: 2px solid var(--ink); + background: #fff; + transform: translateY(-50%) rotate(45deg); +} +.deco-divider::before { left: -12px; } +.deco-divider::after { right: -12px; } +.deco-subtitle { + font-family: 'Noto Serif JP', serif; + font-weight: 700; + letter-spacing: 0.08em; + font-size: clamp(14px, 2.2vw, 18px); + margin: 6px 0 8px 0; +} +.deco-blurb { + font-family: 'Inter', system-ui, sans-serif; + font-size: clamp(12px, 1.8vw, 16px); + line-height: 1.6; + text-align: center; + max-width: 52ch; + margin: 0; +} + +/* ASCII art in hero */ +.ascii-hero { + display: inline-block; + background: #fff; + border: 3px double var(--ink); + box-shadow: 0 8px 16px rgba(26,15,8,0.12); + padding: 8px; +} +.ascii-hero-art { + margin: 0; + white-space: pre; + font-family: 'Courier New', 'MS Gothic', monospace; + font-size: clamp(6px, 0.95vmin, 11px); + line-height: 1.05; + color: #000; + text-align: left; +} + +/* Retro-futuristic racing hero */ +.rf-hero { + position: relative; + width: 100%; + height: 100%; + background: + linear-gradient(180deg, #0a0a0f 0%, #0c0d16 100%); + border: 3px double #111; + overflow: hidden; +} +.rf-speedlines { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 75deg, + rgba(102, 204, 255, 0.06) 0 4px, + transparent 4px 12px + ); + transform: translateX(0); + animation: rf-dash 2.8s linear infinite; + mix-blend-mode: screen; +} +.rf-speedlines.layer-b { + opacity: 0.5; + background: + repeating-linear-gradient( + 105deg, + rgba(255, 102, 153, 0.05) 0 6px, + transparent 6px 16px + ); + animation-duration: 3.8s; +} +@keyframes rf-dash { + from { transform: translateX(0); } + to { transform: translateX(-120px); } +} +.rf-accent-diagonal { + position: absolute; + right: -15%; + top: -10%; + width: 60%; + height: 160%; + background: linear-gradient(180deg, rgba(102,204,255,0.15), rgba(255,102,153,0.15)); + transform: rotate(-18deg); + filter: blur(8px); +} +.rf-hero-content { + position: relative; + z-index: 2; + display: grid; + gap: 10px; + padding: 32px; + justify-items: start; + color: #e6faff; +} +.rf-ascii-overlay { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 1; /* above speedlines/accent, below hero text */ + pointer-events: none; + font-family: 'Courier New', 'MS Gothic', monospace; + font-size: clamp(6px, 0.95vmin, 11px); + line-height: 1.05; + white-space: pre; + text-align: left; + max-width: 48%; + max-height: 70%; + overflow: hidden; +} +.rf-badge { + font-family: 'Notable', sans-serif; + font-size: 11px; + letter-spacing: 0.18em; + padding: 6px 10px; + border: 1px solid rgba(102,204,255,0.6); + background: rgba(10,12,20,0.6); +} +.rf-headline { + margin: 0; + font-family: 'Dela Gothic One', sans-serif; + font-weight: 700; + font-size: clamp(32px, 6vw, 72px); + letter-spacing: 0.04em; +} + +/* Glitch effect for the word "futurist" in the hero headline */ +.glitch-word { + position: relative; + display: inline-block; +} +.glitch-word::before, +.glitch-word::after { + content: attr(data-text); + position: absolute; + top: 0; + left: 0; + opacity: 0; + pointer-events: none; +} +.glitch-word.is-glitching { + text-shadow: -2px 0 #ff00c1, 2px 0 #00fff9; + animation: glitch-main 0.35s steps(10) 1; +} +.glitch-word.is-glitching::before { + opacity: 0.85; + color: #ff00c1; + mix-blend-mode: screen; + animation: glitch-before 0.35s steps(10) 1; +} +.glitch-word.is-glitching::after { + opacity: 0.85; + color: #00fff9; + mix-blend-mode: screen; + animation: glitch-after 0.35s steps(10) 1; +} + +@keyframes glitch-main { + 0% { transform: none; } + 12% { transform: translate(3px, -2px) skew(0.6deg); } + 24% { transform: translate(-3px, 2px) skew(-0.4deg); } + 36% { transform: translate(2px, 0) skew(0.3deg); } + 48% { transform: translate(-2px, 2px) skew(-0.3deg); } + 60% { transform: translate(2px, -2px) skew(0.4deg); } + 72% { transform: translate(0, 2px) skew(-0.3deg); } + 84% { transform: translate(-2px, 0) skew(0.2deg); } + 100% { transform: none; } +} + +@keyframes glitch-before { + 0% { transform: translate(-3px, -2px); clip-path: inset(0 0 85% 0); } + 20% { transform: translate(-4px, 1px); clip-path: inset(15% 0 60% 0); } + 40% { transform: translate(-3px, 0); clip-path: inset(40% 0 40% 0); } + 60% { transform: translate(-5px, -2px); clip-path: inset(60% 0 20% 0); } + 80% { transform: translate(-2px, 2px); clip-path: inset(80% 0 5% 0); } + 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); } +} + +@keyframes glitch-after { + 0% { transform: translate(3px, 2px); clip-path: inset(85% 0 0 0); } + 20% { transform: translate(4px, -2px); clip-path: inset(60% 0 15% 0); } + 40% { transform: translate(3px, 0); clip-path: inset(40% 0 40% 0); } + 60% { transform: translate(5px, 2px); clip-path: inset(20% 0 60% 0); } + 80% { transform: translate(2px, -2px); clip-path: inset(5% 0 80% 0); } + 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); } +} +.rf-tagline { + margin: 0; + font-family: 'Inter', system-ui, sans-serif; + font-size: clamp(14px, 2vw, 22px); + color: #c7eaff; + opacity: 0.9; +} +.rf-stats { + display: flex; + gap: 14px; + margin-top: 6px; +} +.rf-stat { + font-family: 'Orbitron', monospace; + font-size: clamp(12px, 1.6vw, 18px); + color: #9fe3ff; + border-left: 2px solid rgba(102,204,255,0.6); + padding-left: 8px; +} +.rf-stat .label { opacity: 0.7; margin-right: 6px; } +.rf-stat .value { color: #ffffff; } + +@media (max-width: 1024px) { + .ascii-hero { height: 100%; } +} + +/* Gordian knot illustration */ +.deco-illustration { margin: 6px 0 2px 0; } +.gordian-svg { width: min(520px, 80%); height: auto; } +.gordian-svg path, .gordian-svg line, .gordian-svg polygon { + stroke: var(--ink); + stroke-width: 2.2; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} +.gordian-svg polygon { fill: rgba(201,168,118,0.2); } +.gordian-svg .blade { stroke-width: 2.4; } +.gordian-svg .guard { fill: rgba(201,168,118,0.35); stroke: var(--ink); stroke-width: 1.6; } +.gordian-svg .rope-texture { stroke-width: 1.2; stroke-dasharray: 2 6; opacity: 0.35; } +/* Rope appearance: outer dark edge and inner rope tone */ +.gordian-svg .rope-outer { stroke: rgba(26,15,8,0.35); stroke-width: 7; } +.gordian-svg .rope-inner { stroke: #e8dcc3; stroke-width: 5; } +.gordian-svg .rope-rib { stroke: rgba(26,15,8,0.25); stroke-width: 1.2; stroke-dasharray: 3 6; } +.gordian-svg .notch { stroke: #fdf9f0; stroke-width: 8; stroke-linecap: round; opacity: 0.98; } +/* New katana styling */ +.gordian-svg .katana-blade-outline { fill: #f4f4f4; stroke: var(--ink); stroke-width: 1.4; } +.gordian-svg .katana-hamon { stroke: rgba(26,15,8,0.35); stroke-width: 1.2; opacity: 0.6; } +.gordian-svg .katana-guard { fill: rgba(201,168,118,0.45); stroke: var(--ink); stroke-width: 1.2; } +.gordian-svg .katana-grip { stroke: var(--ink); stroke-width: 5; } +.gordian-svg .katana-wrap { stroke: #e8dcc3; stroke-width: 2; stroke-dasharray: 3 5; } + +/* Anime figure illustration */ +.anime-svg { width: min(520px, 80%); height: auto; } +.anime-svg .silhouette { fill: rgba(26,15,8,0.85); stroke: var(--ink); stroke-width: 1.2; } +.anime-svg .eye { stroke: #f4f4f4; stroke-width: 2; fill: none; stroke-linecap: round; } +.anime-svg .eye-glint { stroke: #ffffff; stroke-width: 1.4; stroke-linecap: round; } + +/* Feature grid inside hero */ +.deco-feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + width: 100%; + max-width: 960px; + margin-top: 18px; +} + +.deco-feature-card { + background: #fff; + border: 3px double var(--ink); + background-image: + linear-gradient(180deg, rgba(201,168,118,0.12), transparent 40%), + radial-gradient(120% 120% at 50% 0%, rgba(26,15,8,0.06), transparent 60%); + padding: 16px 18px; + text-align: center; + box-shadow: 0 8px 16px rgba(26,15,8,0.12); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.deco-feature-card:hover { + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(26,15,8,0.18); +} + +.deco-icon { + display: none; +} +.deco-icon-svg { + width: 26px; + height: 26px; + margin-bottom: 8px; + stroke: var(--ink); + fill: none; + stroke-width: 2; +} + +.deco-card-title { + font-family: 'Notable', sans-serif; + letter-spacing: 0.12em; + text-transform: uppercase; + font-size: 13px; + margin-bottom: 6px; +} + +.deco-card-desc { + font-family: 'Inter', system-ui, sans-serif; + font-size: 12px; + line-height: 1.5; + color: #333; + margin: 0; +} + +/* Taishō-modern frame with Deco elements */ +.taisho-modern-frame { + position: relative; + background: linear-gradient(180deg, #ffffff 0%, #fdf9f0 100%); +} + +/* Inner gold accent border */ +.taisho-modern-frame::before { + content: ''; + position: absolute; + inset: 0; /* hug the panel edge */ + border: 3px double var(--mustard); + box-shadow: inset 0 0 0 2px #fff; /* subtle inner light line */ + z-index: 2; /* above hero content */ + pointer-events: none; +} + +@media (max-width: 1024px) { + .hero-frame { min-height: 380px; } +} + +@media (max-width: 1024px) { + .deco-feature-grid { + grid-template-columns: 1fr; + max-width: 560px; + } +} + +.price-badge { + position: absolute; + top: 14px; + right: 14px; + background: #fff; + border: 3px solid var(--ink); + width: 84px; height: 84px; + border-radius: 50%; + display: grid; place-items: center; + box-shadow: 6px 6px 0 rgba(26,15,8,0.25); + text-align: center; +} +.price-badge .yen { font-family: 'DotGothic16', system-ui, sans-serif; font-size: 18px; display: block; } +.price-badge .free { font-family: 'Impact','Arial Black',sans-serif; font-size: 14px; letter-spacing: 2px; } + +/* Coverlines removed */ + +/* (ASCII section removed for updated design) */ +/* .ascii-section / .ascii-frame / .ascii-art-container not used */ + +/* CTA */ +.cta-area { grid-area: cta; align-self: end; display: flex; justify-content: flex-end; } +.taisho-cta { + position: relative; + background: var(--ink); + color: #fff; + border: 0; + outline: 0; + padding: 18px 28px; + display: inline-flex; + align-items: center; + gap: 12px; + box-shadow: + 0 0 0 2px #fff inset, + 8px 8px 0 rgba(26,15,8,0.25); + text-transform: uppercase; + letter-spacing: 0.15em; + cursor: pointer; + transform: rotate(-1deg); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.taisho-cta:hover { transform: rotate(0deg) translate(1px,1px); box-shadow: 0 0 0 2px #fff inset, 6px 6px 0 rgba(26,15,8,0.25); } +.cta-icon { font-size: 18px; } +.cta-text { font-size: 14px; } +.cta-kaomoji { font-size: 18px; opacity: 0.85; } + +/* Barcode */ +.barcode { + grid-column: 1 / 2; + grid-row: 3 / 4; + align-self: end; + width: 160px; + height: 44px; + background: + repeating-linear-gradient( + 90deg, + #000 0 2px, + transparent 2px 4px, + #000 4px 6px, + transparent 6px 9px + ); + border: 2px solid #000; + background-clip: padding-box; +} + +/* Visitor counter (count.getloli.com) */ +.visit-counter { + position: static; + grid-column: 1; + grid-row: 3; + align-self: start; /* above barcode in the same cell */ + justify-self: start; + margin-left: 0; + margin-bottom: 6px; + opacity: 0.98; +} +.visit-counter img { + height: 84px; /* default/base size */ + image-rendering: auto; +} + +/* Responsiveness */ +@media (max-width: 1024px) { + .taisho-cover .cover-content { + grid-template-columns: 1fr; + grid-template-areas: + "masthead" + "hero" + "cta" + "counter"; + gap: 22px; + padding: 24px; + } + .masthead { flex-direction: row; justify-content: space-between; } + .masthead-vertical { writing-mode: initial; padding: 10px 16px; letter-spacing: 3px; } + .masthead-vertical .jp { font-size: 28px; } + .masthead-vertical .en { margin-top: 0; margin-left: 10px; font-size: 12px; } + .hero-frame { min-height: 360px; } + .cta-area { justify-content: center; } + /* Counter handled in explicit medium/mobile blocks */ +} + + +.placeholder-content { + text-align: center; + color: rgba(255, 255, 255, 0.6); +} + +.placeholder-icon { + font-size: 64px; + margin-bottom: 20px; + opacity: 0.5; +} + +.placeholder-content p { + font-family: 'Orbitron', monospace; + font-size: 18px; + margin: 10px 0; +} + +.placeholder-subtitle { + font-size: 14px !important; + opacity: 0.7; +} + +/* Floating Login Button */ +.floating-login-btn { + position: fixed; + bottom: 120px; + right: 40px; + background: linear-gradient(135deg, rgba(255, 102, 153, 0.15), rgba(255, 102, 153, 0.05)); + border: 2px solid rgba(255, 102, 153, 0.6); + border-radius: 4px; + color: #ffffff; + font-family: 'Orbitron', monospace; + font-size: 14px; + font-weight: 600; + padding: 12px 16px; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1); + text-transform: uppercase; + letter-spacing: 1px; + backdrop-filter: blur(20px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 160px; + height: 80px; + z-index: 200; + overflow: visible; + box-shadow: 0 8px 32px rgba(255, 102, 153, 0.2), + 0 0 0 1px rgba(255, 255, 255, 0.1) inset; +} + +.floating-login-btn .btn-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 20px; + transition: transform 0.3s ease; +} + +.floating-login-btn .btn-text { + opacity: 1; + white-space: nowrap; + transform: translateX(0); + transition: all 0.3s ease; + margin: 0; + pointer-events: auto; + font-weight: 600; +} + +.floating-login-btn:hover { + border-color: rgba(255, 102, 153, 0.8); + background: linear-gradient(135deg, rgba(255, 102, 153, 0.25), rgba(255, 102, 153, 0.1)); + transform: translateY(-4px) scale(1.02); + box-shadow: + 0 16px 40px rgba(255, 102, 153, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.2) inset, + 0 8px 32px rgba(255, 102, 153, 0.2); +} + +.floating-login-btn:hover .btn-icon { + transform: scale(1.1) rotate(-5deg); +} + +.floating-login-btn:active { + transform: translateY(-2px) scale(0.98); + transition: all 0.1s ease; +} + +/* Floating Settings Button */ +.floating-settings-btn { + position: fixed; + bottom: 40px; + right: 40px; + background: linear-gradient(135deg, rgba(102, 204, 255, 0.1), rgba(102, 204, 255, 0.05)); + border: 2px solid rgba(102, 204, 255, 0.4); + border-radius: 4px; + color: #ffffff; + font-family: 'Orbitron', monospace; + font-size: 14px; + font-weight: 600; + padding: 10px 16px; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1); + text-transform: uppercase; + letter-spacing: 1px; + backdrop-filter: blur(20px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 160px; + height: 80px; + z-index: 200; + overflow: visible; + box-shadow: 0 6px 24px rgba(102, 204, 255, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.08) inset; +} + +.floating-settings-btn .btn-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 18px; + transition: transform 0.3s ease; +} + +.floating-settings-btn .btn-text { + opacity: 1; + white-space: nowrap; + transform: translateX(0); + transition: all 0.3s ease; + margin: 0; + pointer-events: auto; + font-weight: 500; +} + +.floating-settings-btn:hover { + border-color: rgba(102, 204, 255, 0.6); + background: linear-gradient(135deg, rgba(102, 204, 255, 0.2), rgba(102, 204, 255, 0.08)); + transform: translateY(-3px) scale(1.02); + box-shadow: + 0 12px 32px rgba(102, 204, 255, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.15) inset, + 0 6px 24px rgba(102, 204, 255, 0.15); +} + +.floating-settings-btn:hover .btn-icon { + animation: pulse-bounce 0.6s ease-in-out infinite; +} + +@keyframes pulse-bounce { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } +} + +.floating-settings-btn:active { + transform: translateY(-1px) scale(0.98); + transition: all 0.1s ease; +} + +.brand-section { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; +} + +.brand-title-container { + position: relative; + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 60px; +} + +.ekg-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + opacity: 0.6; +} + +.bottom-heartbeat { + position: fixed; + bottom: 30px; + right: 30px; + width: 200px; + height: 100px; + z-index: 50; + pointer-events: none; + background: linear-gradient(135deg, rgba(248, 244, 240, 0.95) 0%, rgba(232, 223, 213, 0.95) 100%); + border: 3px solid #1a0f08; + border-radius: 0; + padding: 12px; + backdrop-filter: blur(10px); + box-shadow: + 0 0 0 1px #c9a876, + 0 10px 30px rgba(26, 15, 8, 0.3), + inset 0 0 20px rgba(201, 168, 118, 0.1); + clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px); +} + +/* Hide on mobile devices */ +@media (max-width: 768px) { + .bottom-heartbeat { + display: none; + } +} + +/* Phone sizes */ +@media (max-width: 480px) { + .visit-counter img { height: 96px; } +} + +.bottom-ekg-canvas { + width: 100%; + height: 100%; + opacity: 1; + background: linear-gradient(135deg, rgba(26, 15, 8, 0.05) 0%, rgba(139, 111, 71, 0.08) 100%); + border-radius: 0; + border: 1px solid rgba(139, 111, 71, 0.2); +} + +.bottom-heartbeat::before { + content: "心電図"; + position: absolute; + top: 8px; + left: 12px; + font-size: 10px; + color: #1a0f08; + font-family: 'Noto Serif JP', serif; + font-weight: 700; + letter-spacing: 2px; + opacity: 0.7; +} + +.bottom-heartbeat::after { + content: "◆ 72 拍/分"; + position: absolute; + bottom: 8px; + right: 12px; + font-size: 9px; + color: #8b6f47; + font-family: 'Noto Serif JP', serif; + font-weight: 600; + letter-spacing: 1px; +} + +.brand-title { + position: relative; + z-index: 2; + font-family: 'Dela Gothic One', cursive; + font-size: clamp(32px, 6vmin, 48px); + font-weight: 400; + color: #ffffff; + letter-spacing: 0; + margin: 0; + margin-bottom: 6px; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); + cursor: pointer; + perspective: 1000px; + transform-style: preserve-3d; +} + +.title-text { + display: inline-block; + transition: transform 0.6s ease; + backface-visibility: hidden; +} + +.title-text-hover { + position: absolute; + top: 0; + left: 0; + right: 0; + transform: rotateY(180deg); + transition: transform 0.6s ease; + backface-visibility: hidden; +} + +.brand-title:hover .title-text { + transform: rotateY(180deg); +} + +.brand-title:hover .title-text-hover { + transform: rotateY(0deg); +} + +.brand-subtitle { + font-family: 'Notable', sans-serif; + font-size: clamp(10px, 1.4vmin, 12px); + color: var(--accent2); + font-weight: normal; + letter-spacing: 2px; + margin: 0; + margin-bottom: 0; + opacity: 0.8; +} + +.brand-line { + position: relative; + width: 100vw; + height: 60px; + margin: 8px 0; + transform: translateX(-40px); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + + +.main-menu { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 320px; + align-self: center; +} + +.menu-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(102, 204, 255, 0.3); + color: #ffffff; + font-family: 'Orbitron', monospace; + font-size: 14px; + font-weight: 600; + padding: 12px; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1); + text-transform: uppercase; + letter-spacing: 1px; + backdrop-filter: blur(10px); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + width: 48px; + height: 48px; + border-radius: 4px; +} + +.menu-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent 0%, rgba(102, 204, 255, 0.1) 50%, transparent 100%); + transition: left 0.5s ease; +} + +.menu-btn:hover::before { + left: 100%; +} + +.menu-btn .btn-text { + opacity: 0; + width: 0; + overflow: hidden; + white-space: nowrap; + transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1); +} + +.menu-btn:hover { + border-color: #66ccff; + background: rgba(102, 204, 255, 0.1); + transform: translateY(-2px); + box-shadow: + 0 10px 30px rgba(102, 204, 255, 0.2), + 0 0 20px rgba(102, 204, 255, 0.1); + width: auto; + padding: 12px 24px; +} + +.menu-btn:hover .btn-text { + opacity: 1; + width: auto; + margin-left: 8px; +} + +.menu-btn.primary { + border-color: #ff6699; + background: rgba(255, 102, 153, 0.1); +} + +.menu-btn.primary:hover { + border-color: #ff6699; + background: rgba(255, 102, 153, 0.2); + box-shadow: + 0 10px 30px rgba(255, 102, 153, 0.3), + 0 0 20px rgba(255, 102, 153, 0.2); + width: auto; + padding: 12px 24px; +} + +.btn-icon { + font-size: 16px; + opacity: 0.8; +} + +.btn-text { + flex: 1; + text-align: left; +} + +.system-info { + position: absolute; + top: 40px; + right: 40px; + display: flex; + flex-direction: column; + gap: 8px; + font-family: monospace; + font-size: 12px; + opacity: 0.6; + z-index: 20; +} + +.info-item { + display: flex; + justify-content: space-between; + gap: 20px; +} + +.info-label { + color: #66ccff; + font-family: 'MS Gothic', monospace; + font-size: 10px; + font-weight: 600; +} + +.info-value { + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 11px; + font-weight: bold; +} + +.live-status { + display: flex; + align-items: center; + gap: 8px; +} + +.clickable { + cursor: pointer; + transition: opacity 0.2s ease; +} + +.clickable:hover { + opacity: 0.8; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + transition: all 0.3s ease; +} + +.status-dot.live { + background-color: #ff4444; + animation: recording-pulse 2s ease-in-out infinite alternate; +} + +.status-dot.standby { + background-color: #66ccff; + opacity: 1; + box-shadow: 0 0 4px rgba(102, 204, 255, 0.6); +} + +@keyframes recording-pulse { + from { + opacity: 0.4; + box-shadow: 0 0 4px rgba(255, 68, 68, 0.4); + } + to { + opacity: 1; + box-shadow: 0 0 12px rgba(255, 68, 68, 0.8); + } +} + + + +@keyframes line-glow { + from { + box-shadow: 0 0 10px rgba(102, 204, 255, 0.5); + } + to { + box-shadow: 0 0 20px rgba(102, 204, 255, 0.8); + } +} + +@keyframes scan-lines { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(4px); + } +} + +/* Desktop Responsiveness */ +@media (min-width: 1024px) { + + /* Desktop button group - use modern layout */ + .mobile-button-group { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + z-index: 200; + } + + .floating-login-btn { + position: static; + bottom: auto; + right: auto; + left: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + } + + .floating-settings-btn { + position: static; + bottom: auto; + right: auto; + left: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + } +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + .floating-header { + top: 0; + left: 0; + right: 0; + height: 100px; + padding: 15px; + } + + /* Allow scrolling on mobile: stop constraining to viewport */ + .modern-landing-page { + position: relative; + top: 0; + margin-top: 100px; /* account for fixed header height */ + height: auto; + min-height: calc(100dvh - 100px); + width: 100%; + overflow: visible; + } + + /* Let the cover size to content so page can scroll */ + .taisho-cover { + position: relative; + inset: auto; + overflow: visible; + } + + .taisho-cover .cover-content { + height: auto; + /* Extra spacing between rows */ + gap: 28px; + } + + /* (Removed earlier overlap hacks; barcode handled explicitly below) */ + + .header-content { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .brand-section { + align-items: flex-start; + } + + .brand-title { + letter-spacing: 4px; + } + + .brand-line { + width: 100vw; + transform: translateX(-15px); + margin: 6px 0; + height: 40px; + } + + .video-background, .ascii-background { + top: 0; + } + + .ascii-art { + font-size: clamp(8px, 2vmin, 16px); + line-height: 1.1; + padding: 15px; + } + + /* Move barcode to its own row below CTA to avoid overlaps */ + .taisho-cover .cover-content { + grid-template-areas: + "masthead" + "hero" + "coverlines" + "cta" + "barcode"; + } + .barcode { + grid-area: barcode; + justify-self: start; + align-self: start; + margin-top: 8px; + } + .taisho-cover .cover-content { + grid-template-areas: + "masthead" + "hero" + "coverlines" + "cta" + "counter" + "barcode"; + } + .visit-counter { + position: static; + grid-area: counter; + align-self: start; + justify-self: center; /* center horizontally on mobile */ + margin-top: 6px; + } + .visit-counter img { height: 84px; } + + .large-text { + font-size: clamp(44px, 10vw, 90px); + top: 18%; + } + + /* Taisho mobile layout */ + .taisho-content { + grid-template-columns: 1fr; + gap: 0; + padding: 0; + } + + .taisho-content::before, + .taisho-content::after { + display: none; + } + + .taisho-left { + padding: 60px 30px; + gap: 25px; + } + + .taisho-left::before, + .taisho-left::after { + display: none; + } + + .taisho-title { + font-size: clamp(48px, 12vw, 80px); + line-height: 0.9; + } + + .taisho-title-latin { + font-size: clamp(14px, 3vw, 20px); + margin-top: 12px; + } + + .taisho-title-latin::before { + left: -25px; + font-size: 10px; + } + + .taisho-divider { + width: 140px; + margin: 20px 0; + } + + .taisho-divider::after { + left: 70px; + } + + .taisho-tagline { + font-size: clamp(20px, 4vw, 28px); + padding-left: 15px; + } + + .taisho-tagline::before { + font-size: 45px; + top: -10px; + left: -8px; + } + + .taisho-subtitle { + font-size: clamp(14px, 2.5vw, 18px); + padding-left: 15px; + border-left-width: 2px; + } + + .taisho-cta { + padding: 18px 40px; + font-size: 13px; + width: 100%; + justify-content: center; + margin-top: 20px; + } + + .cta-icon { + font-size: 20px; + } + + .cta-text { + font-size: 13px; + } + + .taisho-right { + padding: 60px 30px; + } + + .taisho-right::before, + .taisho-right::after { + display: none; + } + + .code-frame { + padding: 30px; + border-width: 8px; + box-shadow: + 0 0 0 2px #c9a876, + 0 0 0 10px #1a0f08, + 0 20px 40px rgba(26, 15, 8, 0.4), + inset 0 0 60px rgba(201, 168, 118, 0.08); + } + + .code-frame::after { + font-size: 12px; + top: 15px; + left: 15px; + } + + .ascii-art-container { + padding: 25px; + max-height: 400px; + } + + .ascii-art-container .ascii-art { + font-size: clamp(7px, 1.5vw, 10px); + } + + /* Mobile button group container */ + .mobile-button-group { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + z-index: 200; + } + + .floating-login-btn { + position: static; + width: 120px; + height: 60px; + min-width: auto; + padding: 8px 12px; + bottom: auto; + left: auto; + transform: none; + flex-direction: column; + gap: 4px; + } + + .floating-login-btn .btn-text { + opacity: 1; + transform: translateX(0); + pointer-events: auto; + } + + .floating-login-btn:hover { + padding: 8px 12px; + width: 120px; + height: 60px; + } + + .floating-settings-btn { + position: static; + bottom: auto; + left: auto; + width: 120px; + height: 60px; + flex-direction: column; + gap: 4px; + } + + .floating-settings-btn:hover { + padding: 8px 12px; + width: 120px; + height: 60px; + } + + .system-info { + font-size: 10px; + } +} + +/* VN Interface Styles */ +.vn-interface { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #000000; + overflow: hidden; + z-index: 1000; + transition: opacity 0.8s ease-in-out; +} + +.vn-interface.fade-out { + opacity: 0; +} + +.vn-interface.fade-in { + opacity: 1; +} + +.vn-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + z-index: 1; +} + +/* Combined Info Panel (Top Right) */ +.floating-info-panel { + position: fixed; + top: 20px; + right: 20px; + width: 180px; + height: 160px; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(15px); + border: 1px solid rgba(102, 204, 255, 0.3); + z-index: 100; + padding: 15px; +} + +.info-panel-content { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +/* Weather Section */ +.weather-section { + display: flex; + align-items: center; + gap: 10px; +} + +.weather-icon { + font-size: 16px; + opacity: 0.8; +} + +.weather-details { + display: flex; + flex-direction: column; + gap: 2px; +} + +.weather-location { + font-family: 'MS Gothic', monospace; + font-size: 10px; + color: #66ccff; + font-weight: 600; +} + +.weather-temp { + font-family: 'MS Gothic', monospace; + font-size: 12px; + color: #ffffff; + font-weight: bold; +} + +/* Time Section */ +.time-section { + display: flex; + flex-direction: column; + gap: 2px; + align-items: center; + padding: 6px 0; + border-top: 1px solid rgba(102, 204, 255, 0.2); + border-bottom: 1px solid rgba(102, 204, 255, 0.2); +} + +.japan-date { + font-family: 'MS Gothic', monospace; + font-size: 10px; + color: #66ccff; + font-weight: 600; +} + +.japan-time { + font-family: 'MS Gothic', monospace; + font-size: 14px; + color: #ffffff; + font-weight: bold; + letter-spacing: 1px; +} + +/* Status Section */ +.status-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.yen-balance { + color: #ffcc00; + font-weight: bold; +} + +/* VN Viewport */ +.vn-viewport { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.vn-content { + text-align: center; + z-index: 60; +} + +.vn-brand-title { + font-family: 'Dela Gothic One', cursive; + font-size: clamp(52px, 9vmin, 78px); + font-weight: 400; + color: #ffffff; + letter-spacing: 0; + margin: 0; + margin-bottom: 16px; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); +} + +.vn-brand-subtitle { + font-family: 'Notable', sans-serif; + font-size: clamp(14px, 2.5vmin, 18px); + color: var(--accent2); + font-weight: normal; + letter-spacing: 2px; + margin: 0; + opacity: 0.8; +} + +/* Floating Dialogue Panel */ +.floating-dialogue-panel { + position: fixed; + bottom: 170px; + left: 20px; + right: 20px; + height: 150px; + background: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(15px); + border: 1px solid rgba(102, 204, 255, 0.3); + z-index: 100; + padding: 20px; +} + +.dialogue-content { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +.dialogue-speaker { + font-family: 'MS Gothic', monospace; + font-size: 14px; + color: #ff6699; + font-weight: bold; +} + +.dialogue-text { + font-family: 'MS Gothic', monospace; + font-size: 16px; + color: #ffffff; + line-height: 1.5; + flex: 1; +} + +/* Floating Input Panel */ +.floating-input-panel { + position: fixed; + bottom: 20px; + left: 20px; + right: 20px; + min-height: 70px; + max-height: 130px; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(15px); + border: 1px solid rgba(102, 204, 255, 0.3); + z-index: 100; + padding: 15px 20px; + display: flex; + align-items: stretch; + transition: all 0.3s ease; +} + +.input-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 10px; +} + +.vn-text-input { + width: 100%; + height: 40px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(102, 204, 255, 0.3); + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 14px; + padding: 0 15px; + outline: none; + transition: all 0.3s ease; +} + +.vn-text-input::placeholder { + color: rgba(255, 255, 255, 0.4); + font-family: 'MS Gothic', monospace; +} + +.vn-text-input:focus { + border-color: #66ccff; + background: rgba(102, 204, 255, 0.1); + box-shadow: 0 0 10px rgba(102, 204, 255, 0.3); +} + +/* Choice Buttons (for toggle mode) */ +.choice-buttons { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + min-height: 90px; + max-height: 90px; + overflow-y: auto; +} + +.choice-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(102, 204, 255, 0.3); + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 12px; + padding: 8px 12px; + cursor: pointer; + transition: all 0.3s ease; + text-align: left; + flex-shrink: 0; + min-height: 24px; +} + +.choice-btn:hover { + border-color: #66ccff; + background: rgba(102, 204, 255, 0.1); + transform: translateX(2px); +} + +/* Toggle Button */ +.toggle-input-btn { + background: rgba(102, 204, 255, 0.1); + border: 1px solid rgba(102, 204, 255, 0.3); + color: #ffffff; + font-size: 16px; + width: 50px; + height: 40px; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.toggle-input-btn:hover { + border-color: #66ccff; + background: rgba(102, 204, 255, 0.2); + box-shadow: 0 0 5px rgba(102, 204, 255, 0.3); +} + +/* Floating Settings Button (VN Interface) */ +.vn-interface .floating-logout-btn { + position: fixed; + bottom: 350px; + right: 30px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(102, 204, 255, 0.3); + color: #ffffff; + font-family: 'Orbitron', monospace; + font-size: 14px; + font-weight: 600; + padding: 12px; + cursor: pointer; + transition: all 0.25s ease; + text-transform: uppercase; + letter-spacing: 1px; + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: flex-start; + width: 48px; + height: 48px; + z-index: 200; + overflow: visible; +} + +.vn-interface .floating-logout-btn .btn-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.vn-interface .floating-logout-btn .btn-text { + opacity: 0; + white-space: nowrap; + transform: translateX(-10px); + transition: transform 0.25s ease; + margin-left: 8px; + pointer-events: none; +} + +.vn-interface .floating-logout-btn:hover { + border-color: #66ccff; + background: rgba(102, 204, 255, 0.1); + transform: translateY(-2px); + box-shadow: + 0 10px 30px rgba(102, 204, 255, 0.2), + 0 0 20px rgba(102, 204, 255, 0.1); + width: auto; + min-width: 110px; + padding: 12px 16px; +} + +.vn-interface .floating-logout-btn:hover .btn-text { + opacity: 1; + transform: translateX(0); + pointer-events: auto; + transition: opacity 0.2s ease 0.05s, transform 0.25s ease; +} + +.vn-interface .floating-logout-btn:active { + transform: translateY(-1px); +} + +/* Mobile Responsiveness for VN Interface */ +@media (max-width: 768px) { + .floating-info-panel { + top: 10px; + right: 10px; + width: 140px; + height: 130px; + padding: 10px; + } + + .info-panel-content { + gap: 6px; + } + + .weather-section { + gap: 6px; + } + + .weather-icon { + font-size: 12px; + } + + .weather-location { + font-size: 8px; + } + + .weather-temp { + font-size: 10px; + } + + .time-section { + padding: 4px 0; + } + + .japan-date { + font-size: 8px; + } + + .japan-time { + font-size: 12px; + } + + .status-section { + gap: 3px; + } + + .info-label { + font-size: 8px; + } + + .info-value { + font-size: 9px; + } + + .floating-dialogue-panel { + bottom: 120px; + left: 10px; + right: 10px; + height: 120px; + padding: 15px; + } + + .floating-input-panel { + bottom: 10px; + left: 10px; + right: 10px; + min-height: 60px; + max-height: 110px; + padding: 10px 15px; + } + + /* (Removed special choices-mode spacing; not needed for barcode fix) */ + + .vn-text-input { + height: 35px; + font-size: 13px; + padding: 0 12px; + } + + .choice-buttons { + min-height: 75px; + max-height: 75px; + gap: 4px; + } + + .choice-btn { + font-size: 11px; + padding: 6px 8px; + min-height: 20px; + } + + .toggle-input-btn { + width: 45px; + height: 35px; + font-size: 14px; + } + + .vn-interface .floating-logout-btn { + bottom: 280px; + right: 20px; + width: 44px; + height: 44px; + } + + .vn-interface .floating-logout-btn:hover { + padding: 12px 16px; + width: auto; + min-width: 100px; + } +} + +/* Settings Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(5px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; +} + +.settings-modal { + background: rgba(0, 0, 0, 0.95); + border: 2px solid rgba(102, 204, 255, 0.5); + border-radius: 0; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + animation: slideIn 0.3s ease; + backdrop-filter: blur(15px); +} + +.modal-header { + padding: 20px; + border-bottom: 1px solid rgba(102, 204, 255, 0.3); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-title { + font-family: 'MS Gothic', monospace; + font-size: 18px; + font-weight: bold; + color: #66ccff; + margin: 0; + text-transform: uppercase; + letter-spacing: 1px; +} + +.modal-close-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(102, 204, 255, 0.3); + color: #ffffff; + font-size: 18px; + font-weight: bold; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + border-radius: 0; + font-family: 'MS Gothic', monospace; + backdrop-filter: blur(10px); +} + +.modal-close-btn:hover { + background: rgba(102, 204, 255, 0.1); + border-color: #66ccff; + color: #66ccff; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 204, 255, 0.2); +} + +.modal-close-btn:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(102, 204, 255, 0.2); +} + +.modal-content { + padding: 20px; +} + +.settings-section { + margin-bottom: 25px; +} + +.settings-section h3 { + font-family: 'MS Gothic', monospace; + font-size: 14px; + color: #66ccff; + margin: 0 0 15px 0; + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid rgba(102, 204, 255, 0.2); + padding-bottom: 5px; +} + +.setting-item { + margin-bottom: 15px; +} + +.setting-item label { + font-family: 'MS Gothic', monospace; + font-size: 13px; + color: #ffffff; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.setting-item input[type="checkbox"] { + appearance: none; + width: 18px; + height: 18px; + border: 1px solid rgba(102, 204, 255, 0.3); + background: rgba(255, 255, 255, 0.05); + position: relative; + cursor: pointer; + border-radius: 0; + transition: all 0.3s ease; +} + +.setting-item input[type="checkbox"]:checked { + background: rgba(102, 204, 255, 0.2); + border-color: #66ccff; +} + +.setting-item input[type="checkbox"]:checked::after { + content: '✓'; + position: absolute; + top: -2px; + left: 2px; + color: #66ccff; + font-size: 14px; + font-weight: bold; + font-family: 'MS Gothic', monospace; +} + +.setting-item input[type="range"] { + flex: 1; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(102, 204, 255, 0.3); + height: 6px; + outline: none; + appearance: none; + border-radius: 0; +} + +.setting-item input[type="range"]::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + background: #66ccff; + cursor: pointer; + border-radius: 0; + border: none; + transition: all 0.3s ease; +} + +.setting-item input[type="range"]::-webkit-slider-thumb:hover { + background: #80d4ff; + transform: scale(1.1); +} + +.modal-footer { + padding: 20px; + border-top: 1px solid rgba(102, 204, 255, 0.3); + display: flex; + gap: 15px; + justify-content: flex-end; +} + +.modal-btn { + font-family: 'MS Gothic', monospace; + font-size: 13px; + font-weight: bold; + padding: 12px 20px; + border: 1px solid rgba(102, 204, 255, 0.3); + background: rgba(255, 255, 255, 0.05); + color: #ffffff; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + border-radius: 0; + backdrop-filter: blur(10px); +} + +.modal-btn:hover { + border-color: #66ccff; + background: rgba(102, 204, 255, 0.1); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 204, 255, 0.2); +} + +.modal-btn:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(102, 204, 255, 0.2); +} + +.modal-btn.secondary { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); +} + +.modal-btn.secondary:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.4); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.1); +} + +.modal-btn.logout { + background: rgba(255, 100, 100, 0.1); + border-color: rgba(255, 100, 100, 0.5); + color: #ff6464; +} + +.modal-btn.logout:hover { + background: rgba(255, 100, 100, 0.2); + border-color: #ff6464; + box-shadow: 0 4px 12px rgba(255, 100, 100, 0.3); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 768px) { + .settings-modal { + width: 95%; + max-height: 85vh; + } + + .modal-header, .modal-content, .modal-footer { + padding: 15px; + } + + .modal-title { + font-size: 16px; + } + + .modal-footer { + flex-direction: column; + } + + .modal-btn { + width: 100%; + } +} + +/* Loading Screen */ +.loading-screen { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #000000; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + opacity: 1; + transition: opacity 1s ease-out; + pointer-events: auto; + cursor: pointer; +} + +.loading-screen.fade-out { + opacity: 0 !important; + pointer-events: none; + transition: opacity 1s ease-out; +} + +.loading-screen.hide { + display: none; +} + +.main-interface { + opacity: 0; + transition: opacity 0.5s ease-in; +} + +.main-interface.visible { + opacity: 1; +} + +.main-interface.hidden { + opacity: 0; +} + +.loading-logo { + text-align: center; + color: var(--fg); + font-family: 'MS Gothic', monospace; +} + +.heart-container { + position: relative; + display: inline-block; +} + +.sun-rays { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 140vmax; + height: 140vmax; + z-index: 0; + animation: rotate-rays 15s linear infinite; +} + +.rays-svg { + width: 100%; + height: 100%; + filter: drop-shadow(0 0 20px #8B0000) drop-shadow(0 0 40px #8B0000); +} + +.heart-outline { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 450px; + height: 450px; + z-index: 1; +} + +.heart-svg { + width: 100%; + height: 100%; + filter: drop-shadow(0 0 15px #8B0000) drop-shadow(0 0 30px #8B0000); +} + +.heart-container .logo-text, +.heart-container .loading-subtitle, +.heart-container .loading-dots { + position: relative; + z-index: 3; +} + +@keyframes rotate-rays { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +/* Staggered loading animations */ +.loading-animate-heart { + opacity: 0; + transform: translate(-50%, -50%) scale(0.3); + animation: heart-appear 0.8s ease-out 0.2s forwards; +} + +.loading-animate-rays { + opacity: 0; + animation: rays-appear 0.6s ease-out 0.8s forwards, rotate-rays 15s linear 1.4s infinite; +} + +.loading-animate-text { + opacity: 0; + transform: translateY(20px); + animation: text-appear 0.6s ease-out 1.2s forwards; +} + +.loading-animate-subtitle { + opacity: 0; + transform: translateY(20px); + animation: text-appear 0.6s ease-out 1.0s forwards; +} + +@keyframes heart-appear { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.3); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes rays-appear { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes text-appear { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.logo-text { + font-size: clamp(48px, 8vmin, 72px); + font-weight: bold; + font-family: 'Dela Gothic One', cursive; + color: #ffffff; + margin-bottom: 16px; + letter-spacing: 8px; +} + +.loading-subtitle { + font-size: clamp(14px, 2.5vmin, 18px); + color: var(--accent2); + margin-bottom: 24px; + letter-spacing: 2px; + opacity: 0.8; +} + +.loading-dots { + font-size: 24px; + color: var(--accent3); + letter-spacing: 4px; +} + +.loading-dots span { + animation: dot-blink 1.5s ease-in-out infinite; +} + +.loading-dots span:nth-child(2) { + animation-delay: 0.3s; +} + +.loading-dots span:nth-child(3) { + animation-delay: 0.6s; +} + +@keyframes logo-glow { + from { + text-shadow: + 0 0 10px var(--accent), + 0 0 20px var(--accent), + 0 0 30px var(--accent); + } + to { + text-shadow: + 0 0 15px var(--accent), + 0 0 25px var(--accent), + 0 0 40px var(--accent); + } +} + +@keyframes dot-blink { + 0%, 20% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} + +.loading-instruction { + margin-top: 32px; + font-size: clamp(12px, 2vmin, 16px); + color: var(--accent3); + text-align: center; + opacity: 0.8; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +/* Mobile responsive design */ +@media (max-width: 1024px) { + /* Hide pillar borders on smaller screens */ + .left-pillar, + .right-pillar { + display: none; + } + + .screen { + display: block; + position: relative; + width: 100vw; + height: 100vh; + } + + .screen-content { + width: 100%; + height: 100vh; + max-width: none; + } + + /* Move elements to bottom border */ + .screen-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + height: 48px; + } + + /* Hide desktop time in mobile */ + .desktop-time { + display: none; + } + + .mobile-time-money { + display: flex; + gap: 12px; + font-family: 'MS Gothic', monospace; + font-size: 12px; + background: rgba(0, 0, 0, 0.8); + padding: 6px 12px; + border-radius: 4px; + border: 1px solid var(--interface-border); + position: relative; + z-index: 2; + } + + .mobile-time-money::before { + display: none; + } + + .mobile-clock { + color: var(--accent2); + font-weight: bold; + text-shadow: 0 0 4px var(--accent2); + } + + .mobile-money { + color: var(--accent3); + font-weight: bold; + text-shadow: 0 0 4px var(--accent3); + } + + .mobile-money::before { + content: '¥ '; + color: var(--fg); + text-shadow: none; + } + + .mobile-buttons { + display: flex; + gap: 8px; + } + + .mobile-btn { + appearance: none; + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px outset #000000; + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 9px; + font-weight: bold; + padding: 4px 8px; + text-align: center; + cursor: pointer; + box-shadow: inset 0 0 4px rgba(255,255,255,0.3); + text-shadow: 1px 1px 0 rgba(0,0,0,0.5); + } + + .mobile-btn:hover { + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px inset #000000; + } + + /* Hide desktop bottom bar when mobile elements are shown */ + .bottombar { + display: none; + } + + /* Adjust dialogue and input positioning */ + .dialogue { + bottom: 140px; + margin: 0 8px; + } + + .text-input-area { + bottom: 80px; + left: 8px; + right: 8px; + } +} + +/* Config Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.config-modal { + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 4px solid var(--interface-border); + border-radius: 8px; + min-width: 400px; + max-width: 90vw; + max-height: 90vh; + overflow: auto; + box-shadow: + inset 0 0 15px rgba(0,0,0,0.1), + 0 8px 32px rgba(0,0,0,0.6); + position: relative; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: linear-gradient(180deg, #000000, #000000); + border-bottom: 3px solid var(--interface-border); +} + +.modal-header h2 { + margin: 0; + font-family: 'Sylfaen', serif; + font-size: 18px; + font-weight: bold; + color: #ffffff; + text-shadow: 1px 1px 0 rgba(0,0,0,0.5); +} + +.close-btn { + appearance: none; + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px outset #000000; + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 18px; + font-weight: bold; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + box-shadow: inset 0 0 4px rgba(255,255,255,0.3); +} + +.close-btn:hover { + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px inset #000000; +} + +.modal-content { + padding: 24px; + background: linear-gradient(180deg, #000000, #000000, #000000); +} + +.config-option { + margin-bottom: 20px; +} + +.config-label { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + font-family: 'MS Gothic', monospace; + font-size: 14px; + font-weight: bold; + color: #ffffff; +} + +.config-checkbox { + appearance: none; + width: 20px; + height: 20px; + border: 3px inset #000000; + background: #ffffff; + border-radius: 2px; + position: relative; + cursor: pointer; +} + +.config-checkbox:checked { + background: var(--accent2); + border: 3px inset var(--accent2); +} + +.config-checkbox:checked::after { + content: '✓'; + position: absolute; + top: -2px; + left: 2px; + color: #000000; + font-size: 14px; + font-weight: bold; +} + +.config-text { + user-select: none; +} + +.config-description { + margin-top: 8px; + margin-left: 32px; + font-family: 'MS Gothic', monospace; + font-size: 12px; + color: #555; + font-style: italic; +} + +.modal-footer { + padding: 16px 24px; + background: linear-gradient(180deg, #000000, #000000); + border-top: 3px solid var(--interface-border); + display: flex; + justify-content: flex-end; +} + +.modal-btn { + appearance: none; + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px outset #000000; + color: #ffffff; + font-family: 'MS Gothic', monospace; + font-size: 12px; + font-weight: bold; + padding: 8px 16px; + text-align: center; + cursor: pointer; + box-shadow: inset 0 0 4px rgba(255,255,255,0.3); + text-shadow: 1px 1px 0 rgba(0,0,0,0.5); + border-radius: 4px; +} + +.modal-btn:hover { + background: linear-gradient(180deg, #000000, #000000, #000000); + border: 2px inset #000000; +} + +/* Desktop view - hide mobile elements */ +@media (min-width: 1025px) { + .mobile-time-money, + .mobile-buttons { + display: none; + } + + /* Hide desktop time in footer when pillar borders are visible */ + .desktop-time { + visibility: hidden; + } +} +/* Tablet/medium layout */ +@media (min-width: 769px) and (max-width: 1024px) { + .taisho-cover .cover-content { + grid-template-columns: 1fr; + grid-template-areas: + "masthead" + "hero" + "cta" + "counter"; + gap: 24px; + } + .visit-counter { + position: static; + grid-area: counter; + justify-self: start; + align-self: start; + margin-left: 0; + } + .visit-counter img { height: 72px; } +} + +/* Desktop only: place counter above barcode, away from center frame */ +@media (min-width: 1025px) { + .visit-counter { + position: absolute; + left: 12px; + bottom: 24px; + z-index: 220; + } + .visit-counter img { + height: 88px; + max-width: 240px; + } +} + +/* Hide ASCII overlay on small screens to avoid clutter */ +@media (max-width: 900px) { + .rf-ascii-overlay { + display: none; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..c6d1263 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,11 @@ +export type Role = 'user' | 'assistant' | 'system' +export type ChatMessage = { + id: string + role: Role + content: string +} + +export type Choice = { + id: string + label: string +} |
