HorrorGame3d / index.html
varunm2004's picture
Update index.html
022363f verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<title>Lost in the Woods</title>
<!-- Bootstrap 5 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css"/>
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css"/>
<!-- Horror fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Creepster&family=Special+Elite&display=swap" rel="stylesheet"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--blood: #8b0000;
--amber: #e8a020;
--ui-bg: rgba(0,0,0,.6);
--ctrls: 64px;
}
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #000;
font-family: 'Special Elite', serif;
}
#c {
position: fixed; inset: 0;
width: 100%; height: 100%;
display: block;
}
/* Loading */
#loadScreen {
position: fixed; inset: 0; z-index: 9999;
background: #000;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 1.2rem;
transition: opacity .9s;
}
#loadScreen h1 {
font-family: 'Creepster', cursive;
font-size: clamp(2.2rem, 8vw, 4.5rem);
color: var(--blood);
text-shadow: 0 0 30px #8b000077;
letter-spacing: .1em;
}
#loadScreen p { color: #666; font-size: .9rem; letter-spacing: .05em; }
#pBar { width: min(320px,78vw); height: 5px; background: #111; border-radius:3px; overflow:hidden; }
#pFill { height:100%; width:0%; background: linear-gradient(90deg,var(--blood),#ff3333); border-radius:3px; transition: width .3s; }
/* Vignette */
#vig {
position: fixed; inset: 0; z-index: 80; pointer-events: none;
background: radial-gradient(ellipse at center, transparent 38%, rgba(0,0,0,.6) 75%, rgba(0,0,0,.92) 100%);
}
/* HUD */
#hud { position: fixed; inset: 0; z-index: 100; pointer-events: none; }
#stamBar {
position: absolute; top: 16px; left: 16px;
display: flex; align-items: center; gap: 8px;
background: var(--ui-bg); border-radius: 8px;
padding: 6px 14px; backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,.07);
}
#stamBar i { color: var(--amber); }
#stamTrack { width: 100px; height: 5px; background: #222; border-radius:3px; overflow:hidden; }
#stamFill { height:100%; width:100%; background: var(--amber); border-radius:3px; transition: width .15s; }
#objBox {
position: absolute; top: 16px; left: 50%; transform: translateX(-50%);
background: var(--ui-bg); border-radius: 8px;
padding: 6px 18px; backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,.07);
font-size: .8rem; color: #bbb; letter-spacing:.05em; white-space:nowrap;
}
#objBox span { color: var(--amber); }
#xhair {
position: absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:12px; height:12px; border-radius:50%;
border: 1.5px solid rgba(255,255,255,.4);
}
#flash {
position: absolute; inset:0; pointer-events:none;
background: transparent; transition: background .15s;
}
#endMsg {
position: absolute; inset:0;
display:none; flex-direction:column;
align-items:center; justify-content:center; gap:1rem;
background: rgba(0,0,0,.8); backdrop-filter:blur(5px);
text-align:center; pointer-events:all;
}
#endMsg.show { display:flex; }
#endMsg h2 {
font-family:'Creepster',cursive;
font-size: clamp(2rem,8vw,3.8rem);
color: var(--blood);
text-shadow: 0 0 28px #8b000088;
}
#endMsg p { color:#aaa; max-width:300px; line-height:1.7; }
#endMsg button {
font-family:'Special Elite',serif;
background:var(--blood); border:none; color:#fff;
padding:.55rem 2.2rem; border-radius:6px;
font-size:1rem; letter-spacing:.06em; cursor:pointer;
transition:background .2s;
}
#endMsg button:hover { background:#a00; }
/* Android Controls */
#ctrl { position:fixed; inset:0; z-index:200; pointer-events:none; }
#jZone {
position:absolute; left:20px; bottom:28px;
width:154px; height:154px; pointer-events:all;
}
#jBase {
position:absolute; inset:0; border-radius:50%;
background: radial-gradient(circle,rgba(255,255,255,.05),rgba(255,255,255,.01));
border: 2px solid rgba(255,255,255,.16);
backdrop-filter:blur(4px);
}
#jKnob {
position:absolute; width:52px; height:52px; border-radius:50%;
background: radial-gradient(circle,rgba(200,50,50,.85),rgba(120,0,0,.95));
border: 2px solid rgba(255,90,90,.3);
left:50%; top:50%; transform:translate(-50%,-50%);
box-shadow: 0 2px 14px rgba(139,0,0,.55);
}
#lookPad {
position:absolute; right:105px; bottom:220px;
width:130px; height:130px; border-radius:50%;
border: 1.5px dashed rgba(255,255,255,.11);
background: rgba(255,255,255,.02);
display:flex; align-items:center; justify-content:center;
font-size:.62rem; color:rgba(255,255,255,.25); letter-spacing:.06em;
pointer-events:all;
}
#aBtns {
position:absolute; right:22px; bottom:28px;
display:flex; flex-direction:column; align-items:center; gap:10px;
pointer-events:all;
}
.cbtn {
width:var(--ctrls); height:var(--ctrls); border-radius:50%;
background:var(--ui-bg); backdrop-filter:blur(6px);
border:2px solid rgba(255,255,255,.2); color:#ddd;
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:2px; font-size:.58rem; letter-spacing:.06em; cursor:pointer;
user-select:none; -webkit-tap-highlight-color:transparent;
transition: background .1s, transform .1s;
}
.cbtn i { font-size:1.25rem; }
.cbtn:active, .cbtn.on {
background:rgba(139,0,0,.55);
border-color:rgba(255,80,80,.45);
transform:scale(.91);
}
#btnRow { display:flex; gap:10px; }
#kbHint {
position:fixed; bottom:12px; left:50%; transform:translateX(-50%);
font-size:.7rem; color:rgba(255,255,255,.2); letter-spacing:.04em;
z-index:300; pointer-events:none;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="vig"></div>
<!-- HUD -->
<div id="hud">
<div id="stamBar">
<i class="bi bi-wind"></i>
<div id="stamTrack"><div id="stamFill"></div></div>
</div>
<div id="objBox">Goal: <span id="objTxt">Find the house</span></div>
<div id="xhair"></div>
<div id="flash"></div>
<div id="endMsg">
<h2 id="endTitle">You Found Shelter!</h2>
<p id="endBody">The darkness could not take you. The house holds its warmth.</p>
<button onclick="location.reload()">&#9654; Play Again</button>
</div>
</div>
<!-- Loading -->
<div id="loadScreen">
<h1>Lost in the Woods</h1>
<p id="loadTxt">Initializing...</p>
<div id="pBar"><div id="pFill"></div></div>
<p style="font-size:.72rem;color:#444;margin-top:.4rem;">Horror &middot; Forest &middot; Survive</p>
</div>
<!-- Android Controls -->
<div id="ctrl">
<div id="jZone"><div id="jBase"></div><div id="jKnob"></div></div>
<div id="lookPad">LOOK</div>
<div id="aBtns">
<div id="btnRow">
<div class="cbtn" id="btnRun"><i class="bi bi-lightning-fill"></i><span>RUN</span></div>
<div class="cbtn" id="btnEnter"><i class="bi bi-door-open-fill"></i><span>ENTER</span></div>
</div>
</div>
</div>
<div id="kbHint">WASD &middot; Mouse (click to lock) &middot; Shift = Run &middot; E = Enter House</div>
<!-- Three.js r128 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- GLTFLoader for r128 -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<!-- RGBELoader for .hdr equirectangular support -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/RGBELoader.js"></script>
<!-- Cannon.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js"></script>
<!-- Bootstrap JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"></script>
<script>
"use strict";
/* ── Helpers ── */
const $ = id => document.getElementById(id);
const sp = (p, t) => {
$('pFill').style.width = p + '%';
if (t) $('loadTxt').textContent = t;
};
/* ── Constants ── */
const TREE_N = 60;
const F_RADIUS = 58;
const WALK_SPD = 7;
const RUN_SPD = 14;
const P_HEIGHT = 1.75;
const HOUSE_POS = new THREE.Vector3(32, 0, -32);
const WIN_DIST = 5.5;
const STAM_MAX = 100;
const STAM_DRN = 24;
const STAM_RGN = 15;
const LOOK_S_M = 0.0022;
const LOOK_S_T = 0.0042;
const JR = 50;
/* ── Renderer ── */
const renderer = new THREE.WebGLRenderer({ canvas: $('c'), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 0.55;
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
});
/* ── Scene & Camera ── */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x020205);
scene.fog = new THREE.FogExp2(0x050608, 0.025);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 400);
camera.position.set(0, P_HEIGHT, 5);
/* ── Physics World ── */
const world = new CANNON.World();
world.gravity.set(0, -22, 0);
world.broadphase = new CANNON.SAPBroadphase(world);
world.solver.iterations = 10;
/* ── Phong Material Helper ── */
function phong(color, specular, shininess, emissive) {
return new THREE.MeshPhongMaterial({
color: color || 0x888888,
specular: specular || 0x222222,
shininess: shininess || 40,
emissive: emissive || 0x000000,
flatShading: false
});
}
/* ── Lights ── */
const ambient = new THREE.AmbientLight(0x2a2d38, 0.45);
scene.add(ambient);
const moon = new THREE.DirectionalLight(0x8899cc, 0.7);
moon.position.set(-30, 60, 20);
moon.castShadow = true;
moon.shadow.mapSize.set(2048, 2048);
moon.shadow.camera.near = 1;
moon.shadow.camera.far = 220;
moon.shadow.camera.left = moon.shadow.camera.bottom = -90;
moon.shadow.camera.right = moon.shadow.camera.top = 90;
moon.shadow.bias = -0.001;
scene.add(moon);
const lantern = new THREE.PointLight(0xffc044, 3.5, 30, 2);
lantern.position.set(HOUSE_POS.x - 1, 4, HOUSE_POS.z + 4.5);
lantern.castShadow = true;
scene.add(lantern);
/* Lamp glow mesh */
const lampMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 8, 8),
new THREE.MeshStandardMaterial({ emissive: 0xffcc44, emissiveIntensity: 3, color: 0x000000 })
);
lampMesh.position.copy(lantern.position);
scene.add(lampMesh);
/* ── Skybox ─────────────────────────────────────────────────────
Supports both sky.hdr (HDR equirectangular) and sky.jpg / sky.png.
- HDR path: RGBELoader → PMREMGenerator → equirect mapped sphere
(PMREMGenerator converts the lat-long HDR into a proper cube map
used as scene.environment + background, giving correct mapping)
- JPG/PNG path: TextureLoader → equirectangular sphere (BackSide)
The sky file name is auto-detected by extension.
The sphere is rebuilt with higher segment count and UV-corrected
so the equirectangular projection tiles correctly without stretching.
─────────────────────────────────────────────────────────────────*/
sp(8, 'Painting the night sky...');
// Sky sphere (fallback colour shown while texture loads)
const skyGeo = new THREE.SphereGeometry(380, 64, 32);
const skyMat = new THREE.MeshBasicMaterial({ color: 0x050810, side: THREE.BackSide, depthWrite: false });
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
skyMesh.renderOrder = -1;
scene.add(skyMesh);
// Detect sky file — try .hdr first, fall back to .jpg
const SKY_HDR = 'sky.hdr';
const SKY_JPG = 'sky.jpg';
function loadSkyHDR() {
return new Promise((resolve, reject) => {
new THREE.RGBELoader()
.setDataType(THREE.HalfFloatType)
.load(SKY_HDR, resolve, undefined, reject);
});
}
function loadSkyJPG(url) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(url, resolve, undefined, reject);
});
}
// Try HDR first, then JPG, then PNG
(async () => {
try {
const hdrTex = await loadSkyHDR();
// PMREMGenerator converts equirect HDR → correct cube env map
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
const envMap = pmrem.fromEquirectangular(hdrTex);
// Use as scene background (Three.js handles correct mapping internally)
scene.background = envMap.texture;
// Also drive scene environment lighting from the HDR
scene.environment = envMap.texture;
// Remove our manual sphere — scene.background covers it
scene.remove(skyMesh);
hdrTex.dispose();
pmrem.dispose();
console.log('HDR sky loaded via PMREMGenerator');
} catch (hdrErr) {
console.log('sky.hdr not found or failed, trying sky.jpg...', hdrErr.message || hdrErr);
try {
const jpgTex = await loadSkyJPG(SKY_JPG);
// Equirectangular mapping: mapping must be EquirectangularReflectionMapping
// so Three.js knows the texture is a lat-long panorama
jpgTex.mapping = THREE.EquirectangularReflectionMapping;
jpgTex.encoding = THREE.sRGBEncoding;
// Assign as scene.background — Three.js renders it correctly without a sphere
scene.background = jpgTex;
scene.remove(skyMesh);
console.log('JPG sky loaded via scene.background equirectangular');
} catch (jpgErr) {
console.log('sky.jpg also not found, keeping fallback colour', jpgErr.message || jpgErr);
// skyMesh with fallback dark colour remains in scene
}
}
})();
/* ── Ground ── */
sp(16, 'Laying the forest floor...');
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(300, 300),
phong(0x0d1a0a, 0x020402, 5)
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const groundBody = new CANNON.Body({ mass: 0 });
groundBody.addShape(new CANNON.Plane());
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(groundBody);
/* ── Player Body ── */
const playerBody = new CANNON.Body({ mass: 70, linearDamping: 0.97, angularDamping: 1 });
playerBody.addShape(new CANNON.Sphere(0.4));
playerBody.position.set(0, P_HEIGHT, 5);
playerBody.fixedRotation = true;
playerBody.updateMassProperties();
world.addBody(playerBody);
/* ── GLTF Loader helper ── */
const gltfLoader = new THREE.GLTFLoader();
function loadGLTF(url) {
return new Promise((resolve, reject) => {
gltfLoader.load(url, resolve, undefined, reject);
});
}
function applyPhong(root, color, specular, shininess) {
root.traverse(node => {
if (!node.isMesh) return;
const oldMap = (node.material && node.material.map) ? node.material.map : null;
node.material = phong(color, specular, shininess || 35);
if (oldMap) node.material.map = oldMap;
node.castShadow = true;
node.receiveShadow = true;
});
}
/* ── Fallback geometry ── */
function fallbackTree(x, z, sc) {
const g = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(.25, .42, 5, 7), phong(0x2e1a08, 0x0a0602, 8));
trunk.position.y = 2.5; trunk.castShadow = true;
const crown = new THREE.Mesh(new THREE.ConeGeometry(2.2, 5.5, 7), phong(0x0a2208, 0x020801, 10));
crown.position.y = 7.2; crown.castShadow = true;
g.add(trunk, crown);
g.position.set(x, 0, z);
g.scale.setScalar(sc);
scene.add(g);
}
function fallbackHouse() {
const g = new THREE.Group();
const walls = new THREE.Mesh(new THREE.BoxGeometry(7, 5, 6), phong(0x3a2212, 0x0e0906, 20));
walls.position.y = 2.5; walls.castShadow = walls.receiveShadow = true;
const roof = new THREE.Mesh(new THREE.ConeGeometry(5.5, 3.5, 4), phong(0x1e0e06, 0x050302, 8));
roof.position.y = 6.75; roof.rotation.y = Math.PI / 4; roof.castShadow = true;
const door = new THREE.Mesh(new THREE.BoxGeometry(1.2, 2.2, .15), phong(0x120900, 0x040200, 10));
door.position.set(0, 1.1, 3.08);
g.add(walls, roof, door);
g.position.copy(HOUSE_POS);
g.rotation.y = Math.PI;
scene.add(g);
}
function addTreeCollider(x, z) {
const b = new CANNON.Body({ mass: 0 });
b.addShape(new CANNON.Cylinder(.45, .45, 10, 6));
b.position.set(x, 0, z);
world.addBody(b);
}
function addHouseCollider() {
const b = new CANNON.Body({ mass: 0 });
b.addShape(new CANNON.Box(new CANNON.Vec3(3.5, 4, 3)));
b.position.set(HOUSE_POS.x, 4, HOUSE_POS.z);
world.addBody(b);
}
/* ── Fireflies ── */
function makeFireflies() {
const N = 80;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
pos[i*3] = (Math.random() - .5) * F_RADIUS * 2;
pos[i*3+1] = .5 + Math.random() * 2.5;
pos[i*3+2] = (Math.random() - .5) * F_RADIUS * 2;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
color: 0x88ffaa, size: 0.13, transparent: true, opacity: .7,
blending: THREE.AdditiveBlending, depthWrite: false
});
scene.add(new THREE.Points(geo, mat));
const vels = Array.from({ length: N }, () => ({
x: (Math.random()-.5)*.35,
y: Math.random()*.28+.04,
z: (Math.random()-.5)*.35
}));
return function(dt) {
const a = geo.attributes.position.array;
for (let i = 0; i < N; i++) {
a[i*3] += vels[i].x * dt;
a[i*3+1] += vels[i].y * dt;
a[i*3+2] += vels[i].z * dt;
if (a[i*3+1] > 3.5) a[i*3+1] = .4;
}
geo.attributes.position.needsUpdate = true;
mat.opacity = .55 + Math.sin(Date.now() * .001) * .18;
};
}
const tickFF = makeFireflies();
/* ── Input State ── */
const keys = {};
let yaw = 0, pitch = 0;
let mouseLock = false;
let running = false;
let stamina = STAM_MAX;
let won = false;
window.addEventListener('keydown', e => { keys[e.code] = true; });
window.addEventListener('keyup', e => { keys[e.code] = false; });
$('c').addEventListener('click', () => { if (!won) $('c').requestPointerLock(); });
document.addEventListener('pointerlockchange', () => { mouseLock = document.pointerLockElement === $('c'); });
document.addEventListener('mousemove', e => {
if (!mouseLock || won) return;
yaw -= e.movementX * LOOK_S_M;
pitch = Math.max(-1.25, Math.min(.9, pitch - e.movementY * LOOK_S_M));
});
/* Touch joystick */
const jState = { active:false, id:-1, sx:0, sy:0, dx:0, dy:0 };
const lState = { active:false, id:-1, lx:0, ly:0 };
$('jZone').addEventListener('touchstart', e => {
const t = e.changedTouches[0];
Object.assign(jState, { active:true, id:t.identifier, sx:t.clientX, sy:t.clientY, dx:0, dy:0 });
e.preventDefault();
}, { passive:false });
$('lookPad').addEventListener('touchstart', e => {
const t = e.changedTouches[0];
Object.assign(lState, { active:true, id:t.identifier, lx:t.clientX, ly:t.clientY });
e.preventDefault();
}, { passive:false });
document.addEventListener('touchmove', e => {
for (const t of e.changedTouches) {
if (t.identifier === jState.id) {
let dx = t.clientX - jState.sx, dy = t.clientY - jState.sy;
const d = Math.sqrt(dx*dx + dy*dy);
if (d > JR) { dx *= JR/d; dy *= JR/d; }
jState.dx = dx; jState.dy = dy;
$('jKnob').style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
}
if (t.identifier === lState.id) {
yaw -= (t.clientX - lState.lx) * LOOK_S_T;
pitch = Math.max(-1.25, Math.min(.9, pitch - (t.clientY - lState.ly) * LOOK_S_T));
lState.lx = t.clientX; lState.ly = t.clientY;
}
}
e.preventDefault();
}, { passive:false });
document.addEventListener('touchend', e => {
for (const t of e.changedTouches) {
if (t.identifier === jState.id) {
Object.assign(jState, { active:false, dx:0, dy:0 });
$('jKnob').style.transform = 'translate(-50%,-50%)';
}
if (t.identifier === lState.id) lState.active = false;
}
}, { passive:false });
/* Run / Enter buttons */
$('btnRun').addEventListener('touchstart', () => { running = true; $('btnRun').classList.add('on'); }, { passive:true });
$('btnRun').addEventListener('touchend', () => { running = false; $('btnRun').classList.remove('on'); }, { passive:true });
$('btnRun').addEventListener('mousedown', () => { running = true; $('btnRun').classList.add('on'); });
$('btnRun').addEventListener('mouseup', () => { running = false; $('btnRun').classList.remove('on'); });
$('btnEnter').addEventListener('click', tryEnter);
window.addEventListener('keydown', e => { if (e.code === 'KeyE') tryEnter(); });
/* ── Win / Flash ── */
function tryEnter() {
if (won) return;
const dx = playerBody.position.x - HOUSE_POS.x;
const dz = playerBody.position.z - HOUSE_POS.z;
if (Math.sqrt(dx*dx + dz*dz) < WIN_DIST + 3) {
winGame();
} else {
$('flash').style.background = 'rgba(139,0,0,.35)';
setTimeout(() => { $('flash').style.background = ''; }, 300);
}
}
function winGame() {
if (won) return;
won = true;
$('endMsg').classList.add('show');
}
/* ── Main async loader + loop ── */
(async () => {
/* Man */
sp(18, 'Loading the survivor...');
try {
const g = await loadGLTF('man.glb');
applyPhong(g.scene, 0x4a3020, 0x150e08, 25);
g.scene.visible = false;
scene.add(g.scene);
} catch(e) { console.log('man.glb not found, skipping'); }
sp(35, 'Planting the trees...');
/* Trees */
let treeTemplate = null;
try {
const g = await loadGLTF('tree.glb');
applyPhong(g.scene, 0x0e2a0a, 0x030801, 12);
treeTemplate = g.scene;
} catch(e) { console.log('tree.glb not found, using fallback'); }
sp(55, 'Scattering the forest...');
const rng = Math.random;
for (let i = 0; i < TREE_N; i++) {
let tx, tz;
do {
tx = (rng() - .5) * F_RADIUS * 2;
tz = (rng() - .5) * F_RADIUS * 2;
} while (
Math.sqrt(tx*tx + tz*tz) < 9 ||
Math.sqrt((tx - HOUSE_POS.x)**2 + (tz - HOUSE_POS.z)**2) < 8
);
const sc = .7 + rng() * .75;
if (treeTemplate) {
const t = treeTemplate.clone();
t.position.set(tx, 0, tz);
t.scale.setScalar(sc);
t.rotation.y = rng() * Math.PI * 2;
scene.add(t);
} else {
fallbackTree(tx, tz, sc);
}
addTreeCollider(tx, tz);
}
/* House */
sp(70, 'Building the house...');
try {
const g = await loadGLTF('house.glb');
applyPhong(g.scene, 0x3a2212, 0x0e0906, 28);
g.scene.position.copy(HOUSE_POS);
g.scene.rotation.y = Math.PI;
scene.add(g.scene);
} catch(e) {
console.log('house.glb not found, using fallback');
fallbackHouse();
}
addHouseCollider();
sp(95, 'Opening the forest gate...');
await new Promise(r => setTimeout(r, 400));
sp(100, 'Enter if you dare...');
await new Promise(r => setTimeout(r, 700));
const ls = $('loadScreen');
ls.style.opacity = '0';
setTimeout(() => { ls.style.display = 'none'; }, 900);
/* ── Game Loop ── */
const clock = new THREE.Clock();
const FIXED_DT = 1 / 60;
let accum = 0;
function loop() {
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), .1);
if (!won) {
/* Movement input */
let fx = 0, fz = 0;
if (keys['KeyW'] || keys['ArrowUp']) fz -= 1;
if (keys['KeyS'] || keys['ArrowDown']) fz += 1;
if (keys['KeyA'] || keys['ArrowLeft']) fx -= 1;
if (keys['KeyD'] || keys['ArrowRight']) fx += 1;
if (jState.active) {
const jx = jState.dx / JR;
const jy = jState.dy / JR;
if (Math.abs(jx) > .06 || Math.abs(jy) > .06) { fx = jx; fz = jy; }
}
const mag = Math.sqrt(fx*fx + fz*fz);
if (mag > 1) { fx /= mag; fz /= mag; }
if (keys['ShiftLeft'] || keys['ShiftRight']) running = true;
/* Stamina */
if (running && mag > .05) {
stamina = Math.max(0, stamina - STAM_DRN * dt);
if (stamina <= 0) running = false;
} else {
stamina = Math.min(STAM_MAX, stamina + STAM_RGN * dt);
}
$('stamFill').style.width = (stamina / STAM_MAX * 100) + '%';
const speed = (running && stamina > 4) ? RUN_SPD : WALK_SPD;
const sinY = Math.sin(yaw), cosY = Math.cos(yaw);
const wfx = fx * cosY + fz * sinY;
const wfz = -fx * sinY + fz * cosY;
const vy = playerBody.velocity.y;
playerBody.velocity.set(wfx * speed, vy, wfz * speed);
playerBody.angularVelocity.set(0, 0, 0);
/* Physics step */
accum += dt;
while (accum >= FIXED_DT) {
world.step(FIXED_DT);
accum -= FIXED_DT;
}
/* Sync camera to physics body */
camera.position.set(
playerBody.position.x,
playerBody.position.y + P_HEIGHT * .55,
playerBody.position.z
);
camera.rotation.order = 'YXZ';
camera.rotation.y = yaw;
camera.rotation.x = pitch;
/* Lantern flicker */
lantern.intensity = 3.2
+ Math.sin(Date.now() * .007) * .22
+ Math.sin(Date.now() * .019) * .1;
/* Objective HUD */
const dx = playerBody.position.x - HOUSE_POS.x;
const dz = playerBody.position.z - HOUSE_POS.z;
const dist = Math.sqrt(dx*dx + dz*dz);
$('objTxt').textContent = dist < 35
? `House is ${Math.round(dist)}m away — press ENTER or E`
: 'Find the house in the fog';
/* Win check */
if (dist < WIN_DIST) winGame();
/* Fireflies */
tickFF(dt);
}
renderer.render(scene, camera);
}
loop();
})();
</script>
</body>
</html>