Compare commits
60 Commits
f7b3153b92
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab3bf047d4 | |||
| f1ab2b692d | |||
| 673aabce50 | |||
| b3a5712c85 | |||
| 4691a9fbf4 | |||
| 0fca4db440 | |||
| 930139d1df | |||
| 4120e5ec72 | |||
| fd314cf2ec | |||
| e3ab974988 | |||
| e4a0c57e79 | |||
| ebda4b281e | |||
| 8dcc888d5c | |||
| b7e61b4240 | |||
| 23c39a71a6 | |||
| a0ee50703c | |||
| 8c4080f10c | |||
| b9eeed848b | |||
| d506071ce2 | |||
| cad47f07bd | |||
| 10543bba89 | |||
| aeee2158ba | |||
| 569a4f29fb | |||
| 9a67a800fa | |||
| 719a75d393 | |||
| f583cfdc57 | |||
| 5665804b8f | |||
| 67bf6325fa | |||
| 6d7651dec9 | |||
| ee2eb45527 | |||
| 079986ebec | |||
| a0b416c412 | |||
| c582d6b745 | |||
| beff5e3265 | |||
| c04d8536c0 | |||
| cb15cc3d95 | |||
| 0d72d49d7b | |||
| d66c898f23 | |||
| 566a684bfa | |||
| df81fc1ee0 | |||
| dd5e8a2ae2 | |||
| 20b6a559fd | |||
| eec01440f9 | |||
| 339c660bcb | |||
| fe686b0071 | |||
| 092faa9449 | |||
| a163a12483 | |||
| 30498e4faa | |||
| df44640ecb | |||
| 5de127449a | |||
| 7a1c28cd19 | |||
| 606dde5122 | |||
| 59e5a8c0c0 | |||
| a40ee3854b | |||
| 18246eab22 | |||
| a6a6cdd168 | |||
| d9869a469e | |||
| 0db7c08168 | |||
| 5e8ebf9491 | |||
| 4d28cf8bcb |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 MiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
.canvas {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import './page.css';
|
||||
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { BrightnessContrast, EffectComposer, HueSaturation, Noise, Pixelation, Vignette } from "@react-three/postprocessing";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { AmbientSound } from './scene-components/ambient-sound';
|
||||
|
||||
import { FEAR_SETTINGS, fearState } from './state';
|
||||
|
||||
import TheCreature from './scene-components/creature';
|
||||
import Player from './scene-components/player';
|
||||
import Hallway from './scene-components/hallway';
|
||||
|
||||
import { AudioListener } from 'three';
|
||||
import FinaleText from './scene-components/finale-text';
|
||||
|
||||
function PostProcessing() {
|
||||
const [wasCaught, setWasCaught] = useState(fearState.wasCaught);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = fearState.subscribe(() => {
|
||||
setWasCaught(fearState.wasCaught);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (<EffectComposer>
|
||||
<Pixelation granularity={wasCaught ? 18 : 10} />
|
||||
<Vignette />
|
||||
<Noise opacity={wasCaught ? 0.01 : 0.003} />
|
||||
<BrightnessContrast
|
||||
brightness={-0.01}
|
||||
contrast={0.05}
|
||||
/>
|
||||
<HueSaturation saturation={wasCaught ? 1 : 0} />
|
||||
</EffectComposer>)
|
||||
}
|
||||
|
||||
function ListenerCreator() {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = new AudioListener();
|
||||
camera.add(listener);
|
||||
|
||||
return () => {
|
||||
camera.remove(listener);
|
||||
};
|
||||
}, [camera]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function FearStateUpdater() {
|
||||
useFrame((state, delta) => {
|
||||
fearState.update(delta);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Fear() {
|
||||
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
|
||||
const [wasCaught, setWasCaught] = useState(fearState.isRustActive);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = fearState.subscribe(() => {
|
||||
setIsRustActive(fearState.isRustActive);
|
||||
setWasCaught(fearState.wasCaught)
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (<>
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{ antialias: true }}
|
||||
className='canvas'
|
||||
camera={{ position: [0, 3, -5], fov: 55, far: 100 }}
|
||||
>
|
||||
<FearStateUpdater />
|
||||
|
||||
<ListenerCreator />
|
||||
|
||||
<color attach="background" args={['#050505']} />
|
||||
|
||||
{FEAR_SETTINGS.TEST_MODE ? <ambientLight intensity={2} /> : <ambientLight intensity={0.0225} />}
|
||||
{FEAR_SETTINGS.TEST_MODE ? null : <fogExp2 attach='fog' args={[0x050505, 0.035]} />}
|
||||
{FEAR_SETTINGS.TEST_MODE ? null : < PostProcessing />}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Hallway />
|
||||
<TheCreature />
|
||||
<Player />
|
||||
</Suspense>
|
||||
|
||||
<AmbientSound
|
||||
key="ambient-1"
|
||||
url='fear/snd/ambience.mp3'
|
||||
volume={isRustActive ? 0 : 0.5}
|
||||
/>
|
||||
|
||||
<AmbientSound
|
||||
key="ambient-2"
|
||||
url='fear/snd/ambience2.mp3'
|
||||
volume={isRustActive ? 1 : 0}
|
||||
/>
|
||||
|
||||
{wasCaught ? <AmbientSound
|
||||
key="ambient-glitch"
|
||||
url='fear/snd/glitch.mp3'
|
||||
volume={1}
|
||||
/> : null}
|
||||
</Canvas>
|
||||
|
||||
<FinaleText />
|
||||
</>)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface AmbientSoundProps {
|
||||
url: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const targetVolumeRef = useRef<number>(volume)
|
||||
|
||||
targetVolumeRef.current = volume
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio(url)
|
||||
audio.loop = true
|
||||
audio.volume = 0
|
||||
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 () => {
|
||||
componentsMounted = false
|
||||
window.removeEventListener('click', attemptPlay)
|
||||
window.removeEventListener('keydown', attemptPlay)
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [url])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (volume === 0) {
|
||||
if (!audio.paused) audio.pause()
|
||||
} else {
|
||||
audio.volume = volume
|
||||
if (audio.paused) {
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useTexture, PositionalAudio } from "@react-three/drei";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import * as THREE from "three";
|
||||
import { FEAR_SETTINGS, fearState } from "../state";
|
||||
import { ShaderPatch } from "../shader-patch";
|
||||
|
||||
useTexture.preload('fear/img/creature.png');
|
||||
|
||||
export default function TheCreature() {
|
||||
const baseTexture = useTexture('fear/img/creature.png');
|
||||
|
||||
const texture = useMemo(() => {
|
||||
const t = baseTexture.clone();
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
}, [baseTexture]);
|
||||
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const audioRef = useRef<THREE.PositionalAudio>(null);
|
||||
const { camera } = useThree();
|
||||
|
||||
const [hasTriggered, setHasTriggered] = useState(false);
|
||||
const [isSpawned, setIsSpawned] = useState(false);
|
||||
|
||||
const globalDistance = useRef<number>(32);
|
||||
const [finaleTriggered, setFinaleTriggered] = useState(fearState.finaleTriggered);
|
||||
|
||||
const audioPlaying = useRef<boolean>(false);
|
||||
|
||||
const movePhase = useRef<'frozen' | 'lurching'>('frozen');
|
||||
const phaseTimer = useRef<number>(1.5);
|
||||
|
||||
const glitchCooldown = useRef<number>(0);
|
||||
const isGlitchSpiking = useRef<boolean>(false);
|
||||
const flickerCooldown = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = fearState.subscribe(() => {
|
||||
setFinaleTriggered(fearState.finaleTriggered);
|
||||
|
||||
if (!fearState.finaleTriggered) {
|
||||
setIsSpawned(false);
|
||||
setHasTriggered(false);
|
||||
globalDistance.current = 32;
|
||||
audioPlaying.current = false;
|
||||
movePhase.current = 'frozen';
|
||||
phaseTimer.current = 1.5;
|
||||
|
||||
if (audioRef.current && audioRef.current.isPlaying)
|
||||
audioRef.current.stop();
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (!fearState.finaleTriggered) return;
|
||||
|
||||
const creature = meshRef.current;
|
||||
if (!creature) return;
|
||||
|
||||
if (!isSpawned) {
|
||||
setIsSpawned(true);
|
||||
globalDistance.current = 32;
|
||||
movePhase.current = 'frozen';
|
||||
phaseTimer.current = 1.0 + Math.random() * 1.5;
|
||||
}
|
||||
|
||||
if (!hasTriggered) {
|
||||
if (globalDistance.current < 40)
|
||||
setHasTriggered(true);
|
||||
|
||||
}
|
||||
|
||||
if (hasTriggered) {
|
||||
phaseTimer.current -= delta;
|
||||
|
||||
if (phaseTimer.current <= 0) {
|
||||
if (movePhase.current === 'frozen') {
|
||||
movePhase.current = 'lurching';
|
||||
phaseTimer.current = 0.05 + Math.random() * 0.2;
|
||||
} else {
|
||||
movePhase.current = 'frozen';
|
||||
const proximityFactor = Math.max(0.05, globalDistance.current / 32);
|
||||
phaseTimer.current = (0.2 + Math.random() * 1.0) * proximityFactor;
|
||||
}
|
||||
}
|
||||
|
||||
if (movePhase.current === 'lurching') {
|
||||
globalDistance.current -= FEAR_SETTINGS.CREATURE_SPEED * 3 * delta;
|
||||
}
|
||||
|
||||
if (audioRef.current && !audioPlaying.current) {
|
||||
audioPlaying.current = true;
|
||||
if (audioRef.current.context.state === 'suspended')
|
||||
audioRef.current.context.resume();
|
||||
audioRef.current.play();
|
||||
}
|
||||
|
||||
const shakeIntensity = Math.max(0, 1 - (globalDistance.current / 32)) * 0.22;
|
||||
camera.position.x += (Math.random() - 0.5) * shakeIntensity;
|
||||
camera.position.y += (Math.random() - 0.5) * shakeIntensity;
|
||||
|
||||
if (globalDistance.current <= 0.1) {
|
||||
window.location.href = '/';
|
||||
fearState.registerCaught();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const forwardVector = new THREE.Vector3();
|
||||
camera.getWorldDirection(forwardVector);
|
||||
const lookDirZ = forwardVector.z < 0 ? -1 : 1;
|
||||
|
||||
const calculatedZ = camera.position.z + (lookDirZ * globalDistance.current);
|
||||
|
||||
creature.position.set(0, 1.6, calculatedZ);
|
||||
creature.lookAt(camera.position.x, creature.position.y, camera.position.z);
|
||||
|
||||
if (!hasTriggered) return;
|
||||
|
||||
const proximity = 1 - Math.max(0, Math.min(1, globalDistance.current / 32));
|
||||
const jitterX = 1.0 + (Math.random() - 0.5) * 0.04 * proximity;
|
||||
let jitterY = 1.0 + (Math.random() - 0.5) * 0.06 * proximity;
|
||||
|
||||
glitchCooldown.current -= delta;
|
||||
if (glitchCooldown.current <= 0) {
|
||||
if (Math.random() < 0.25 + proximity * 0.35) {
|
||||
isGlitchSpiking.current = true;
|
||||
glitchCooldown.current = 0.03 + Math.random() * 0.08;
|
||||
} else {
|
||||
isGlitchSpiking.current = false;
|
||||
glitchCooldown.current = 0.08 + Math.random() * 0.4 * (1 - proximity * 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
if (isGlitchSpiking.current) {
|
||||
const spike = 0.15 + Math.random() * 0.35;
|
||||
jitterY += Math.random() > 0.5 ? spike : -spike * 0.6;
|
||||
}
|
||||
|
||||
creature.scale.set(jitterX, jitterY, 1.0);
|
||||
|
||||
flickerCooldown.current -= delta;
|
||||
if (flickerCooldown.current <= 0) {
|
||||
if (creature.visible && Math.random() < 0.12 + proximity * 0.08) {
|
||||
creature.visible = false;
|
||||
flickerCooldown.current = 0.02 + Math.random() * 0.05;
|
||||
} else {
|
||||
creature.visible = true;
|
||||
flickerCooldown.current = 0.05 + Math.random() * 0.3 * (1 - proximity * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
texture.offset.set(
|
||||
(Math.random() - 0.5) * 0.025 * proximity,
|
||||
(Math.random() - 0.5) * 0.025 * proximity
|
||||
);
|
||||
|
||||
if (proximity > 0.2) {
|
||||
creature.position.x += (Math.random() - 0.5) * 0.12 * proximity;
|
||||
creature.position.y += (Math.random() - 0.5) * 0.06 * proximity;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
visible={finaleTriggered}
|
||||
>
|
||||
<planeGeometry args={[3.0, 4.8]} />
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
transparent={true}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
emissive="#ffffff"
|
||||
emissiveMap={texture}
|
||||
emissiveIntensity={0.15}
|
||||
/>
|
||||
|
||||
{finaleTriggered && (
|
||||
<PositionalAudio
|
||||
url="fear/snd/riser.mp3"
|
||||
ref={audioRef}
|
||||
distance={25}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
/>
|
||||
)}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@font-face {
|
||||
font-family: 'VCR';
|
||||
src: url('/fear/fonts/vcr.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.finale-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0vh;
|
||||
|
||||
display: grid;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
/* filter: invert(100%); */
|
||||
backdrop-filter: brightness(100%);
|
||||
|
||||
grid-auto-rows: 5vh;
|
||||
/* grid-template-columns: 0; */
|
||||
grid-template-rows: repeat(auto-fit, max-content);
|
||||
|
||||
user-select: none;
|
||||
|
||||
will-change: filter;
|
||||
animation: invertFlicker 0.07s infinite alternate;
|
||||
|
||||
}
|
||||
|
||||
@keyframes invertFlicker {
|
||||
|
||||
0%,
|
||||
43%,
|
||||
45%,
|
||||
88%,
|
||||
92% {
|
||||
filter: invert(0%) contrast(100%) brightness(100%);
|
||||
backdrop-filter: brightness(100%) hue-rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
46%,
|
||||
89%,
|
||||
93%,
|
||||
100% {
|
||||
filter: invert(100%) contrast(300%) brightness(150%);
|
||||
backdrop-filter: brightness(30%) hue-rotate(180deg) saturate(500%);
|
||||
}
|
||||
}
|
||||
|
||||
.finale-text {
|
||||
font-family: 'VCR', sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.1em;
|
||||
|
||||
height: 0px;
|
||||
width: 100%;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: 8vh;
|
||||
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 900;
|
||||
background: repeating-linear-gradient(rgba(0, 0, 0, 0) 0px,
|
||||
rgba(0, 0, 0, 0) 2px,
|
||||
rgba(0, 0, 0, 0.3) 2px,
|
||||
rgba(0, 0, 0, 0.3) 4px);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { JSX, useEffect, useState } from "react"
|
||||
import { fearState } from "../state"
|
||||
|
||||
import './finale-text.css';
|
||||
|
||||
const BLOCKS = [
|
||||
"▀", "▂", "▃", "▄", "▅", "▆", "▇",
|
||||
"█", "▉", "▊", "▋", "▌", "▍", "▎", "▏",
|
||||
"▐", "░", "▒", "▓", "▔", "▕", "▖", "▗",
|
||||
"▘", "▙", "▚", "▛", "▜", "▝", "▞", "▟"
|
||||
];
|
||||
|
||||
export default function FinaleText() {
|
||||
const [wasCaught, setWasCaught] = useState(fearState.wasCaught);
|
||||
const [elements, setElements] = useState<JSX.Element[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = fearState.subscribe(() => {
|
||||
setWasCaught(fearState.wasCaught)
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!wasCaught)
|
||||
return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (Math.random() > 0.9) return;
|
||||
|
||||
const baseText = "bwaaaaaaaaa";
|
||||
const corrupted = baseText
|
||||
.split("")
|
||||
.map((char) => (Math.random() > 0.98 ? BLOCKS[Math.floor(Math.random() * BLOCKS.length)] : char))
|
||||
.join("");
|
||||
|
||||
setElements((prev) => [...prev.slice(-30),
|
||||
<span className="finale-text" key={crypto.randomUUID()}>
|
||||
{corrupted}
|
||||
</span>
|
||||
]);
|
||||
}, 10);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [wasCaught]);
|
||||
|
||||
if (!wasCaught) return null;
|
||||
|
||||
return (<>
|
||||
<div className="finale-container">
|
||||
{elements}
|
||||
</div>
|
||||
<div className="scanlines" />
|
||||
</>)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FEAR_SETTINGS, fearState } from "../state";
|
||||
import { useTexture, PositionalAudio } from "@react-three/drei";
|
||||
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { ShaderPatch } from "../shader-patch";
|
||||
|
||||
interface DoorProps {
|
||||
position: [number, number, number];
|
||||
rotation: [number, number, number];
|
||||
}
|
||||
function Door({ position, rotation }: DoorProps) {
|
||||
const [soundUrl, setSoundUrl] = useState<string | null>(null);
|
||||
const currentSound = useRef<string | null>(null);
|
||||
const steelTex = useTexture('fear/img/steel.png');
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (Math.random() < 0.02) {
|
||||
const chosenSound = Math.random() < 0.5 ? "fear/snd/knock1.mp3" : "fear/snd/knock2.mp3";
|
||||
|
||||
setSoundUrl(chosenSound);
|
||||
currentSound.current = chosenSound;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleAudioEnded = () => {
|
||||
setSoundUrl(null);
|
||||
currentSound.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation}>
|
||||
{/* frame */}
|
||||
<mesh position={[0, 2, -0.1]}>
|
||||
<boxGeometry args={[2.4, 4.0, 0.2, 4, 4, 1]} />
|
||||
<meshStandardMaterial map={steelTex} color="#8d8d8d" onBeforeCompile={ShaderPatch} />
|
||||
</mesh>
|
||||
|
||||
{/* panel */}
|
||||
<mesh position={[0, 1.95, -0.0]}>
|
||||
<boxGeometry args={[2.1, 3.8, 0.1, 4, 4, 1]} />
|
||||
<meshStandardMaterial map={steelTex} color="#4e4a4a" onBeforeCompile={ShaderPatch} />
|
||||
</mesh>
|
||||
|
||||
{/* handle */}
|
||||
<mesh position={[0.75, 1.8, .085]}>
|
||||
<boxGeometry args={[0.3, 0.08, 0.1]} />
|
||||
<meshStandardMaterial map={steelTex} color="#ffffff" onBeforeCompile={ShaderPatch} />
|
||||
</mesh>
|
||||
|
||||
{soundUrl && (
|
||||
<PositionalAudio
|
||||
url={soundUrl}
|
||||
distance={25}
|
||||
loop={false}
|
||||
autoplay={true}
|
||||
onEnded={handleAudioEnded}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hallway() {
|
||||
const [width, setWidth] = useState(fearState.currentWidth);
|
||||
const [floorTex, wallTex, rustWallTex, rustFloorTex] = useTexture([
|
||||
'fear/img/concrete-floor.png',
|
||||
'fear/img/concrete-wall.png',
|
||||
'fear/img/rust.png',
|
||||
'fear/img/rust.png'
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
[floorTex, wallTex, rustWallTex, rustFloorTex].forEach((tex) => {
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.minFilter = tex.magFilter = THREE.NearestFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
});
|
||||
}, [floorTex, wallTex, rustWallTex, rustFloorTex]);
|
||||
|
||||
|
||||
const segmentPool = [0, 1, 2, 3, 4];
|
||||
const segmentCount = segmentPool.length;
|
||||
|
||||
const lightRefs = useRef<(THREE.PointLight | null)[]>([]);
|
||||
const matRefs = useRef<(THREE.MeshStandardMaterial | null)[]>([]);
|
||||
|
||||
const lightState = useRef<'normal' | 'flickering' | 'dead'>('normal');
|
||||
const stateEndTime = useRef<number>(0);
|
||||
const nextEventTime = useRef<number>(5);
|
||||
|
||||
const segmentsRef = useRef<THREE.Group[]>([]);
|
||||
const wallMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||
const floorMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||
const pipeMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||
const bracketMaterialsRef = useRef<THREE.MeshStandardMaterial[]>([]);
|
||||
|
||||
wallMaterialsRef.current = [];
|
||||
floorMaterialsRef.current = [];
|
||||
pipeMaterialsRef.current = [];
|
||||
bracketMaterialsRef.current = [];
|
||||
|
||||
const [isRustActive, setIsRustActive] = useState(fearState.isRustActive);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = fearState.subscribe(() => {
|
||||
setWidth(fearState.currentWidth);
|
||||
setIsRustActive(fearState.isRustActive);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const time = state.clock.elapsedTime;
|
||||
|
||||
/*
|
||||
lights
|
||||
*/
|
||||
let intensity1 = 0.85 + Math.sin(time * 2) * 0.03;
|
||||
if (time > nextEventTime.current && lightState.current === 'normal') {
|
||||
lightState.current = 'flickering';
|
||||
stateEndTime.current = time + 1.5 + Math.random() * 2;
|
||||
}
|
||||
if (lightState.current === 'flickering') {
|
||||
if (time > stateEndTime.current) {
|
||||
if (Math.random() > 0.4) {
|
||||
lightState.current = 'dead';
|
||||
stateEndTime.current = time + 1.0 + Math.random() * 2.5;
|
||||
} else {
|
||||
lightState.current = 'normal';
|
||||
nextEventTime.current = time + 10 + Math.random() * 20;
|
||||
}
|
||||
} else {
|
||||
const baseWave = Math.sin(time * 45) * 0.4 + Math.sin(time * 90) * 0.3;
|
||||
intensity1 = 0.5 + baseWave;
|
||||
if (Math.sin(time * 150) + Math.cos(time * 220) > 1.2) intensity1 *= Math.random() > 0.5 ? 0.0 : 0.15;
|
||||
}
|
||||
}
|
||||
if (lightState.current === 'dead') {
|
||||
if (time > stateEndTime.current) {
|
||||
lightState.current = 'normal';
|
||||
nextEventTime.current = time + 12 + Math.random() * 15;
|
||||
} else {
|
||||
intensity1 = Math.random() > 0.98 ? 0.08 : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
objects
|
||||
*/
|
||||
const length = FEAR_SETTINGS.HALLWAY_LENGTH;
|
||||
const playerSegmentZ = Math.floor(state.camera.position.z / length);
|
||||
|
||||
const horizontalTexRepeat = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||
floorTex.repeat.set(horizontalTexRepeat, 10);
|
||||
wallTex.repeat.set(10, 1);
|
||||
rustWallTex.repeat.set(10, 1);
|
||||
rustFloorTex.repeat.set(horizontalTexRepeat, 10);
|
||||
|
||||
floorTex.needsUpdate = true;
|
||||
wallTex.needsUpdate = true;
|
||||
rustWallTex.needsUpdate = true;
|
||||
rustFloorTex.needsUpdate = true;
|
||||
|
||||
let closestPoolIndex = 0;
|
||||
let minDistance = Infinity;
|
||||
|
||||
segmentsRef.current.forEach((segGroup, poolIndex) => {
|
||||
if (!segGroup) return;
|
||||
|
||||
let segmentZIndex = poolIndex - Math.floor(segmentCount / 2) + playerSegmentZ;
|
||||
segGroup.position.z = segmentZIndex * length;
|
||||
|
||||
const distance = Math.abs(segGroup.position.z - state.camera.position.z);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestPoolIndex = poolIndex;
|
||||
}
|
||||
|
||||
const leftWallGroup = segGroup.getObjectByName("left-wall-group");
|
||||
if (leftWallGroup) leftWallGroup.position.x = -width / 2;
|
||||
|
||||
const rightWallGroup = segGroup.getObjectByName("right-wall-group");
|
||||
if (rightWallGroup) rightWallGroup.position.x = width / 2;
|
||||
|
||||
const floorMesh = segGroup.getObjectByName("floor-mesh");
|
||||
if (floorMesh) floorMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||
|
||||
const ceilingMesh = segGroup.getObjectByName("ceiling-mesh");
|
||||
if (ceilingMesh) ceilingMesh.scale.x = width / FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const pipe = segGroup.getObjectByName(`pipe-${i}`);
|
||||
if (pipe) pipe.position.x = -width / 2 + 0.4 + (i * 0.20);
|
||||
}
|
||||
const bracketGroup = segGroup.getObjectByName("brackets-group");
|
||||
if (bracketGroup) {
|
||||
bracketGroup.children.forEach(b => {
|
||||
b.position.x = -width / 2 + 0.6;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
dyn light
|
||||
*/
|
||||
segmentPool.forEach((poolIndex) => {
|
||||
const light = lightRefs.current[poolIndex];
|
||||
const mat = matRefs.current[poolIndex];
|
||||
|
||||
if (poolIndex === closestPoolIndex) {
|
||||
if (light) light.intensity = intensity1 * 1.2;
|
||||
if (mat) {
|
||||
mat.emissiveIntensity = intensity1 * 2.5;
|
||||
if (lightState.current !== 'normal') mat.emissive.setHSL(0.07, 0.4, Math.min(intensity1, 0.7));
|
||||
else mat.emissive.setHex(0xa8a1a1);
|
||||
}
|
||||
} else {
|
||||
if (light) light.intensity = 0.9;
|
||||
if (mat) {
|
||||
mat.emissiveIntensity = 0.8;
|
||||
mat.emissive.setHex(0xa8a1a1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
materials
|
||||
*/
|
||||
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 => {
|
||||
if (!mat) return;
|
||||
const targetTex = isRustActive ? targetRustTex : defaultTex;
|
||||
if (mat.map !== targetTex) {
|
||||
mat.map = targetTex;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
mat.color.set(isRustActive ? activeColor : defaultColor);
|
||||
mat.roughness = isRustActive ? activeRough : defaultRough;
|
||||
mat.metalness = isRustActive ? activeMetal : defaultMetal;
|
||||
});
|
||||
};
|
||||
|
||||
updateMaterials(wallMaterialsRef.current, wallTex, rustWallTex, "#c5c0be", "#ffffff", 0.95, 0.7, 0.05, 0.1);
|
||||
updateMaterials(floorMaterialsRef.current, floorTex, rustFloorTex, "#cabdb9", "#ffffff", 0.95, 0.8, 0.05, 0.2);
|
||||
|
||||
pipeMaterialsRef.current.forEach(mat => {
|
||||
if (!mat) return;
|
||||
mat.color.set(isRustActive ? "#3d1b0f" : "#a5aca8");
|
||||
mat.roughness = isRustActive ? 0.95 : 0.0;
|
||||
mat.metalness = isRustActive ? 0.05 : 0.4;
|
||||
});
|
||||
|
||||
bracketMaterialsRef.current.forEach(mat => {
|
||||
if (!mat) return;
|
||||
mat.color.set(isRustActive ? "#1b0b05" : "#a5aca8");
|
||||
mat.roughness = isRustActive ? 0.95 : 0.0;
|
||||
mat.metalness = isRustActive ? 0.05 : 0.4;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{segmentPool.map((poolIndex) => (
|
||||
<group
|
||||
key={poolIndex}
|
||||
ref={(el) => { if (el) segmentsRef.current[poolIndex] = el; }}
|
||||
position={[0, 0, 0]}
|
||||
>
|
||||
{/* lights */}
|
||||
<group position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.1, -FEAR_SETTINGS.HALLWAY_LENGTH / 4]}>
|
||||
<pointLight
|
||||
ref={(el) => { lightRefs.current[poolIndex] = el; }}
|
||||
intensity={0.9}
|
||||
distance={15}
|
||||
color="#a8a1a1"
|
||||
/>
|
||||
<mesh position={[0, 0.09, 0]}>
|
||||
<boxGeometry args={[0.3, 0.01, 0.3]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => { matRefs.current[poolIndex] = el; }}
|
||||
color="#111111"
|
||||
emissive="#a8a1a1"
|
||||
emissiveIntensity={0.8}
|
||||
roughness={0.9}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* floor */}
|
||||
<mesh
|
||||
name="floor-mesh"
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
position={[0, 0, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
||||
>
|
||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH, 4, 10]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => { if (el) floorMaterialsRef.current.push(el); }}
|
||||
map={floorTex}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* ceiling */}
|
||||
<mesh
|
||||
name="ceiling-mesh"
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
||||
>
|
||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_WIDTH, FEAR_SETTINGS.HALLWAY_LENGTH, 4, 10]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => { if (el) floorMaterialsRef.current.push(el); }}
|
||||
map={floorTex}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* left wall */}
|
||||
<group name="left-wall-group">
|
||||
<mesh rotation={[0, Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
|
||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT, 10, 4]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => { if (el) wallMaterialsRef.current.push(el); }}
|
||||
map={wallTex}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
{!isRustActive && (
|
||||
<>
|
||||
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.25]} rotation={[0, Math.PI / 2, 0]} />
|
||||
<Door position={[0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.85]} rotation={[0, Math.PI / 2, 0]} />
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
|
||||
{/* right wall */}
|
||||
<group name="right-wall-group">
|
||||
<mesh rotation={[0, -Math.PI / 2, 0]} position={[0, FEAR_SETTINGS.HALLWAY_HEIGHT / 2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}>
|
||||
<planeGeometry args={[FEAR_SETTINGS.HALLWAY_LENGTH, FEAR_SETTINGS.HALLWAY_HEIGHT, 10, 4]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => { if (el) wallMaterialsRef.current.push(el); }}
|
||||
map={wallTex}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
{!isRustActive && (
|
||||
<Door position={[-0.05, 0, -FEAR_SETTINGS.HALLWAY_LENGTH * 0.65]} rotation={[0, -Math.PI / 2, 0]} />
|
||||
)}
|
||||
</group>
|
||||
|
||||
{/* pipes */}
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<mesh
|
||||
key={idx}
|
||||
name={`pipe-${idx}`}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.4 + (idx * 0.20), FEAR_SETTINGS.HALLWAY_HEIGHT - 0.2, -FEAR_SETTINGS.HALLWAY_LENGTH / 2]}
|
||||
>
|
||||
<cylinderGeometry args={[0.06, 0.06, FEAR_SETTINGS.HALLWAY_LENGTH, 4]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => el && pipeMaterialsRef.current.push(el)}
|
||||
color="#a5aca8"
|
||||
roughness={0.0}
|
||||
metalness={0.4}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* brackets */}
|
||||
<group name="brackets-group">
|
||||
{Array.from({ length: 5 }).map((_, idx) => {
|
||||
const zOffset = -(idx * 8 + 4);
|
||||
return (
|
||||
<mesh
|
||||
key={`bracket-${idx}`}
|
||||
position={[-FEAR_SETTINGS.HALLWAY_WIDTH / 2 + 0.6, FEAR_SETTINGS.HALLWAY_HEIGHT - 0.15, zOffset]}
|
||||
>
|
||||
<boxGeometry args={[0.7, 0.3, 0.15]} />
|
||||
<meshStandardMaterial
|
||||
ref={(el) => el && bracketMaterialsRef.current.push(el)}
|
||||
color="#a5aca8"
|
||||
roughness={0.0}
|
||||
metalness={0.4}
|
||||
onBeforeCompile={ShaderPatch}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FEAR_SETTINGS, fearState } from "../state";
|
||||
import { PointerLockControls } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
const forward = new THREE.Vector3();
|
||||
const side = 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() {
|
||||
const keys = useRef({ Forward: false, Backward: false, Left: false, Right: false });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = true;
|
||||
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = true;
|
||||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = true;
|
||||
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = true;
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === 'KeyW' || e.code === 'ArrowUp') keys.current.Forward = false;
|
||||
if (e.code === 'KeyS' || e.code === 'ArrowDown') keys.current.Backward = false;
|
||||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keys.current.Left = false;
|
||||
if (e.code === 'KeyD' || e.code === 'ArrowRight') keys.current.Right = false;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return keys.current;
|
||||
}
|
||||
|
||||
export default function Player() {
|
||||
const { camera } = useThree();
|
||||
const controls = usePlayerControls();
|
||||
|
||||
const flashlightRef = useRef<THREE.SpotLight>(null);
|
||||
const movementCounter = useRef<number>(0);
|
||||
const bobIntensity = useRef<number>(0);
|
||||
|
||||
const confirmedSegment = useRef<number>(0);
|
||||
const hasTriggeredThisSegment = useRef<boolean>(false);
|
||||
|
||||
const footstepAudio = useRef<HTMLAudioElement[]>([]);
|
||||
const hasStepped = useRef<boolean>(false);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
playerRoot.set(camera.position.x, FEAR_SETTINGS.PLAYER_HEIGHT, camera.position.z);
|
||||
footstepAudio.current = Array.from({ length: 6 }, (_, i) => {
|
||||
const audio = new Audio(`fear/snd/footstep${i + 1}.mp3`);
|
||||
audio.volume = 0.4;
|
||||
return audio;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const playRandomFootstep = () => {
|
||||
if (footstepAudio.current.length === 0) return;
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * footstepAudio.current.length);
|
||||
const audio = footstepAudio.current[randomIndex];
|
||||
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch((err) => {
|
||||
console.warn("Footstep playback blocked by browser autocomplete/interaction rules.", err);
|
||||
});
|
||||
};
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const dt = Math.min(delta, 0.1);
|
||||
|
||||
camera.getWorldDirection(forward);
|
||||
forward.y = 0;
|
||||
forward.normalize();
|
||||
side.crossVectors(forward, THREE.Object3D.DEFAULT_UP).normalize();
|
||||
|
||||
const moveForward = Number(controls.Forward) - Number(controls.Backward);
|
||||
const moveSide = Number(controls.Right) - Number(controls.Left);
|
||||
|
||||
targetVelocity.set(0, 0, 0);
|
||||
if (moveForward !== 0) targetVelocity.addScaledVector(forward, moveForward);
|
||||
if (moveSide !== 0) targetVelocity.addScaledVector(side, moveSide);
|
||||
|
||||
if (targetVelocity.lengthSq() > 0)
|
||||
targetVelocity.normalize().multiplyScalar(FEAR_SETTINGS.PLAYER_SPEED);
|
||||
|
||||
currentVelocity.lerp(targetVelocity, 10 * dt);
|
||||
|
||||
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;
|
||||
|
||||
bobIntensity.current = THREE.MathUtils.lerp(bobIntensity.current, isMoving ? 1 : 0, 8 * dt);
|
||||
|
||||
if (isMoving)
|
||||
movementCounter.current += dt * 12;
|
||||
|
||||
const sinWave = Math.sin(movementCounter.current);
|
||||
const moveBobY = sinWave * 0.06 * bobIntensity.current;
|
||||
const moveBobX = Math.cos(movementCounter.current / 2) * 0.04 * bobIntensity.current;
|
||||
|
||||
if (isMoving && sinWave < -0.9) {
|
||||
if (!hasStepped.current) {
|
||||
playRandomFootstep();
|
||||
hasStepped.current = true;
|
||||
}
|
||||
} else if (sinWave > 0) {
|
||||
hasStepped.current = false;
|
||||
}
|
||||
|
||||
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) {
|
||||
flashlightRef.current.position.lerp(camera.position, 7 * dt);
|
||||
camera.getWorldDirection(viewDirection);
|
||||
|
||||
targetDest
|
||||
.copy(camera.position)
|
||||
.addScaledVector(viewDirection, 10);
|
||||
|
||||
flashlightRef.current.target.position.lerp(targetDest, 12 * dt);
|
||||
flashlightRef.current.target.updateMatrixWorld();
|
||||
|
||||
flashlightRef.current.intensity =
|
||||
FEAR_SETTINGS.FLASHLIGHT_INTENSITY_BASE +
|
||||
Math.sin(state.clock.elapsedTime * 30) * 0.15 * Math.cos(state.clock.elapsedTime * 3);
|
||||
}
|
||||
|
||||
const length = FEAR_SETTINGS.HALLWAY_LENGTH;
|
||||
const absoluteZ = -playerRoot.z;
|
||||
const rawSegmentIndex = Math.floor(absoluteZ / length);
|
||||
const progressZ = ((absoluteZ % length) + length) % length / length;
|
||||
|
||||
if (rawSegmentIndex > confirmedSegment.current && progressZ > 0.25) {
|
||||
if (!hasTriggeredThisSegment.current) {
|
||||
fearState.registerLoop('forward');
|
||||
hasTriggeredThisSegment.current = true;
|
||||
}
|
||||
confirmedSegment.current = rawSegmentIndex;
|
||||
}
|
||||
else if (rawSegmentIndex < confirmedSegment.current && progressZ < 0.75) {
|
||||
if (!hasTriggeredThisSegment.current) {
|
||||
fearState.registerLoop('backward');
|
||||
hasTriggeredThisSegment.current = true;
|
||||
}
|
||||
confirmedSegment.current = rawSegmentIndex;
|
||||
}
|
||||
|
||||
if (rawSegmentIndex === confirmedSegment.current && progressZ > 0.35 && progressZ < 0.65) {
|
||||
hasTriggeredThisSegment.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PointerLockControls />
|
||||
<spotLight
|
||||
ref={flashlightRef}
|
||||
distance={25}
|
||||
angle={0.35}
|
||||
penumbra={0.8}
|
||||
intensity={0}
|
||||
color="#fffaed"
|
||||
decay={2}
|
||||
castShadow
|
||||
shadow-bias={-0.001}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export function ShaderPatch(shader: { vertexShader: string, fragmentShader: string, uniforms: Object }) {
|
||||
shader.vertexShader = `
|
||||
varying float vDepth;
|
||||
#ifdef USE_MAP
|
||||
varying vec2 vAffineUv;
|
||||
#endif
|
||||
${shader.vertexShader}
|
||||
`;
|
||||
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
`#include <project_vertex>`,
|
||||
`
|
||||
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
|
||||
float precisionModifier = 200.0;
|
||||
gl_Position.xy /= gl_Position.w;
|
||||
gl_Position.xy = floor(gl_Position.xy * precisionModifier) / precisionModifier;
|
||||
gl_Position.xy *= gl_Position.w;
|
||||
|
||||
vDepth = gl_Position.w;
|
||||
|
||||
#ifdef USE_MAP
|
||||
vAffineUv = vMapUv * gl_Position.w;
|
||||
#endif
|
||||
`
|
||||
);
|
||||
|
||||
shader.fragmentShader = `
|
||||
varying float vDepth;
|
||||
#ifdef USE_MAP
|
||||
varying vec2 vAffineUv;
|
||||
#endif
|
||||
${shader.fragmentShader}
|
||||
`;
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
`#include <map_fragment>`,
|
||||
`
|
||||
#ifdef USE_MAP
|
||||
vec2 flatAffineUV = vAffineUv / max(vDepth, 0.001);
|
||||
|
||||
vec2 warpDiff = flatAffineUV - vMapUv;
|
||||
float warpDist = length(warpDiff);
|
||||
|
||||
float maxDistortion = 0.25;
|
||||
|
||||
float falloff = maxDistortion / (maxDistortion + warpDist);
|
||||
|
||||
vec2 finalUV = vMapUv + (warpDiff * falloff);
|
||||
|
||||
vec4 texelColor = texture2D( map, finalUV );
|
||||
diffuseColor *= texelColor;
|
||||
#endif
|
||||
`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export const FEAR_SETTINGS = {
|
||||
HALLWAY_LENGTH: 40,
|
||||
HALLWAY_WIDTH: 6,
|
||||
HALLWAY_HEIGHT: 5,
|
||||
PLAYER_HEIGHT: 3,
|
||||
PLAYER_SPEED: 4,
|
||||
FLASHLIGHT_INTENSITY_BASE: 8,
|
||||
WALL_BUFFER: 0.6,
|
||||
CREATURE_SPEED: 8,
|
||||
|
||||
EVENT_NARROW_LOOP_COUNT: 2,
|
||||
EVENT_RUST_LOOP_COUNT: 4,
|
||||
EVENT_FINALE_LOOP_COUNT: 5,
|
||||
|
||||
EVENT_FINALE_DURATION: 1,
|
||||
|
||||
TEST_MODE: false
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export const fearState = {
|
||||
loopCount: 0,
|
||||
currentWidth: FEAR_SETTINGS.HALLWAY_WIDTH,
|
||||
isRustActive: false,
|
||||
finaleTriggered: false,
|
||||
wasCaught: false,
|
||||
finaleProgression: 0,
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
},
|
||||
|
||||
emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
|
||||
update(delta: number) {
|
||||
const targetWidth = this.loopCount >= FEAR_SETTINGS.EVENT_NARROW_LOOP_COUNT ? 2.5 : FEAR_SETTINGS.HALLWAY_WIDTH;
|
||||
const newWidth = THREE.MathUtils.lerp(this.currentWidth, targetWidth, 2 * delta);
|
||||
|
||||
if (Math.abs(this.currentWidth - newWidth) > 0.001) {
|
||||
this.currentWidth = newWidth;
|
||||
}
|
||||
|
||||
if (this.wasCaught) {
|
||||
if (this.finaleProgression < FEAR_SETTINGS.EVENT_FINALE_DURATION) {
|
||||
|
||||
this.finaleProgression = Math.min(this.finaleProgression + delta, FEAR_SETTINGS.EVENT_FINALE_DURATION);
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
this.emit();
|
||||
},
|
||||
|
||||
registerLoop(direction: 'forward' | 'backward') {
|
||||
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();
|
||||
},
|
||||
|
||||
registerCaught() {
|
||||
this.wasCaught = true;
|
||||
this.emit();
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -5,7 +5,7 @@ export const metadata: Metadata = {
|
||||
title: '⛧',
|
||||
// description: '',
|
||||
openGraph: {
|
||||
title: '⛧',
|
||||
// title: '⛧',
|
||||
// description: '',
|
||||
images: [
|
||||
{
|
||||
|
||||
+35
-24
@@ -3,12 +3,14 @@
|
||||
import './page.css';
|
||||
|
||||
import { Environment, OrbitControls, useProgress } from "@react-three/drei";
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Bloom, BrightnessContrast, DepthOfField, EffectComposer, HueSaturation, Noise, SMAA, SSAO, Vignette } from '@react-three/postprocessing';
|
||||
import { Canvas, useLoader } from '@react-three/fiber';
|
||||
import { Bloom, BrightnessContrast, DepthOfField, EffectComposer, HueSaturation, LUT, Noise, SMAA, SSAO, Vignette } from '@react-three/postprocessing';
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { folder, useControls, Leva } from 'leva';
|
||||
import SealCube from './scene-components/sealcube';
|
||||
import Terrain from './scene-components/terrain';
|
||||
import { LUTCubeLoader } from 'three/examples/jsm/Addons.js';
|
||||
import { AmbientSound } from './scene-components/ambient-sound';
|
||||
|
||||
function Loader() {
|
||||
const { progress, active } = useProgress();
|
||||
@@ -52,24 +54,24 @@ function Scene() {
|
||||
grassLODExponent
|
||||
} = useControls('Environment', {
|
||||
Terrain: folder({
|
||||
terrainDryColor: '#20270a',
|
||||
terrainLushColor: '#0f240f',
|
||||
terrainDryColor: '#232a0c',
|
||||
terrainLushColor: '#142a14',
|
||||
chunks: { value: 16, min: 4, max: 24, step: 2 },
|
||||
chunkSize: { value: 10.0, min: 5.0, max: 40.0, step: 1.0 },
|
||||
resolution: { value: 8.0, min: 4.0, max: 30.0, step: 1.0 },
|
||||
hillScale: { value: 0.1, min: 0.01, max: 0.5, step: 0.01 },
|
||||
hillHeight: { value: 6.0, min: 0.0, max: 20.0, step: 0.5 },
|
||||
hillScale: { value: 0.15, min: 0.01, max: 0.5, step: 0.01 },
|
||||
hillHeight: { value: 4.0, min: 0.0, max: 20.0, step: 0.5 },
|
||||
detailScale: { value: 1.0, min: 0.1, max: 5.0, step: 0.1 },
|
||||
detailHeight: { value: 0.2, min: 0.0, max: 2.0, step: 0.05 },
|
||||
detailHeight: { value: 0.3, min: 0.0, max: 2.0, step: 0.05 },
|
||||
}),
|
||||
Grass: folder({
|
||||
grassDryColor: '#495a17',
|
||||
grassLushColor: '#255825',
|
||||
grassCount: { value: 8000, min: 1000, max: 30000, step: 500 },
|
||||
grassSize: { value: 0.65, min: 0.1, max: 2.0, step: 0.05 },
|
||||
grassSize: { value: 0.85, min: 0.1, max: 2.0, step: 0.05 },
|
||||
grassLOD: { value: 60, min: 10, max: 200, step: 5 },
|
||||
grassBlades: { value: 3, min: 1, max: 4, step: 1 },
|
||||
grassSegments: { value: 3, min: 1, max: 5, step: 1 },
|
||||
grassBlades: { value: 3, min: 1, max: 5, step: 1 },
|
||||
grassSegments: { value: 4, min: 1, max: 5, step: 1 },
|
||||
grassLODStart: { value: 0.15, min: 0.0, max: 0.9, step: 0.05 },
|
||||
grassLODExponent: { value: 1.8, min: 0.5, max: 3.0, step: 0.1 },
|
||||
})
|
||||
@@ -78,11 +80,17 @@ function Scene() {
|
||||
return (<>
|
||||
<Environment
|
||||
files={'niko/hdr/sky.hdr'}
|
||||
environmentIntensity={1}
|
||||
environmentIntensity={0.85}
|
||||
background
|
||||
/>
|
||||
|
||||
<fogExp2 attach='fog' args={[0x9a9a9a, 0.01]} />
|
||||
<fogExp2 attach='fog' args={[0xa3a5ba, 0.0125]} />
|
||||
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight
|
||||
position={[15, 25, 15]}
|
||||
intensity={1}
|
||||
/>
|
||||
|
||||
<Terrain
|
||||
chunks={chunks}
|
||||
@@ -110,19 +118,25 @@ function Scene() {
|
||||
</>)
|
||||
}
|
||||
|
||||
function LutEffect() {
|
||||
const lutTexture = useLoader(LUTCubeLoader, 'niko/lut/Landscape6.cube');
|
||||
return <LUT lut={lutTexture.texture3D} />;
|
||||
}
|
||||
|
||||
function PostProcessing() {
|
||||
return (<EffectComposer>
|
||||
<DepthOfField target={[0, 3, 0]} focalLength={10} bokehScale={5} />
|
||||
<Vignette />
|
||||
<Noise opacity={0.05} />
|
||||
<Bloom
|
||||
intensity={2}
|
||||
luminanceThreshold={0.5}
|
||||
luminanceSmoothing={0.1}
|
||||
intensity={0.8}
|
||||
luminanceThreshold={0.4}
|
||||
luminanceSmoothing={0.5}
|
||||
/>
|
||||
<SMAA />
|
||||
<HueSaturation saturation={0.1} />
|
||||
<HueSaturation saturation={0.3} />
|
||||
<BrightnessContrast brightness={0.05} contrast={-0.1} />
|
||||
<LutEffect />
|
||||
</EffectComposer>)
|
||||
}
|
||||
|
||||
@@ -137,24 +151,21 @@ export default function Seal() {
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{ position: [0, 5, 15], fov: 50, far: 100 }}
|
||||
gl={{ antialias: true }}
|
||||
gl={{ antialias: false, powerPreference: "high-performance" }}
|
||||
className='canvas'
|
||||
>
|
||||
<AmbientSound url="niko/snd/wind.mp3" volume={0.4} />
|
||||
<AmbientSound url="niko/snd/birds.mp3" volume={0.1} />
|
||||
|
||||
<Scene />
|
||||
<PostProcessing />
|
||||
|
||||
<Environment
|
||||
files={'niko/hdr/sky.hdr'}
|
||||
environmentIntensity={1}
|
||||
background
|
||||
/>
|
||||
|
||||
<OrbitControls
|
||||
target={[0, 3, 0]}
|
||||
enablePan={false}
|
||||
makeDefault
|
||||
minDistance={2}
|
||||
maxDistance={10}
|
||||
maxDistance={6}
|
||||
/>
|
||||
</Canvas>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface AmbientSoundProps {
|
||||
url: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export function AmbientSound({ url, volume = 0.5 }: AmbientSoundProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio(url)
|
||||
audio.loop = true
|
||||
audio.volume = volume
|
||||
audioRef.current = audio
|
||||
|
||||
const startAudio = () => {
|
||||
audio.play().catch((err) => {
|
||||
console.warn('Autoplay blocked. Waiting for user interaction.', err)
|
||||
})
|
||||
}
|
||||
|
||||
startAudio()
|
||||
|
||||
window.addEventListener('click', startAudio, { once: true })
|
||||
window.addEventListener('keydown', startAudio, { once: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', startAudio)
|
||||
window.removeEventListener('keydown', startAudio)
|
||||
audio.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [url])
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume
|
||||
}
|
||||
}, [volume])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -7,27 +7,27 @@ import grassVert from './shaders/grass.vert';
|
||||
import grassFrag from './shaders/grass.frag';
|
||||
|
||||
interface GrassProps {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
count: number;
|
||||
grassSize: number;
|
||||
scale: number;
|
||||
hillScale: number;
|
||||
hillHeight: number;
|
||||
detailScale: number;
|
||||
detailHeight: number;
|
||||
noise2D: (x: number, y: number) => number;
|
||||
grassLOD: number;
|
||||
dryColor: string;
|
||||
lushColor: string;
|
||||
grassBlades?: number;
|
||||
grassSegments?: number;
|
||||
grassLODStart?: number;
|
||||
grassLODExponent?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
count: number;
|
||||
grassSize: number;
|
||||
scale: number;
|
||||
hillScale: number;
|
||||
hillHeight: number;
|
||||
detailScale: number;
|
||||
detailHeight: number;
|
||||
noise2D: (x: number, y: number) => number;
|
||||
grassLOD: number;
|
||||
dryColor: string;
|
||||
lushColor: string;
|
||||
grassBlades?: number;
|
||||
grassSegments?: number;
|
||||
grassLODStart?: number;
|
||||
grassLODExponent?: number;
|
||||
}
|
||||
|
||||
export default function({
|
||||
export default function Grass({
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
@@ -173,24 +173,27 @@ export default function({
|
||||
dummy.rotation.x = (Math.random() - 0.5) * 0.2;
|
||||
dummy.rotation.z = (Math.random() - 0.5) * 0.2;
|
||||
|
||||
const baseScale = grassSize + Math.random() * grassSize * 0.5;
|
||||
const heightMult = 0.5 + Math.random() * 1.0;
|
||||
dummy.scale.set(baseScale, baseScale * heightMult, baseScale);
|
||||
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
|
||||
const t = (noiseVal + 1) / 2;
|
||||
const randomInternal = (Math.random() - 0.5) * 0.2;
|
||||
const finalT = Math.max(0, Math.min(1, t + randomInternal));
|
||||
color.lerpColors(dryColorObj, lushColorObj, finalT);
|
||||
meshRef.current.setColorAt(instanceIndex, color);
|
||||
|
||||
const heightNoise = noise2D(globalX * 0.08, globalZ * 0.08);
|
||||
const macroHeight = (heightNoise + 1.0) * 0.5; // 0..1
|
||||
const microNoise = noise2D(globalX * 0.3, globalZ * 0.3);
|
||||
const microHeight = (microNoise + 1.0) * 0.25; // 0..0.5
|
||||
const perBladeRandom = Math.random() * 0.4;
|
||||
|
||||
const grassWidth = grassSize * (0.7 + Math.random() * 0.5);
|
||||
const grassHeight = grassSize * (0.4 + macroHeight * 0.8 + microHeight + perBladeRandom);
|
||||
|
||||
dummy.scale.set(grassWidth, grassHeight, grassWidth);
|
||||
|
||||
dummy.updateMatrix();
|
||||
meshRef.current.setMatrixAt(instanceIndex, dummy.matrix);
|
||||
|
||||
const noiseVal = noise2D(globalX * 0.02, globalZ * 0.02);
|
||||
|
||||
const t = (noiseVal + 1) / 2;
|
||||
|
||||
const randomInternal = (Math.random() - 0.5) * 0.2;
|
||||
const finalT = Math.max(0, Math.min(1, t + randomInternal));
|
||||
|
||||
color.lerpColors(dryColorObj, lushColorObj, finalT);
|
||||
|
||||
meshRef.current.setColorAt(instanceIndex, color);
|
||||
|
||||
instanceIndex++;
|
||||
}
|
||||
meshRef.current.count = instanceIndex;
|
||||
@@ -222,62 +225,61 @@ export default function({
|
||||
shader.uniforms.uTime = { value: 0 };
|
||||
|
||||
shader.vertexShader = `
|
||||
uniform float uTime;
|
||||
varying vec2 vGrassUv;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
float frequency = 1.0;
|
||||
|
||||
for(int i = 0; i < 4; i++) {
|
||||
value += amplitude * noise(p * frequency);
|
||||
frequency *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
${shader.vertexShader}
|
||||
`;
|
||||
uniform float uTime;
|
||||
varying vec2 vGrassUv;
|
||||
varying vec3 vWorldPos;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
float frequency = 1.0;
|
||||
for(int i = 0; i < 4; i++) {
|
||||
value += amplitude * noise(p * frequency);
|
||||
frequency *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
${shader.vertexShader}
|
||||
`;
|
||||
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
'#include <begin_vertex>',
|
||||
`
|
||||
#include <begin_vertex>
|
||||
vGrassUv = uv;
|
||||
${grassVert}
|
||||
`
|
||||
#include <begin_vertex>
|
||||
${grassVert}
|
||||
`
|
||||
);
|
||||
|
||||
shader.fragmentShader = `
|
||||
uniform float uTime;
|
||||
varying vec2 vGrassUv;
|
||||
${shader.fragmentShader}
|
||||
`;
|
||||
uniform float uTime;
|
||||
varying vec2 vGrassUv;
|
||||
varying vec3 vWorldPos;
|
||||
${shader.fragmentShader}
|
||||
`;
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
'#include <color_fragment>',
|
||||
`
|
||||
#include <color_fragment>
|
||||
${grassFrag}
|
||||
`
|
||||
#include <color_fragment>
|
||||
${grassFrag}
|
||||
`
|
||||
);
|
||||
|
||||
if (materialRef.current) {
|
||||
|
||||
@@ -12,13 +12,19 @@ const SealCube = forwardRef<Mesh>((props, ref) => {
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.x += delta * 0.5;
|
||||
meshRef.current.rotation.y += delta * 0.5;
|
||||
meshRef.current.position.y = 3 + Math.sin(state.clock.getElapsedTime() * 1) * 0.15;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} position={[0, 3, 0]} castShadow receiveShadow>
|
||||
<boxGeometry args={[0.85, 0.85, 0.85]} />
|
||||
<meshBasicMaterial map={texture} depthWrite={true} />
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
roughness={0.4}
|
||||
metalness={0.1}
|
||||
envMapIntensity={1.2}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
float ao = smoothstep(0.0, 0.7, vGrassUv.y);
|
||||
ao = mix(0.05, 1.0, ao);
|
||||
ao = mix(0.05, 1.0, pow(ao, 1.6));
|
||||
|
||||
vec3 rootColor = diffuseColor.rgb * 0.4;
|
||||
vec3 tipColor = diffuseColor.rgb * 1.5;
|
||||
vec3 rootColor = diffuseColor.rgb * 0.15;
|
||||
vec3 midColor = diffuseColor.rgb;
|
||||
vec3 tipColor = diffuseColor.rgb * 1.3 + vec3(0.06, 0.08, 0.0);
|
||||
|
||||
vec3 grassColor = mix(rootColor, tipColor, vGrassUv.y);
|
||||
grassColor *= ao;
|
||||
float heightParam = vGrassUv.y;
|
||||
vec3 grassColor;
|
||||
if (heightParam < 0.4) {
|
||||
float t = smoothstep(0.0, 0.4, heightParam);
|
||||
grassColor = mix(rootColor, midColor, t);
|
||||
} else {
|
||||
float t = smoothstep(0.4, 1.0, heightParam);
|
||||
grassColor = mix(midColor, tipColor, t);
|
||||
}
|
||||
|
||||
float translucency = pow(vGrassUv.y, 2.0) * 0.5;
|
||||
grassColor += diffuseColor.rgb * translucency;
|
||||
vec3 viewDir = normalize(cameraPosition - vWorldPos);
|
||||
vec3 lightDir = normalize(vec3(15.0, 25.0, 15.0));
|
||||
|
||||
diffuseColor.rgb = grassColor;
|
||||
float VdotL = max(0.0, dot(viewDir, -lightDir));
|
||||
float sss = pow(VdotL, 3.0) * smoothstep(0.2, 0.9, vGrassUv.y);
|
||||
|
||||
vec3 sssColor = diffuseColor.rgb * vec3(0.6, 1.0, 0.15) * 1.8;
|
||||
grassColor += sssColor * sss * 2.0;
|
||||
|
||||
float NdotV = 1.0 - max(0.0, dot(normalize(vNormal), viewDir));
|
||||
float rim = pow(NdotV, 3.0) * smoothstep(0.3, 1.0, vGrassUv.y) * 0.15;
|
||||
grassColor += vec3(0.3, 0.5, 0.1) * rim;
|
||||
|
||||
diffuseColor.rgb = grassColor * ao;
|
||||
@@ -1,3 +1,5 @@
|
||||
vGrassUv = uv;
|
||||
|
||||
vec4 worldPos = modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
float gx = worldPos.x;
|
||||
float gz = worldPos.z;
|
||||
@@ -19,19 +21,32 @@ float spring = sin(uTime * 2.0 + phase) * 0.06 + sin(uTime * 4.5 + phase * 1.5)
|
||||
float angleNoise = fbm(windSamplePos * 2.0 + uTime * 0.1) - 0.5;
|
||||
vec2 windDir = normalize(mainWindDir + vec2(-mainWindDir.y, mainWindDir.x) * angleNoise * 0.4);
|
||||
|
||||
float taperFactor = pow(uv.y, 4.0);
|
||||
float taper = 1.0 - taperFactor * 0.6;
|
||||
// taper (fade)
|
||||
float taperFactor = uv.y * uv.y * uv.y;
|
||||
float taper = 1.0 - taperFactor * 0.85;
|
||||
transformed.x *= taper;
|
||||
transformed.z *= taper;
|
||||
|
||||
// curve
|
||||
float curveVal = fbm(vec2(gx, gz) * 0.5);
|
||||
float curveStrength = 2.0 + curveVal * 2.0;
|
||||
float curveStrength = 1.5 + curveVal * 2.5;
|
||||
float curveAmount = uv.y * uv.y * curveStrength;
|
||||
vec2 curveDir = normalize(vec2(curveVal, fbm(vec2(gz, gx))) - 0.5);
|
||||
transformed.x += curveAmount * curveDir.x * 0.5;
|
||||
transformed.z += curveAmount * curveDir.y * 0.5;
|
||||
transformed.x += curveAmount * curveDir.x * 0.4;
|
||||
transformed.z += curveAmount * curveDir.y * 0.4;
|
||||
|
||||
// sway
|
||||
float swayAmount = (totalWind + spring) * uv.y * uv.y;
|
||||
transformed.x += swayAmount * windDir.x;
|
||||
transformed.z += swayAmount * windDir.y;
|
||||
transformed.y -= abs(swayAmount) * 0.2;
|
||||
transformed.y -= abs(swayAmount) * 0.2;
|
||||
|
||||
// normal comp
|
||||
vec2 totalBend = curveDir * curveAmount * 0.4 + windDir * swayAmount;
|
||||
float bendMag = length(totalBend);
|
||||
vec3 bentNormal = normalize(vec3(-totalBend.x * 0.5, 1.0, -totalBend.y * 0.5));
|
||||
|
||||
// normal mix
|
||||
objectNormal = normalize(mix(vec3(0.0, 1.0, 0.0), bentNormal, uv.y));
|
||||
|
||||
vWorldPos = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz;
|
||||
@@ -214,7 +214,7 @@ interface TerrainProps {
|
||||
grassLODStart?: number;
|
||||
grassLODExponent?: number;
|
||||
}
|
||||
export default function({
|
||||
export default function Terrain({
|
||||
chunks = 5,
|
||||
chunkSize = 10,
|
||||
resolution = 8,
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ function Content() {
|
||||
<>
|
||||
<div className="main-frame">
|
||||
<a href="/niko" className="decorative-sparkle" title="✧" style={{ left: '10px' }}>✧</a>
|
||||
<a href="/img/boom.gif" className="decorative-sparkle" title="✧" style={{ right: '10px' }}>✧</a>
|
||||
<a href="/fear" className="decorative-sparkle" title="✧" style={{ right: '10px' }}>✧</a>
|
||||
|
||||
<header>
|
||||
<h1>neru</h1>
|
||||
|
||||
Reference in New Issue
Block a user