Spaces:
Running
Running
| <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()">▶ 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 · Castle · River · 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 · Mouse=Look · Shift=Run · E=Enter · V=Camera · F=Fly · 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> | |
| ; | |
| /* ═══════════════════════════════════════════════════════════════════ | |
| 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> |