Compare commits

..

14 Commits

8 changed files with 161 additions and 101 deletions
Binary file not shown.
+6
View File
@@ -1,4 +1,10 @@
.canvas { .canvas {
width: 100vw !important; width: 100vw !important;
height: 100vh !important; height: 100vh !important;
position: absolute;
top: 0;
left: 0;
outline: none;
user-select: none;
touch-action: none;
} }
+20 -2
View File
@@ -2,7 +2,7 @@
import './page.css'; import './page.css';
import { Canvas, useThree } from "@react-three/fiber"; import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { BrightnessContrast, EffectComposer, HueSaturation, Noise, Pixelation, Vignette } from "@react-three/postprocessing"; import { BrightnessContrast, EffectComposer, HueSaturation, Noise, Pixelation, Vignette } from "@react-three/postprocessing";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
@@ -53,6 +53,13 @@ function ListenerCreator() {
return null; return null;
} }
function FearStateUpdater() {
useFrame((state, delta) => {
fearState.update(delta);
});
return null;
}
export default function Fear() { export default function Fear() {
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive); const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
const [wasCaught, setWasCaught] = useState(fearState.isRustActive); const [wasCaught, setWasCaught] = useState(fearState.isRustActive);
@@ -72,10 +79,13 @@ export default function Fear() {
className='canvas' className='canvas'
camera={{ position: [0, 3, -5], fov: 65, far: 100 }} camera={{ position: [0, 3, -5], fov: 65, far: 100 }}
> >
<FearStateUpdater />
<ListenerCreator /> <ListenerCreator />
<color attach="background" args={['#050505']} /> <color attach="background" args={['#050505']} />
<ambientLight intensity={0.0225} />
<fogExp2 attach='fog' args={[0x050505, 0.035]} /> <fogExp2 attach='fog' args={[0x050505, 0.035]} />
<PostProcessing /> <PostProcessing />
@@ -83,17 +93,25 @@ export default function Fear() {
<Hallway /> <Hallway />
<TheCreature /> <TheCreature />
<Player /> <Player />
</Suspense>
<AmbientSound <AmbientSound
key="ambient-1"
url='fear/snd/ambience.mp3' url='fear/snd/ambience.mp3'
volume={isRustActive ? 0 : 1} volume={isRustActive ? 0 : 1}
/> />
<AmbientSound
key="ambient-2"
url='fear/snd/ambience2.mp3'
volume={isRustActive ? 1 : 0}
/>
{wasCaught ? <AmbientSound {wasCaught ? <AmbientSound
key="ambient-glitch"
url='fear/snd/glitch.mp3' url='fear/snd/glitch.mp3'
volume={1} volume={1}
/> : null} /> : null}
</Suspense>
</Canvas> </Canvas>
</>) </>)
} }
+30 -19
View File
@@ -7,13 +7,39 @@ interface AmbientSoundProps {
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) { export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const targetVolumeRef = useRef<number>(volume)
targetVolumeRef.current = volume
useEffect(() => { useEffect(() => {
const audio = new Audio(url) const audio = new Audio(url)
audio.loop = true audio.loop = true
audio.volume = 0
audioRef.current = audio audioRef.current = audio
let componentsMounted = true
const attemptPlay = () => {
if (!audioRef.current || !componentsMounted) return
audio.volume = targetVolumeRef.current
if (audio.volume > 0 && audio.paused) {
audio.play().catch((err) => {
console.warn('Autoplay management holding clip playback execution.', err)
})
}
}
attemptPlay()
window.addEventListener('click', attemptPlay)
window.addEventListener('keydown', attemptPlay)
return () => { return () => {
componentsMounted = false
window.removeEventListener('click', attemptPlay)
window.removeEventListener('keydown', attemptPlay)
audio.pause() audio.pause()
audio.src = '' audio.src = ''
audioRef.current = null audioRef.current = null
@@ -24,28 +50,13 @@ export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
const audio = audioRef.current const audio = audioRef.current
if (!audio) return if (!audio) return
audio.volume = volume
const startAudio = () => {
if (audio.volume > 0) {
audio.play().catch((err) => {
console.warn('Autoplay blocked. Waiting for user interaction.', err)
})
}
}
if (volume === 0) { if (volume === 0) {
audio.pause() if (!audio.paused) audio.pause()
} else { } else {
startAudio() audio.volume = volume
if (audio.paused) {
window.addEventListener('click', startAudio, { once: true }) audio.play().catch(() => {})
window.addEventListener('keydown', startAudio, { once: true })
} }
return () => {
window.removeEventListener('click', startAudio)
window.removeEventListener('keydown', startAudio)
} }
}, [volume]) }, [volume])
+11 -11
View File
@@ -3,7 +3,9 @@ import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { fearState } from "../state"; import { FEAR_SETTINGS, fearState } from "../state";
useTexture.preload('fear/img/creature.png');
export default function TheCreature() { export default function TheCreature() {
const texture = useTexture('fear/img/creature.png'); const texture = useTexture('fear/img/creature.png');
@@ -14,32 +16,30 @@ export default function TheCreature() {
const [hasTriggered, setHasTriggered] = useState(false); const [hasTriggered, setHasTriggered] = useState(false);
const [isSpawned, setIsSpawned] = useState(false); const [isSpawned, setIsSpawned] = useState(false);
const speed = 15;
const globalDistance = useRef<number>(32); const globalDistance = useRef<number>(32);
const [currentLoop, setCurrentLoop] = useState(fearState.loopCount); const [finaleTriggered, setFinaleTriggered] = useState(fearState.finaleTriggered);
const audioPlaying = useRef<boolean>(false); const audioPlaying = useRef<boolean>(false);
useEffect(() => { useEffect(() => {
const unsubscribe = fearState.subscribe(() => { const unsubscribe = fearState.subscribe(() => {
setCurrentLoop(fearState.loopCount); setFinaleTriggered(fearState.finaleTriggered);
if (fearState.loopCount < 4) { if (!fearState.finaleTriggered) {
setIsSpawned(false); setIsSpawned(false);
setHasTriggered(false); setHasTriggered(false);
globalDistance.current = 32; globalDistance.current = 32;
audioPlaying.current = false; audioPlaying.current = false;
if (audioRef.current && audioRef.current.isPlaying) { if (audioRef.current && audioRef.current.isPlaying)
audioRef.current.stop(); audioRef.current.stop();
} }
}
}); });
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
useFrame((state, delta) => { useFrame((state, delta) => {
if (fearState.loopCount < 4) return; if (!fearState.finaleTriggered) return;
const creature = meshRef.current; const creature = meshRef.current;
if (!creature) return; if (!creature) return;
@@ -56,7 +56,7 @@ export default function TheCreature() {
} }
if (hasTriggered) { if (hasTriggered) {
globalDistance.current -= speed * delta; globalDistance.current -= FEAR_SETTINGS.CREATURE_SPEED * delta;
if (audioRef.current && !audioPlaying.current) { if (audioRef.current && !audioPlaying.current) {
audioPlaying.current = true; audioPlaying.current = true;
@@ -89,7 +89,7 @@ export default function TheCreature() {
return ( return (
<mesh <mesh
ref={meshRef} ref={meshRef}
visible={currentLoop >= 4} visible={finaleTriggered}
> >
<planeGeometry args={[3.0, 4.8]} /> <planeGeometry args={[3.0, 4.8]} />
<meshBasicMaterial <meshBasicMaterial
@@ -99,7 +99,7 @@ export default function TheCreature() {
side={THREE.DoubleSide} side={THREE.DoubleSide}
/> />
{currentLoop >= 4 && ( {finaleTriggered && (
<PositionalAudio <PositionalAudio
url="fear/snd/riser.mp3" url="fear/snd/riser.mp3"
ref={audioRef} ref={audioRef}
+13 -16
View File
@@ -32,19 +32,21 @@ function Door({ position, rotation }: DoorProps) {
export default function Hallway() { export default function Hallway() {
const [width, setWidth] = useState(fearState.currentWidth); const [width, setWidth] = useState(fearState.currentWidth);
const [floorTex, wallTex, rustTex] = useTexture([ const [floorTex, wallTex, rustWallTex, rustFloorTex] = useTexture([
'fear/img/concrete-floor.png', 'fear/img/concrete-floor.png',
'fear/img/concrete-wall.png', 'fear/img/concrete-wall.png',
'fear/img/rust.png',
'fear/img/rust.png' 'fear/img/rust.png'
]); ]);
useEffect(() => { useEffect(() => {
[floorTex, wallTex, rustTex].forEach((tex) => { [floorTex, wallTex, rustWallTex, rustFloorTex].forEach((tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.minFilter = tex.magFilter = THREE.NearestFilter; tex.minFilter = tex.magFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = THREE.SRGBColorSpace;
}); });
}, [floorTex, wallTex, rustTex]); }, [floorTex, wallTex, rustWallTex, rustFloorTex]);
const segmentPool = [0, 1, 2, 3, 4]; const segmentPool = [0, 1, 2, 3, 4];
const segmentCount = segmentPool.length; const segmentCount = segmentPool.length;
@@ -80,10 +82,6 @@ export default function Hallway() {
useFrame((state, delta) => { useFrame((state, delta) => {
const time = state.clock.elapsedTime; const time = state.clock.elapsedTime;
fearState.update(delta);
if (fearState.isRustActive !== isRustActive)
setIsRustActive(fearState.isRustActive);
/* /*
lights lights
*/ */
@@ -125,11 +123,13 @@ export default function Hallway() {
const horizontalTexRepeat = width / FEAR_SETTINGS.HALLWAY_WIDTH; const horizontalTexRepeat = width / FEAR_SETTINGS.HALLWAY_WIDTH;
floorTex.repeat.set(horizontalTexRepeat, 10); floorTex.repeat.set(horizontalTexRepeat, 10);
wallTex.repeat.set(10, 1); wallTex.repeat.set(10, 1);
rustTex.repeat.set(horizontalTexRepeat, 10); rustWallTex.repeat.set(10, 1);
rustFloorTex.repeat.set(horizontalTexRepeat, 10);
floorTex.needsUpdate = true; floorTex.needsUpdate = true;
wallTex.needsUpdate = true; wallTex.needsUpdate = true;
rustTex.needsUpdate = true; rustWallTex.needsUpdate = true;
rustFloorTex.needsUpdate = true;
let closestPoolIndex = 0; let closestPoolIndex = 0;
let minDistance = Infinity; let minDistance = Infinity;
@@ -140,7 +140,6 @@ export default function Hallway() {
let segmentZIndex = poolIndex - Math.floor(segmentCount / 2) + playerSegmentZ; let segmentZIndex = poolIndex - Math.floor(segmentCount / 2) + playerSegmentZ;
segGroup.position.z = segmentZIndex * length; segGroup.position.z = segmentZIndex * length;
// Track which pool index is currently physically closest to the player's camera position
const distance = Math.abs(segGroup.position.z - state.camera.position.z); const distance = Math.abs(segGroup.position.z - state.camera.position.z);
if (distance < minDistance) { if (distance < minDistance) {
minDistance = distance; minDistance = distance;
@@ -197,10 +196,10 @@ export default function Hallway() {
/* /*
materials materials
*/ */
const updateMaterials = (materials: THREE.MeshStandardMaterial[], defaultTex: THREE.Texture, activeColor: string, defaultColor: string, activeRough: number, defaultRough: number, activeMetal: number, defaultMetal: number) => { const updateMaterials = (materials: THREE.MeshStandardMaterial[], defaultTex: THREE.Texture, targetRustTex: THREE.Texture, activeColor: string, defaultColor: string, activeRough: number, defaultRough: number, activeMetal: number, defaultMetal: number) => {
materials.forEach(mat => { materials.forEach(mat => {
if (!mat) return; if (!mat) return;
const targetTex = isRustActive ? rustTex : defaultTex; const targetTex = isRustActive ? targetRustTex : defaultTex;
if (mat.map !== targetTex) { if (mat.map !== targetTex) {
mat.map = targetTex; mat.map = targetTex;
mat.needsUpdate = true; mat.needsUpdate = true;
@@ -211,8 +210,8 @@ export default function Hallway() {
}); });
}; };
updateMaterials(wallMaterialsRef.current, wallTex, "#918a87", "#ffffff", 0.95, 0.7, 0.05, 0.1); updateMaterials(wallMaterialsRef.current, wallTex, rustWallTex, "#c5c0be", "#ffffff", 0.95, 0.7, 0.05, 0.1);
updateMaterials(floorMaterialsRef.current, floorTex, "#8b827f", "#ffffff", 0.95, 0.8, 0.05, 0.2); updateMaterials(floorMaterialsRef.current, floorTex, rustFloorTex, "#cabdb9", "#ffffff", 0.95, 0.8, 0.05, 0.2);
pipeMaterialsRef.current.forEach(mat => { pipeMaterialsRef.current.forEach(mat => {
if (!mat) return; if (!mat) return;
@@ -231,8 +230,6 @@ export default function Hallway() {
return ( return (
<> <>
<ambientLight intensity={0.0225} />
{segmentPool.map((poolIndex) => ( {segmentPool.map((poolIndex) => (
<group <group
key={poolIndex} key={poolIndex}
+55 -34
View File
@@ -2,13 +2,16 @@ import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { FEAR_SETTINGS, fearState } from "../state"; import { FEAR_SETTINGS, fearState } from "../state";
import { PointerLockControls } from "@react-three/drei"; import { PointerLockControls } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
const forward = new THREE.Vector3(); const forward = new THREE.Vector3();
const side = new THREE.Vector3(); const side = new THREE.Vector3();
const direction = new THREE.Vector3();
const viewDirection = new THREE.Vector3(); const viewDirection = new THREE.Vector3();
const targetDest = new THREE.Vector3();
const playerRoot = new THREE.Vector3(0, FEAR_SETTINGS.PLAYER_HEIGHT, 0);
const targetVelocity = new THREE.Vector3();
const currentVelocity = new THREE.Vector3();
function usePlayerControls() { function usePlayerControls() {
const keys = useRef({ Forward: false, Backward: false, Left: false, Right: false }); const keys = useRef({ Forward: false, Backward: false, Left: false, Right: false });
@@ -46,63 +49,77 @@ export default function Player() {
const flashlightRef = useRef<THREE.SpotLight>(null); const flashlightRef = useRef<THREE.SpotLight>(null);
const movementCounter = useRef<number>(0); const movementCounter = useRef<number>(0);
const bobIntensity = useRef<number>(0);
const confirmedSegment = useRef<number>(0); const confirmedSegment = useRef<number>(0);
const hasTriggeredThisSegment = useRef<boolean>(false); const hasTriggeredThisSegment = useRef<boolean>(false);
useEffect(() => {
playerRoot.set(camera.position.x, FEAR_SETTINGS.PLAYER_HEIGHT, camera.position.z);
}, []);
useFrame((state, delta) => { useFrame((state, delta) => {
const dt = Math.min(delta, 0.1);
camera.getWorldDirection(forward); camera.getWorldDirection(forward);
forward.y = 0; forward.y = 0;
forward.normalize(); forward.normalize();
side.crossVectors(forward, THREE.Object3D.DEFAULT_UP).normalize();
side.crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const moveForward = Number(controls.Forward) - Number(controls.Backward); const moveForward = Number(controls.Forward) - Number(controls.Backward);
const moveSide = Number(controls.Right) - Number(controls.Left); const moveSide = Number(controls.Right) - Number(controls.Left);
direction.set(0, 0, 0); targetVelocity.set(0, 0, 0);
if (moveForward !== 0) direction.addScaledVector(forward, moveForward); if (moveForward !== 0) targetVelocity.addScaledVector(forward, moveForward);
if (moveSide !== 0) direction.addScaledVector(side, moveSide); if (moveSide !== 0) targetVelocity.addScaledVector(side, moveSide);
if (direction.lengthSq() > 0) if (targetVelocity.lengthSq() > 0)
direction.normalize().multiplyScalar(FEAR_SETTINGS.PLAYER_SPEED * delta); targetVelocity.normalize().multiplyScalar(FEAR_SETTINGS.PLAYER_SPEED);
camera.position.x += direction.x; currentVelocity.lerp(targetVelocity, 10 * dt);
camera.position.z += direction.z;
playerRoot.x += currentVelocity.x * dt;
playerRoot.z += currentVelocity.z * dt;
const minX = -fearState.currentWidth / 2 + FEAR_SETTINGS.WALL_BUFFER;
const maxX = fearState.currentWidth / 2 - FEAR_SETTINGS.WALL_BUFFER;
playerRoot.x = THREE.MathUtils.clamp(playerRoot.x, minX, maxX);
const isMoving = controls.Forward || controls.Backward || controls.Left || controls.Right; const isMoving = controls.Forward || controls.Backward || controls.Left || controls.Right;
if (isMoving) {
movementCounter.current += delta * 10; bobIntensity.current = THREE.MathUtils.lerp(bobIntensity.current, isMoving ? 1 : 0, 8 * dt);
camera.position.y = FEAR_SETTINGS.PLAYER_HEIGHT + Math.sin(movementCounter.current) * 0.08;
camera.position.x += Math.cos(movementCounter.current / 2) * 0.006; if (isMoving)
} else { movementCounter.current += dt * 12;
const breatheTime = state.clock.elapsedTime * 1.5;
const breatheY = FEAR_SETTINGS.PLAYER_HEIGHT + Math.sin(breatheTime) * 0.1; const moveBobY = Math.sin(movementCounter.current) * 0.06 * bobIntensity.current;
camera.position.y = THREE.MathUtils.lerp(camera.position.y, breatheY, 4 * delta); const moveBobX = Math.cos(movementCounter.current / 2) * 0.04 * bobIntensity.current;
}
const breatheTime = state.clock.elapsedTime * 1.8;
const breatheBobY = Math.sin(breatheTime) * 0.03 * (1 - bobIntensity.current * 0.5);
camera.position.copy(playerRoot);
camera.position.y += moveBobY + breatheBobY;
camera.position.addScaledVector(side, moveBobX);
if (flashlightRef.current) { if (flashlightRef.current) {
flashlightRef.current.position.copy(camera.position); flashlightRef.current.position.copy(camera.position);
camera.getWorldDirection(viewDirection); camera.getWorldDirection(viewDirection);
const targetDest = new THREE.Vector3() targetDest
.copy(camera.position) .copy(camera.position)
.addScaledVector(viewDirection, 10); .addScaledVector(viewDirection, 10);
flashlightRef.current.target.position.lerp(targetDest, 10 * delta); flashlightRef.current.target.position.lerp(targetDest, 12 * dt);
flashlightRef.current.target.updateMatrixWorld(); flashlightRef.current.target.updateMatrixWorld();
flashlightRef.current.intensity = 5 + Math.sin(state.clock.elapsedTime * 30) * 0.3; flashlightRef.current.intensity =
FEAR_SETTINGS.FLASHLIGHT_INTENSITY_BASE +
Math.sin(state.clock.elapsedTime * 30) * 0.15 * Math.cos(state.clock.elapsedTime * 3);
} }
const minX = -fearState.currentWidth / 2 + FEAR_SETTINGS.WALL_BUFFER;
const maxX = fearState.currentWidth / 2 - FEAR_SETTINGS.WALL_BUFFER;
if (camera.position.x < minX) camera.position.x = minX;
if (camera.position.x > maxX) camera.position.x = maxX;
const length = FEAR_SETTINGS.HALLWAY_LENGTH; const length = FEAR_SETTINGS.HALLWAY_LENGTH;
const absoluteZ = -camera.position.z; const absoluteZ = -playerRoot.z;
const rawSegmentIndex = Math.floor(absoluteZ / length); const rawSegmentIndex = Math.floor(absoluteZ / length);
const progressZ = ((absoluteZ % length) + length) % length / length; const progressZ = ((absoluteZ % length) + length) % length / length;
@@ -121,8 +138,9 @@ export default function Player() {
confirmedSegment.current = rawSegmentIndex; confirmedSegment.current = rawSegmentIndex;
} }
if (rawSegmentIndex === confirmedSegment.current && progressZ > 0.35 && progressZ < 0.65) if (rawSegmentIndex === confirmedSegment.current && progressZ > 0.35 && progressZ < 0.65) {
hasTriggeredThisSegment.current = false; hasTriggeredThisSegment.current = false;
}
}); });
return ( return (
@@ -130,11 +148,14 @@ export default function Player() {
<PointerLockControls /> <PointerLockControls />
<spotLight <spotLight
ref={flashlightRef} ref={flashlightRef}
distance={22} distance={25}
angle={0.35} angle={0.35}
penumbra={0.7} penumbra={0.8}
intensity={5} intensity={0}
color="#fffaed" color="#fffaed"
decay={2}
castShadow
shadow-bias={-0.001}
/> />
</> </>
); );
+11 -4
View File
@@ -6,7 +6,13 @@ export const FEAR_SETTINGS = {
HALLWAY_HEIGHT: 5, HALLWAY_HEIGHT: 5,
PLAYER_HEIGHT: 3, PLAYER_HEIGHT: 3,
PLAYER_SPEED: 4, PLAYER_SPEED: 4,
FLASHLIGHT_INTENSITY_BASE: 8,
WALL_BUFFER: 0.6, WALL_BUFFER: 0.6,
CREATURE_SPEED: 8,
EVENT_NARROW_LOOP_COUNT: 2,
EVENT_RUST_LOOP_COUNT: 4,
EVENT_FINALE_LOOP_COUNT: 5
}; };
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -28,10 +34,7 @@ export const fearState = {
}, },
update(delta: number) { update(delta: number) {
this.isRustActive = this.loopCount >= 3; const targetWidth = this.loopCount >= FEAR_SETTINGS.EVENT_NARROW_LOOP_COUNT ? 2.5 : FEAR_SETTINGS.HALLWAY_WIDTH;
this.finaleTriggered = this.loopCount >= 4;
const targetWidth = this.loopCount >= 2 ? 2.5 : FEAR_SETTINGS.HALLWAY_WIDTH;
const newWidth = THREE.MathUtils.lerp(this.currentWidth, targetWidth, 2 * delta); const newWidth = THREE.MathUtils.lerp(this.currentWidth, targetWidth, 2 * delta);
if (Math.abs(this.currentWidth - newWidth) > 0.001) { if (Math.abs(this.currentWidth - newWidth) > 0.001) {
@@ -42,6 +45,10 @@ export const fearState = {
registerLoop(direction: 'forward' | 'backward') { registerLoop(direction: 'forward' | 'backward') {
this.loopCount += 1; this.loopCount += 1;
this.isRustActive = this.loopCount >= FEAR_SETTINGS.EVENT_RUST_LOOP_COUNT;
this.finaleTriggered = this.loopCount >= FEAR_SETTINGS.EVENT_FINALE_LOOP_COUNT;
this.emit(); this.emit();
}, },