🎮ArcadeLab

SCP-173: Containment

by BraveMaker51
822 lines36.9 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>SCP-173 · Containment</title>
<style>
  :root{
    --concrete:#8f8a82; --fluoro:#dfeae0; --amber:#e6a417; --hazard:#c9302c;
    --ink:#0b0c0d; --bone:#cdd0cb;
  }
  *{margin:0;padding:0;box-sizing:border-box;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;}
  html,body{height:100%;background:#000;overflow:hidden;font-family:"Courier New",ui-monospace,monospace;color:var(--bone);}
  #app{position:fixed;inset:0;}
  canvas{display:block;width:100%;height:100%;}

  /* ---- post overlays ---- */
  #grain{position:fixed;inset:-50%;pointer-events:none;opacity:.02;mix-blend-mode:overlay;z-index:5;
    background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
    animation:grain .6s steps(3) infinite;}
  @keyframes grain{0%{transform:translate(0,0)}33%{transform:translate(-4%,3%)}66%{transform:translate(3%,-4%)}100%{transform:translate(0,0)}}
  #vignette{position:fixed;inset:0;pointer-events:none;z-index:6;
    box-shadow:inset 0 0 150px 24px rgba(0,0,0,.3);}
  #danger{position:fixed;inset:0;pointer-events:none;z-index:7;opacity:0;transition:opacity .18s;
    box-shadow:inset 0 0 180px 40px rgba(150,10,10,.75);}
  /* eyelids for involuntary blink */
  #lidTop,#lidBot{position:fixed;left:0;right:0;height:0;background:#000;z-index:9;pointer-events:none;transition:height .09s ease-in;}
  #lidTop{top:0;border-bottom:1px solid #050505;}
  #lidBot{bottom:0;border-top:1px solid #050505;}

  /* ---- HUD ---- */
  #hud{position:fixed;inset:0;z-index:8;pointer-events:none;}
  #cross{position:absolute;left:50%;top:50%;width:16px;height:16px;transform:translate(-50%,-50%);opacity:.55;}
  #cross:before,#cross:after{content:"";position:absolute;background:var(--bone);}
  #cross:before{left:7px;top:0;width:2px;height:16px;}
  #cross:after{top:7px;left:0;height:2px;width:16px;}
  #obj{position:absolute;left:16px;top:14px;font-size:12px;letter-spacing:.14em;line-height:1.7;text-shadow:0 1px 3px #000;max-width:60%;}
  #obj b{color:var(--amber);}
  #obj .tag{color:#7d817a;}
  #watch{position:absolute;right:16px;top:14px;text-align:right;font-size:12px;letter-spacing:.1em;text-shadow:0 1px 3px #000;}
  #watch .st{color:#6fae6f;font-weight:bold;}
  #watch .st.bad{color:var(--hazard);}
  #survivors{position:absolute;right:16px;top:44px;text-align:right;font-size:11px;color:#9aa0995e;line-height:1.6;}
  #survivors span{color:#b9beb6;}
  #hint{position:absolute;left:0;right:0;bottom:26px;text-align:center;font-size:11px;letter-spacing:.2em;color:#8b908880;text-shadow:0 1px 2px #000;}

  /* ---- overlays ---- */
  .screen{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;
    background:radial-gradient(ellipse at center,#111 0%,#000 75%);}
  .card{width:min(640px,90vw);border:1px solid #2a2c2b;background:#0d0e0eee;padding:26px 26px 22px;box-shadow:0 20px 80px #000;}
  .file{font-size:11px;letter-spacing:.28em;color:var(--amber);border-bottom:1px solid #2a2c2b;padding-bottom:10px;margin-bottom:14px;display:flex;justify-content:space-between;}
  .file .cls{color:var(--hazard);}
  .card h1{font-family:"Arial Narrow",Arial,sans-serif;font-weight:800;font-size:34px;letter-spacing:.02em;color:#e9ece7;line-height:1;margin-bottom:4px;}
  .card h1 small{display:block;font-size:12px;letter-spacing:.34em;color:#7d817a;font-weight:400;margin-top:8px;}
  .card p{font-size:13px;line-height:1.85;color:#b7bcb5;margin:12px 0;}
  .card p .rd{background:#1b1c1c;color:#1b1c1c;padding:0 4px;border-radius:2px;}
  .keys{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0 6px;font-size:11px;color:#8b908a;}
  .keys kbd{border:1px solid #33363a;border-bottom-width:2px;border-radius:4px;padding:3px 8px;background:#161819;color:#cdd0cb;letter-spacing:.08em;}
  .btn{margin-top:16px;display:inline-block;pointer-events:auto;cursor:pointer;
    font-family:"Arial Narrow",Arial,sans-serif;font-weight:800;letter-spacing:.22em;font-size:15px;
    color:#0b0c0d;background:var(--amber);padding:12px 26px;border:0;text-transform:uppercase;
    box-shadow:0 6px 0 #8a6410, 0 10px 24px rgba(0,0,0,.6);transition:transform .06s, box-shadow .06s;}
  .btn:active{transform:translateY(3px);box-shadow:0 3px 0 #8a6410;}
  .btn.dead{background:var(--hazard);box-shadow:0 6px 0 #7c1c19,0 10px 24px #000;}
  .btn.win{background:#2f9e57;box-shadow:0 6px 0 #1c6236,0 10px 24px #000;}
  .subnote{margin-top:12px;font-size:10.5px;color:#6a6f68;letter-spacing:.1em;}
  .hidden{display:none !important;}

  /* ---- touch ---- */
  #touch{position:fixed;inset:0;z-index:12;pointer-events:none;}
  #stick{position:absolute;left:22px;bottom:22px;width:132px;height:132px;border-radius:50%;
    border:1px solid #ffffff26;background:#ffffff0d;pointer-events:auto;}
  #nub{position:absolute;left:41px;top:41px;width:50px;height:50px;border-radius:50%;background:#ffffff33;border:1px solid #ffffff40;}
  #lookpad{position:absolute;right:0;top:0;width:55%;height:100%;pointer-events:auto;}
  .flag{color:var(--amber);font-weight:bold;}
</style>
</head>
<body>
<div id="app"></div>

<div id="grain"></div>
<div id="vignette"></div>
<div id="danger"></div>
<div id="lidTop"></div>
<div id="lidBot"></div>

<div id="hud" class="hidden">
  <div id="cross"></div>
  <div id="obj"><span class="tag">OBJECTIVE //</span> <span id="objtext">Locate the keycard</span></div>
  <div id="watch">SCP-173 · <span class="st" id="statestr">CONTAINED</span></div>
  <div id="survivors"></div>
  <div id="hint">KEEP IT IN VIEW — it moves the instant no one is watching</div>
</div>

<!-- touch controls -->
<div id="touch" class="hidden">
  <div id="stick"><div id="nub"></div></div>
  <div id="lookpad"></div>
</div>

<!-- intro -->
<div id="intro" class="screen">
  <div class="card">
    <div class="file"><span>ITEM # &nbsp;SCP-173</span><span class="cls">OBJECT CLASS: EUCLID</span></div>
    <h1>THE SCULPTURE<small>SITE-19 · CONTAINMENT CELL 173 · LIVE OBSERVATION</small></h1>
    <p><b class="flag">Special Containment Procedures.</b> The object is a construct of concrete and rebar. It is animate and extremely hostile. <b class="flag">It cannot move while within a direct line of sight.</b> Personnel entering the cell must maintain eye contact at all times and alert one another before <span class="rd">blinking</span>.</p>
    <p><b class="flag">Situation.</b> Containment integrity is failing. You and the remaining personnel must recover the cell keycard and reach the blast door. As long as <i>someone</i> is watching, it stays frozen. The moment everyone looks away — it closes the distance.</p>
    <div class="keys" id="keysDesktop">
      <kbd>W A S D</kbd> move <kbd>MOUSE</kbd> look (or click-drag) <kbd>SHIFT</kbd> run
    </div>
    <div class="keys hidden" id="keysTouch">
      <kbd>LEFT STICK</kbd> move &nbsp; <kbd>RIGHT SIDE</kbd> drag to look
    </div>
    <button class="btn" id="startBtn">Enter the cell</button>
    <div class="subnote">Best experienced with sound on. Other personnel are AI-simulated — no network required.</div>
  </div>
</div>

<!-- gameover -->
<div id="over" class="screen hidden">
  <div class="card">
    <div class="file"><span>INCIDENT REPORT</span><span class="cls">CASUALTY</span></div>
    <h1 id="overTitle">NECK SNAPPED<small id="overSub">You looked away.</small></h1>
    <p id="overBody">SCP-173 breached the gap and terminated the subject with a single motion. Standard cause of death: strangulation or broken neck. Re-containment in progress.</p>
    <button class="btn dead" id="retryBtn">Re-enter containment</button>
  </div>
</div>

<!-- win -->
<div id="win" class="screen hidden">
  <div class="card">
    <div class="file"><span>CONTAINMENT RESTORED</span><span class="cls" style="color:#2f9e57">SURVIVED</span></div>
    <h1>BLAST DOOR SEALED<small>SCP-173 RE-CONTAINED</small></h1>
    <p id="winBody">You reached the door and sealed the cell. The Foundation thanks you for your service. Do not discuss the events of Cell 173 outside of debrief.</p>
    <button class="btn win" id="againBtn">Run it again</button>
  </div>
</div>

<!-- three.js is injected by ArcadeLab from the libraries header (do not add a CDN tag here) -->
<script>
(function(){
"use strict";

// ---------- guard ----------
if(typeof THREE==="undefined"){
  document.getElementById("intro").innerHTML='<div class="card"><h1>OFFLINE</h1><p>The 3D engine failed to load. Check your connection and reload.</p></div>';
  return;
}

// ---------- basics ----------
var app=document.getElementById("app");
var W=window.innerWidth, H=window.innerHeight;
var isTouch = ('ontouchstart' in window)||navigator.maxTouchPoints>0;

var renderer=new THREE.WebGLRenderer({antialias:!isTouch,powerPreference:"high-performance"});
renderer.setSize(W,H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, isTouch?1.5:2));
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.outputEncoding=THREE.sRGBEncoding;
renderer.toneMapping=THREE.NoToneMapping;
renderer.toneMappingExposure=1.0;
app.appendChild(renderer.domElement);

var scene=new THREE.Scene();
scene.background=new THREE.Color(0x8b98a2);
scene.fog=new THREE.FogExp2(0x8b98a2, 0.010);

var camera=new THREE.PerspectiveCamera(74, W/H, 0.05, 120);
var EYE=1.68;
camera.position.set(-7.5, EYE, 7.5);

// ---------- procedural textures ----------
function concreteTexture(base, streaks){
  var c=document.createElement("canvas"); c.width=c.height=256; var x=c.getContext("2d");
  x.fillStyle=base; x.fillRect(0,0,256,256);
  // mottling
  for(var i=0;i<9000;i++){
    var v=(Math.random()*46-23)|0;
    x.fillStyle="rgba("+(90+v)+","+(88+v)+","+(82+v)+",0.05)";
    x.fillRect(Math.random()*256,Math.random()*256,2,2);
  }
  // cracks
  x.strokeStyle="rgba(20,20,22,0.5)"; x.lineWidth=1;
  for(var k=0;k<14;k++){
    x.beginPath();var px=Math.random()*256,py=Math.random()*256;x.moveTo(px,py);
    for(var s=0;s<6;s++){px+=Math.random()*40-20;py+=Math.random()*40-20;x.lineTo(px,py);}
    x.stroke();
  }
  // stains
  for(var m=0;m<5;m++){
    var g=x.createRadialGradient(Math.random()*256,Math.random()*256,2,Math.random()*256,Math.random()*256,60);
    g.addColorStop(0,"rgba(30,28,24,0.25)");g.addColorStop(1,"rgba(30,28,24,0)");
    x.fillStyle=g;x.fillRect(0,0,256,256);
  }
  if(streaks){
    for(var b=0;b<7;b++){
      var sx=Math.random()*256, sy=Math.random()*90;
      var gg=x.createLinearGradient(sx,sy,sx,sy+120+Math.random()*80);
      gg.addColorStop(0,"rgba(96,32,22,0.55)");gg.addColorStop(1,"rgba(60,18,14,0)");
      x.fillStyle=gg;x.fillRect(sx-3,sy,4+Math.random()*4,180);
    }
  }
  var t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=4;
  return t;
}
var floorTex=concreteTexture("#4b4a47",false); floorTex.repeat.set(6,6);
var wallTex=concreteTexture("#5a5854",false); wallTex.repeat.set(3,1.4);
var pillarTex=concreteTexture("#54524e",false); pillarTex.repeat.set(2,2);
var statueTex=concreteTexture("#8b867d",true);

// ---------- room ----------
var HALF=10, WALLH=4.2;
var mats={
  floor:new THREE.MeshLambertMaterial({color:0x9298a0,flatShading:true}),
  wall:new THREE.MeshLambertMaterial({color:0xc7cace,flatShading:true}),
  ceil:new THREE.MeshLambertMaterial({color:0xe6e9ec}),
  pillar:new THREE.MeshLambertMaterial({color:0xb0b4b8,flatShading:true})
};

var floor=new THREE.Mesh(new THREE.PlaneGeometry(HALF*2,HALF*2),mats.floor);
floor.rotation.x=-Math.PI/2; floor.receiveShadow=true; scene.add(floor);
var ceil=new THREE.Mesh(new THREE.PlaneGeometry(HALF*2,HALF*2),mats.ceil);
ceil.rotation.x=Math.PI/2; ceil.position.y=WALLH; scene.add(ceil);

function wall(w,h,d,x,y,z,ry){
  var m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),mats.wall);
  m.position.set(x,y,z); if(ry)m.rotation.y=ry;
  m.castShadow=true; m.receiveShadow=true; scene.add(m); return m;
}
var obstacles=[]; // {minX,maxX,minZ,maxZ}
function addOb(minX,maxX,minZ,maxZ){obstacles.push({minX:minX,maxX:maxX,minZ:minZ,maxZ:maxZ});}
var occluders=[]; // meshes for raycasting line of sight

// perimeter walls
var wt=0.4;
wall(HALF*2,WALLH,wt, 0,WALLH/2,-HALF); // north
wall(HALF*2,WALLH,wt, 0,WALLH/2, HALF); // south
wall(wt,WALLH,HALF*2, -HALF,WALLH/2,0); // west
wall(wt,WALLH,HALF*2,  HALF,WALLH/2,0); // east
occluders.push.apply(occluders, scene.children.filter(function(o){return o.geometry&&o.geometry.type==="BoxGeometry";}));

// central pillar (breaks line of sight)
var pil=new THREE.Mesh(new THREE.BoxGeometry(3,WALLH,3),mats.pillar);
pil.position.set(0,WALLH/2,0); pil.castShadow=true; pil.receiveShadow=true; scene.add(pil);
addOb(-1.5,1.5,-1.5,1.5); occluders.push(pil);

// a partial divider wall to make sightlines interesting
var div=wall(0.4,WALLH,6, 4.5,WALLH/2,-2.5);
addOb(4.3,4.7,-5.5,0.5); occluders.push(div);

// floor drain detail
(function(){
  var d=new THREE.Mesh(new THREE.CircleGeometry(0.55,20),new THREE.MeshStandardMaterial({color:0x111214,roughness:1}));
  d.rotation.x=-Math.PI/2; d.position.set(0.2,0.011,3.2); scene.add(d);
})();

// blast door (exit) on east wall
var doorMat=new THREE.MeshStandardMaterial({color:0x3a3d42,metalness:.6,roughness:.5});
var door=new THREE.Mesh(new THREE.BoxGeometry(0.25,3,2.4),doorMat);
door.position.set(HALF-0.18, 1.5, -6.5); door.castShadow=true; scene.add(door);
// hazard stripes above door
(function(){
  var st=document.createElement("canvas");st.width=128;st.height=32;var g=st.getContext("2d");
  for(var i=-32;i<160;i+=24){g.fillStyle=(i/24)%2?"#111":"#e6a417";g.save();g.translate(i,0);g.rotate(.5);g.fillRect(0,-20,14,70);g.restore();}
  var tt=new THREE.CanvasTexture(st);
  var band=new THREE.Mesh(new THREE.BoxGeometry(0.26,0.35,2.4),new THREE.MeshStandardMaterial({map:tt,roughness:.8}));
  band.position.set(HALF-0.18,3.15,-6.5);scene.add(band);
})();
var EXIT={x:HALF-1.2, z:-6.5, r:1.6};

// ---------- lighting ----------
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
var hemi=new THREE.HemisphereLight(0xeef3f7, 0x60656b, 0.75); scene.add(hemi);

// two fluorescent fixtures
function fixture(x,z,flicker){
  var box=new THREE.Mesh(new THREE.BoxGeometry(2.4,0.12,0.5),new THREE.MeshStandardMaterial({color:0x1b1c1e,emissive:0xbfe9d6,emissiveIntensity:1.4,roughness:.6}));
  box.position.set(x,WALLH-0.08,z); scene.add(box);
  var l=new THREE.PointLight(0xffffff, 1.9, 22, 2);
  l.position.set(x,WALLH-0.3,z); l.castShadow=true;
  l.shadow.mapSize.width=1024; l.shadow.mapSize.height=1024; l.shadow.bias=-0.0008;
  scene.add(l);
  return {light:l,bulb:box,flicker:flicker,base:2.2};
}
var lights=[fixture(-4,4,false), fixture(4,-4,true)];

// ---------- SCP-173 (original concrete sculpt) ----------
var statueMat=new THREE.MeshLambertMaterial({color:0x9d988e,flatShading:true});
var darkMat=new THREE.MeshStandardMaterial({color:0x0a0908,roughness:1});
var scp=new THREE.Group();
(function(){
  // base
  var base=new THREE.Mesh(new THREE.CylinderGeometry(0.55,0.62,0.35,10),statueMat);
  base.position.y=0.175; scp.add(base);
  // hunched torso (tapered box via scaled boxes)
  var torso=new THREE.Mesh(new THREE.CylinderGeometry(0.34,0.52,1.35,9),statueMat);
  torso.position.y=1.05; torso.rotation.z=0.06; scp.add(torso);
  // shoulders lump
  var sh=new THREE.Mesh(new THREE.SphereGeometry(0.5,10,8),statueMat);
  sh.scale.set(1.25,0.6,0.9); sh.position.set(0,1.7,0); scp.add(sh);
  // stubby arms
  function arm(sx){
    var a=new THREE.Mesh(new THREE.CylinderGeometry(0.15,0.13,0.9,7),statueMat);
    a.position.set(sx*0.5,1.35,0.05); a.rotation.z=sx*0.35; a.rotation.x=-0.2; scp.add(a);
  }
  arm(-1);arm(1);
  // head - blocky boulder
  var head=new THREE.Mesh(new THREE.SphereGeometry(0.42,10,9),statueMat);
  head.scale.set(1,0.95,1.05); head.position.set(0,2.18,0.04); scp.add(head);
  // brow slab
  var brow=new THREE.Mesh(new THREE.BoxGeometry(0.7,0.16,0.5),statueMat);
  brow.position.set(0,2.28,0.14); scp.add(brow);
  // hollow eyes
  function eye(sx){
    var e=new THREE.Mesh(new THREE.SphereGeometry(0.1,8,6),darkMat);
    e.position.set(sx*0.16,2.16,0.36); scp.add(e);
  }
  eye(-1);eye(1);
  // gaping mouth
  var mouth=new THREE.Mesh(new THREE.BoxGeometry(0.34,0.2,0.16),darkMat);
  mouth.position.set(0,1.93,0.34); scp.add(mouth);
  scp.traverse(function(o){if(o.isMesh){o.castShadow=true;o.receiveShadow=true;}});
})();
scp.position.set(0,0,-7.5);
scene.add(scp);
var scpRadius=0.7;

// ---------- keycard ----------
var card=new THREE.Group();
(function(){
  var body=new THREE.Mesh(new THREE.BoxGeometry(0.32,0.5,0.02),new THREE.MeshStandardMaterial({color:0xd9b23a,emissive:0x5a4400,emissiveIntensity:.6,roughness:.4,metalness:.5}));
  card.add(body);
  var strip=new THREE.Mesh(new THREE.BoxGeometry(0.32,0.08,0.021),new THREE.MeshStandardMaterial({color:0x111,roughness:.6}));
  strip.position.y=0.12; card.add(strip);
})();
var cardLight=new THREE.PointLight(0xffcf5a,1.1,4,2);
card.add(cardLight);
// small crate to place it on
var crate=new THREE.Mesh(new THREE.BoxGeometry(0.9,0.9,0.9),new THREE.MeshStandardMaterial({color:0x4a463f,roughness:.9}));
crate.position.set(-7.5,0.45,-7.2); crate.castShadow=true; crate.receiveShadow=true; scene.add(crate);
addOb(-8.1,-6.9,-7.8,-6.6); occluders.push(crate);
card.position.set(-7.5,1.15,-7.2);
scene.add(card);
var CARDPOS=new THREE.Vector3(-7.5,1.15,-7.2);
var hasCard=false;

// ---------- simulated survivors ----------
var NAMES=["D-4021","D-1063","AGENT Voss\u2009","D-9970"];
var COLORS=[0xd0662f,0x2f78d0,0x3fae5a,0x9a4fd0];
var bots=[];
function makeBot(i,x,z){
  var g=new THREE.Group();
  var mat=new THREE.MeshStandardMaterial({color:COLORS[i%COLORS.length],roughness:.7,metalness:.1});
  var torso=new THREE.Mesh(new THREE.BoxGeometry(0.44,0.7,0.28),mat); torso.position.y=1.05; g.add(torso);
  var head=new THREE.Mesh(new THREE.BoxGeometry(0.3,0.3,0.3),new THREE.MeshStandardMaterial({color:0xd9c3a5,roughness:.8})); head.position.y=1.6; g.add(head);
  var legL=new THREE.Mesh(new THREE.BoxGeometry(0.16,0.7,0.18),new THREE.MeshStandardMaterial({color:0x222,roughness:.9})); legL.position.set(-0.12,0.35,0); g.add(legL);
  var legR=legL.clone(); legR.position.x=0.12; g.add(legR);
  // face marker (front = +z of group)
  var face=new THREE.Mesh(new THREE.BoxGeometry(0.12,0.06,0.02),new THREE.MeshStandardMaterial({color:0x111,roughness:1}));
  face.position.set(0,1.62,0.151); g.add(face);
  g.traverse(function(o){if(o.isMesh){o.castShadow=true;o.receiveShadow=true;}});
  g.position.set(x,0,z);
  scene.add(g);
  return {
    grp:g, name:NAMES[i%NAMES.length], alive:true,
    heading:Math.random()*Math.PI*2,      // facing angle (yaw)
    lookAtStatue:true, lookTimer:1+Math.random()*2,
    moveTimer:1+Math.random()*2, target:new THREE.Vector2(x,z),
    blink:0, blinkTimer:3+Math.random()*4,
    legL:legL, legR:legR, phase:Math.random()*6
  };
}
bots.push(makeBot(0,-5,3));
bots.push(makeBot(1,5,5));
bots.push(makeBot(2,-3,-4));

// ---------- controls ----------
var yaw=Math.atan2(-(scp.position.x-camera.position.x),-(scp.position.z-camera.position.z)); // face statue-ish
var pitch=0;
var keys={};
var running=false, started=false, dead=false, won=false;

window.addEventListener("keydown",function(e){keys[e.code]=true;});
window.addEventListener("keyup",function(e){keys[e.code]=false;});

// look controls (desktop): pointer lock if the sandbox allows it, else click-drag fallback
var canvas=renderer.domElement;
var everLocked=false, dragging=false, lastMX=0, lastMY=0;
if(!isTouch){
  canvas.addEventListener("mousedown",function(e){
    if(!started||dead||won)return;
    if(document.pointerLockElement!==canvas && canvas.requestPointerLock) canvas.requestPointerLock();
    dragging=true; lastMX=e.clientX; lastMY=e.clientY;
  });
  window.addEventListener("mouseup",function(){dragging=false;});
  document.addEventListener("mousemove",function(e){
    if(document.pointerLockElement===canvas){
      yaw   -= e.movementX*0.0022;
      pitch -= e.movementY*0.0022;
      pitch=Math.max(-1.3,Math.min(1.3,pitch));
    } else if(dragging && running){
      yaw   -= (e.clientX-lastMX)*0.004;
      pitch -= (e.clientY-lastMY)*0.004;
      pitch=Math.max(-1.3,Math.min(1.3,pitch));
      lastMX=e.clientX; lastMY=e.clientY;
    }
  });
}

// touch controls
var move2=new THREE.Vector2(0,0);
if(isTouch){
  document.getElementById("touch").classList.remove("hidden");
  document.getElementById("keysDesktop").classList.add("hidden");
  document.getElementById("keysTouch").classList.remove("hidden");
  var stick=document.getElementById("stick"), nub=document.getElementById("nub");
  var stickId=null, sc={x:0,y:0};
  function stickStart(t){var r=stick.getBoundingClientRect();sc.x=r.left+r.width/2;sc.y=r.top+r.height/2;stickId=t.identifier;stickMove(t);}
  function stickMove(t){
    var dx=t.clientX-sc.x, dy=t.clientY-sc.y, d=Math.hypot(dx,dy), max=52;
    if(d>max){dx*=max/d;dy*=max/d;}
    nub.style.left=(41+dx)+"px"; nub.style.top=(41+dy)+"px";
    move2.x=dx/max; move2.y=dy/max;
  }
  function stickEnd(){stickId=null;move2.set(0,0);nub.style.left="41px";nub.style.top="41px";}

  var lookId=null, lastLook={x:0,y:0};
  var lookpad=document.getElementById("lookpad");
  function within(el,t){var r=el.getBoundingClientRect();return t.clientX>=r.left&&t.clientX<=r.right&&t.clientY>=r.top&&t.clientY<=r.bottom;}
  window.addEventListener("touchstart",function(e){
    for(var i=0;i<e.changedTouches.length;i++){var t=e.changedTouches[i];
      if(within(stick,t)&&stickId===null){stickStart(t);}
      else if(within(lookpad,t)&&lookId===null){lookId=t.identifier;lastLook.x=t.clientX;lastLook.y=t.clientY;}
    }
  },{passive:false});
  window.addEventListener("touchmove",function(e){
    for(var i=0;i<e.changedTouches.length;i++){var t=e.changedTouches[i];
      if(t.identifier===stickId)stickMove(t);
      else if(t.identifier===lookId){
        yaw-=(t.clientX-lastLook.x)*0.006; pitch-=(t.clientY-lastLook.y)*0.006;
        pitch=Math.max(-1.3,Math.min(1.3,pitch)); lastLook.x=t.clientX; lastLook.y=t.clientY;
      }
    }
    if(started&&!dead&&!won)e.preventDefault();
  },{passive:false});
  window.addEventListener("touchend",function(e){
    for(var i=0;i<e.changedTouches.length;i++){var t=e.changedTouches[i];
      if(t.identifier===stickId)stickEnd();
      if(t.identifier===lookId)lookId=null;
    }
  });
}

// ---------- audio (WebAudio, generated) ----------
var AC=null, drone=null, droneGain=null, heart=null, heartGain=null;
function initAudio(){
  try{
    AC=new (window.AudioContext||window.webkitAudioContext)();
    // low drone
    drone=AC.createOscillator(); drone.type="sawtooth"; drone.frequency.value=42;
    var lp=AC.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=140;
    droneGain=AC.createGain(); droneGain.gain.value=0.05;
    drone.connect(lp); lp.connect(droneGain); droneGain.connect(AC.destination); drone.start();
    // heartbeat gain (silent until near)
    heartGain=AC.createGain(); heartGain.gain.value=0; heartGain.connect(AC.destination);
  }catch(e){AC=null;}
}
function noiseBurst(dur,freq,vol){
  if(!AC)return;
  var n=AC.createBufferSource(); var len=AC.sampleRate*dur;
  var buf=AC.createBuffer(1,len,AC.sampleRate); var dt=buf.getChannelData(0);
  for(var i=0;i<len;i++)dt[i]=(Math.random()*2-1)*Math.pow(1-i/len,2);
  n.buffer=buf; var bp=AC.createBiquadFilter(); bp.type="bandpass"; bp.frequency.value=freq; bp.Q.value=0.8;
  var g=AC.createGain(); g.gain.value=vol;
  n.connect(bp); bp.connect(g); g.connect(AC.destination); n.start();
}
function scrapeSound(){ noiseBurst(0.22, 320, 0.18); }
function snapSound(){ noiseBurst(0.05, 1800, 0.4); setTimeout(function(){noiseBurst(0.3,90,0.5);},40); }
function pickupSound(){ if(!AC)return;var o=AC.createOscillator();o.frequency.value=880;var g=AC.createGain();g.gain.value=0.12;o.connect(g);g.connect(AC.destination);o.start();o.frequency.exponentialRampToValueAtTime(1320,AC.currentTime+.12);g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+.2);o.stop(AC.currentTime+.22);}
function doorSound(){ noiseBurst(0.5,120,0.3); }
var lastHeart=0;
function heartbeatTick(t,close){
  if(!AC)return;
  var interval=close?0.55:0.95;
  if(t-lastHeart>interval && close>0){
    lastHeart=t; var o=AC.createOscillator();o.frequency.value=55;
    var g=AC.createGain();g.gain.value=0.001;o.connect(g);g.connect(AC.destination);o.start();
    g.gain.setValueAtTime(0.0001,AC.currentTime);
    g.gain.linearRampToValueAtTime(0.12*close,AC.currentTime+0.02);
    g.gain.exponentialRampToValueAtTime(0.0001,AC.currentTime+0.18);
    o.stop(AC.currentTime+0.2);
  }
}

// ---------- observation logic ----------
var frustum=new THREE.Frustum();
var projScreen=new THREE.Matrix4();
var ray=new THREE.Raycaster();
var scpCenter=new THREE.Vector3();
var tmp=new THREE.Vector3(), tmp2=new THREE.Vector3();

function playerSees(){
  if(playerBlink>0) return false;
  scp.getWorldPosition(scpCenter); scpCenter.y=1.4;
  // frustum test (sphere)
  projScreen.multiplyMatrices(camera.projectionMatrix,camera.matrixWorldInverse);
  frustum.setFromProjectionMatrix(projScreen);
  if(!frustum.containsPoint(scpCenter)){
    // also test head/base points for partial visibility
    tmp.copy(scp.position); tmp.y=2.2;
    tmp2.copy(scp.position); tmp2.y=0.3;
    if(!frustum.containsPoint(tmp)&&!frustum.containsPoint(tmp2)) return false;
  }
  // occlusion
  tmp.copy(camera.position);
  tmp2.copy(scpCenter).sub(tmp); var dist=tmp2.length(); tmp2.normalize();
  ray.set(tmp,tmp2); ray.far=dist-0.3;
  var hit=ray.intersectObjects(occluders,false);
  if(hit.length>0) return false;
  return true;
}
function botSees(b){
  if(!b.alive||!b.lookAtStatue||b.blink>0) return false;
  scp.getWorldPosition(scpCenter); scpCenter.y=1.4;
  tmp.set(b.grp.position.x,1.5,b.grp.position.z);
  tmp2.copy(scpCenter).sub(tmp); var dist=tmp2.length();
  if(dist>14) return false;
  tmp2.normalize();
  // facing cone
  var fx=Math.sin(b.heading), fz=Math.cos(b.heading);
  var dot=fx*tmp2.x+fz*tmp2.z;
  if(dot<0.5) return false; // ~60deg half-cone
  // occlusion
  ray.set(tmp,tmp2); ray.far=dist-0.3;
  if(ray.intersectObjects(occluders,false).length>0) return false;
  return true;
}

// ---------- collision ----------
function resolve(pos,radius){
  // room bounds
  var b=HALF-0.4-radius;
  if(pos.x<-b)pos.x=-b; if(pos.x>b)pos.x=b; if(pos.z<-b)pos.z=-b; if(pos.z>b)pos.z=b;
  // obstacles (circle vs AABB push-out)
  for(var i=0;i<obstacles.length;i++){
    var o=obstacles[i];
    var cx=Math.max(o.minX,Math.min(pos.x,o.maxX));
    var cz=Math.max(o.minZ,Math.min(pos.z,o.maxZ));
    var dx=pos.x-cx, dz=pos.z-cz, d2=dx*dx+dz*dz;
    if(d2<radius*radius){
      var d=Math.sqrt(d2)||0.0001;
      // push out along nearest axis if inside
      if(cx>o.minX&&cx<o.maxX&&cz>o.minZ&&cz<o.maxZ){
        // deep inside: push to nearest edge
        var toL=pos.x-o.minX,toR=o.maxX-pos.x,toB=pos.z-o.minZ,toT=o.maxZ-pos.z;
        var mn=Math.min(toL,toR,toB,toT);
        if(mn===toL)pos.x=o.minX-radius; else if(mn===toR)pos.x=o.maxX+radius;
        else if(mn===toB)pos.z=o.minZ-radius; else pos.z=o.maxZ+radius;
      }else{
        pos.x=cx+(dx/d)*radius; pos.z=cz+(dz/d)*radius;
      }
    }
  }
}

// ---------- HUD refs ----------
var elHud=document.getElementById("hud");
var elState=document.getElementById("statestr");
var elObj=document.getElementById("objtext");
var elSurv=document.getElementById("survivors");
var elDanger=document.getElementById("danger");
var elLidT=document.getElementById("lidTop"), elLidB=document.getElementById("lidBot");
var elHint=document.getElementById("hint");

// ---------- game state ----------
var playerBlink=0, playerBlinkTimer=5+Math.random()*4;
var vel=new THREE.Vector3();
var bob=0;
var clock=new THREE.Clock();

function updateSurvivorHUD(){
  var s="";
  for(var i=0;i<bots.length;i++){
    if(bots[i].alive) s+='<span>'+bots[i].name+'</span> · alert<br>';
    else s+='<span style="color:#7a2b2b">'+bots[i].name+'</span> · <span style="color:#7a2b2b">KIA</span><br>';
  }
  elSurv.innerHTML=s;
}

function die(reason){
  if(dead||won)return; dead=true; running=false;
  snapSound();
  if(document.exitPointerLock)document.exitPointerLock();
  document.getElementById("overSub").textContent=reason;
  document.getElementById("over").classList.remove("hidden");
  elHud.classList.add("hidden");
  document.getElementById("touch").classList.add("hidden");
}
function winGame(){
  if(dead||won)return; won=true; running=false;
  doorSound();
  if(document.exitPointerLock)document.exitPointerLock();
  var alive=bots.filter(function(b){return b.alive;}).length;
  document.getElementById("winBody").textContent=
    "You sealed the cell with "+alive+" of "+bots.length+" personnel still breathing. The Foundation thanks you for your service. Do not discuss the events of Cell 173 outside of debrief.";
  document.getElementById("win").classList.remove("hidden");
  elHud.classList.add("hidden");
  document.getElementById("touch").classList.add("hidden");
}

// ---------- main loop ----------
function animate(){
  requestAnimationFrame(animate);
  var dt=Math.min(clock.getDelta(),0.05);
  var t=clock.elapsedTime;

  // camera orientation
  camera.rotation.order="YXZ";
  camera.rotation.y=yaw; camera.rotation.x=pitch;

  if(running){
    // ---- player movement ----
    var speed=(keys["ShiftLeft"]||keys["ShiftRight"]?4.6:2.8);
    var fwd=0,strafe=0;
    if(keys["KeyW"]||keys["ArrowUp"])fwd+=1;
    if(keys["KeyS"]||keys["ArrowDown"])fwd-=1;
    if(keys["KeyA"]||keys["ArrowLeft"])strafe-=1;
    if(keys["KeyD"]||keys["ArrowRight"])strafe+=1;
    if(isTouch){ fwd+=-move2.y; strafe+=move2.x; }
    var len=Math.hypot(fwd,strafe); if(len>1){fwd/=len;strafe/=len;}
    var sinY=Math.sin(yaw), cosY=Math.cos(yaw);
    var mx=(strafe*cosY - fwd*sinY);
    var mz=(strafe*sinY + fwd*cosY)*-1; // forward is -z
    // recompute properly:
    mx=(strafe*cosY + fwd*(-sinY));
    mz=(strafe*sinY + fwd*(cosY))*-1;
    var moving=(len>0.05);
    vel.x=mx*speed; vel.z=mz*speed;
    camera.position.x+=vel.x*dt; camera.position.z+=vel.z*dt;
    var p=new THREE.Vector3(camera.position.x,0,camera.position.z);
    resolve(p,0.35); camera.position.x=p.x; camera.position.z=p.z;
    // head bob + breathing
    bob+= moving?dt*speed*2.2:dt*1.5;
    camera.position.y=EYE + (moving?Math.sin(bob)*0.045:Math.sin(t*1.3)*0.012);

    // ---- involuntary player blink ----
    playerBlinkTimer-=dt;
    if(playerBlink>0){ playerBlink-=dt; }
    else if(playerBlinkTimer<=0){ playerBlink=0.22; playerBlinkTimer=4+Math.random()*5; }
    var lid=playerBlink>0?50:0;
    elLidT.style.height=lid+"%"; elLidB.style.height=lid+"%";

    // ---- bots update ----
    for(var i=0;i<bots.length;i++){
      var b=bots[i]; if(!b.alive)continue;
      // blink
      b.blinkTimer-=dt;
      if(b.blink>0)b.blink-=dt;
      else if(b.blinkTimer<=0){b.blink=0.2;b.blinkTimer=3+Math.random()*5;}
      // decide to look at statue or wander-look
      b.lookTimer-=dt;
      if(b.lookTimer<=0){ b.lookAtStatue=Math.random()<0.72; b.lookTimer=1.2+Math.random()*2.4; }
      // heading
      var desired;
      if(b.lookAtStatue){
        desired=Math.atan2(scp.position.x-b.grp.position.x, scp.position.z-b.grp.position.z);
      }else{
        desired=b.heading+ (Math.sin(t*0.6+b.phase))*0.6;
      }
      // smooth turn
      var da=((desired-b.heading+Math.PI*3)%(Math.PI*2))-Math.PI;
      b.heading+=da*Math.min(1,dt*3.5);
      b.grp.rotation.y=b.heading;
      // wander movement toward a shuffling target (staying near center-ish)
      b.moveTimer-=dt;
      if(b.moveTimer<=0){
        b.target.set((Math.random()*2-1)*7,(Math.random()*2-1)*7);
        b.moveTimer=2+Math.random()*3;
      }
      var tx=b.target.x-b.grp.position.x, tz=b.target.y-b.grp.position.z, td=Math.hypot(tx,tz);
      if(td>0.4){
        var bs=1.1*dt;
        var np=new THREE.Vector3(b.grp.position.x+ (tx/td)*bs, 0, b.grp.position.z+ (tz/td)*bs);
        resolve(np,0.32); b.grp.position.x=np.x; b.grp.position.z=np.z;
        b.phase+=dt*6;
        b.legL.rotation.x=Math.sin(b.phase)*0.5; b.legR.rotation.x=-Math.sin(b.phase)*0.5;
      }else{ b.legL.rotation.x*=0.9; b.legR.rotation.x*=0.9; }
    }

    // ---- observation: is 173 watched by ANYONE? ----
    var watched=playerSees();
    if(!watched){ for(var j=0;j<bots.length;j++){ if(botSees(bots[j])){watched=true;break;} } }

    // ---- 173 behaviour ----
    scp.getWorldPosition(scpCenter);
    var pdx=camera.position.x-scp.position.x, pdz=camera.position.z-scp.position.z;
    var pdist=Math.hypot(pdx,pdz);

    if(!watched){
      // pick nearest living person (player or bot)
      var targetX=camera.position.x, targetZ=camera.position.z, best=pdist, targetBot=-1;
      for(var k=0;k<bots.length;k++){
        if(!bots[k].alive)continue;
        var bd=Math.hypot(bots[k].grp.position.x-scp.position.x,bots[k].grp.position.z-scp.position.z);
        if(bd<best){best=bd;targetX=bots[k].grp.position.x;targetZ=bots[k].grp.position.z;targetBot=k;}
      }
      // lunge toward target
      var lx=targetX-scp.position.x, lz=targetZ-scp.position.z, ld=Math.hypot(lx,lz)||0.0001;
      var lunge=6.5*dt; // fast
      var np2=new THREE.Vector3(scp.position.x+(lx/ld)*lunge,0,scp.position.z+(lz/ld)*lunge);
      resolve(np2,scpRadius); scp.position.x=np2.x; scp.position.z=np2.z;
      scp.rotation.y=Math.atan2(lx,lz);
      if(t*10%3<1)scrapeSound();
      // kills
      if(targetBot>=0){
        var kd=Math.hypot(bots[targetBot].grp.position.x-scp.position.x,bots[targetBot].grp.position.z-scp.position.z);
        if(kd<1.15){ bots[targetBot].alive=false; bots[targetBot].grp.visible=false; snapSound(); updateSurvivorHUD(); }
      }
      // player death recompute
      pdist=Math.hypot(camera.position.x-scp.position.x,camera.position.z-scp.position.z);
      if(pdist<1.25){ die("It moved while no one watched."); }
    } else {
      scp.rotation.y=Math.atan2(pdx,pdz); // stare back (allowed; it's frozen in place)
    }

    // ---- HUD state ----
    if(watched){ elState.textContent="CONTAINED"; elState.className="st"; }
    else { elState.textContent="MOVING"; elState.className="st bad"; }
    // danger vignette by proximity
    var dv=Math.max(0,1-(pdist-1.2)/4.5);
    elDanger.style.opacity=(dv*0.9).toFixed(2);
    heartbeatTick(t, dv>0.15?dv:0);

    // ---- keycard pickup ----
    if(!hasCard){
      card.rotation.y+=dt*1.4; card.position.y=1.15+Math.sin(t*2)*0.05;
      var cd=Math.hypot(camera.position.x-CARDPOS.x,camera.position.z-CARDPOS.z);
      if(cd<1.5){ hasCard=true; card.visible=false; pickupSound();
        elObj.innerHTML='<b>Keycard acquired.</b> Reach the blast door (east wall).';
        elHint.textContent="GET TO THE DOOR — don't lose sight of it";
      }
    } else {
      var ed=Math.hypot(camera.position.x-EXIT.x,camera.position.z-EXIT.z);
      if(ed<EXIT.r) winGame();
    }
  }

  // fluorescent flicker (always)
  for(var f=0;f<lights.length;f++){
    var L=lights[f];
    if(L.flicker){
      var fl=(Math.random()<0.02)?(0.6+Math.random()*0.35):1;
      L.light.intensity=L.base*fl;
      L.bulb.material.emissiveIntensity=1.4*fl;
    }
  }

  renderer.render(scene,camera);
}
animate();

// ---------- wire up buttons ----------
function beginGame(){
  document.getElementById("intro").classList.add("hidden");
  document.getElementById("over").classList.add("hidden");
  document.getElementById("win").classList.add("hidden");
  elHud.classList.remove("hidden");
  if(isTouch)document.getElementById("touch").classList.remove("hidden");
  if(!AC)initAudio(); else if(AC.state==="suspended")AC.resume();
  updateSurvivorHUD();
  started=true; running=true;
  if(!isTouch)canvas.requestPointerLock&&canvas.requestPointerLock();
}
function resetGame(){
  // reset player
  camera.position.set(-7.5,EYE,7.5);
  yaw=Math.atan2(-(scp.position.x-camera.position.x),-(scp.position.z-camera.position.z));
  pitch=0;
  // reset statue
  scp.position.set(0,0,-7.5);
  // reset card
  hasCard=false; card.visible=true; card.position.copy(CARDPOS);
  elObj.innerHTML="Locate the keycard, then reach the blast door.";
  elHint.textContent="KEEP IT IN VIEW — it moves the instant no one is watching";
  // reset bots
  var starts=[[-5,3],[5,5],[-3,-4]];
  for(var i=0;i<bots.length;i++){
    bots[i].alive=true; bots[i].grp.visible=true;
    bots[i].grp.position.set(starts[i][0],0,starts[i][1]);
    bots[i].heading=Math.random()*Math.PI*2; bots[i].lookAtStatue=true;
  }
  playerBlink=0; playerBlinkTimer=5;
  dead=false; won=false;
  beginGame();
}
document.getElementById("startBtn").addEventListener("click",beginGame);
document.getElementById("retryBtn").addEventListener("click",resetGame);
document.getElementById("againBtn").addEventListener("click",resetGame);

// pause on pointer-unlock (desktop)
document.addEventListener("pointerlockchange",function(){
  if(document.pointerLockElement===canvas) everLocked=true;
  if(!isTouch && started && !dead && !won && everLocked){
    running = (document.pointerLockElement===canvas);
    elHint.textContent = running ? "KEEP IT IN VIEW — it moves the instant no one is watching"
                                 : "PAUSED — click to resume";
  }
});

// resize
window.addEventListener("resize",function(){
  W=window.innerWidth;H=window.innerHeight;
  camera.aspect=W/H;camera.updateProjectionMatrix();
  renderer.setSize(W,H);
});
})();
</script>
</body>
</html>

Game Source: SCP-173: Containment

Creator: BraveMaker51

Libraries: three

Complexity: complex (822 lines, 36.9 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: scp-173-containment-bravemaker51" to link back to the original. Then publish at arcadelab.ai/publish.