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