Meta Description" name="description" />
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whisper House</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
:root{--bg:#060606;--fg:#c4b8a8;--muted:#5a5044;--accent:#8b1a1a;--card:#0d0d0d;--border:#1e1a14;--glow:#c9a44c}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:'Crimson Text',serif;overflow:hidden;height:100vh;width:100vw;user-select:none}
h1,h2,h3,.tf{font-family:'Cinzel',serif}
.screen{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:100;transition:opacity .8s ease}
.screen.hidden{opacity:0;pointer-events:none}
#menu-screen{background:var(--bg)}
.menu-bg{position:absolute;inset:0;overflow:hidden}
.menu-bg::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at 50% 80%,rgba(139,26,26,.08) 0%,transparent 60%),radial-gradient(ellipse at 20% 20%,rgba(201,164,76,.03) 0%,transparent 50%)}
.mp{position:absolute;width:2px;height:2px;background:rgba(201,164,76,.15);border-radius:50%;animation:fu linear infinite}
@keyframes fu{0%{transform:translateY(100vh) scale(1);opacity:0}10%{opacity:1}90%{opacity:1}100%{transform:translateY(-10vh) scale(0);opacity:0}}
.mc{position:relative;z-index:2;text-align:center;padding:1rem}
.mt{font-size:clamp(2.5rem,6vw,4.5rem);font-weight:900;letter-spacing:.15em;color:var(--fg);text-shadow:0 0 40px rgba(139,26,26,.3);line-height:1.1}
.ms{font-size:clamp(.85rem,1.5vw,1.1rem);color:var(--muted);letter-spacing:.3em;text-transform:uppercase;margin-top:.5rem}
.md{width:120px;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);margin:1.8rem auto}
.btn{display:inline-flex;align-items:center;gap:.75rem;padding:.85rem 2.2rem;border:1px solid var(--border);background:var(--card);color:var(--fg);font-family:'Cinzel',serif;font-size:.95rem;letter-spacing:.08em;cursor:pointer;transition:all .3s ease;min-width:240px;justify-content:center}
.btn:hover{border-color:var(--accent);background:rgba(139,26,26,.12);box-shadow:0 0 20px rgba(139,26,26,.15)}
.btn:active{transform:scale(.97)}
.btn-p{border-color:var(--accent);background:rgba(139,26,26,.18)}
.ctrl-box{background:rgba(13,13,13,.6);border:1px solid var(--border);padding:1.2rem 1.5rem;margin-top:1.5rem;text-align:left;max-width:460px;width:90%}
.ctrl-box h3{font-size:.75rem;letter-spacing:.15em;color:var(--glow);margin-bottom:.6rem;text-transform:uppercase}
.ctrl-row{display:flex;justify-content:space-between;font-size:.8rem;color:var(--muted);padding:.15rem 0}
.ctrl-row span:last-child{color:var(--fg);font-size:.75rem}
#game-screen{z-index:50}
#game-canvas{position:fixed;inset:0;width:100%;height:100%;display:block}
#hud{position:fixed;inset:0;pointer-events:none;z-index:60}
#crosshair{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:4px;border:1px solid rgba(196,184,168,.4);border-radius:50%;transition:all .2s}
#crosshair.act{width:8px;height:8px;border-color:var(--glow);box-shadow:0 0 8px rgba(201,164,76,.3)}
#prompt{position:absolute;top:56%;left:50%;transform:translateX(-50%);font-family:'Cinzel',serif;font-size:.78rem;letter-spacing:.1em;color:var(--glow);opacity:0;transition:opacity .2s;text-shadow:0 0 10px rgba(201,164,76,.3);white-space:nowrap}
#prompt.vis{opacity:1}
#inv{position:absolute;bottom:1.5rem;left:50%;transform:translateX(-50%);display:flex;gap:.6rem}
.islot{width:52px;height:52px;border:1px solid var(--border);background:rgba(13,13,13,.85);display:flex;align-items:center;justify-content:center;position:relative;transition:all .3s}
.islot.has{border-color:var(--glow);box-shadow:0 0 10px rgba(201,164,76,.1)}
.islot i{font-size:1rem;color:var(--glow);opacity:.7}
.islot .il{position:absolute;bottom:-1.1rem;left:50%;transform:translateX(-50%);font-size:.55rem;color:var(--muted);white-space:nowrap}
#smsg{position:absolute;top:22%;left:50%;transform:translateX(-50%);font-family:'Cinzel',serif;font-size:.82rem;letter-spacing:.08em;color:var(--fg);opacity:0;transition:opacity .5s;text-align:center;max-width:420px;pointer-events:none}
#smsg.vis{opacity:1}
#note-panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--card);border:1px solid var(--border);padding:2rem 2.2rem;max-width:380px;width:90%;z-index:75;pointer-events:auto;opacity:0;transition:opacity .4s;cursor:pointer}
#note-panel.hid{opacity:0;pointer-events:none}
.nt{font-family:'Cinzel',serif;font-size:.82rem;color:var(--glow);letter-spacing:.1em;margin-bottom:.7rem}
.nb{font-size:1rem;line-height:1.7;color:var(--fg);font-style:italic}
.nh{font-size:.65rem;color:var(--muted);margin-top:1rem;letter-spacing:.05em}
#pindicator{position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);font-family:'Cinzel',serif;font-size:.7rem;letter-spacing:.12em;color:var(--muted);background:rgba(6,6,6,.6);padding:.3rem 1rem;border:1px solid var(--border)}
#pindicator .pname{color:var(--glow)}
#vignette{position:fixed;inset:0;pointer-events:none;z-index:55;background:radial-gradient(ellipse at center,transparent 35%,rgba(0,0,0,.9) 100%)}
#distort{position:fixed;inset:0;pointer-events:none;z-index:56;opacity:0;transition:opacity .1s;mix-blend-mode:screen;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(139,26,26,.04) 2px,rgba(139,26,26,.04) 4px)}
#esc-screen{background:rgba(6,6,6,.95)}
.et{font-size:clamp(2rem,5vw,3.5rem);color:var(--fg);letter-spacing:.1em}
#lt{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-family:'Cinzel',serif;font-size:.85rem;color:var(--muted);letter-spacing:.15em;animation:lp 1.5s ease infinite}
@keyframes lp{0%,100%{opacity:.3}50%{opacity:.8}}
@media(prefers-reduced-motion:reduce){.mp{animation:none;display:none}*{transition-duration:0s!important;animation-duration:0s!important}}
</style>
</head>
<body>
<div id="menu-screen" class="screen">
<div class="menu-bg" id="mbg"></div>
<div class="mc">
<div class="mt">WHISPER HOUSE</div>
<div class="ms">A Cooperative Horror Experience</div>
<div class="md"></div>
<button class="btn btn-p" id="btn-start"><i class="fas fa-door-open"></i> Enter the House</button>
<div class="ctrl-box">
<h3>Local Co-op Controls</h3>
<div class="ctrl-row"><span>Player 1 — Move</span><span>W A S D</span></div>
<div class="ctrl-row"><span>Player 1 — Look</span><span>Mouse</span></div>
<div class="ctrl-row"><span>Player 1 — Interact</span><span>E</span></div>
<div class="ctrl-row" style="margin-top:.5rem;border-top:1px solid var(--border);padding-top:.5rem"><span>Player 2 — Move</span><span>Arrow Keys</span></div>
<div class="ctrl-row"><span>Player 2 — Look</span><span>I J K L</span></div>
<div class="ctrl-row"><span>Player 2 — Interact</span><span>U</span></div>
<div class="ctrl-row" style="margin-top:.5rem;border-top:1px solid var(--border);padding-top:.5rem"><span>Switch Active Player</span><span>Tab</span></div>
</div>
<div style="margin-top:1.5rem;font-size:.65rem;color:var(--muted);letter-spacing:.08em;max-width:380px;margin-left:auto;margin-right:auto;line-height:1.5">
Two players explore the same house. Each sees different clues. Communicate to escape.
</div>
</div>
</div>
<div id="game-screen" class="screen hidden">
<canvas id="game-canvas"></canvas>
<div id="vignette"></div>
<div id="distort"></div>
<div id="hud">
<div id="pindicator">Active: <span class="pname" id="pname">Player 1</span> (Tab to switch)</div>
<div id="crosshair"></div>
<div id="prompt">[E] INTERACT</div>
<div id="smsg"></div>
<div id="inv">
<div class="islot" id="s0"><span class="il"></span></div>
<div class="islot" id="s1"><span class="il"></span></div>
</div>
</div>
<div id="note-panel" class="hid">
<div class="nt" id="ntitle"></div>
<div class="nb" id="nbody"></div>
<div class="nh">Click to close</div>
</div>
<div id="lt">ENTERING THE HOUSE...</div>
</div>
<div id="esc-screen" class="screen hidden">
<div class="et tf">YOU ESCAPED</div>
<div class="md" style="margin:1.5rem auto"></div>
<div style="font-size:1.05rem;color:var(--muted);max-width:400px;text-align:center;line-height:1.8;margin-bottom:2rem;padding:0 1rem">
The door yields to the night air. Cold wind rushes past your face.<br>
Behind you, the house groans — then falls silent.<br><br>
<em>Don't look back.</em>
</div>
<button class="btn" id="btn-re"><i class="fas fa-rotate-left"></i> Return to Menu</button>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
/* ===== CONSTANTS ===== */
const RW=5.5, RH=3.2, RD=5.5, WL=0.15, FY=0, CY=3.2;
const PH=1.6, PR=0.28, MSPD=2.8, SSPD=4.2;
const IDIST=2.2, IANGLE=0.55;
const ROOMS=[
{id:'foyer',name:'Foyer',x:0,z:0},
{id:'kitchen',name:'Kitchen',x:5.65,z:0},
{id:'study',name:'Study',x:0,z:-5.65},
{id:'corridor',name:'Corridor',x:5.65,z:-5.65}
];
const ITEMP={
brassKey:{name:'Brass Key',icon:'fa-key'},
moonLight:{name:'Moonstone',icon:'fa-moon'}
};
const NOTES={
tornPage:{title:'Torn Page',body:'I can hear it moving above me now. Not walking — sliding. Like something dragged across the floor by invisible hands. The door won\'t hold. The code is the date we arrived. 1-8-4-7. Remember the order. Remember the—'},
journal:{title:'Journal Entry',body:'November 14th. The door to the east appeared today — the one I\'ve been trying to reach. It\'s sealed with a lock I don\'t recognize. The key must be somewhere in this house. I\'ve searched the kitchen a hundred times. Maybe it doesn\'t want to be found.'},
diary:{title:'Diary Page',body:'We found the moonstone in the corridor. It pulses when the entity is near. I don\'t know what it means. But I know we shouldn\'t stay here after dark. The whispers get louder when the lights go out. If you\'re reading this — check the keypad by the front door. The code was written on a page. Find it.'}
};
const DLABEL={foyer_kitchen:'Door to Kitchen',foyer_study:'Door to Study',kitchen_corridor:'Door to Corridor',study_corridor:'Door to Corridor'};
/* ===== STATE ===== */
const S={
screen:'menu',activeP:0,noteOpen:false,locked:false,
opened:new Set(),readN:new Set(),solved:false,escaped:false,
inv:[],horrorT:10,ftT:0,
p:[
{inv:[],readN:new Set()},
{inv:[],readN:new Set()}
]
};
const K={};
const players=[null,null];
let scene,cam,ren,clock,controls,fl,flT;
let colls=[],inters=[],dMeshes={},entObj;
let listener,droneOsc,droneG,droneF,ambG;
const rv=new THREE.Vector3(),rv2=new THREE.Vector3(),rc=new THREE.Raycaster();
/* ===== MENU PARTICLES ===== */
(function(){const b=document.getElementById('mbg');for(let i=0;i<25;i++){const p=document.createElement('div');p.className='mp';p.style.left=Math.random()*100+'%';p.style.animationDuration=(8+Math.random()*12)+'s';p.style.animationDelay=Math.random()*10+'s';b.appendChild(p)}})();
/* ===== UI ===== */
function switchScreen(n){S.screen=n;['menu-screen','game-screen','esc-screen'].forEach(id=>{document.getElementById(id).classList.toggle('hidden',id!==n+'-screen')})}
function showMsg(t,d=2500){const e=document.getElementById('smsg');e.textContent=t;e.classList.add('vis');setTimeout(()=>e.classList.remove('vis'),d)}
function showNote(id){const n=NOTES[id];if(!n)return;document.getElementById('ntitle').textContent=n.title;document.getElementById('nbody').textContent=n.body;document.getElementById('note-panel').classList.remove('hid');S.noteOpen=true;if(controls)controls.unlock()}
function hideNote(){document.getElementById('note-panel').classList.add('hid');S.noteOpen=false}
function updInv(){const inv=S.p[S.activeP].inv;for(let i=0;i<2;i++){const sl=document.getElementById('s'+i);const it=inv[i];if(it){const d=ITEMP[it];sl.innerHTML=`<i class="fas ${d.icon}"></i><span class="il">${d.name}</span>`;sl.classList.add('has')}else{sl.innerHTML='<span class="il"></span>';sl.classList.remove('has')}}}
function updPInd(){document.getElementById('pname').textContent='Player '+(S.activeP+1)}
/* ===== TEXTURES ===== */
function mkTex(bR,bG,bB,planks){
const c=document.createElement('canvas');c.width=256;c.height=256;const x=c.getContext('2d');
x.fillStyle=`rgb(${bR},${bG},${bB})`;x.fillRect(0,0,256,256);
const pw=256/planks;
for(let i=0;i<planks;i++){const px=i*pw;x.fillStyle='rgba(0,0,0,0.1)';x.fillRect(px,0,1,256);
for(let j=0;j<30;j++){const gy=Math.random()*256;x.strokeStyle=`rgba(0,0,0,${0.02+Math.random()*0.03})`;x.lineWidth=.5+Math.random();x.beginPath();x.moveTo(px+2,gy);x.lineTo(px+pw-2,gy+(Math.random()-.5)*5);x.stroke()}}
for(let i=0;i<200;i++){x.fillStyle=`rgba(0,0,0,${Math.random()*0.03})`;x.fillRect(Math.random()*256,Math.random()*256,1+Math.random(),1)}
const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;return t;
}
function mkWallTex(){
const c=document.createElement('canvas');c.width=256;c.height=256;const x=c.getContext('2d');
x.fillStyle='#1a1714';x.fillRect(0,0,256,256);
for(let i=0;i<256;i+=16){x.fillStyle='rgba(30,26,20,0.25)';x.fillRect(i,0,8,256)}
for(let s=0;s<4;s++){const sx=Math.random()*256,sy=Math.random()*256,sr=15+Math.random()*25;
const g=x.createRadialGradient(sx,sy,0,sx,sy,sr);g.addColorStop(0,'rgba(10,8,5,0.12)');g.addColorStop(1,'rgba(10,8,5,0)');x.fillStyle=g;x.fillRect(sx-sr,sy-sr,sr*2,sr*2)}
for(let i=0;i<400;i++){x.fillStyle=`rgba(0,0,0,${Math.random()*0.04})`;x.fillRect(Math.random()*256,Math.random()*256,1,1)}
const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;return t;
}
function mkCeilTex(){
const c=document.createElement('canvas');c.width=128;c.height=128;const x=c.getContext('2d');
x.fillStyle='#141210';x.fillRect(0,0,128,128);
for(let i=0;i<2;i++){x.strokeStyle='rgba(0,0,0,0.15)';x.lineWidth=.5;x.beginPath();let cx=Math.random()*128,cy=Math.random()*128;x.moveTo(cx,cy);for(let j=0;j<4;j++){cx+=(Math.random()-.5)*25;cy+=(Math.random()-.5)*25;x.lineTo(cx,cy)}x.stroke()}
for(let i=0;i<150;i++){x.fillStyle=`rgba(0,0,0,${Math.random()*0.03})`;x.fillRect(Math.random()*128,Math.random()*128,1,1)}
const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;return t;
}
let wTex,wallTex,cTex,dwTex;
function initTex(){wTex=mkTex(42,30,18,5);dwTex=mkTex(28,20,12,4);wallTex=mkWallTex();cTex=mkCeilTex()}
/* ===== THREE INIT ===== */
function initThree(){
scene=new THREE.Scene();scene.background=new THREE.Color(0x020202);scene.fog=new THREE.FogExp2(0x020202,0.11);
cam=new THREE.PerspectiveCamera(68,innerWidth/innerHeight,0.05,50);
cam.position.set(0,PH,1.5);
ren=new THREE.WebGLRenderer({canvas:document.getElementById('game-canvas'),antialias:true});
ren.setSize(innerWidth,innerHeight);ren.setPixelRatio(Math.min(devicePixelRatio,2));
ren.shadowMap.enabled=true;ren.shadowMap.type=THREE.PCFSoftShadowMap;
ren.toneMapping=THREE.ACESFilmicToneMapping;ren.toneMappingExposure=0.55;
controls=new PointerLockControls(cam,document.body);
controls.addEventListener('lock',()=>{S.locked=true});
controls.addEventListener('unlock',()=>{if(!S.noteOpen)S.locked=false});
listener=new THREE.AudioListener();cam.add(listener);
clock=new THREE.Clock();
addEventListener('resize',()=>{cam.aspect=innerWidth/innerHeight;cam.updateProjectionMatrix();ren.setSize(innerWidth,innerHeight)});
}
/* ===== BUILDING ===== */
function addC(m){colls.push(m);return m}
function mkWall(x,y,z,w,h,d){
const wt=wallTex.clone();const sx=Math.max(1,Math.round(Math.max(w,d)/2.5)),sy=Math.max(1,Math.round(h/2.5));
wt.repeat.set(sx,sy);wt.wrapS=wt.wrapT=THREE.RepeatWrapping;
const g=new THREE.BoxGeometry(Math.max(.01,w),Math.max(.01,h),Math.max(.01,d));
const m=new THREE.Mesh(g,new THREE.MeshStandardMaterial({map:wt,roughness:.92}));
m.position.set(x,y,z);m.receiveShadow=true;scene.add(m);addC(m);return m;
}
function buildHouse(){
// Floors & Ceilings
ROOMS.forEach(r=>{
const ft=wTex.clone();ft.repeat.set(3,3);ft.wrapS=ft.wrapT=THREE.RepeatWrapping;
const fl=new THREE.Mesh(new THREE.PlaneGeometry(RW,RD),new THREE.MeshStandardMaterial({map:ft,roughness:.85}));
fl.rotation.x=-Math.PI/2;fl.position.set(r.x,FY+.01,r.z);fl.receiveShadow=true;scene.add(fl);
const ct=cTex.clone();ct.repeat.set(2,2);ct.wrapS=ct.wrapT=THREE.RepeatWrapping;
const ce=new THREE.Mesh(new THREE.PlaneGeometry(RW,RD),new THREE.MeshStandardMaterial({map:ct,roughness:.95}));
ce.rotation.x=Math.PI/2;ce.position.set(r.x,CY,r.z);scene.add(ce);
});
const RHW=RW/2,RHD=RD/2,dW=1.0,dH=2.3;
// Solid walls
// Foyer: South + West
mkWall(0,RH/2,RHD,RW,RH,WL);mkWall(-RHW,RH/2,0,WL,RH,RD);
// Kitchen: East + South
mkWall(ROOMS[1].x+RHW,RH/2,0,WL,RH,RD);mkWall(ROOMS[1].x,RH/2,RHD,RW,RH,WL);
// Study: West + North
mkWall(-RHW,RH/2,ROOMS[2].z,WL,RH,RD);mkWall(0,RH/2,ROOMS[2].z-RHD,RW,RH,WL);
// Corridor: North
mkWall(ROOMS[3].x,RH/2,ROOMS[3].z-RHD,RW,RH,WL);
// Doorways
mkDoor(0,0,'foyer_kitchen','x');mkDoor(0,0,'foyer_study','z');
mkDoor(ROOMS[1].x,ROOMS[1].z,'kitchen_corridor','z');
mkDoor(ROOMS[2].x,ROOMS[2].z,'study_corridor','x');
mkExit(ROOMS[3].x+RHW,ROOMS[3].z,'x');
buildFurniture();
}
function mkDoor(rx,rz,id,axis){
const RHW=RW/2,RHD=RD/2,dW=1.0,dH=2.3;
let cx,cz;
if(axis==='x'){cx=rx+RHW;cz=rz}else{cx=rx;cz=rz-RHD}
if(axis==='x'){
if(RHD-dW/2>.01){mkWall(cx,RH/2,cz-RHD+(RHD-dW/2)/2,WL,RH,RHD-dW/2);mkWall(cx,RH/2,cz+RHD-(RHD-dW/2)/2,WL,RH,RHD-dW/2)}
mkWall(cx,dH+(RH-dH)/2,cz,WL,RH-dH,dW);
}else{
if(RHW-dW/2>.01){mkWall(cx-RHW+(RHW-dW/2)/2,RH/2,cz,RHW-dW/2,RH,WL);mkWall(cx+RHW-(RHW-dW/2)/2,RH/2,cz,RHW-dW/2,RH,WL)}
mkWall(cx,dH+(RH-dH)/2,cz,dW,RH-dH,WL);
}
const dg=new THREE.BoxGeometry(axis==='x'?.08:dW,dH,axis==='z'?.08:dW);
const dm=new THREE.MeshStandardMaterial({map:dwTex.clone(),roughness:.8,color:0x2a1e14});
const mesh=new THREE.Mesh(dg,dm);mesh.position.set(cx,dH/2,cz);mesh.castShadow=true;mesh.receiveShadow=true;scene.add(mesh);addC(mesh);
dMeshes[id]={mesh,cx,cz,axis,dW,dH};
inters.push({id,type:'door',mesh,label:DLABEL[id],getPos:()=>new THREE.Vector3(cx,dH/2,cz),act:()=>openDoor(id)});
}
function mkExit(cx,cz,axis){
const RHD=RD/2,dW=1.1,dH=2.4;
if(RHD-dW/2>.01){mkWall(cx,RH/2,cz-RHD+(RHD-dW/2)/2,WL,RH,RHD-dW/2);mkWall(cx,RH/2,cz+RHD-(RHD-dW/2)/2,WL,RH,RHD-dW/2)}
mkWall(cx,dH+(RH-dH)/2,cz,WL,RH-dH,dW);
const dg=new THREE.BoxGeometry(.12,dH,dW);
const dm=new THREE.MeshStandardMaterial({map:dwTex.clone(),roughness:.7,metalness:.2,color:0x1a1208});
const mesh=new THREE.Mesh(dg,dm);mesh.position.set(cx,dH/2,cz);mesh.castShadow=true;scene.add(mesh);addC(mesh);
// Lock
const lk=new THREE.Mesh(new THREE.BoxGeometry(.05,.08,.08),new THREE.MeshStandardMaterial({color:0x8b7340,roughness:.3,metalness:.8}));
lk.position.set(cx-.07,dH/2,cz);scene.add(lk);
dMeshes.exit={mesh,cx,cz,axis,dW,dH};
inters.push({id:'exit',type:'exit',mesh,label:'Front Door — Locked',getPos:()=>new THREE.Vector3(cx,dH/2,cz),act:()=>tryExit()});
// Keypad
const kp=new THREE.Mesh(new THREE.BoxGeometry(.04,.2,.15),new THREE.MeshStandardMaterial({color:0x222222,roughness:.5,metalness:.5}));
kp.position.set(cx-.55,1.3,cz);scene.add(kp);
const led=new THREE.Mesh(new THREE.SphereGeometry(.015,8,8),new THREE.MeshStandardMaterial({color:0x8b1a1a,emissive:0x8b1a1a,emissiveIntensity:.5}));
led.position.set(cx-.57,1.3,cz);scene.add(led);
inters.push({id:'keypad',type:'puzzle',mesh:kp,led,label:'Keypad — Enter Code',getPos:()=>kp.position.clone(),act:()=>tryPuzzle()});
}
function buildFurniture(){
const tm=new THREE.MeshStandardMaterial({map:dwTex.clone(),roughness:.8,color:0x2a2018});
const dm=new THREE.MeshStandardMaterial({color:0x1a1410,roughness:.85});
// Foyer table
const ft=new THREE.Mesh(new THREE.BoxGeometry(.8,.75,.5),tm);ft.position.set(-1.8,.375,0);ft.castShadow=true;scene.add(ft);addC(ft);
// Foyer chair
const cs=new THREE.Mesh(new THREE.BoxGeometry(.4,.05,.4),dm);cs.position.set(1.5,.45,1.5);cs.castShadow=true;scene.add(cs);addC(cs);
const cb=new THREE.Mesh(new THREE.BoxGeometry(.4,.5,.05),dm);cb.position.set(1.5,.7,1.3);cb.castShadow=true;scene.add(cb);addC(cb);
// Kitchen counter
const cn=new THREE.Mesh(new THREE.BoxGeometry(.6,.9,3.5),tm.clone());cn.position.set(7.8,.45,0);cn.castShadow=true;scene.add(cn);addC(cn);
// Stove
const st=new THREE.Mesh(new THREE.BoxGeometry(.6,.9,.7),new THREE.MeshStandardMaterial({color:0x1a1a1a,roughness:.6,metalness:.3}));st.position.set(7.8,.45,-2);st.castShadow=true;scene.add(st);addC(st);
// BRASS KEY (Kitchen)
const km=new THREE.MeshStandardMaterial({color:0xc9a44c,roughness:.3,metalness:.9,emissive:0xc9a44c,emissiveIntensity:.12});
const keyM=new THREE.Mesh(new THREE.CylinderGeometry(.02,.02,.08,8),km);keyM.position.set(7.4,.95,-.5);keyM.rotation.z=Math.PI/2;keyM.castShadow=true;scene.add(keyM);
const ring=new THREE.Mesh(new THREE.TorusGeometry(.04,.005,6,12),km);ring.position.set(7.4,.95,-.42);scene.add(ring);
inters.push({id:'brassKey',type:'item',iid:'brassKey',mesh:keyM,ex:[ring],label:'Brass Key',getPos:()=>keyM.position.clone(),act:()=>pickItem('brassKey',keyM,[ring])});
// Study desk
const dk=new THREE.Mesh(new THREE.BoxGeometry(1.2,.75,.6),tm.clone());dk.position.set(-.5,.375,-7.5);dk.castShadow=true;scene.add(dk);addC(dk);
// Bookshelf
for(let i=0;i<3;i++){const sh=new THREE.Mesh(new THREE.BoxGeometry(.35,.03,1.8),tm.clone());sh.position.set(-2.5,.8+i*.9,-5.65);sh.castShadow=true;scene.add(sh);
for(let b=0;b<5;b++){const bh=.2+Math.random()*.4;const bc=[0x3a1515,0x152a3a,0x1a3320,0x3a2a10,0x2a1530][Math.floor(Math.random()*5)];
const bk=new THREE.Mesh(new THREE.BoxGeometry(.2,bh,.03+Math.random()*.04),new THREE.MeshStandardMaterial({color:bc,roughness:.8}));bk.position.set(-2.5,.83+i*.9+bh/2,-6.2+b*.35);scene.add(bk)}}
const sb=new THREE.Mesh(new THREE.BoxGeometry(.03,2.7,1.8),dm.clone());sb.position.set(-2.68,1.35,-5.65);scene.add(sb);addC(sb);
// JOURNAL (Study)
const jM=new THREE.Mesh(new THREE.BoxGeometry(.2,.03,.15),new THREE.MeshStandardMaterial({color:0x3a2810,roughness:.7}));jM.position.set(-.3,.78,-7.5);jM.rotation.y=.3;jM.castShadow=true;scene.add(jM);
inters.push({id:'journal',type:'note',nid:'journal',mesh:jM,label:'Old Journal',getPos:()=>jM.position.clone(),act:()=>readN('journal')});
// Corridor table
const ct=new THREE.Mesh(new THREE.BoxGeometry(.5,.6,.4),tm.clone());ct.position.set(5.65,.3,-6.5);ct.castShadow=true;scene.add(ct);addC(ct);
// TORN PAGE (Corridor)
const pM=new THREE.Mesh(new THREE.PlaneGeometry(.18,.25),new THREE.MeshStandardMaterial({color:0xd4c8a8,roughness:.9,side:THREE.DoubleSide}));pM.position.set(5.65,.63,-6.5);pM.rotation.x=-Math.PI/2;pM.rotation.z=.15;scene.add(pM);
inters.push({id:'tornPage',type:'note',nid:'tornPage',mesh:pM,label:'Torn Page',getPos:()=>pM.position.clone(),act:()=>readN('tornPage')});
// MOONSTONE (Corridor floor)
const mm=new THREE.Mesh(new THREE.SphereGeometry(.05,12,12),new THREE.MeshStandardMaterial({color:0xc8d8e8,roughness:.2,metalness:.3,emissive:0x4a5a7a,emissiveIntensity:.4}));
mm.position.set(4.8,.05,-8.2);mm.castShadow=true;scene.add(mm);
const ml=new THREE.PointLight(0x4a5a7a,.3,2);ml.position.set(4.8,.15,-8.2);scene.add(ml);
inters.push({id:'moonLight',type:'item',iid:'moonLight',mesh:mm,ex:[ml],label:'Moonstone',getPos:()=>mm.position.clone(),act:()=>pickItem('moonLight',mm,[ml])});
// DIARY (Study - hidden behind books)
const dM=new THREE.Mesh(new THREE.BoxGeometry(.15,.02,.12),new THREE.MeshStandardMaterial({color:0x4a3820,roughness:.8}));dM.position.set(-2.45,.83+.9*2+.02,-5.3);dM.rotation.y=-.2;scene.add(dM);
inters.push({id:'diary',type:'note',nid:'diary',mesh:dM,label:'Small Diary',getPos:()=>dM.position.clone(),act:()=>readN('diary')});
}
/* ===== LIGHTING ===== */
function setupLights(){
scene.add(new THREE.AmbientLight(0x0a0a0a,.3));
// Foyer candle
const fl=new THREE.PointLight(0xffa040,.8,8,2);fl.position.set(0,2.8,0);fl.castShadow=true;fl.shadow.mapSize.set(512,512);fl.shadow.bias=-.002;scene.add(fl);
const candle=new THREE.Mesh(new THREE.CylinderGeometry(.02,.02,.12,8),new THREE.MeshStandardMaterial({color:0xe8dcc8,roughness:.8}));candle.position.set(0,FY+.06,0);scene.add(candle);
const flame=new THREE.Mesh(new THREE.SphereGeometry(.015,6,6),new THREE.MeshStandardMaterial({color:0xff8820,emissive:0xff6600,emissiveIntensity:3}));flame.position.set(0,FY+.14,0);flame.scale.set(1,1.5,1);scene.add(flame);
fl._flame=flame;fl._bi=.8;scene._fL=fl;
// Kitchen
const kl=new THREE.PointLight(0x2040a0,.12,6,2);kl.position.set(5.65,2.8,0);scene.add(kl);
// Study
const sl=new THREE.PointLight(0xff8040,.18,6,2);sl.position.set(0,2.5,-5.65);scene.add(sl);
// Corridor
const cl=new THREE.PointLight(0x601010,.06,5,2);cl.position.set(5.65,2.8,-5.65);scene.add(cl);
// Flashlight
flT=new THREE.Object3D();scene.add(flT);
fl=new THREE.SpotLight(0xffe8c0,2.5,14,Math.PI/6.5,.4,1.5);fl.castShadow=true;fl.shadow.mapSize.set(512,512);fl.shadow.bias=-.002;fl.target=flT;fl.position.set(0,-.1,0);cam.add(fl);scene.add(cam);
}
/* ===== PLAYERS ===== */
function mkPlayer(idx){
const startPos=idx===0?{x:0,z:1.5}:{x:1,z:1.5};
const p={pos:new THREE.Vector3(startPos.x,PH,startPos.z),yaw:0,pitch:0,vel:new THREE.Vector3()};
players[idx]=p;
return p;
}
/* ===== ENTITY ===== */
function mkEntity(){
const g=new THREE.Group();
const bm=new THREE.MeshBasicMaterial({color:0x000000,transparent:true,opacity:0});
const body=new THREE.Mesh(new THREE.CylinderGeometry(.15,.2,2.2,8),bm);body.position.y=1.1;g.add(body);
const hm=bm.clone();const head=new THREE.Mesh(new THREE.SphereGeometry(.18,8,8),hm);head.position.y=2.35;g.add(head);
const el=new THREE.PointLight(0x8b1a1a,0,5,2);el.position.set(0,2.3,.15);g.add(el);
g.position.set(5.65,0,-5.65);g.visible=false;scene.add(g);
entObj={g,bm,hm,el};
}
/* ===== INTERACTIONS ===== */
function pickItem(iid,mesh,ex){
const pInv=S.p[S.activeP].inv;
if(pInv.includes(iid))return;
if(pInv.length>=2){showMsg('Inventory full',1500);return}
pInv.push(iid);mesh.visible=false;if(ex)ex.forEach(e=>{if(e.visible!==undefined)e.visible=false;if(e.intensity!==undefined)e.intensity=0});
updInv();showMsg('Picked up '+ITEMP[iid].name,2000);
}
function readN(nid){
if(S.p[S.activeP].readN.has(nid)){showMsg('Already read',1000);return}
S.p[S.activeP].readN.add(nid);S.readN.add(nid);showNote(nid);
}
function openDoor(id){
if(S.opened.has(id))return;S.opened.add(id);syncDoors();
showMsg(DLABEL[id]+' opened',2000);
}
function syncDoors(){
Object.entries(dMeshes).forEach(([id,d])=>{
if(S.opened.has(id)){
if(d.axis==='x')d.mesh.position.x=d.cx+.5;else d.mesh.position.z=d.cz+.5;
const i=colls.indexOf(d.mesh);if(i>-1)colls.splice(i,1);
}
});
}
function tryPuzzle(){
if(S.solved){showMsg('Already unlocked',1500);return}
showMsg('Enter the code: 1-8-4-7',3000);
setTimeout(()=>{
if(S.readN.has('tornPage')){
S.solved=true;showMsg('The lock clicks open...',3000);
const kp=inters.find(i=>i.id==='keypad');if(kp&&kp.led){kp.led.material.color.setHex(0x2d8a2d);kp.led.material.emissive.setHex(0x2d8a2d)}
setTimeout(()=>triggerHorror('strong'),2000);
}else{showMsg('Wrong code. You need to find it somewhere...',2500)}
},1500);
}
function tryExit(){
if(S.solved){S.escaped=true;setTimeout(()=>{controls.unlock();switchScreen('esc')},1500);return}
showMsg('The door is sealed. A keypad glows faintly beside it.',2500);
}
/* ===== AUDIO ===== */
function initAudio(){
const ctx=listener.context;
droneOsc=ctx.createOscillator();droneG=ctx.createGain();droneF=ctx.createBiquadFilter();
droneOsc.type='sawtooth';droneOsc.frequency.value=38;droneF.type='lowpass';droneF.frequency.value=80;droneF.Q.value=3;droneG.gain.value=.05;
droneOsc.connect(droneF);droneF.connect(droneG);droneG.connect(ctx.destination);droneOsc.start();
const d2=ctx.createOscillator();const g2=ctx.createGain();const f2=ctx.createBiquadFilter();
d2.type='sine';d2.frequency.value=55;f2.type='lowpass';f2.frequency.value=100;g2.gain.value=.03;
d2.connect(f2);f2.connect(g2);g2.connect(ctx.destination);d2.start();
const bs=ctx.sampleRate*2;const nb=ctx.createBuffer(1,bs,ctx.sampleRate);const nd=nb.getChannelData(0);
for(let i=0;i<bs;i++)nd[i]=(Math.random()*2-1)*.3;
const ns=ctx.createBufferSource();ns.buffer=nb;ns.loop=true;
const nf=ctx.createBiquadFilter();nf.type='bandpass';nf.frequency.value=200;nf.Q.value=.5;
ambG=ctx.createGain();ambG.gain.value=.012;
ns.connect(nf);nf.connect(ambG);ambG.connect(ctx.destination);ns.start();
}
function playStep(){
if(!listener)return;const ctx=listener.context;const bs=ctx.sampleRate*.07;
const b=ctx.createBuffer(1,bs,ctx.sampleRate);const d=b.getChannelData(0);
for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*Math.exp(-i/(ctx.sampleRate*.012));
const s=ctx.createBufferSource();s.buffer=b;
const f=ctx.createBiquadFilter();f.type='lowpass';f.frequency.value=350+Math.random()*200;
const g=ctx.createGain();g.gain.value=.07;s.connect(f);f.connect(g);g.connect(ctx.destination);s.start();
}
function playWhisper(){
if(!listener)return;const ctx=listener.context;const dur=1.5+Math.random()*2;
const b=ctx.createBuffer(1,ctx.sampleRate*dur,ctx.sampleRate);const d=b.getChannelData(0);
for(let i=0;i<d.length;i++){const t=i/ctx.sampleRate;d[i]=(Math.random()*2-1)*Math.sin(Math.PI*t/dur)*.3}
const s=ctx.createBufferSource();s.buffer=b;
const bp=ctx.createBiquadFilter();bp.type='bandpass';bp.frequency.value=1500+Math.random()*2000;bp.Q.value=5;
const g=ctx.createGain();g.gain.value=.02;
const pan=ctx.createStereoPanner();pan.pan.value=Math.random()*2-1;
s.connect(bp);bp.connect(g);g.connect(pan);pan.connect(ctx.destination);s.start();
}
function playCreak(){
if(!listener)return;const ctx=listener.context;
const o=ctx.createOscillator();o.type='sawtooth';o.frequency.value=80+Math.random()*40;
const g=ctx.createGain();g.gain.value=.03;g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+.5);
const f=ctx.createBiquadFilter();f.type='bandpass';f.frequency.value=200;f.Q.value=2;
o.connect(f);f.connect(g);g.connect(ctx.destination);o.start();o.stop(ctx.currentTime+.5);
}
function playSting(){
if(!listener)return;const ctx=listener.context;
const o=ctx.createOscillator();o.type='sawtooth';o.frequency.value=60;
o.frequency.exponentialRampToValueAtTime(30,ctx.currentTime+1);
const g=ctx.createGain();g.gain.value=.1;g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+1.5);
const f=ctx.createBiquadFilter();f.type='lowpass';f.frequency.value=150;
o.connect(f);f.connect(g);g.connect(ctx.destination);o.start();o.stop(ctx.currentTime+1.5);
}
/* ===== HORROR ===== */
function triggerHorror(intensity){
const ov=document.getElementById('distort');
if(intensity==='subtle'){ov.style.opacity='.4';setTimeout(()=>ov.style.opacity='0',150);if(Math.random()>.5)playWhisper()}
else if(intensity==='medium'){playCreak();ov.style.opacity='.7';setTimeout(()=>ov.style.opacity='0',300);cam.userData.shake=.3;setTimeout(()=>cam.userData.shake=0,400)}
else if(intensity==='strong'){playSting();ov.style.opacity='1';setTimeout(()=>ov.style.opacity='0',500);cam.userData.shake=.6;setTimeout(()=>cam.userData.shake=0,800);
if(entObj){const eg=entObj.g;eg.visible=true;entObj.bm.opacity=.3;entObj.hm.opacity=.3;entObj.el.intensity=.5;
const a=Math.random()*Math.PI*2;eg.position.set(cam.position.x+Math.cos(a)*3,0,cam.position.z+Math.sin(a)*3);eg.lookAt(cam.position.x,0,cam.position.z);
setTimeout(()=>{entObj.bm.opacity=0;entObj.hm.opacity=0;entObj.el.intensity=0;setTimeout(()=>eg.visible=false,500)},1500)}
}
}
/* ===== COLLISION ===== */
function checkCol(pos){
const pb=new THREE.Box3(new THREE.Vector3(pos.x-PR,pos.y-PH/2,pos.z-PR),new THREE.Vector3(pos.x+PR,pos.y+PH/2,pos.z+PR));
const tb=new THREE.Box3();
for(const c of colls){tb.setFromObject(c);if(pb.intersectsBox(tb))return true}
return false;
}
/* ===== GAME LOOP ===== */
function update(dt){
if(S.screen!=='game'||!S.locked||S.noteOpen)return;
const p=players[S.activeP];if(!p)return;
const spd=K['ShiftLeft']?SSPD:MSPD;
// Build forward/right from yaw
const fwd=new THREE.Vector3(-Math.sin(p.yaw),0,-Math.cos(p.yaw));
const rt=new THREE.Vector3(Math.cos(p.yaw),0,-Math.sin(p.yaw));
const dir=new THREE.Vector3();
if(S.activeP===0){
if(K['KeyW'])dir.add(fwd);if(K['KeyS'])dir.sub(fwd);if(K['KeyD'])dir.add(rt);if(K['KeyA'])dir.sub(rt);
}else{
if(K['ArrowUp'])dir.add(fwd);if(K['ArrowDown'])dir.sub(fwd);if(K['ArrowRight'])dir.add(rt);if(K['ArrowLeft'])dir.sub(rt);
}
let moving=false;
if(dir.lengthSq()>0){
dir.normalize();const np=p.pos.clone().add(dir.clone().multiplyScalar(spd*dt));
if(!checkCol(np))p.pos.copy(np);
else{const nx=p.pos.clone();nx.x+=dir.x*spd*dt;if(!checkCol(nx))p.pos.x=nx.x;
const nz=p.pos.clone();nz.z+=dir.z*spd*dt;if(!checkCol(nz))p.pos.z=nz.z}
moving=true;
}
// Apply camera
cam.position.set(p.pos.x,PH,p.pos.z);
cam.rotation.order='YXZ';cam.rotation.y=p.yaw;cam.rotation.x=p.pitch;
// Flashlight target
const flFwd=new THREE.Vector3();cam.getWorldDirection(flFwd);flT.position.copy(cam.position).add(flFwd.multiplyScalar(5));
// Footsteps
if(moving){S.ftT-=dt;if(S.ftT<=0){playStep();S.ftT=K['ShiftLeft']?.3:.45}}else S.ftT=0;
// Interaction
updInteract();
// Foyer flicker
if(scene._fL){const f=scene._fL;const t=clock.elapsedTime;
f.intensity=Math.max(.1,f._bi+Math.sin(t*15)*.1+Math.sin(t*23)*.05+(Math.random()>.97?-.5:0));
if(f._flame){f._flame.scale.y=1.5+Math.sin(t*12)*.3;f._flame.position.x=Math.sin(t*8)*.003}}
// Camera shake
if(cam.userData.shake){const s=cam.userData.shake;cam.position.x+=(Math.random()-.5)*s*.1;cam.position.z+=(Math.random()-.5)*s*.1;cam.userData.shake*=.95;if(cam.userData.shake<.01)cam.userData.shake=0}
// Horror
S.horrorT-=dt;if(S.horrorT<=0){triggerHorror(['subtle','subtle','subtle','medium'][Math.floor(Math.random()*4)]);S.horrorT=8+Math.random()*15}
if(Math.random()<.002)playCreak();if(Math.random()<.001)playWhisper();
}
function updInteract(){
const p=players[S.activeP];if(!p)return;
const fwd=new THREE.Vector3(-Math.sin(p.yaw),0,-Math.cos(p.yaw));
const camDir=new THREE.Vector3(0,0,-1).applyQuaternion(cam.quaternion);
rc.set(cam.position,camDir);rc.far=IDIST;
let closest=null,cd=Infinity;
for(const obj of inters){
if(obj.type==='item'&&S.p[S.activeP].inv.includes(obj.iid))continue;
if(obj.type==='door'&&S.opened.has(obj.id))continue;
const pos=obj.getPos();rv.copy(pos).sub(cam.position);const dist=rv.length();
if(dist>IDIST)continue;rv.normalize();if(camDir.dot(rv)<IANGLE)continue;
if(dist<cd){cd=dist;closest=obj}
}
const pr=document.getElementById('prompt'),ch=document.getElementById('crosshair');
if(closest){let lb=closest.label;if(closest.type==='note'&&S.p[S.activeP].readN.has(closest.nid))lb+=' (read)';
if(closest.type==='exit'&&S.solved)lb='Front Door — Open';
pr.textContent='[E] '+lb;pr.classList.add('vis');ch.classList.add('act');
S._interact=closest;
}else{pr.classList.remove('vis');ch.classList.remove('act');S._interact=null}
}
/* ===== INPUT ===== */
document.addEventListener('keydown',e=>{
K[e.code]=true;
if(e.code==='Tab'){e.preventDefault();switchPlayer();return}
if(e.code==='KeyE'&&S.locked&&!S.noteOpen&&S._interact){S._interact.act()}
if(e.code==='KeyU'&&S.locked&&!S.noteOpen&&S._interact){S._interact.act()}
});
document.addEventListener('keyup',e=>{K[e.code]=false});
document.addEventListener('mousemove',e=>{
if(!S.locked||S.noteOpen)return;
const p=players[S.activeP];if(!p)return;
const sens=.002;
if(S.activeP===0){p.yaw-=e.movementX*sens;p.pitch-=e.movementY*sens}
// P2 mouse look not used (uses IJKL instead)
});
// P2 look keys - handled in update via continuous key checking
document.getElementById('note-panel').addEventListener('click',hideNote);
document.getElementById('game-screen').addEventListener('click',()=>{if(S.screen==='game'&&!S.locked&&!S.noteOpen)controls.lock()});
function switchPlayer(){
S.activeP=S.activeP===0?1:0;
const p=players[S.activeP];if(!p)return;
cam.position.set(p.pos.x,PH,p.pos.z);
cam.rotation.order='YXZ';cam.rotation.y=p.yaw;cam.rotation.x=p.pitch;
updInv();updPInd();showMsg('Switched to Player '+(S.activeP+1),1200);
}
// P2 look handled in update
const origUpdate=update;
const patchedUpdate=function(dt){
// P2 look from IJKL keys
if(S.activeP===1&&S.locked&&!S.noteOpen){
const p=players[1];if(p){
const ls=.03;
if(K['KeyJ'])p.yaw+=ls;
if(K['KeyL'])p.yaw-=ls;
if(K['KeyI'])p.pitch=Math.min(Math.PI/2-.1,p.pitch+ls);
if(K['KeyK'])p.pitch=Math.max(-Math.PI/2+.1,p.pitch-ls);
}
}
origUpdate(dt);
};
/* ===== MENU ===== */
document.getElementById('btn-start').addEventListener('click',startGame);
document.getElementById('btn-re').addEventListener('click',()=>location.reload());
function startGame(){
switchScreen('game');document.getElementById('lt').style.display='block';
initThree();initTex();buildHouse();setupLights();mkPlayer(0);mkPlayer(1);mkEntity();
const resumeA=()=>{if(listener.context.state==='suspended')listener.context.resume();document.removeEventListener('click',resumeA);document.removeEventListener('keydown',resumeA)};
document.addEventListener('click',resumeA);document.addEventListener('keydown',resumeA);
setTimeout(()=>{document.getElementById('lt').style.display='none';initAudio();controls.lock();updInv();updPInd()},1200);
function animate(){requestAnimationFrame(animate);const dt=Math.min(clock.getDelta(),.05);patchedUpdate(dt);ren.render(scene,cam)}
animate();
}
</script>
</body>
</html>12
6
690KB
2043KB
614.0ms
408.0ms
758.0ms