diff --git a/src/App.jsx b/src/App.jsx index f8d1fdf..a6ada8f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,7 +3,7 @@ import { Canvas } from '@react-three/fiber'; import AtmosphereScene from './AtmosphereScene'; import AvatarScene from './AvatarScene'; -import { LOCATIONS, teleportTo } from './worldState'; +import { LOCATIONS, teleportTo, worldState } from './worldState'; import { adminMode, onAdminChange } from './controls/KeyInput'; import ControlPanel from './ui/ControlPanel'; @@ -23,6 +23,13 @@ export default function App() { const [lang, setLang] = useState('en'); const [volume, setVolume] = useState(0); const [burstSky, setBurstSky] = useState(null); + const [damageNum, setDamageNum] = useState(null); + const [score, setScore] = useState(0); + const scoreTimerRef = useRef(null); + const [scoreTimeLeft, setScoreTimeLeft] = useState(0); + const scoreEndRef = useRef(0); + const scoreRafRef = useRef(null); + const [crowns, setCrowns] = useState({ bronze: false, silver: false, gold: false }); const langRef = useRef('en'); const volumeRef = useRef(0); const voiceIndexRef = useRef(0); @@ -162,6 +169,50 @@ export default function App() { setBurstSky(null); if (windRef.current) { windRef.current.play().catch(() => {}); } }, 4500); + // Damage number - show during blackhole phase + setTimeout(() => { + const baseDmg = Math.floor(Math.random() * 100000) + 1; + // x2 if ai_mode + const modeMult = vrmModel === 'ai_mode.vrm' ? 2 : 1; + // x1-x12 based on sun position (noon=x12, midnight=x1) + const hour = worldState.currentHour; + const hourMult = Math.max(1, Math.round(12 - Math.abs(hour - 12)) + 1); + // x1-x3 based on location + const locMult = { Tokyo: 1, Fuji: 2, Space: 3 }[worldState.currentLocation] || 1; + const dmg = baseDmg * modeMult * hourMult * locMult; + const x = 40 + Math.random() * 20; + const y = 35 + Math.random() * 20; + setDamageNum({ value: dmg, x, y }); + setTimeout(() => setDamageNum(null), 1500); + // Score system + setScore(prev => { + const newScore = prev + dmg; + // Reset timer: 10s after first damage + // Only start timer on first damage + if (!scoreTimerRef.current) { + scoreEndRef.current = Date.now() + 20000; + const tick = () => { + const left = Math.max(0, Math.ceil((scoreEndRef.current - Date.now()) / 1000)); + setScoreTimeLeft(left); + if (left > 0) scoreRafRef.current = requestAnimationFrame(tick); + }; + tick(); + scoreTimerRef.current = setTimeout(() => { + setScore(prev => { + setCrowns(c => ({ + bronze: c.bronze || prev >= 100, + silver: c.silver || prev >= 100000, + gold: c.gold || prev >= 1000000, + })); + return 0; + }); + setScoreTimeLeft(0); + scoreTimerRef.current = null; + }, 20000); + } + return newScore; + }); + }, 2500); const suffix = langRef.current === 'en' ? '_en' : ''; const file = `${import.meta.env.BASE_URL}voice/ai/burst_1${suffix}.mp3`; const audio = new Audio(file); @@ -270,6 +321,114 @@ export default function App() { /> )} + {/* Score */} + {!isNasa && score > 0 && ( +
+ + SCORE {score} + + {scoreTimeLeft > 0 && ( + + {scoreTimeLeft}s + + )} +
+ )} + + {/* Damage number */} + {damageNum && ( +
+ {damageNum.value} +
+ )} + + + {/* Clear crowns - gold(top), silver, bronze(bottom) */} + {!isNasa && (crowns.bronze || crowns.silver || crowns.gold) && ( +
+
+ {crowns.gold && ( + gold + )} +
+
+ {crowns.silver && ( + silver + )} +
+
+ {crowns.bronze && ( + bronze + )} +
+
+ )} + {/* Admin controls */} {isAdmin && !isNasa && (
diff --git a/src/AtmosphereScene.jsx b/src/AtmosphereScene.jsx index 97d1852..ab6db7d 100644 --- a/src/AtmosphereScene.jsx +++ b/src/AtmosphereScene.jsx @@ -19,6 +19,7 @@ import { import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'; import { FollowCamera } from './controls/CameraRig'; +import { worldState } from './worldState'; const TIME_SCALE = 100; const INITIAL_DATE = new Date('2024-06-21T12:00:00'); @@ -111,6 +112,7 @@ export default function AtmosphereScene({ timeScale: timeScaleProp, overrideDate useFrame((_, delta) => { const currentDate = dateRef.current; currentDate.setTime(currentDate.getTime() + delta * timeScaleRef.current * 1000); + worldState.currentHour = currentDate.getHours() + currentDate.getMinutes() / 60; if (atmosphereRef.current) { atmosphereRef.current.updateByDate(currentDate); diff --git a/src/AvatarScene.jsx b/src/AvatarScene.jsx index 28fce1d..35e6253 100644 --- a/src/AvatarScene.jsx +++ b/src/AvatarScene.jsx @@ -11,7 +11,7 @@ import { worldState } from './worldState'; const STAGE_ROTATE_SPEED = 0.1; // キャラ周回速度 -const BREATH_NEAR = 1.8; // カメラ最近距離 +const BREATH_NEAR = 0.5; // カメラ最近距離 const BREATH_FAR = 4.0; // カメラ最遠距離 const BREATH_SPEED = 0.35; // 寄り引き速度 const BREATH_HEIGHT_RATIO = 0.4; // 距離に対する上下移動の割合 diff --git a/src/worldState.js b/src/worldState.js index 1cd011e..4fe2aa9 100644 --- a/src/worldState.js +++ b/src/worldState.js @@ -8,6 +8,8 @@ export const worldState = { quaternion: new Quaternion(), speed: 1000.0, stageRotationY: 0, + currentHour: 12, + currentLocation: 'Tokyo', }; // Pre-allocated temp objects (avoid GC in useFrame) @@ -49,7 +51,10 @@ export const LOCATIONS = [ { name: 'Space', longitude: 139.7671, latitude: 35.6812, heading: 0, pitch: -90, distance: 100000 }, ]; +const LOCATION_MULT = { Tokyo: 1, Fuji: 2, Space: 3 }; + export function teleportTo(location) { + worldState.currentLocation = location.name; const { longitude, latitude, heading, pitch, distance } = location; const position = new Vector3(); const globalQuaternion = new Quaternion();