diff --git a/src/App.jsx b/src/App.jsx index dd5a0b6..996c837 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -21,7 +21,10 @@ export default function App() { const [camSpeed, setCamSpeed] = useState(0.05); const [vrmModel, setVrmModel] = useState('ai.vrm'); const [lang, setLang] = useState('en'); + const [volume, setVolume] = useState(0); + const [burstSky, setBurstSky] = useState(null); const langRef = useRef('en'); + const volumeRef = useRef(0); const voiceIndexRef = useRef(0); const voicePattern = ['normal','normal','normal','normal','normal','normal','normal','normal','skill','skill']; const actionIndexRef = useRef(0); @@ -33,6 +36,7 @@ export default function App() { }, []); const handleLangChange = useCallback((v) => { setLang(v); langRef.current = v; }, []); + const handleVolumeChange = useCallback((v) => { setVolume(v); volumeRef.current = v; }, []); const playSkillVoice = useCallback(() => { const type = voicePattern[voiceIndexRef.current % voicePattern.length]; @@ -40,7 +44,7 @@ export default function App() { const suffix = langRef.current === 'en' ? '_en' : ''; const file = `${import.meta.env.BASE_URL}voice/ai/${type}_1${suffix}.mp3`; const audio = new Audio(file); - audio.volume = 0.7; + audio.volume = volumeRef.current; audio.play().catch(() => {}); }, []); @@ -76,6 +80,9 @@ export default function App() { } else if (e.code === 'KeyS') { e.preventDefault(); doSkillRef.current?.(); + } else if (e.code === 'KeyB') { + e.preventDefault(); + doBurstRef.current?.(); } }, [playAnim, view]); @@ -114,6 +121,24 @@ export default function App() { teleportIndexRef.current = idx + 1; }, [playAnim]); + const burstCoolRef = useRef(0); + const doBurstRef = useRef(null); + const doBurst = useCallback(() => { + const now = Date.now(); + if (now - burstCoolRef.current < 10000) return; + burstCoolRef.current = now; + playAnim('burst'); + // Sky: sunset → night during burst + setBurstSky(new Date('2024-06-21T17:30:00')); + setTimeout(() => setBurstSky(null), 4500); + const suffix = langRef.current === 'en' ? '_en' : ''; + const file = `${import.meta.env.BASE_URL}voice/ai/burst_1${suffix}.mp3`; + const audio = new Audio(file); + audio.volume = volumeRef.current; + audio.play().catch(() => {}); + }, [playAnim]); + doBurstRef.current = doBurst; + const skillCoolRef = useRef(0); const doSkill = useCallback(() => { const now = Date.now(); @@ -187,7 +212,7 @@ export default function App() { {/* Atmosphere background */}
- +
@@ -206,8 +231,11 @@ export default function App() { camSpeed={camSpeed} onCamSpeedChange={setCamSpeed} onSkill={doSkill} + onBurst={doBurst} lang={lang} onLangChange={handleLangChange} + volume={volume} + onVolumeChange={handleVolumeChange} /> )} diff --git a/src/AtmosphereScene.jsx b/src/AtmosphereScene.jsx index 86dbde7..97d1852 100644 --- a/src/AtmosphereScene.jsx +++ b/src/AtmosphereScene.jsx @@ -69,13 +69,28 @@ function GoogleMaps3DTiles() { ); } -export default function AtmosphereScene({ timeScale: timeScaleProp }) { +export default function AtmosphereScene({ timeScale: timeScaleProp, overrideDate, overrideTimeScale }) { const { gl } = useThree(); const sunRef = useRef(); const atmosphereRef = useRef(); const dateRef = useRef(new Date(INITIAL_DATE)); + const savedDateRef = useRef(null); const timeScaleRef = useRef(timeScaleProp ?? TIME_SCALE); - timeScaleRef.current = timeScaleProp ?? TIME_SCALE; + + // Override date for burst effect + const hasBurstRef = useRef(false); + useEffect(() => { + if (overrideDate) { + hasBurstRef.current = true; + dateRef.current = new Date(overrideDate); + } else if (hasBurstRef.current) { + hasBurstRef.current = false; + // Burst ended: set to sunrise + dateRef.current = new Date('2024-06-22T10:00:00'); + } + }, [overrideDate]); + + timeScaleRef.current = overrideTimeScale ?? timeScaleProp ?? TIME_SCALE; const [weather, setWeather] = useState(WEATHER_PRESETS[1]); useEffect(() => { diff --git a/src/AvatarScene.jsx b/src/AvatarScene.jsx index 98fb9b8..28fce1d 100644 --- a/src/AvatarScene.jsx +++ b/src/AvatarScene.jsx @@ -11,8 +11,8 @@ import { worldState } from './worldState'; const STAGE_ROTATE_SPEED = 0.1; // キャラ周回速度 -const BREATH_NEAR = 2.5; // カメラ最近距離 -const BREATH_FAR = 5.0; // カメラ最遠距離 +const BREATH_NEAR = 1.8; // カメラ最近距離 +const BREATH_FAR = 4.0; // カメラ最遠距離 const BREATH_SPEED = 0.35; // 寄り引き速度 const BREATH_HEIGHT_RATIO = 0.4; // 距離に対する上下移動の割合 const BREATH_BASE_Y = 1.5; // カメラ基準Y位置 @@ -128,6 +128,10 @@ export default function AvatarScene({ selectedAnimation: animState, onZoomOut, c setEffect({ type: 'earth' }); const timer = setTimeout(() => setEffect(null), SKILL_DURATION); return () => clearTimeout(timer); + } else if (animName === 'burst') { + setEffect({ type: 'burst' }); + const timer = setTimeout(() => setEffect(null), 4500); + return () => clearTimeout(timer); } }, [animState]); diff --git a/src/SkillEffects.jsx b/src/SkillEffects.jsx index d3904cd..e20e27d 100644 --- a/src/SkillEffects.jsx +++ b/src/SkillEffects.jsx @@ -101,8 +101,6 @@ const SPHERE_PRESET = { { radius: 0.2, segments: 48, size: 0.01, opacity: 0.4 }, { radius: 0.26, segments: 32, size: 0.006, opacity: 0.15 }, ], - lightColor: '#aaccff', - lightIntensity: 0.3, rotateSpeed: 0.8, pulseSpeed: 1.0, pulseRange: 0.12, @@ -171,12 +169,228 @@ function SphereEffect({ position = [0, 1.0, 0], scale = 1 }) { ); } +// --- BlackHole Effect (burst) - fullscreen raymarching --- + +const bhVertexShader = ` +attribute vec3 position; +varying vec2 vUv; +void main() { + vUv = position.xy; + gl_Position = vec4(position.xy, 0.0, 1.0); +} +`; + +const bhFragmentShader = ` +precision highp float; +varying vec2 vUv; +uniform vec2 uResolution; +uniform float uTime; +uniform float uPhase; // 0-1: blackhole, 1-2: explode, 2-3: dissipate + +#define MAX_STEPS 48 +#define STEP_SIZE 0.1 +#define PI 3.14159265 + +float hash(float n){ return fract(sin(n)*43758.5453123); } +float hash2(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); } + +float noise(vec3 p){ + vec3 i=floor(p); vec3 f=fract(p); f=f*f*(3.0-2.0*f); + float n=i.x+i.y*57.0+113.0*i.z; + return mix(mix(mix(hash(n),hash(n+1.0),f.x),mix(hash(n+57.0),hash(n+58.0),f.x),f.y), + mix(mix(hash(n+113.0),hash(n+114.0),f.x),mix(hash(n+170.0),hash(n+171.0),f.x),f.y),f.z); +} + +vec3 starField(vec3 dir){ + vec3 col=vec3(0.0); + col+=smoothstep(0.4,0.9,noise(dir*3.0+vec3(0.5,1.0,0.2))+0.5*noise(dir*7.0))*vec3(0.05,0.06,0.15); + for(int i=0;i<3;i++){ + float sc=pow(8.0,float(i+1)); + vec3 g=fract(dir*sc)-0.5; + float id=floor(dir.x*sc)*1.3+floor(dir.y*sc)*4.7+floor(dir.z*sc)*9.1; + col+=step(0.995-float(i)*0.003,hash(id))*smoothstep(0.12,0.0,length(g))*vec3(0.9,0.95,1.0)*(1.0-float(i)*0.3); + } + return col; +} + +// --- Phase 1: Black Hole --- +vec3 traceBlackHole(vec3 ro, vec3 rd, float mass){ + float rs=1.5*mass, rs2=rs*rs; + vec3 pos=ro, vel=normalize(rd); + for(int i=0;i20.0) break; + } + return starField(normalize(vel)); +} + +// --- Phase 2: Supernova Explosion --- +vec3 supernova(vec2 uv, float t, float sr){ + vec3 col=vec3(0.0); + float angle=atan(uv.y,uv.x); + + // sharp shockwave rings + for(int i=0;i<3;i++){ + float delay=float(i)*0.15; + float rt=max(0.0, t-delay); + float ringR=rt*(2.0+float(i)*0.6); + // very thin ring with sharp falloff + float ringDist=abs(sr-ringR); + float ringW=0.008+float(i)*0.004; + float ring=exp(-ringDist*ringDist/(ringW*ringW)); + // bright edge glow just outside ring + float edgeGlow=exp(-ringDist*ringDist/(ringW*ringW*8.0))*0.3; + vec3 ringCol=mix(vec3(1.0,0.95,0.85),vec3(1.0,0.75,0.3),float(i)/3.0); + float fade=exp(-rt*1.5)*(3.0-float(i)*0.5); + col+=ringCol*(ring+edgeGlow)*fade; + } + + // intense central flash with slow decay + float flash=1.0/(sr*6.0+0.03)*max(0.0,1.0-t*1.2); + col+=vec3(1.0,0.97,0.92)*flash*0.6; + + // hot debris particles with trails + for(int i=0;i<50;i++){ + float fi=float(i); + float a=hash(fi*1.17)*PI*2.0; + float speed=0.8+hash(fi*2.31)*2.5; + float r=t*speed; + float life=1.0-smoothstep(0.0,0.6+hash(fi*3.7)*0.6, t); + vec2 dir=vec2(cos(a),sin(a)); + vec2 ppos=dir*r; + // spiral motion + float spin=hash(fi*7.3)*2.0-1.0; + ppos+=vec2(-dir.y,dir.x)*sin(t*3.0+fi)*0.1*spin; + float d=length(uv-ppos); + // sharp tiny particle with subtle trail + float trail=exp(-d*d*8000.0)+exp(-pow(dot(uv-ppos,dir),2.0)*20000.0-pow(length(uv-ppos-dir*0.01),2.0)*5000.0)*0.3; + float bright=life*trail; + // temperature gradient: white-hot core -> gold -> amber + float temp=hash(fi*9.1); + vec3 pcol=mix(vec3(1.0,0.95,0.85),mix(vec3(1.0,0.75,0.3),vec3(0.9,0.5,0.1),temp),t*0.8); + col+=pcol*bright*1.2; + } + + + return col; +} + +// --- Phase 3: Supernova afterglow (reuse explosion, fade out) --- +vec3 stardust(vec2 uv, float t, float sr){ + // Continue supernova at t=1.0 state, then fade everything out + float fade=pow(1.0-t, 2.0); + // Run supernova frozen near end, slowly expanding + vec2 expandedUv=uv/(1.0+t*0.3); + float expandedSr=length(expandedUv); + vec3 col=supernova(expandedUv, 0.85+t*0.15, expandedSr)*fade; + return col; +} + +void main(){ + float aspect=uResolution.x/uResolution.y; + vec2 uv=vec2(vUv.x*aspect, vUv.y); + float sr=length(uv); + + vec3 col=vec3(0.0); + float alpha=0.0; + + if(uPhase<1.0){ + // Phase 1: Black hole forming (0 -> 1) + float grow=uPhase*uPhase; + vec2 bhUv=uv*16.0; + float mass=3.0*grow; + vec3 cp=vec3(0.0,0.0,3.0); + vec3 rd=normalize(vec3(bhUv*(1.4-mass*0.1),-1.0)); + col=traceBlackHole(cp,rd,mass); + + float bhSr=length(bhUv); + float rs=1.5*mass; + float g=max(grow,0.001); + col+=vec3(0.3,0.5,1.0)*0.5*0.01*g/(bhSr*5.0/g+0.008)*rs; + col+=vec3(0.6,0.75,1.0)*exp(-abs(bhSr-0.055*g)*50.0/g)*g*0.6; + + col=pow(col/(1.0+col),vec3(0.45)); + alpha=clamp(grow*2.0,0.0,1.0)*clamp(length(col)*3.0,0.0,1.0); + + } else if(uPhase<1.1){ + // White flash transition + float t=(uPhase-1.0)/0.1; + float flash=1.0-t; + col=vec3(1.0,0.98,0.95)*flash*flash; + alpha=flash; + + } else if(uPhase<2.0){ + // Phase 2: Supernova explosion + float t=(uPhase-1.1)/0.9; + col=supernova(uv, t, sr); + col=min(col, vec3(1.0)); + alpha=clamp(length(col)*5.0,0.0,1.0); + + } else { + // Phase 3: Stardust fade out + float t=(uPhase-2.0)/1.0; + col=stardust(uv, t, sr); + col=min(col, vec3(1.0)); + alpha=clamp(length(col)*5.0,0.0,1.0)*(1.0-t*t); + } + + gl_FragColor=vec4(col,alpha); +} +`; + +const BURST_TOTAL_DURATION = 4.5; // seconds for full animation + +function BlackHoleEffect() { + const matRef = useRef(); + const startRef = useRef(null); + const uniforms = useMemo(() => ({ + uTime: { value: 0 }, + uPhase: { value: 0 }, + uResolution: { value: new THREE.Vector2(1, 1) }, + }), []); + + useFrame(({ clock, gl }) => { + if (!matRef.current) return; + const t = clock.getElapsedTime(); + if (startRef.current === null) startRef.current = t; + const elapsed = t - startRef.current; + // Map elapsed time to 0-3 phase range over BURST_TOTAL_DURATION + const phase = Math.min(3.0, (elapsed / BURST_TOTAL_DURATION) * 3.0); + matRef.current.uniforms.uTime.value = t; + matRef.current.uniforms.uPhase.value = phase; + const size = gl.getSize(new THREE.Vector2()); + matRef.current.uniforms.uResolution.value.copy(size); + }); + + return ( + + + + + ); +} + // --- Unified export --- function EnergySphere({ type = 'sun', position = [0, 1.0, 0], scale = 1 }) { if (type === 'earth' || type === 'moon') { return ; } + if (type === 'burst') { + return ; + } return ; } diff --git a/src/VrmCharacter.jsx b/src/VrmCharacter.jsx index cd30ad7..19ee8ae 100644 --- a/src/VrmCharacter.jsx +++ b/src/VrmCharacter.jsx @@ -22,6 +22,7 @@ export const VRMA_FILES = [ { name: 'skill', label: 'スキル', file: 'skill_end.vrma', loop: false }, { name: 'skill_loop', label: 'スキル継続', file: 'skill_loop.vrma', loop: true }, { name: 'skill_end', label: 'スキル終了', file: 'skill_end.vrma', loop: false }, + { name: 'burst', label: 'バースト', file: 'burst.vrma', loop: false }, ]; const VRMA_URLS = VRMA_FILES.map(v => `${BASE_URL}animation/${v.file}`); diff --git a/src/ui/ControlPanel.jsx b/src/ui/ControlPanel.jsx index b65e7b5..eb44796 100644 --- a/src/ui/ControlPanel.jsx +++ b/src/ui/ControlPanel.jsx @@ -44,7 +44,7 @@ const btnStyle = { width: '100%', }; -export default function ControlPanel({ timeScale, onTimeScaleChange, camSpeed, onCamSpeedChange, onSkill, lang, onLangChange }) { +export default function ControlPanel({ timeScale, onTimeScaleChange, camSpeed, onCamSpeedChange, onSkill, onBurst, lang, onLangChange, volume, onVolumeChange }) { return (
@@ -77,13 +77,35 @@ export default function ControlPanel({ timeScale, onTimeScaleChange, camSpeed, o style={sliderStyle} />
+
+
+ Volume + {Math.round(volume * 100)}% +
+ onVolumeChange(Number(e.target.value) / 100)} + style={sliderStyle} + /> +
+