ЛЕС — 3D Хоррор
by ElectricCoder50424 lines16.6 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ЛЕС — 3D Хоррор</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:monospace;color:#ccc;}
canvas{display:block;position:fixed;inset:0;z-index:1;}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:5;
background:radial-gradient(ellipse at center,transparent 35%,rgba(0,0,0,.9) 100%);}
#info{position:fixed;top:12px;left:12px;font-size:14px;z-index:10;
text-shadow:0 0 6px #000;pointer-events:none;line-height:1.5;}
#notes{position:fixed;top:12px;right:12px;font-size:16px;z-index:10;
text-shadow:0 0 6px #000;pointer-events:none;text-align:right;}
#hint{position:fixed;bottom:60px;left:50%;transform:translateX(-50%);font-size:18px;
background:rgba(0,0,0,.7);padding:10px 20px;border-radius:8px;display:none;z-index:10;
pointer-events:none;border:1px solid #554;}
#note{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);max-width:560px;
width:90%;background:#1a1408;border:1px solid #6b5a2a;padding:30px;font-size:17px;
line-height:1.6;color:#e8d9a0;display:none;z-index:20;box-shadow:0 0 60px #000;}
#flash{position:fixed;inset:0;background:#8b0000;opacity:0;pointer-events:none;
z-index:25;transition:opacity .3s;}
#start{position:fixed;inset:0;background:#000;display:flex;flex-direction:column;
align-items:center;justify-content:center;z-index:30;text-align:center;padding:20px;}
#start h1{font-size:clamp(32px,8vw,60px);color:#8b0000;letter-spacing:8px;
margin-bottom:16px;text-shadow:0 0 40px #8b0000;}
#start p{max-width:520px;line-height:1.7;color:#888;font-size:15px;}
.btn{margin-top:28px;padding:16px 48px;background:#1a1a1a;border:1px solid #666;color:#ddd;
font-size:18px;cursor:pointer;letter-spacing:3px;font-family:monospace;
transition:all .2s;}
.btn:hover{background:#8b0000;border-color:#8b0000;color:#fff;letter-spacing:5px;}
#end{position:fixed;inset:0;background:rgba(0,0,0,.95);display:none;flex-direction:column;
align-items:center;justify-content:center;z-index:30;text-align:center;padding:20px;}
#end h1{font-size:clamp(30px,7vw,52px);letter-spacing:6px;margin-bottom:16px;}
#end p{color:#999;line-height:1.7;max-width:520px;font-size:15px;}
#loading{position:fixed;inset:0;background:#000;z-index:40;display:none;
align-items:center;justify-content:center;color:#8b0000;font-size:22px;letter-spacing:4px;}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="vignette"></div>
<div id="info"></div>
<div id="notes"></div>
<div id="hint">Нажми <b>E</b> чтобы прочитать</div>
<div id="note"></div>
<div id="flash"></div>
<div id="loading">ЗАГРУЗКА...</div>
<div id="start">
<h1>ЛЕС</h1>
<p>Ты очнулся ночью в лесу. Не помнишь, как здесь оказался.<br><br>
Где-то в темноте ходит <b style="color:#8b0000">оно</b>.<br><br>
Найди <b>3 записки</b> и доберись до выхода.<br>
Оно слышит твои шаги.</p>
<p style="margin-top:22px;color:#555;font-size:13px">
WASD / стрелки — идти · SHIFT — бежать · Мышь — смотреть · E — взять записку
</p>
<button class="btn" id="startBtn">ВОЙТИ В ЛЕС</button>
</div>
<div id="end">
<h1 id="endTitle"></h1>
<p id="endText"></p>
<button class="btn" onclick="location.reload()">ПОПРОБОВАТЬ СНОВА</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r140/three.min.js"></script>
<script>
let scene, camera, renderer, clock;
let monster, notesFound = 0, noteMeshes = [];
let keys = {}, yaw = 0, pitch = 0;
let monsterTarget = null, gameActive = false, paused = false;
let audioCtx, footstepTimer = 0, heartbeat = 0;
let exitPos = null;
const MAP_SIZE = 120, PLAYER_SPEED = 7, RUN_SPEED = 12;
const NOTE_TEXTS = [
"«День 1. Машина заглохла на трассе. Я пошёл за помощью в лес. Кажется, я слышу шаги за спиной. Оно не отстаёт.»",
"«День 3. Оно охотится на звук. Я перестал дышать, когда оно прошло в метре от меня. Оно слепое, но слышит ВСЁ.»",
"«День 7. Выход на северо-востоке, у старой вышки. Если ты читаешь это — беги. И НЕ ОБОРАЧИВАЙСЯ.»"
];
document.getElementById('startBtn').addEventListener('click', startGame);
function startGame(){
document.getElementById('start').style.display = 'none';
document.getElementById('loading').style.display = 'flex';
setTimeout(() => {
try {
init();
gameActive = true;
document.getElementById('loading').style.display = 'none';
clock = new THREE.Clock();
animate();
if(document.body.requestPointerLock) document.body.requestPointerLock();
} catch(e) {
console.error('Ошибка запуска:', e);
document.getElementById('loading').textContent = 'ОШИБКА: ' + e.message;
}
}, 100);
}
function init(){
scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060a);
scene.fog = new THREE.FogExp2(0x05060a, 0.045);
camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.1, 300);
camera.position.set(0, 1.7, 0);
camera.rotation.order = 'YXZ';
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('game'),
antialias: true
});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const moon = new THREE.DirectionalLight(0x8899bb, 0.35);
moon.position.set(-40, 60, -30);
moon.castShadow = true;
moon.shadow.mapSize.set(1024,1024);
moon.shadow.camera.left=-80;moon.shadow.camera.right=80;
moon.shadow.camera.top=80;moon.shadow.camera.bottom=-80;
scene.add(moon);
scene.add(new THREE.AmbientLight(0x1a2030, 0.6));
const groundGeo = new THREE.PlaneGeometry(400,400,60,60);
const groundMat = new THREE.MeshStandardMaterial({color:0x0d1408, roughness:1});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI/2;
ground.receiveShadow = true;
const pos = groundGeo.attributes.position;
for(let i=0;i<pos.count;i++){
const x=pos.getX(i), y=pos.getY(i);
pos.setZ(i, Math.sin(x*0.15)*0.4 + Math.cos(y*0.2)*0.3);
}
groundGeo.computeVertexNormals();
scene.add(ground);
const trunkMat = new THREE.MeshStandardMaterial({color:0x1a1208, roughness:1});
const leafMat = new THREE.MeshStandardMaterial({color:0x071005, roughness:1});
for(let i=0;i<300;i++){
const tree = new THREE.Group();
const h = 4 + Math.random()*6;
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.25,0.4,h,6), trunkMat);
trunk.position.y = h/2;
trunk.castShadow = true;
tree.add(trunk);
for(let j=0;j<3;j++){
const cone = new THREE.Mesh(new THREE.ConeGeometry(1.8-j*0.4, 2.5, 7), leafMat);
cone.position.y = h - 0.5 + j*1.4;
cone.castShadow = true;
tree.add(cone);
}
const angle = Math.random()*Math.PI*2;
const dist = 8 + Math.random()*55;
tree.position.set(Math.cos(angle)*dist, 0, Math.sin(angle)*dist);
scene.add(tree);
}
const noteMat = new THREE.MeshStandardMaterial({
color:0xd4c48a, emissive:0x885522, emissiveIntensity:1
});
const positions = [[-25,20],[30,-35],[-15,-40]];
for(let i=0;i<3;i++){
const n = new THREE.Mesh(new THREE.PlaneGeometry(0.7,0.9), noteMat);
n.position.set(positions[i][0], 0.6, positions[i][1]);
n.rotation.x = -Math.PI/2 + 0.3;
n.userData.index = i;
scene.add(n);
noteMeshes.push(n);
const light = new THREE.PointLight(0xffcc66, 1.5, 10);
light.position.copy(n.position).add(new THREE.Vector3(0,1,0));
scene.add(light);
}
const exitGroup = new THREE.Group();
const frame = new THREE.Mesh(
new THREE.TorusGeometry(2.2, 0.25, 8, 24),
new THREE.MeshStandardMaterial({color:0x333333, emissive:0x88aaff, emissiveIntensity:0.8})
);
exitGroup.add(frame);
const exitLight = new THREE.PointLight(0x88aaff, 3, 20);
exitGroup.add(exitLight);
exitGroup.position.set(50, 2.2, -50);
scene.add(exitGroup);
exitPos = exitGroup.position;
monster = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({color:0x0a0a0a, roughness:0.9, emissive:0x220000});
const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.6, 2.2, 4, 8), bodyMat);
body.position.y = 1.8;
body.castShadow = true;
monster.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 12), bodyMat);
head.position.y = 3.3;
monster.add(head);
const eyeMat = new THREE.MeshBasicMaterial({color:0xff0000});
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.09,8,8), eyeMat);
eyeL.position.set(-0.2, 3.35, 0.45);
const eyeR = eyeL.clone(); eyeR.position.x = 0.2;
monster.add(eyeL, eyeR);
for(const side of [-1,1]){
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.18, 2.2, 4, 6), bodyMat);
arm.position.set(side*0.85, 2, 0);
arm.rotation.z = side*0.25;
monster.add(arm);
}
monster.position.set(35, 0, 35);
scene.add(monster);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', e => keys[e.code] = false);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('click', () => {
if(gameActive && document.body.requestPointerLock) document.body.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
paused = gameActive && !document.pointerLockElement;
});
addEventListener('resize', onResize);
initAudio();
updateHUD();
}
function onKeyDown(e){
keys[e.code] = true;
if(e.code === 'KeyE') tryPickNote();
}
function onMouseMove(e){
if(document.pointerLockElement){
yaw -= e.movementX * 0.002;
pitch -= e.movementY * 0.002;
pitch = Math.max(-Math.PI/2+0.1, Math.min(Math.PI/2-0.1, pitch));
}
}
function onResize(){
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
}
function initAudio(){
try { audioCtx = new (window.AudioContext||window.webkitAudioContext)(); } catch(e){}
}
function playTone(freq, dur, vol=0.08, type='sine'){
if(!audioCtx) return;
if(audioCtx.state === 'suspended') audioCtx.resume();
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(vol, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
o.connect(g); g.connect(audioCtx.destination);
o.start(); o.stop(audioCtx.currentTime + dur);
}
function tryPickNote(){
if(!gameActive) return;
for(let i=0;i<noteMeshes.length;i++){
const n = noteMeshes[i];
if(!n.visible) continue;
if(camera.position.distanceTo(n.position) < 2.5){
n.visible = false;
notesFound++;
updateHUD();
playTone(880, 0.15, 0.1, 'triangle');
setTimeout(()=>playTone(1320, 0.25, 0.08, 'triangle'), 120);
showNote(NOTE_TEXTS[n.userData.index]);
return;
}
}
}
function showNote(text){
const el = document.getElementById('note');
el.textContent = text;
el.style.display = 'block';
if(document.exitPointerLock) document.exitPointerLock();
setTimeout(()=>{
el.style.display = 'none';
if(gameActive && document.body.requestPointerLock) document.body.requestPointerLock();
}, 6000);
}
function updateHUD(){
document.getElementById('notes').innerHTML =
`📜 Записки: <b>${notesFound}/3</b>` +
(notesFound===3 ? '<br><span style="color:#8f8">Выход открыт! Сев.-восток →</span>' : '');
}
function animate(){
requestAnimationFrame(animate);
if(!gameActive) return;
if(paused){ clock.getDelta(); return; }
const dt = Math.min(clock.getDelta(), 0.05);
const speed = (keys['ShiftLeft']||keys['ShiftRight']) ? RUN_SPEED : PLAYER_SPEED;
const dir = new THREE.Vector3();
if(keys['KeyW']||keys['ArrowUp']) dir.z -= 1;
if(keys['KeyS']||keys['ArrowDown']) dir.z += 1;
if(keys['KeyA']||keys['ArrowLeft']) dir.x -= 1;
if(keys['KeyD']||keys['ArrowRight']) dir.x += 1;
const moving = dir.length() > 0;
if(moving){
dir.normalize();
dir.applyAxisAngle(new THREE.Vector3(0,1,0), yaw);
camera.position.addScaledVector(dir, speed*dt);
const r = Math.hypot(camera.position.x, camera.position.z);
if(r > MAP_SIZE){ camera.position.multiplyScalar(MAP_SIZE/r); }
footstepTimer -= dt;
if(footstepTimer <= 0){
footstepTimer = (keys['ShiftLeft']||keys['ShiftRight']) ? 0.25 : 0.42;
playTone(60 + Math.random()*30, 0.08, 0.04, 'square');
}
}
camera.position.y = 1.7;
camera.rotation.y = yaw;
camera.rotation.x = pitch;
const mp = monster.position, pp = camera.position;
const distToPlayer = mp.distanceTo(pp);
let target;
if(moving || distToPlayer < 25){
target = pp.clone();
} else {
if(!monsterTarget || mp.distanceTo(monsterTarget) < 3){
monsterTarget = new THREE.Vector3(
(Math.random()-0.5)*MAP_SIZE*1.4, 0, (Math.random()-0.5)*MAP_SIZE*1.4
);
}
target = monsterTarget;
}
const dirTo = target.clone().sub(mp); dirTo.y = 0;
const d = dirTo.length();
if(d > 0.5){
dirTo.normalize();
const spd = (moving || distToPlayer < 25) ? 6.5 : 2.5;
mp.addScaledVector(dirTo, spd*dt);
const targetYaw = Math.atan2(dirTo.x, dirTo.z);
let diff = targetYaw - monster.rotation.y;
while(diff > Math.PI) diff -= Math.PI*2;
while(diff < -Math.PI) diff += Math.PI*2;
monster.rotation.y += diff * Math.min(1, dt*4);
}
monster.position.y = Math.abs(Math.sin(performance.now()*0.008)) * 0.15;
if(distToPlayer < 1.6){ die(); return; }
heartbeat -= dt;
if(distToPlayer < 15 && heartbeat <= 0){
heartbeat = Math.max(0.25, distToPlayer/15);
playTone(45, 0.15, 0.15 * (1 - distToPlayer/15), 'sine');
}
if(notesFound === 3 && camera.position.distanceTo(exitPos) < 3.5){ win(); return; }
for(const n of noteMeshes){
if(n.visible){
n.rotation.z += dt * 1.5;
n.position.y = 0.6 + Math.sin(performance.now()*0.003 + n.userData.index)*0.1;
}
}
updateInfo();
renderer.render(scene, camera);
}
function updateInfo(){
const el = document.getElementById('info');
if(notesFound < 3){
el.innerHTML = 'Найди 3 записки...<br><span style="color:#555">Оно где-то рядом.</span>';
} else {
el.innerHTML = 'БЕГИ К ВЫХОДУ!<br><span style="color:#8f8">Северо-восток 🌙</span>';
}
const hint = document.getElementById('hint');
let near = false;
for(const n of noteMeshes){
if(n.visible && camera.position.distanceTo(n.position) < 2.5){ near = true; break; }
}
hint.style.display = near ? 'block' : 'none';
}
function die(){
gameActive = false;
if(document.exitPointerLock) document.exitPointerLock();
document.getElementById('flash').style.opacity = '1';
playTone(80, 1.5, 0.3, 'sawtooth');
setTimeout(()=>{
document.getElementById('endTitle').textContent = 'ОНО НАШЛО ТЕБЯ';
document.getElementById('endTitle').style.color = '#8b0000';
document.getElementById('endText').innerHTML =
`Ты собрал ${notesFound} из 3 записок.<br>Лес снова забрал ещё одного.`;
document.getElementById('end').style.display = 'flex';
}, 400);
}
function win(){
gameActive = false;
if(document.exitPointerLock) document.exitPointerLock();
playTone(523, 0.3, 0.15);
setTimeout(()=>playTone(659,0.3,0.15),200);
setTimeout(()=>playTone(784,0.6,0.15),400);
document.getElementById('endTitle').textContent = 'ТЫ ВЫБРАЛСЯ';
document.getElementById('endTitle').style.color = '#8f8';
document.getElementById('endText').innerHTML =
'Ты нашёл все записки и добрался до выхода.<br>Позади — только тьма и шаги, которые не догонят.';
document.getElementById('end').style.display = 'flex';
}
</script>
</body>
</html>Game Source: ЛЕС — 3D Хоррор
Creator: ElectricCoder50
Libraries: three
Complexity: complex (424 lines, 16.6 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: 3d-electriccoder50" to link back to the original. Then publish at arcadelab.ai/publish.