HorrorGame3dpart2 / index.html
varunm2004's picture
Update index.html
da5ac88 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>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css"/>
<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>
/* ═══════════════════════════════════════════════════════════
BASE
═══════════════════════════════════════════════════════════ */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--blood:#8b0000;--amber:#e8a020;--ui:rgba(0,0,0,.65);
--cs:58px; --hint:#aaa;
}
html,body{width:100%;height:100%;overflow:hidden;background:#000;
font-family:'Special Elite',serif;color:#ccc}
#c{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
/* ── Loading ── */
#ls{position:fixed;inset:0;z-index:9999;background:#000 url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>') no-repeat;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.2rem;
transition:opacity 1s}
#ls h1{font-family:'Creepster',cursive;font-size:clamp(2.4rem,9vw,5rem);
color:var(--blood);text-shadow:0 0 40px #8b000066,0 0 80px #8b000033;letter-spacing:.12em}
#ls .sub{font-size:.8rem;color:#444;letter-spacing:.08em}
#pb{width:min(340px,80vw);height:4px;background:#111;border-radius:2px;overflow:hidden}
#pf{height:100%;width:0%;background:linear-gradient(90deg,#4a0000,var(--blood),#ff4444);
border-radius:2px;transition:width .2s ease}
#lt{font-size:.82rem;color:#555;letter-spacing:.05em;min-height:1.2em}
/* ── HUD root ── */
#hud{position:fixed;inset:0;z-index:200;pointer-events:none}
/* ── Vignette ── */
#vig{position:fixed;inset:0;z-index:180;pointer-events:none;
background:radial-gradient(ellipse at center,transparent 34%,rgba(0,0,0,.5) 70%,rgba(0,0,0,.88) 100%)}
/* ── Castle glow overlay ── */
@keyframes castlePulse{0%,100%{opacity:.15}50%{opacity:.35}}
#cgo{position:fixed;inset:0;z-index:179;pointer-events:none;opacity:0;transition:opacity 2s;
background:radial-gradient(ellipse 55% 38% at 50% 50%,rgba(90,20,180,.25),transparent 68%)}
#cgo.near{opacity:1;animation:castlePulse 3.5s ease-in-out infinite}
/* ── GTA HUD: bottom-right health / stamina bars ── */
#gtaHud{position:absolute;bottom:155px;right:22px;display:flex;flex-direction:column;
align-items:flex-end;gap:6px}
.gtaBar{display:flex;align-items:center;gap:8px}
.gtaBar .lbl{font-size:.68rem;letter-spacing:.08em;color:#888;min-width:14px;text-align:right}
.gtaBar .track{width:120px;height:7px;background:rgba(0,0,0,.5);border-radius:2px;
border:1px solid rgba(255,255,255,.1);overflow:hidden}
.gtaBar .fill{height:100%;border-radius:2px;transition:width .12s}
#sfill{background:linear-gradient(90deg,#aaaa00,#ffff00)}
#hfill{background:linear-gradient(90deg,#006600,#00cc00)}
/* ── Objective (GTA top-left radar style) ── */
#obj{position:absolute;top:18px;left:18px;background:rgba(0,0,0,.7);
border-radius:4px;padding:7px 16px;backdrop-filter:blur(8px);
border-left:3px solid var(--amber);font-size:.8rem;color:#ccc;letter-spacing:.04em;
max-width:280px;line-height:1.5}
#obj .objHead{font-size:.65rem;color:var(--amber);letter-spacing:.1em;margin-bottom:2px}
/* ── Radar / Minimap (GTA bottom-left) ── */
#radar{position:absolute;bottom:28px;left:20px;width:110px;height:110px;
border-radius:50%;overflow:hidden;
border:2px solid rgba(255,255,255,.25);
box-shadow:0 0 0 1px rgba(0,0,0,.8),0 0 20px rgba(0,0,0,.6);
background:#0a0a12}
#radar canvas{width:100%;height:100%}
/* Radar blip ring */
#radar::after{content:'';position:absolute;inset:0;border-radius:50%;
border:1px solid rgba(255,255,255,.08);pointer-events:none}
/* ── Camera badge (top-right, GTA style) ── */
#camBadge{position:absolute;top:18px;right:18px;
background:rgba(0,0,0,.7);border-radius:4px;padding:6px 14px;
backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.1);
font-size:.72rem;color:#aaa;letter-spacing:.08em;
display:flex;align-items:center;gap:7px;pointer-events:all;cursor:pointer}
#camBadge i{color:var(--amber)}
#camBadge:hover{border-color:rgba(255,255,255,.25)}
/* ── Flash ── */
#flash{position:absolute;inset:0;pointer-events:none;background:transparent;transition:background .12s}
/* ── Crosshair (FP only) ── */
#xh{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:14px;height:14px;pointer-events:none}
#xh::before,#xh::after{content:'';position:absolute;background:rgba(255,255,255,.5)}
#xh::before{width:1px;height:100%;left:50%;transform:translateX(-50%)}
#xh::after{height:1px;width:100%;top:50%;transform:translateY(-50%)}
#xh.hide{display:none}
/* ── Win screen ── */
#end{position:absolute;inset:0;display:none;flex-direction:column;
align-items:center;justify-content:center;gap:1.2rem;
background:rgba(0,0,0,.85);backdrop-filter:blur(6px);
text-align:center;pointer-events:all}
#end.show{display:flex}
#end h2{font-family:'Creepster',cursive;font-size:clamp(2rem,8vw,4rem);
color:var(--blood);text-shadow:0 0 30px #8b000077}
#end p{color:#888;max-width:320px;line-height:1.8}
#end .stats{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08);
border-radius:6px;padding:12px 24px;font-size:.82rem;color:#666;
letter-spacing:.05em}
#end button{font-family:'Special Elite',serif;background:var(--blood);border:none;color:#fff;
padding:.6rem 2.5rem;border-radius:4px;font-size:1rem;cursor:pointer;
letter-spacing:.06em;transition:background .2s;margin-top:.5rem}
#end button:hover{background:#a00}
/* ── Fly bar ── */
#flyBar{position:absolute;top:52px;right:18px;background:rgba(0,40,120,.6);
border-radius:4px;padding:5px 14px;backdrop-filter:blur(6px);
border:1px solid rgba(80,140,255,.3);font-size:.7rem;color:#88aaff;
letter-spacing:.08em;display:none}
#flyBar.show{display:block}
/* ═══════════════════════════════════════════════════════════
ANDROID / MOBILE CONTROLS (GTA mobile layout)
Left: dynamic joystick
Right: look pad (swipe to orbit)
Right-lower: action buttons in cross pattern
═══════════════════════════════════════════════════════════ */
#ctrl{position:fixed;inset:0;z-index:300;pointer-events:none}
/* --- Joystick (left) --- */
#jZone{position:absolute;left:16px;bottom:130px;width:140px;height:140px;pointer-events:all}
#jBase{position:absolute;inset:0;border-radius:50%;
background:radial-gradient(circle,rgba(255,255,255,.04),rgba(0,0,0,.2));
border:2px solid rgba(255,255,255,.14);backdrop-filter:blur(3px)}
#jKnob{position:absolute;width:54px;height:54px;border-radius:50%;
background:radial-gradient(circle at 38% 35%,rgba(220,60,60,.9),rgba(100,0,0,.95));
border:1.5px solid rgba(255,100,100,.25);
left:50%;top:50%;transform:translate(-50%,-50%);
box-shadow:0 3px 16px rgba(139,0,0,.6),inset 0 1px 2px rgba(255,180,180,.15)}
/* --- Look pad (right side swipe area) --- */
#lookPad{position:absolute;right:170px;bottom:130px;width:130px;height:130px;
border-radius:50%;border:1.5px dashed rgba(255,255,255,.09);
background:rgba(255,255,255,.015);
display:flex;align-items:center;justify-content:center;
font-size:.58rem;color:rgba(255,255,255,.2);letter-spacing:.07em;pointer-events:all}
/* --- Action buttons: cross layout (right side) --- */
#actBtns{position:absolute;right:14px;bottom:110px;
width:160px;height:160px;pointer-events:all}
/* Cross layout: top(Y), left(X), right(B), bottom(A) */
.actBtn{position:absolute;width:52px;height:52px;border-radius:50%;
display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:1px;font-size:.52rem;letter-spacing:.05em;cursor:pointer;
user-select:none;-webkit-tap-highlight-color:transparent;
border:1.5px solid rgba(255,255,255,.18);backdrop-filter:blur(5px);
transition:transform .08s,background .08s}
.actBtn i{font-size:1.1rem}
.actBtn:active,.actBtn.on{transform:scale(.88)}
/* Positions */
#bY{top:0; left:50%;transform:translateX(-50%);
background:rgba(80,80,200,.5);border-color:rgba(120,120,255,.3);color:#aac}
#bY.on{transform:translateX(-50%) scale(.88)}
#bX{top:50%; left:0; transform:translateY(-50%);
background:rgba(180,80,20,.5);border-color:rgba(255,140,60,.3);color:#da8}
#bX.on{transform:translateY(-50%) scale(.88)}
#bB{top:50%; right:0; transform:translateY(-50%);
background:rgba(160,20,20,.55);border-color:rgba(255,80,80,.3);color:#f88}
#bB.on{transform:translateY(-50%) scale(.88)}
#bA{bottom:0; left:50%;transform:translateX(-50%);
background:rgba(20,120,40,.55);border-color:rgba(60,220,80,.3);color:#8e8}
#bA.on{transform:translateX(-50%) scale(.88)}
/* --- Shoulder buttons (top) --- */
#shoulders{position:absolute;top:14px;left:0;right:0;
display:flex;justify-content:space-between;padding:0 10px;pointer-events:all}
.shBtn{padding:7px 18px;border-radius:4px;font-size:.7rem;letter-spacing:.07em;cursor:pointer;
user-select:none;-webkit-tap-highlight-color:transparent;
background:rgba(0,0,0,.65);border:1px solid rgba(255,255,255,.15);color:#aaa;
backdrop-filter:blur(5px);transition:background .1s}
.shBtn:active,.shBtn.on{background:rgba(139,0,0,.5);border-color:rgba(255,80,80,.3)}
/* --- D-pad for fly up/down (shown only in fly mode) --- */
#dpad{position:absolute;left:170px;bottom:130px;
display:none;flex-direction:column;align-items:center;gap:6px;pointer-events:all}
#dpad.show{display:flex}
.dpBtn{width:44px;height:44px;border-radius:50%;
background:rgba(0,60,180,.5);border:1px solid rgba(80,140,255,.3);
color:#88aaff;display:flex;align-items:center;justify-content:center;
font-size:1.2rem;cursor:pointer;user-select:none;transition:background .1s}
.dpBtn:active,.dpBtn.on{background:rgba(0,40,140,.7)}
/* --- Keyboard hint --- */
#kbHint{position:fixed;bottom:8px;left:50%;transform:translateX(-50%);
z-index:301;pointer-events:none;font-size:.62rem;
color:rgba(255,255,255,.16);letter-spacing:.04em}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="vig"></div>
<div id="cgo"></div>
<!-- ═══ HUD ═══ -->
<div id="hud">
<!-- Objective top-left -->
<div id="obj">
<div class="objHead">OBJECTIVE</div>
<span id="ot">Find the castle deep in the forest</span>
</div>
<!-- Camera badge top-right -->
<div id="camBadge" onclick="toggleCam()">
<i class="bi bi-camera-video-fill"></i>
<span id="camLbl">FIRST PERSON</span>
</div>
<div id="flyBar">✈ FLY MODE — Space/C = Up/Down</div>
<!-- Radar bottom-left -->
<div id="radar"><canvas id="mm" width="110" height="110"></canvas></div>
<!-- GTA health/stamina bars bottom-right -->
<div id="gtaHud">
<div class="gtaBar">
<span class="lbl" style="color:#aaaa00"><i class="bi bi-lightning-fill"></i></span>
<div class="track"><div class="fill" id="sfill" style="width:100%"></div></div>
</div>
<div class="gtaBar">
<span class="lbl" style="color:#00cc00"><i class="bi bi-heart-fill"></i></span>
<div class="track"><div class="fill" id="hfill" style="width:100%"></div></div>
</div>
</div>
<!-- FP crosshair -->
<div id="xh"></div>
<!-- Flash -->
<div id="flash"></div>
<!-- Win screen -->
<div id="end">
<h2 id="et">You Reached the Castle!</h2>
<p id="eb">The ancient walls offer cold shelter.<br>You survived the forest.</p>
<div class="stats" id="stats"></div>
<button onclick="location.reload()">&#9654; Play Again</button>
</div>
</div>
<!-- ═══ LOADING ═══ -->
<div id="ls">
<h1>Lost in the Woods</h1>
<p id="lt">Initializing...</p>
<div id="pb"><div id="pf"></div></div>
<p class="sub">100 Acres &middot; Castle &middot; River &middot; Horror</p>
</div>
<!-- ═══ MOBILE CONTROLS ═══ -->
<div id="ctrl">
<!-- Shoulder buttons -->
<div id="shoulders">
<div class="shBtn" id="shL" onclick="toggleCam()"><i class="bi bi-camera-fill"></i> CAM</div>
<div class="shBtn" id="shR" onclick="toggleFly()"><i class="bi bi-airplane-fill"></i> FLY</div>
</div>
<!-- Joystick -->
<div id="jZone"><div id="jBase"></div><div id="jKnob"></div></div>
<!-- Look pad -->
<div id="lookPad">LOOK</div>
<!-- Fly dpad -->
<div id="dpad">
<div class="dpBtn" id="dpUp"><i class="bi bi-caret-up-fill"></i></div>
<div class="dpBtn" id="dpDn"><i class="bi bi-caret-down-fill"></i></div>
</div>
<!-- Action cross -->
<div id="actBtns">
<!-- Y = Run/Sprint -->
<div class="actBtn" id="bY"><i class="bi bi-lightning-fill"></i><span>RUN</span></div>
<!-- X = Enter/Interact -->
<div class="actBtn" id="bX"><i class="bi bi-door-open-fill"></i><span>ENTER</span></div>
<!-- B = Cancel / unused -->
<div class="actBtn" id="bB"><i class="bi bi-x-circle-fill"></i><span>BACK</span></div>
<!-- A = Jump (not used / placeholder) -->
<div class="actBtn" id="bA"><i class="bi bi-person-fill"></i><span>CROUCH</span></div>
</div>
</div>
<div id="kbHint">WASD=Move &middot; Mouse=Look &middot; Shift=Run &middot; E=Enter &middot; V=Camera &middot; F=Fly &middot; Space/C=Up/Down</div>
<!-- ═══ SCRIPTS ═══ -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/RGBELoader.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js"></script>
<script>
"use strict";
/* ═══════════════════════════════════════════════════════════════════
LOST IN THE WOODS · GTA V style
Three.js r128 + Cannon.js
═══════════════════════════════════════════════════════════════════ */
const $ = id => document.getElementById(id);
const sp = (p, t) => { $('pf').style.width = p + '%'; if (t) $('lt').textContent = t; };
/* ── World ── */
const W = 640; // 640×640 ≈ 100 acres
const HW = W / 2;
/* ── Game Constants ── */
const TREE_N = 400;
const WALK_SPD = 7;
const RUN_SPD = 15;
const P_HEIGHT = 1.75; // eye height above physics body centre
const MAN_H = 1.8; // target man.glb height in world units
const STAM_MAX = 100;
const STAM_DRN = 20;
const STAM_RGN = 13;
const WIN_DIST = 20;
const JR = 52; // joystick radius px
/* ── Key world positions ── */
const SPAWN_POS = new THREE.Vector3( 0, 0, 60);
const HOUSE_POS = new THREE.Vector3( 85, 0, 35);
const CASTLE_POS = new THREE.Vector3(-185, 0, -205);
const RIVER_X = -65;
const RIVER_W = 20;
const BRIDGE_Z = -85;
/* ── Renderer ── */
const renderer = new THREE.WebGLRenderer({ canvas: $('c'), antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
});
/* ── Scene ── */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x020206);
scene.fog = new THREE.FogExp2(0x030408, 0.008);
/* ── Camera ── */
const camera = new THREE.PerspectiveCamera(65, innerWidth / innerHeight, 0.15, 1400);
/* ── Physics ── */
const world = new CANNON.World();
world.gravity.set(0, -22, 0);
world.broadphase = new CANNON.SAPBroadphase(world);
world.solver.iterations = 8;
world.defaultContactMaterial.friction = 0.5;
world.defaultContactMaterial.restitution = 0.0;
/* ───────────────────────────────────────────────────────────
MATERIAL HELPERS
─────────────────────────────────────────────────────────── */
function phong(col, spec, shin, emis) {
return new THREE.MeshPhongMaterial({
color: col || 0x888888, specular: spec || 0x111111,
shininess: shin || 35, emissive: emis || 0x000000,
flatShading: false
});
}
/**
* applyPhong — walks all meshes in a GLTF scene root,
* preserves the original diffuse/albedo texture if present,
* replaces material with Phong.
*/
function applyPhong(root, col, spec, shin) {
root.traverse(n => {
if (!n.isMesh) return;
const oldMap = n.material?.map || n.material?.albedoTexture || null;
n.material = phong(col, spec, shin);
if (oldMap) n.material.map = oldMap;
n.castShadow = n.receiveShadow = true;
});
}
/**
* autoScale — scales a GLTF root so its tallest dimension = targetH,
* then lifts it so its lowest point sits at y=0.
* Returns the scale factor applied.
*/
function autoScale(root, targetH) {
// Measure raw bounding box
const box = new THREE.Box3().setFromObject(root);
const size = new THREE.Vector3();
box.getSize(size);
const rawH = Math.max(size.x, size.y, size.z);
if (rawH === 0) return 1;
const sf = targetH / rawH;
root.scale.setScalar(sf);
// Re-measure after scale to find lowest point
const box2 = new THREE.Box3().setFromObject(root);
root.position.y = -box2.min.y;
console.log(`autoScale: rawH=${rawH.toFixed(3)} → scale=${sf.toFixed(4)}, liftY=${(-box2.min.y).toFixed(3)}`);
return sf;
}
/* ───────────────────────────────────────────────────────────
LIGHTS
─────────────────────────────────────────────────────────── */
scene.add(new THREE.AmbientLight(0x182028, 0.55));
// Moon
const moon = new THREE.DirectionalLight(0x6677aa, 0.7);
moon.position.set(-90, 130, 70);
moon.castShadow = true;
moon.shadow.mapSize.set(4096, 4096);
moon.shadow.camera.near = 1;
moon.shadow.camera.far = 900;
moon.shadow.camera.left = moon.shadow.camera.bottom = -340;
moon.shadow.camera.right = moon.shadow.camera.top = 340;
moon.shadow.bias = -0.0004;
scene.add(moon);
// House lantern
const hLantern = new THREE.PointLight(0xffc044, 4.0, 40, 2);
hLantern.position.set(HOUSE_POS.x - 1, 4.5, HOUSE_POS.z + 5.5);
hLantern.castShadow = true;
scene.add(hLantern);
const hLampGlow = new THREE.Mesh(
new THREE.SphereGeometry(0.2, 8, 8),
new THREE.MeshStandardMaterial({ emissive: 0xffcc44, emissiveIntensity: 3.5, color: 0x000000 })
);
hLampGlow.position.copy(hLantern.position);
scene.add(hLampGlow);
// Castle torches (orange)
const castleTorches = [];
[[-12,0,10],[12,0,10],[-12,0,-10],[12,0,-10]].forEach(([ox,,oz]) => {
const tl = new THREE.PointLight(0xff7700, 2.8, 32, 2);
tl.position.set(CASTLE_POS.x + ox, 6.5, CASTLE_POS.z + oz);
scene.add(tl);
castleTorches.push(tl);
const tm = new THREE.Mesh(
new THREE.SphereGeometry(0.14, 6, 6),
new THREE.MeshStandardMaterial({ emissive: 0xff6600, emissiveIntensity: 4.5, color: 0x000000 })
);
tm.position.copy(tl.position);
scene.add(tm);
});
// Castle evil purple glow system
const castleHaze = new THREE.PointLight(0x5500aa, 1.4, 90, 1.5);
castleHaze.position.set(CASTLE_POS.x, 4, CASTLE_POS.z);
scene.add(castleHaze);
// Castle upward spotbeams
const castleSpots = [];
[[-10,10],[10,10],[-10,-10],[10,-10]].forEach(([ox,oz]) => {
const sl = new THREE.SpotLight(0x7722ee, 4.0, 140, Math.PI / 13, 0.55, 1.4);
sl.position.set(CASTLE_POS.x + ox, 1.5, CASTLE_POS.z + oz);
sl.target.position.set(CASTLE_POS.x + ox, 100, CASTLE_POS.z + oz);
scene.add(sl); scene.add(sl.target);
castleSpots.push(sl);
});
// Castle rotating tower lights
const rotLights = [];
[[-10, 24, -10],[10, 24, 10]].forEach(([ox,oy,oz], i) => {
const rl = new THREE.PointLight(0xcc33ff, 2.2, 70, 2);
rl.position.set(CASTLE_POS.x + ox, oy, CASTLE_POS.z + oz);
scene.add(rl);
rotLights.push({ light: rl, bx: CASTLE_POS.x + ox, bz: CASTLE_POS.z + oz, phase: i * Math.PI });
});
// Castle billboard halos
const castleHalos = [];
function makeHalo(x, y, z, size, col) {
const m = new THREE.Mesh(
new THREE.PlaneGeometry(size, size),
new THREE.MeshBasicMaterial({
color: col, transparent: true, opacity: 0.14,
blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
})
);
m.position.set(x, y, z);
m.renderOrder = 2;
scene.add(m);
castleHalos.push(m);
}
makeHalo(CASTLE_POS.x, 11, CASTLE_POS.z, 60, 0x6611cc);
makeHalo(CASTLE_POS.x, 22, CASTLE_POS.z, 38, 0x9933ff);
[[-10,25,-10],[-10,25,10],[10,25,-10],[10,25,10]].forEach(([ox,oy,oz]) =>
makeHalo(CASTLE_POS.x+ox, oy, CASTLE_POS.z+oz, 14, 0xaa44ff)
);
/* ───────────────────────────────────────────────────────────
SKYBOX
─────────────────────────────────────────────────────────── */
sp(5, 'Painting the sky...');
const skyGeo = new THREE.SphereGeometry(950, 64, 32);
const skyMat = new THREE.MeshBasicMaterial({ color: 0x050810, side: THREE.BackSide, depthWrite: false });
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
scene.add(skyMesh);
(async function loadSky() {
try {
const hdr = await new Promise((res, rej) =>
new THREE.RGBELoader().setDataType(THREE.HalfFloatType).load('sky.hdr', res, undefined, rej));
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
const env = pmrem.fromEquirectangular(hdr);
scene.background = env.texture;
if ('backgroundIntensity' in scene) scene.backgroundIntensity = 0.75;
scene.remove(skyMesh);
hdr.dispose(); pmrem.dispose();
} catch (_) {
try {
const tex = await new Promise((res, rej) =>
new THREE.TextureLoader().load('sky.jpg', res, undefined, rej));
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.encoding = THREE.sRGBEncoding;
scene.background = tex;
scene.remove(skyMesh);
} catch (__) { /* keep dark fallback */ }
}
})();
/* ───────────────────────────────────────────────────────────
GROUND
─────────────────────────────────────────────────────────── */
sp(8, 'Laying the forest floor...');
const groundMat = phong(0x0b1609, 0x020302, 3);
const gnd = new THREE.Mesh(new THREE.PlaneGeometry(W, W, 6, 6), groundMat);
gnd.rotation.x = -Math.PI / 2;
gnd.receiveShadow = true;
scene.add(gnd);
const gndBody = new CANNON.Body({ mass: 0 });
gndBody.addShape(new CANNON.Plane());
gndBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(gndBody);
/* ───────────────────────────────────────────────────────────
RIVER
─────────────────────────────────────────────────────────── */
sp(10, 'Carving the river...');
const riverMat = new THREE.MeshPhongMaterial({
color: 0x081828, specular: 0x1144aa, shininess: 140,
transparent: true, opacity: 0.84
});
const river = new THREE.Mesh(new THREE.PlaneGeometry(RIVER_W, W), riverMat);
river.rotation.x = -Math.PI / 2;
river.position.set(RIVER_X, 0.06, 0);
scene.add(river);
// Banks
[-1, 1].forEach(s => {
const bk = new THREE.Mesh(new THREE.BoxGeometry(3.5, 0.35, W), phong(0x080c06, 0x020202, 3));
bk.position.set(RIVER_X + s * (RIVER_W / 2 + 1.75), 0.12, 0);
scene.add(bk);
});
// River physics walls
[-1, 1].forEach(s => {
const rb = new CANNON.Body({ mass: 0 });
rb.addShape(new CANNON.Box(new CANNON.Vec3(1.2, 5, HW)));
rb.position.set(RIVER_X + s * (RIVER_W / 2 + 1.2), 0, 0);
world.addBody(rb);
});
let riverT = 0;
/* ───────────────────────────────────────────────────────────
BRIDGE (procedural + optional bridge.glb overlay)
─────────────────────────────────────────────────────────── */
sp(12, 'Building the bridge...');
const bridgeDeck = new THREE.Mesh(
new THREE.BoxGeometry(RIVER_W + 5, 0.55, 9),
phong(0x2a1c0e, 0x080604, 14)
);
bridgeDeck.position.set(RIVER_X, 0.38, BRIDGE_Z);
bridgeDeck.castShadow = bridgeDeck.receiveShadow = true;
scene.add(bridgeDeck);
[-1, 1].forEach(s => {
const rail = new THREE.Mesh(new THREE.BoxGeometry(RIVER_W + 5, 1.3, 0.28), phong(0x1a1008, 0x050402, 8));
rail.position.set(RIVER_X, 1.0, BRIDGE_Z + s * 4.4);
rail.castShadow = true;
scene.add(rail);
for (let bx = RIVER_X - RIVER_W / 2 - 2; bx <= RIVER_X + RIVER_W / 2 + 2; bx += 3.8) {
const post = new THREE.Mesh(new THREE.BoxGeometry(0.28, 1.5, 0.28), phong(0x1a1008, 0x050402, 8));
post.position.set(bx, 1.0, BRIDGE_Z + s * 4.4);
post.castShadow = true;
scene.add(post);
}
});
const bridgeBody = new CANNON.Body({ mass: 0 });
bridgeBody.addShape(new CANNON.Box(new CANNON.Vec3(RIVER_W / 2 + 2.5, 0.28, 4.5)));
bridgeBody.position.set(RIVER_X, 0.38, BRIDGE_Z);
world.addBody(bridgeBody);
/* ───────────────────────────────────────────────────────────
GLTF LOADER
─────────────────────────────────────────────────────────── */
const gltfLoader = new THREE.GLTFLoader();
function loadGLTF(url) {
return new Promise((res, rej) => gltfLoader.load(url, res, undefined, rej));
}
// Fire-and-forget bridge.glb (never blocks)
loadGLTF('bridge.glb').then(g => {
applyPhong(g.scene, 0x2a1c0e, 0x080604, 18);
g.scene.position.set(RIVER_X, 0.3, BRIDGE_Z);
scene.add(g.scene);
}).catch(() => {});
/* ───────────────────────────────────────────────────────────
PLAYER PHYSICS BODY
─────────────────────────────────────────────────────────── */
const playerBody = new CANNON.Body({ mass: 75, linearDamping: 0.95, angularDamping: 1.0 });
playerBody.addShape(new CANNON.Sphere(0.42));
playerBody.position.set(SPAWN_POS.x, P_HEIGHT + 0.5, SPAWN_POS.z);
playerBody.fixedRotation = true;
playerBody.updateMassProperties();
world.addBody(playerBody);
/* ───────────────────────────────────────────────────────────
PLAYER VISUAL GROUP (3rd person mesh)
─────────────────────────────────────────────────────────── */
const playerGroup = new THREE.Group();
playerGroup.visible = false; // hidden in FP mode by default
scene.add(playerGroup);
/* Shadow-casting point under player for 3rd-person grounding feel */
const playerShadowLight = new THREE.PointLight(0x000000, 0, 0); // invisible, just for shadow
scene.add(playerShadowLight);
/* ───────────────────────────────────────────────────────────
ANIMATION STATE
─────────────────────────────────────────────────────────── */
let manMixer = null;
let clipIdle = null;
let clipWalk = null;
let clipRun = null;
let activeAction = null; // currently playing AnimationAction object
function playClip(next, xfade) {
if (!next) return;
// Deduplicate by object reference, not by name string
// (when clips share the same object this still works correctly)
if (next === activeAction) return;
// Fade out everything that isn't the incoming clip
[clipIdle, clipWalk, clipRun].forEach(a => {
if (a && a !== next && a.isRunning()) a.fadeOut(xfade);
});
// Reset + fade in the new clip
next.reset()
.setEffectiveTimeScale(1)
.setEffectiveWeight(1)
.fadeIn(xfade)
.play();
activeAction = next;
}
/* ───────────────────────────────────────────────────────────
GTA V CAMERA STATE
─────────────────────────────────────────────────────────── */
let camMode = 0; // 0 = FP, 1 = TP (GTA V)
/* GTA V camera parameters */
const GTA = {
/* Shoulder offset (right shoulder, slightly behind, above) */
shR: 1.0, // units right of player in their local space
shUp: 1.35, // units above player origin
shBack: 4.0, // units behind player
/* Smoothed state */
pos: new THREE.Vector3(),
look: new THREE.Vector3(),
/* Spring dynamics */
velP: new THREE.Vector3(),
velL: new THREE.Vector3(),
/* FOV */
fov: 65,
fovVel: 0,
/* Head-bob */
bobT: 0,
bobAmt: 0,
/* Collision avoidance: push camera forward if wall behind */
minDist: 1.5,
maxDist: 4.5,
curDist: 4.0,
};
/* First-person camera state */
const FP = { fov: 65, fovVel: 0 };
/* ───────────────────────────────────────────────────────────
INPUT STATE
─────────────────────────────────────────────────────────── */
const keys = {};
let yaw = 0;
let pitch = 0; // FP: free pitch. TP: orbit pitch (clamped tighter)
let mouseLock = false;
let running = false;
let stamina = STAM_MAX;
let health = 100;
let won = false;
let startTime = Date.now();
/* Flying */
let flying = false;
let flyVelY = 0;
const FLY_S = 14;
let tUp = false, tDown = false;
addEventListener('keydown', e => { keys[e.code] = true; });
addEventListener('keyup', e => { keys[e.code] = false; });
addEventListener('keydown', e => {
if (e.code === 'KeyV') toggleCam();
if (e.code === 'KeyE') tryEnter();
if (e.code === 'KeyF') toggleFly();
});
/* Pointer lock */
$('c').addEventListener('click', () => { if (!won) $('c').requestPointerLock(); });
document.addEventListener('pointerlockchange', () => { mouseLock = document.pointerLockElement === $('c'); });
document.addEventListener('mousemove', e => {
if (!mouseLock || won) return;
const s = 0.0020;
yaw -= e.movementX * s;
pitch = Math.max(-1.3, Math.min(1.0, pitch - e.movementY * s));
});
/* Touch joystick */
const jSt = { active: false, id: -1, sx: 0, sy: 0, dx: 0, dy: 0 };
const lSt = { active: false, id: -1, lx: 0, ly: 0 };
$('jZone').addEventListener('touchstart', e => {
const t = e.changedTouches[0];
Object.assign(jSt, { 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(lSt, { 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 === jSt.id) {
let dx = t.clientX - jSt.sx, dy = t.clientY - jSt.sy;
const d = Math.sqrt(dx*dx+dy*dy);
if (d > JR) { dx *= JR/d; dy *= JR/d; }
jSt.dx = dx; jSt.dy = dy;
$('jKnob').style.transform = `translate(calc(-50% + ${dx}px),calc(-50% + ${dy}px))`;
}
if (t.identifier === lSt.id) {
const s = camMode === 0 ? 0.0038 : 0.003;
yaw -= (t.clientX - lSt.lx) * s;
pitch = Math.max(-1.3, Math.min(1.0, pitch - (t.clientY - lSt.ly) * s));
lSt.lx = t.clientX; lSt.ly = t.clientY;
}
}
e.preventDefault();
}, { passive: false });
document.addEventListener('touchend', e => {
for (const t of e.changedTouches) {
if (t.identifier === jSt.id) {
Object.assign(jSt, { active: false, dx: 0, dy: 0 });
$('jKnob').style.transform = 'translate(-50%,-50%)';
}
if (t.identifier === lSt.id) lSt.active = false;
}
}, { passive: false });
/* Action buttons */
function bindBtn(id, down, up) {
const el = $(id); if (!el) return;
el.addEventListener('touchstart', () => { down(); el.classList.add('on'); }, { passive: true });
el.addEventListener('touchend', () => { up(); el.classList.remove('on'); }, { passive: true });
el.addEventListener('mousedown', () => { down(); el.classList.add('on'); });
el.addEventListener('mouseup', () => { up(); el.classList.remove('on'); });
}
bindBtn('bY', () => running = true, () => running = false);
bindBtn('dpUp',() => tUp = true, () => tUp = false);
bindBtn('dpDn',() => tDown = true, () => tDown = false);
$('bX').addEventListener('click', tryEnter);
/* ───────────────────────────────────────────────────────────
UI TOGGLE FUNCTIONS
─────────────────────────────────────────────────────────── */
function toggleCam() {
camMode = camMode === 0 ? 1 : 0;
$('camLbl').textContent = camMode === 0 ? 'FIRST PERSON' : 'THIRD PERSON';
$('xh').classList.toggle('hide', camMode === 1);
playerGroup.visible = camMode === 1;
if (camMode === 1) {
/* Seed GTA camera at current position to avoid pop-in */
const p = playerBody.position;
const sinY = Math.sin(yaw), cosY = Math.cos(yaw);
GTA.pos.set(
p.x + sinY * GTA.shBack + cosY * GTA.shR,
p.y + GTA.shUp,
p.z + cosY * GTA.shBack - sinY * GTA.shR
);
GTA.look.set(p.x, p.y + 1.2, p.z);
GTA.velP.set(0, 0, 0);
GTA.velL.set(0, 0, 0);
GTA.curDist = GTA.shBack;
}
}
function toggleFly() {
flying = !flying;
$('flyBar').classList.toggle('show', flying);
$('shR').classList.toggle('on', flying);
$('dpad').classList.toggle('show', flying);
if (flying) {
playerBody.linearDamping = 0.94;
playerBody.velocity.set(0, 0, 0);
flyVelY = 0;
} else {
playerBody.linearDamping = 0.95;
}
}
/* ───────────────────────────────────────────────────────────
WIN / FLASH
─────────────────────────────────────────────────────────── */
function tryEnter() {
if (won) return;
const px = playerBody.position.x, pz = playerBody.position.z;
const cd = Math.sqrt((px-CASTLE_POS.x)**2+(pz-CASTLE_POS.z)**2);
const hd = Math.sqrt((px-HOUSE_POS.x)**2+(pz-HOUSE_POS.z)**2);
if (cd < WIN_DIST + 8) { winGame('castle'); return; }
if (hd < 10) { winGame('house'); return; }
$('flash').style.background = 'rgba(139,0,0,.3)';
setTimeout(() => $('flash').style.background = '', 280);
}
function winGame(type) {
if (won) return;
won = true;
const elapsed = Math.round((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60), secs = elapsed % 60;
$('et').textContent = type === 'castle' ? '🏰 Castle Reached!' : '🏠 House Found!';
$('eb').textContent = type === 'castle'
? 'The ancient walls offer cold shelter. You survived the forest.'
: 'A small refuge from the darkness. The castle still looms beyond…';
$('stats').textContent = `Time: ${mins}:${secs.toString().padStart(2,'0')} · Stamina used: ${Math.round((1-stamina/STAM_MAX)*100)}%`;
$('end').classList.add('show');
}
/* ───────────────────────────────────────────────────────────
FALLBACK GEOMETRY
─────────────────────────────────────────────────────────── */
function fallbackTree(x, z, sc) {
const g = new THREE.Group();
const tr = new THREE.Mesh(new THREE.CylinderGeometry(.22, .4, 5.5, 7), phong(0x2e1a08, 0x0a0602, 8));
tr.position.y = 2.75; tr.castShadow = true;
const cr = new THREE.Mesh(new THREE.ConeGeometry(2.2, 6, 7), phong(0x0a2208, 0x020801, 10));
cr.position.y = 7.5; cr.castShadow = true;
g.add(tr, cr); g.position.set(x, 0, z); g.scale.setScalar(sc);
scene.add(g);
}
function fallbackHouse() {
const g = new THREE.Group();
const w = new THREE.Mesh(new THREE.BoxGeometry(7, 5, 6), phong(0x3a2212, 0x0e0906, 20));
w.position.y = 2.5; w.castShadow = w.receiveShadow = true;
const r = new THREE.Mesh(new THREE.ConeGeometry(5.5, 3.5, 4), phong(0x1e0e06, 0x050302, 8));
r.position.y = 6.75; r.rotation.y = Math.PI / 4; r.castShadow = true;
g.add(w, r); g.position.copy(HOUSE_POS); g.rotation.y = Math.PI;
scene.add(g);
}
function fallbackCastle() {
const g = new THREE.Group();
const keep = new THREE.Mesh(new THREE.BoxGeometry(22, 20, 22), phong(0x2a2520, 0x0a0908, 10));
keep.position.y = 10; keep.castShadow = keep.receiveShadow = true;
g.add(keep);
for (let i = 0; i < 8; i++) {
const bt = new THREE.Mesh(new THREE.BoxGeometry(2.2, 2.2, 2.2), phong(0x242014, 0x080706, 7));
const a = (i / 8) * Math.PI * 2;
bt.position.set(Math.cos(a)*12, 20, Math.sin(a)*12); bt.castShadow = true; g.add(bt);
}
[[-10,0,-10],[-10,0,10],[10,0,-10],[10,0,10]].forEach(([ox,,oz]) => {
const tw = new THREE.Mesh(new THREE.CylinderGeometry(2.4, 2.8, 25, 10), phong(0x2a2520, 0x0a0908, 8));
tw.position.set(ox, 12.5, oz); tw.castShadow = true; g.add(tw);
const tc = new THREE.Mesh(new THREE.ConeGeometry(2.8, 4.5, 10), phong(0x1a1512, 0x060504, 7));
tc.position.set(ox, 27, oz); tc.castShadow = true; g.add(tc);
});
g.position.copy(CASTLE_POS);
scene.add(g);
}
function fallbackMan() {
const g = new THREE.Group();
const torso = new THREE.Mesh(new THREE.CylinderGeometry(.22,.26,1.0,8), phong(0x3d2b1a,0x100a05,18));
torso.position.y = .85; torso.castShadow = true;
const topCap = new THREE.Mesh(new THREE.SphereGeometry(.22,8,6), phong(0x3d2b1a,0x100a05,18));
topCap.position.y = 1.35; topCap.castShadow = true;
const botCap = new THREE.Mesh(new THREE.SphereGeometry(.26,8,6), phong(0x3d2b1a,0x100a05,18));
botCap.position.y = .35; botCap.castShadow = true;
const head = new THREE.Mesh(new THREE.SphereGeometry(.2,8,8), phong(0x4a3020,0x120c06,22));
head.position.y = 1.72; head.castShadow = true;
g.add(torso, topCap, botCap, head);
return g;
}
/* ───────────────────────────────────────────────────────────
PHYSICS HELPERS
─────────────────────────────────────────────────────────── */
function addTreeCol(x, z) {
const b = new CANNON.Body({ mass: 0 });
b.addShape(new CANNON.Cylinder(0.42, 0.42, 12, 6));
b.position.set(x, 0, z);
world.addBody(b);
}
function addBoxCol(px, py, pz, sx, sy, sz) {
const b = new CANNON.Body({ mass: 0 });
b.addShape(new CANNON.Box(new CANNON.Vec3(sx, sy, sz)));
b.position.set(px, py, pz);
world.addBody(b);
}
/* ───────────────────────────────────────────────────────────
FIREFLIES
─────────────────────────────────────────────────────────── */
function makeFireflies() {
const N = 130;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
pos[i*3] = (Math.random() - .5) * W * .85;
pos[i*3+1] = .5 + Math.random() * 2.8;
pos[i*3+2] = (Math.random() - .5) * W * .85;
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
color: 0x88ffaa, size: 0.15, transparent: true, opacity: .7,
blending: THREE.AdditiveBlending, depthWrite: false
});
scene.add(new THREE.Points(geo, mat));
const vel = Array.from({ length: N }, () => ({
x: (Math.random()-.5)*.32, y: Math.random()*.24+.04, z: (Math.random()-.5)*.32
}));
return dt => {
const a = geo.attributes.position.array;
for (let i = 0; i < N; i++) {
a[i*3] += vel[i].x * dt;
a[i*3+1] += vel[i].y * dt;
a[i*3+2] += vel[i].z * dt;
if (a[i*3+1] > 3.8) a[i*3+1] = .5;
}
geo.attributes.position.needsUpdate = true;
mat.opacity = .52 + Math.sin(Date.now() * .0012) * .2;
};
}
const tickFF = makeFireflies();
/* ───────────────────────────────────────────────────────────
MINIMAP / RADAR
─────────────────────────────────────────────────────────── */
const mmCtx = $('mm').getContext('2d');
const MM = 110;
const toMM = (wx, wz) => ({ x: (wx / W + .5) * MM, y: (wz / W + .5) * MM });
function drawRadar() {
mmCtx.clearRect(0, 0, MM, MM);
// Background
mmCtx.fillStyle = 'rgba(5,8,18,.88)';
mmCtx.fillRect(0, 0, MM, MM);
// Radar grid rings
mmCtx.strokeStyle = 'rgba(255,255,255,.04)';
mmCtx.lineWidth = 1;
[0.25, 0.5, 0.75].forEach(r => {
mmCtx.beginPath(); mmCtx.arc(MM/2, MM/2, r*MM/2, 0, Math.PI*2); mmCtx.stroke();
});
// River (blue strip)
const r1 = toMM(RIVER_X - RIVER_W/2, -HW);
const r2 = toMM(RIVER_X + RIVER_W/2, HW);
mmCtx.fillStyle = 'rgba(10,28,60,.9)';
mmCtx.fillRect(r1.x, 0, r2.x - r1.x, MM);
// Bridge (tan bar)
const br = toMM(RIVER_X, BRIDGE_Z);
mmCtx.fillStyle = '#6a4818';
mmCtx.fillRect(r1.x - 1, br.y - 4, r2.x - r1.x + 2, 8);
// House blip (orange)
const hp = toMM(HOUSE_POS.x, HOUSE_POS.z);
mmCtx.fillStyle = '#dd8822';
mmCtx.fillRect(hp.x - 3, hp.y - 3, 6, 6);
// Castle blip (grey square with glow)
const cp = toMM(CASTLE_POS.x, CASTLE_POS.z);
mmCtx.shadowColor = '#aa44ff'; mmCtx.shadowBlur = 6;
mmCtx.strokeStyle = '#ccaaff'; mmCtx.lineWidth = 1.5;
mmCtx.strokeRect(cp.x - 5, cp.y - 5, 10, 10);
mmCtx.shadowBlur = 0;
// Player blip (red dot)
const pp = toMM(playerBody.position.x, playerBody.position.z);
mmCtx.fillStyle = '#ff3333';
mmCtx.shadowColor = '#ff0000'; mmCtx.shadowBlur = 5;
mmCtx.beginPath(); mmCtx.arc(pp.x, pp.y, 3.5, 0, Math.PI * 2); mmCtx.fill();
mmCtx.shadowBlur = 0;
// Direction arrow
mmCtx.strokeStyle = '#ff5555'; mmCtx.lineWidth = 1.5;
mmCtx.beginPath();
mmCtx.moveTo(pp.x, pp.y);
mmCtx.lineTo(pp.x - Math.sin(yaw) * 9, pp.y - Math.cos(yaw) * 9);
mmCtx.stroke();
// Clip to circle mask
mmCtx.globalCompositeOperation = 'destination-in';
mmCtx.beginPath(); mmCtx.arc(MM/2, MM/2, MM/2, 0, Math.PI * 2); mmCtx.fill();
mmCtx.globalCompositeOperation = 'source-over';
}
/* ═══════════════════════════════════════════════════════════
MAIN ASYNC LOADER
═══════════════════════════════════════════════════════════ */
(async () => {
try {
/* ── Man / Player model ── */
sp(15, 'Loading the survivor...');
try {
const g = await loadGLTF('man.glb');
applyPhong(g.scene, 0x4a3020, 0x150e08, 25);
/* ── Auto-scale to MAN_H world units ── */
autoScale(g.scene, MAN_H);
playerGroup.add(g.scene);
/* ── Animation mixer ── */
if (g.animations && g.animations.length > 0) {
manMixer = new THREE.AnimationMixer(g.scene);
console.log('man.glb clips:', g.animations.map(a => a.name));
g.animations.forEach(clip => {
const n = clip.name.toLowerCase();
if (!clipRun && (n.includes('run') || n.includes('sprint') || n.includes('jog'))) {
clipRun = manMixer.clipAction(clip); clipRun.name = 'run';
} else if (!clipWalk && (n.includes('walk') || n.includes('move') || n.includes('stroll'))) {
clipWalk = manMixer.clipAction(clip); clipWalk.name = 'walk';
} else if (!clipIdle && (n.includes('idle') || n.includes('stand') || n.includes('breath') || n.includes('rest'))) {
clipIdle = manMixer.clipAction(clip); clipIdle.name = 'idle';
}
});
/* Index-based fallbacks */
const cl = g.animations;
if (!clipIdle && cl[0]) { clipIdle = manMixer.clipAction(cl[0]); clipIdle.name = 'idle'; }
if (!clipWalk && cl[1]) { clipWalk = manMixer.clipAction(cl[1]); clipWalk.name = 'walk'; }
if (!clipRun && cl[2]) { clipRun = manMixer.clipAction(cl[2]); clipRun.name = 'run'; }
/* If only 1 clip, reuse */
if (!clipWalk) clipWalk = clipIdle;
if (!clipRun) clipRun = clipWalk;
if (clipIdle) { clipIdle.play(); activeAction = clipIdle; }
}
} catch (_) {
playerGroup.add(fallbackMan());
console.log('man.glb missing, using fallback');
}
playerGroup.visible = false;
/* ── Trees ── */
sp(22, 'Planting the forest...');
let treeTpl = null;
try {
const g = await loadGLTF('tree.glb');
applyPhong(g.scene, 0x0e2a0a, 0x030801, 12);
// Auto-scale trees to reasonable height (5–8m)
autoScale(g.scene, 6.0);
treeTpl = g.scene;
} catch (_) { console.log('tree.glb missing, using fallback'); }
function isClear(x, z) {
if (Math.sqrt(x*x + (z-60)**2) < 16) return false;
if (Math.sqrt((x-HOUSE_POS.x)**2 + (z-HOUSE_POS.z)**2) < 14) return false;
if (Math.sqrt((x-CASTLE_POS.x)**2 + (z-CASTLE_POS.z)**2) < 30) return false;
if (Math.abs(x - RIVER_X) < RIVER_W/2 + 1.5) return false;
if (Math.abs(x - RIVER_X) < RIVER_W/2 + 6 && Math.abs(z - BRIDGE_Z) < 14) return false;
return true;
}
const rng = Math.random;
const treePos = [];
let att = 0;
while (treePos.length < TREE_N && att < TREE_N * 15) {
att++;
const tx = (rng()-.5)*W*.95, tz = (rng()-.5)*W*.95;
if (isClear(tx, tz)) treePos.push([tx, tz]);
}
const BATCH = 40;
for (let s = 0; s < treePos.length; s += BATCH) {
const e = Math.min(s + BATCH, treePos.length);
for (let i = s; i < e; i++) {
const [tx, tz] = treePos[i];
const sc = .65 + rng() * .85;
if (treeTpl) {
const t = treeTpl.clone();
t.position.set(tx, 0, tz);
t.scale.multiplyScalar(sc); // multiply on top of autoScale
t.rotation.y = rng() * Math.PI * 2;
scene.add(t);
} else {
fallbackTree(tx, tz, sc);
}
// Selective physics colliders
const nearPath =
Math.abs(tx - RIVER_X) < 45 ||
Math.abs(tz - BRIDGE_Z) < 45 ||
Math.sqrt((tx-CASTLE_POS.x)**2+(tz-CASTLE_POS.z)**2) < 65 ||
Math.sqrt((tx-HOUSE_POS.x)**2 +(tz-HOUSE_POS.z)**2) < 45;
if (nearPath) addTreeCol(tx, tz);
}
sp(22 + Math.round((e / treePos.length) * 26), `Planting trees... ${e}/${treePos.length}`);
await new Promise(r => setTimeout(r, 0));
}
/* ── House ── */
sp(52, '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 (_) { fallbackHouse(); console.log('house.glb missing'); }
addBoxCol(HOUSE_POS.x, 4, HOUSE_POS.z, 4.5, 4, 3.5);
/* ── Castle ── */
sp(66, 'Raising the castle...');
try {
const g = await loadGLTF('castle.glb');
applyPhong(g.scene, 0x2a2520, 0x0a0908, 14);
// Auto-scale castle to ~30m tall
autoScale(g.scene, 30.0);
g.scene.position.copy(CASTLE_POS);
scene.add(g.scene);
} catch (_) { fallbackCastle(); console.log('castle.glb missing, using procedural'); }
// Castle perimeter walls — 4 thin walls leaving a gate gap on the south face
// Half-extents: wall thickness=1, height=12, length=half-castle-side=12
const CW = 13; // half-length of castle side
const CT = 1; // wall thickness half-extent
const CH = 12; // wall height half-extent
const cx = CASTLE_POS.x, cz = CASTLE_POS.z;
// North wall (full)
addBoxCol(cx, CH, cz - CW, CW, CH, CT);
// East wall (full)
addBoxCol(cx + CW, CH, cz, CT, CH, CW);
// West wall (full)
addBoxCol(cx - CW, CH, cz, CT, CH, CW);
// South wall split into two halves — 5-unit gap in centre for gate
const GATE_W = 5;
addBoxCol(cx + (CW - GATE_W) / 2 + GATE_W / 2, CH, cz + CW, (CW - GATE_W) / 2, CH, CT);
addBoxCol(cx - (CW - GATE_W) / 2 - GATE_W / 2, CH, cz + CW, (CW - GATE_W) / 2, CH, CT);
sp(88, 'Preparing the night...');
await new Promise(r => setTimeout(r, 300));
sp(100, 'Enter if you dare...');
await new Promise(r => setTimeout(r, 700));
$('ls').style.opacity = '0';
setTimeout(() => $('ls').style.display = 'none', 900);
/* ═════════════════════════════════════════════════════
GAME LOOP
═════════════════════════════════════════════════════ */
const clock = new THREE.Clock();
const FXDT = 1 / 60;
let accum = 0;
// Reusable vectors
const _tgt = new THREE.Vector3();
const _lTgt = new THREE.Vector3();
function loop() {
requestAnimationFrame(loop);
const dt = Math.min(clock.getDelta(), 0.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 (jSt.active) {
const jx = jSt.dx / JR, jy = jSt.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);
}
$('sfill').style.width = (stamina / STAM_MAX * 100) + '%';
$('hfill').style.width = health + '%';
const speed = (running && stamina > 5) ? 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;
/* ── Flying ── */
if (flying) {
let fy = 0;
if (keys['Space'] || keys['KeyQ'] || tUp) fy = 1;
if (keys['KeyC'] || keys['ControlLeft'] || keys['KeyZ'] || tDown) fy = -1;
flyVelY = flyVelY * 0.88 + fy * 22 * dt;
if (playerBody.position.y < P_HEIGHT + 0.5 && flyVelY < 0) flyVelY = 0;
}
playerBody.velocity.set(wfx * speed, flying ? flyVelY * FLY_S : playerBody.velocity.y, wfz * speed);
playerBody.angularVelocity.set(0, 0, 0);
/* ── Physics step ── */
accum += dt;
if (flying) {
const sg = world.gravity.y;
world.gravity.y = 0;
while (accum >= FXDT) { world.step(FXDT); accum -= FXDT; }
world.gravity.y = sg;
} else {
flyVelY *= 0.82;
while (accum >= FXDT) { world.step(FXDT); accum -= FXDT; }
}
const px = playerBody.position.x;
const py = playerBody.position.y;
const pz = playerBody.position.z;
const isRunning = running && stamina > 5 && mag > .05;
/* ── Player mesh sync (3rd person) ── */
playerGroup.position.set(px, py - 0.42, pz);
playerGroup.rotation.y = yaw + Math.PI;
/* ── Animation state machine ── */
if (manMixer) {
if (isRunning) playClip(clipRun, 0.18);
else if (mag > .06) playClip(clipWalk, 0.22);
else playClip(clipIdle, 0.28);
}
/* ════════════════════════════════════════════════
GTA V CAMERA
════════════════════════════════════════════════ */
if (camMode === 0) {
/* ── First Person ── */
camera.position.set(px, py + P_HEIGHT * 0.55, pz);
camera.rotation.order = 'YXZ';
camera.rotation.y = yaw;
camera.rotation.x = pitch;
// FOV breath: 65 idle → 75 sprint
FP.fovVel = FP.fovVel * 0.85 + ((isRunning ? 75 : 65) - FP.fov) * 5 * dt;
FP.fov += FP.fovVel;
camera.fov = FP.fov;
camera.updateProjectionMatrix();
} else {
/* ── GTA V Third Person ──────────────────────────────────
Right-shoulder camera with:
• Spring-damped follow (not lerp — spring gives GTA feel)
• Pitch-driven vertical arc (look-pad tilts camera up/down)
• FOV ramps 65→82 during sprint
• Camera-bob synced to footsteps
• Collision push: camera zooms in if something is behind player
──────────────────────────────────────────────────────── */
// Spring constants: higher = snappier
const sK = isRunning ? 10 : 7.5; // position spring
const dK = 0.70; // damping ratio
// Pitch-driven back distance: looking up moves camera further back
const pitchFactor = 1.0 + Math.max(0, -pitch) * 0.5;
const targetDist = GTA.shBack * pitchFactor;
// Shoulder offset rotated by yaw
// Behind: along -forward direction; Right: perpendicular
const fwdX = sinY; // forward X in world
const fwdZ = cosY; // forward Z in world
const rightX = cosY; // right X
const rightZ = -sinY; // right Z
// Head bob
GTA.bobT += dt * (isRunning ? 9 : 5) * (mag > .05 ? 1 : 0);
const bob = Math.sin(GTA.bobT) * (isRunning ? 0.14 : 0.05) * (mag > .05 ? 1 : 0);
// Pitch-driven vertical arc for camera
const pitchUp = Math.sin(pitch) * 2.8;
// Target camera position (world space)
_tgt.set(
px + fwdX * targetDist + rightX * GTA.shR,
py + GTA.shUp + bob - pitchUp * 0.3,
pz + fwdZ * targetDist + rightZ * GTA.shR
);
// Spring integration for position
GTA.velP.x = GTA.velP.x * dK + (_tgt.x - GTA.pos.x) * sK * dt;
GTA.velP.y = GTA.velP.y * dK + (_tgt.y - GTA.pos.y) * sK * dt;
GTA.velP.z = GTA.velP.z * dK + (_tgt.z - GTA.pos.z) * sK * dt;
GTA.pos.x += GTA.velP.x;
GTA.pos.y += GTA.velP.y;
GTA.pos.z += GTA.velP.z;
camera.position.copy(GTA.pos);
// Look target: slightly ahead of player + pitch offset
// Looking up in TP = camera looks further above player (GTA aim-up feel)
const aheadScale = isRunning ? 2.5 : 1.5;
_lTgt.set(
px - fwdX * aheadScale,
py + 1.25 + pitchUp,
pz - fwdZ * aheadScale
);
GTA.look.lerp(_lTgt, isRunning ? 0.18 : 0.12);
camera.lookAt(GTA.look);
// FOV spring
const tFov = isRunning ? 82 : 65;
GTA.fovVel = GTA.fovVel * 0.86 + (tFov - GTA.fov) * 5 * dt;
GTA.fov += GTA.fovVel;
camera.fov = GTA.fov;
camera.updateProjectionMatrix();
}
/* ── Castle glow animation ── */
const ct = Date.now() * 0.001;
const cDist = Math.sqrt((px-CASTLE_POS.x)**2+(pz-CASTLE_POS.z)**2);
// Halos face camera + pulse
castleHalos.forEach((m, i) => {
m.lookAt(camera.position);
const prox = Math.max(0, 1 - cDist / 260);
m.material.opacity = 0.1 + Math.sin(ct * 1.3 + i * 0.9) * 0.06 + prox * 0.16;
});
// Spotbeam pulse
castleSpots.forEach((sl, i) => {
sl.intensity = 3.2 + Math.sin(ct * 1.9 + i * 1.1) * 1.4;
});
// Rotating tower lights
rotLights.forEach(({ light, bx, bz, phase }, i) => {
const a = ct * 0.55 + phase;
light.position.x = bx + Math.cos(a) * 7;
light.position.z = bz + Math.sin(a) * 7;
light.intensity = 2.0 + Math.sin(ct * 2.1 + i) * 0.7;
});
castleHaze.intensity = 1.1 + Math.sin(ct * 0.6) * 0.4;
// CSS overlay near castle
if (cDist < 220) $('cgo').classList.add('near');
else $('cgo').classList.remove('near');
/* ── House lantern flicker ── */
const tn = Date.now();
hLantern.intensity = 3.8 + Math.sin(tn * .007) * .25 + Math.sin(tn * .021) * .12;
/* ── Castle torch flicker ── */
castleTorches.forEach((tl, i) => {
tl.intensity = 2.6 + Math.sin(tn * .009 + i) * .2 + Math.sin(tn * .025 + i) * .09;
});
/* ── River shimmer ── */
riverT += dt;
riverMat.opacity = 0.8 + Math.sin(riverT * .9) * 0.04;
riverMat.color.setHSL(0.6, 0.6, 0.06 + Math.sin(riverT * .5) * 0.01);
/* ── Objective HUD ── */
const hDist = Math.sqrt((px-HOUSE_POS.x)**2+(pz-HOUSE_POS.z)**2);
let objTxt;
if (cDist < 60)
objTxt = `Castle is ${Math.round(cDist)}m away — press E or ENTER`;
else if (hDist < 35)
objTxt = `House is ${Math.round(hDist)}m away — seek shelter`;
else if (cDist < 150)
objTxt = `Castle glimpsed ${Math.round(cDist)}m ahead — keep moving`;
else
objTxt = 'Find the castle deep in the forest';
$('ot').textContent = objTxt;
/* ── Win ── */
if (cDist < WIN_DIST) winGame('castle');
/* ── Fireflies ── */
tickFF(dt);
/* ── Radar ── */
drawRadar();
}
/* ── Animation mixer: tick every frame (even FP, even won) ── */
if (manMixer) manMixer.update(dt);
renderer.render(scene, camera);
}
loop();
} catch (err) {
console.error('Fatal error:', err);
$('lt').textContent = 'Error: ' + (err.message || err);
$('lt').style.color = '#f55';
}
})();
</script>
</body>
</html>