One Portal test chamber, built by Claude Haiku 5.5
Hostedvia Claude CodeOpenRouter
Show the code the model wroteHide the code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portal Chamber</title>
<style>
:root { --ink: #e9eef4; --dim: #9aa6b2; color-scheme: dark; }
html, body { margin: 0; height: 100%; background: #1b1f24; overflow: hidden; }
body { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; color: var(--ink); }
canvas { display: block; width: 100vw; height: 100vh; }
#dot { position: fixed; left: 50%; top: 50%; width: 6px; height: 6px; margin: -3px 0 0 -3px;
border-radius: 50%; background: #fff; box-shadow: 0 0 0 1px rgba(0, 0, 0, .45); pointer-events: none; }
.screen { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 14px; padding: 16px; box-sizing: border-box; text-align: center; }
.hidden { display: none !important; }
#overlay { background: rgba(8, 10, 13, .42); cursor: pointer; }
#overlay h1 { margin: 0; font-size: clamp(28px, 6vw, 56px); font-weight: 300;
letter-spacing: .18em; text-transform: uppercase; }
#overlayMsg { font-size: 18px; }
.keys { font-size: 14px; color: var(--dim); line-height: 1.7; max-width: 560px; }
#won { background: rgba(8, 10, 13, .6); }
#won .card { background: #242a31; border: 1px solid #3a424c; border-radius: 14px; padding: 28px 32px;
min-width: 260px; box-shadow: 0 20px 60px rgba(0, 0, 0, .5); }
#won h2 { margin: 0 0 6px; font-weight: 500; font-size: 24px; }
#won p { margin: 0 0 18px; color: var(--dim); font-size: 14px; }
#won button { font: inherit; font-size: 15px; padding: 10px 22px; border-radius: 8px; border: 0;
background: var(--ink); color: #14181d; cursor: pointer; }
#won button:hover { background: #fff; }
</style>
</head>
<body>
<div id="dot"></div>
<div id="overlay" class="screen">
<h1>Portal Chamber</h1>
<div id="overlayMsg">Click to start</div>
<div class="keys">WASD move · Space jump · Mouse look<br>
Left click: blue portal · Right click: orange portal<br>Esc to pause</div>
</div>
<div id="won" class="screen hidden">
<div class="card">
<h2>Chamber complete</h2>
<p>Portals are a two-way street. Well done.</p>
<button id="restart">Restart chamber</button>
</div>
</div>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
// ---------- layout (metres, y up) ----------
const ROOM = { hx: 7, hz: 7, h: 6 };
const LEDGE = { x0: -3, x1: 3, z0: -7, z1: -4.5, top: 3.2 };
const DOOR = { x: -1.9, y: 4.35, w: 1.4, h: 2.3 };
const START = new THREE.Vector3(0, 0, 3);
const DEMO_START = new THREE.Vector3(-2, 0, 0);
const DEMO_YAW = -Math.PI / 2; // faces +x, toward the blue portal
// ---------- player / portal tuning ----------
const EYE = 1.6, CHEST = 0.8, BODY_R = 0.3, STEP = 0.6;
const WALK = 4.2, JUMP = 6.6, GRAVITY = 20;
const PORTAL_HW = 0.6, PORTAL_HH = 1.0;
const CHEST_V = new THREE.Vector3(0, CHEST, 0);
const UP = new THREE.Vector3(0, 1, 0);
const NO_INPUT = { f: 0, s: 0, l: 0, r: 0, jump: false };
const V = (x, y, z) => new THREE.Vector3(x, y, z);
// ---------- renderer / scene / camera ----------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1b1f24);
const camera = new THREE.PerspectiveCamera(74, innerWidth / innerHeight, 0.02, 80);
camera.rotation.order = 'YXZ';
scene.add(camera);
// ---------- lights ----------
scene.add(new THREE.HemisphereLight(0xe3ebf5, 0x3a3f46, 1.0));
scene.add(new THREE.AmbientLight(0xffffff, 0.6)); // fill so the white panels read as white
const sun = new THREE.DirectionalLight(0xffffff, 1.9);
sun.position.set(4, 10, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 1, far: 30 });
sun.shadow.camera.updateProjectionMatrix();
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.02;
scene.add(sun);
// The room is static, so the shadow map is baked once instead of per render pass.
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true;
// ---------- materials ----------
function seamTexture(bg, line, lw) {
const c = document.createElement('canvas');
c.width = c.height = 256;
const g = c.getContext('2d');
g.fillStyle = bg;
g.fillRect(0, 0, 256, 256);
g.strokeStyle = line;
g.lineWidth = lw;
g.strokeRect(lw / 2, lw / 2, 256 - lw, 256 - lw);
const t = new THREE.CanvasTexture(c);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 8;
return t;
}
const whiteSeam = seamTexture('#eef2f5', '#aeb7c1', 7);
const floorSeam = seamTexture('#565e68', '#3a4048', 9);
floorSeam.repeat.set(ROOM.hx, ROOM.hz); // 2 m tiles across the 14 m floor
const matDark = new THREE.MeshStandardMaterial({ color: 0x59616c, metalness: 0.3, roughness: 0.5 });
const matFloor = new THREE.MeshStandardMaterial({ map: floorSeam, metalness: 0.2, roughness: 0.6 });
function whiteMat(w, h) {
const t = whiteSeam.clone();
t.repeat.set(w / 2, h / 2); // one seam every 2 m
t.needsUpdate = true;
return new THREE.MeshStandardMaterial({ map: t, roughness: 0.8, metalness: 0 });
}
// ---------- room ----------
const solids = []; // everything a shot can hit
const panels = []; // white, portal-able surfaces
function wall(c, n, w, h, white) {
const r = new THREE.Vector3().crossVectors(UP, n).normalize();
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), white ? whiteMat(w, h) : matDark);
mesh.position.copy(c).addScaledVector(n, white ? 0.01 : 0);
mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(r, UP, n));
mesh.receiveShadow = true;
scene.add(mesh);
solids.push(mesh);
if (white) {
const rec = { c0: c.clone(), n: n.clone(), r, u: UP.clone(), w, h };
panels.push(rec);
mesh.userData.panel = rec;
}
}
const floor = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), matFloor);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
solids.push(floor);
const ceiling = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), matDark);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.y = ROOM.h;
scene.add(ceiling);
solids.push(ceiling);
// right wall (x = +7)
wall(V(7, 3, 0), V(-1, 0, 0), 14, 6, false);
wall(V(7, 3, 0), V(-1, 0, 0), 12, 6, true);
// left wall (x = -7)
wall(V(-7, 3, 0), V(1, 0, 0), 14, 6, false);
wall(V(-7, 3, 0), V(1, 0, 0), 12, 6, true);
// front wall (z = +7)
wall(V(0, 3, 7), V(0, 0, -1), 14, 6, false);
wall(V(0, 3, 7), V(0, 0, -1), 12, 6, true);
// back wall (z = -7): dark metal, white only above the ledge, with a gap for the door
wall(V(0, 3, -7), V(0, 0, 1), 14, 6, false);
wall(V(-4.8, 4.6, -7), V(0, 0, 1), 4.4, 2.8, true);
wall(V(2.9, 4.6, -7), V(0, 0, 1), 8.2, 2.8, true);
// ledge: too high to jump onto
const ledge = new THREE.Mesh(
new THREE.BoxGeometry(LEDGE.x1 - LEDGE.x0, LEDGE.top, LEDGE.z1 - LEDGE.z0), matDark);
ledge.position.set((LEDGE.x0 + LEDGE.x1) / 2, LEDGE.top / 2, (LEDGE.z0 + LEDGE.z1) / 2);
ledge.castShadow = true;
ledge.receiveShadow = true;
scene.add(ledge);
solids.push(ledge);
// exit door on the ledge's wall
const doorFrame = new THREE.Mesh(new THREE.PlaneGeometry(DOOR.w, DOOR.h),
new THREE.MeshStandardMaterial({ color: 0x20262d, metalness: 0.7, roughness: 0.35 }));
doorFrame.position.set(DOOR.x, DOOR.y, -6.985);
scene.add(doorFrame);
solids.push(doorFrame);
const doorGlow = new THREE.Mesh(new THREE.PlaneGeometry(DOOR.w - 0.3, DOOR.h - 0.3),
new THREE.MeshStandardMaterial({ color: 0x0b2b24, emissive: 0x2a9d7a, emissiveIntensity: 0.35 }));
doorGlow.position.set(DOOR.x, DOOR.y, -6.98);
scene.add(doorGlow);
solids.push(doorGlow);
// ceiling light strips
const lightMat = new THREE.MeshBasicMaterial({ color: 0xf3f7fb });
for (const x of [-3.5, 3.5]) {
const strip = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 8), lightMat);
strip.rotation.x = Math.PI / 2;
strip.position.set(x, ROOM.h - 0.03, 0);
scene.add(strip);
}
// ---------- portal gun (viewmodel) ----------
const gun = new THREE.Group();
const gunBody = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.14, 0.46),
new THREE.MeshStandardMaterial({ color: 0x9aa3ad, metalness: 0.8, roughness: 0.3 }));
gunBody.position.z = -0.12;
const gunNozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.14, 16),
new THREE.MeshStandardMaterial({ color: 0x5b636d, metalness: 0.8, roughness: 0.35 }));
gunNozzle.rotation.x = Math.PI / 2;
gunNozzle.position.z = -0.4;
const gunBlue = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 8), new THREE.MeshBasicMaterial({ color: 0x3aa0ff }));
gunBlue.position.set(-0.07, 0.09, -0.3);
const gunOrange = new THREE.Mesh(new THREE.SphereGeometry(0.035, 12, 8), new THREE.MeshBasicMaterial({ color: 0xff8c2a }));
gunOrange.position.set(0.07, 0.09, -0.3);
gun.add(gunBody, gunNozzle, gunBlue, gunOrange);
gun.position.set(0.3, -0.27, -0.5);
camera.add(gun);
// ---------- portals ----------
const VERT = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;
// Inside the rim we sample the partner's view by screen position: the virtual camera
// is built so its image lines up with this pixel. Outside the rim we draw a soft glow.
const FRAG = `
uniform sampler2D tex;
uniform vec2 res;
uniform vec3 glowColor;
uniform float time;
uniform vec2 halfSize;
uniform vec2 quadSize;
varying vec2 vUv;
void main() {
vec2 q = (vUv - 0.5) * quadSize;
float d = length(q / halfSize);
float edge = abs(d - 1.0);
float rim = exp(-edge * edge * 1800.0) + 0.35 * exp(-edge * 9.0);
rim *= 0.9 + 0.1 * sin(time * 3.0);
if (d < 1.0) {
vec3 seen = texture2D(tex, gl_FragCoord.xy / res).rgb;
gl_FragColor = vec4(seen + glowColor * rim, 1.0);
} else {
gl_FragColor = vec4(glowColor * rim, clamp(rim, 0.0, 1.0));
}
#include <colorspace_fragment>
}`;
function makePortal(hex) {
const rt = new THREE.WebGLRenderTarget(4, 4);
const material = new THREE.ShaderMaterial({
uniforms: {
tex: { value: rt.texture },
res: { value: new THREE.Vector2(1, 1) },
glowColor: { value: new THREE.Color(hex) },
time: { value: 0 },
halfSize: { value: new THREE.Vector2(PORTAL_HW, PORTAL_HH) },
quadSize: { value: new THREE.Vector2(PORTAL_HW * 2.5, PORTAL_HH * 2.5) },
},
vertexShader: VERT,
fragmentShader: FRAG,
transparent: true,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(PORTAL_HW * 2.5, PORTAL_HH * 2.5), material);
mesh.visible = false;
scene.add(mesh);
return {
placed: false, visible: false,
c: new THREE.Vector3(), n: new THREE.Vector3(), r: new THREE.Vector3(), u: UP.clone(),
rt, material, mesh, cam: new THREE.PerspectiveCamera(),
};
}
const portals = { blue: makePortal(0x3aa0ff), orange: makePortal(0xff8c2a) };
const PORTAL_LIST = [portals.blue, portals.orange];
const other = P => (P === portals.blue ? portals.orange : portals.blue);
const drawSize = new THREE.Vector2();
function syncSize() {
renderer.getDrawingBufferSize(drawSize);
for (const P of PORTAL_LIST) {
P.rt.setSize(drawSize.x, drawSize.y);
P.material.uniforms.res.value.copy(drawSize);
}
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
}
syncSize();
// Frame of a portal: the portal's room-side normal and its right/up axes.
// Frame A maps onto frame B with the normal reversed and a 180° turn about up,
// so walking in at A walks out at B facing away from the wall.
function portalRotation(P, Q) {
const B = new THREE.Matrix4().makeBasis(Q.r.clone().negate(), Q.u, Q.n.clone().negate());
const A = new THREE.Matrix4().makeBasis(P.r, P.u, P.n).transpose();
return B.multiply(A);
}
function portalTransform(P, Q) {
return new THREE.Matrix4()
.makeTranslation(Q.c.x, Q.c.y, Q.c.z)
.multiply(portalRotation(P, Q))
.multiply(new THREE.Matrix4().makeTranslation(-P.c.x, -P.c.y, -P.c.z));
}
// Oblique near plane (Lengyel): clip everything behind the partner portal's wall.
function obliqueClip(vc, point, normal) {
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point).applyMatrix4(vc.matrixWorldInverse);
const clip = new THREE.Vector4(plane.normal.x, plane.normal.y, plane.normal.z, plane.constant);
const e = vc.projectionMatrix.elements;
const q = new THREE.Vector4(
(Math.sign(clip.x) + e[8]) / e[0],
(Math.sign(clip.y) + e[9]) / e[5],
-1.0,
(1.0 + e[10]) / e[14]);
clip.multiplyScalar(2.0 / clip.dot(q));
e[2] = clip.x;
e[6] = clip.y;
e[10] = clip.z + 1.0;
e[14] = clip.w;
}
// Render what is visible through P: the scene seen from behind Q, as if the camera stood at P.
function renderView(P, Q) {
const vc = P.cam;
const M = new THREE.Matrix4().multiplyMatrices(portalTransform(P, Q), camera.matrixWorld);
M.decompose(vc.position, vc.quaternion, vc.scale);
vc.updateMatrixWorld(true);
vc.projectionMatrix.copy(camera.projectionMatrix);
obliqueClip(vc, Q.c, Q.n);
renderer.setRenderTarget(P.rt);
renderer.clear();
renderer.render(scene, vc);
renderer.setRenderTarget(null);
}
function renderFrame() {
gun.visible = false;
for (const P of PORTAL_LIST) P.mesh.visible = false;
for (const P of PORTAL_LIST) {
P.visible = P.placed && camera.position.clone().sub(P.c).dot(P.n) > 0;
if (!P.visible) continue;
if (other(P).placed) {
renderView(P, other(P));
} else {
renderer.setRenderTarget(P.rt);
renderer.setClearColor(0x06080b, 1);
renderer.clear();
renderer.setRenderTarget(null);
}
}
for (const P of PORTAL_LIST) P.mesh.visible = P.visible;
gun.visible = true;
renderer.render(scene, camera);
}
function placePortal(P, point, panel) {
const mx = panel.w / 2 - PORTAL_HW, my = panel.h / 2 - PORTAL_HH;
if (mx < 0 || my < 0) return false; // panel too small for a portal
const d = point.clone().sub(panel.c0);
const lx = THREE.MathUtils.clamp(d.dot(panel.r), -mx, mx);
let ly = THREE.MathUtils.clamp(d.dot(panel.u), -my, my);
// Snap the bottom edge onto the floor, or onto the ledge top on the ledge wall, when close.
// Walking through then lands at exactly the same height, with no step.
const supports = [0];
const wx = panel.c0.x + panel.r.x * lx;
if (panel.n.z > 0.99 && wx > LEDGE.x0 && wx < LEDGE.x1) supports.push(LEDGE.top);
for (const s of supports) {
const snapped = s + PORTAL_HH - panel.c0.y;
if (Math.abs(snapped) <= my && Math.abs(panel.c0.y + ly - PORTAL_HH - s) < 0.75) ly = snapped;
}
P.c.copy(panel.c0).addScaledVector(panel.r, lx).addScaledVector(panel.u, ly);
P.n.copy(panel.n);
P.r.copy(panel.r);
P.u.copy(panel.u);
P.placed = true;
P.mesh.position.copy(P.c).addScaledVector(P.n, 0.01);
P.mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(P.r, P.u, P.n));
return true;
}
function findPanel(p) {
return panels.find(rec => {
const d = p.clone().sub(rec.c0);
return Math.abs(d.dot(rec.n)) < 0.05 && Math.abs(d.dot(rec.r)) <= rec.w / 2 && Math.abs(d.dot(rec.u)) <= rec.h / 2;
});
}
// ---------- fizzle effect ----------
const fx = [];
function fizzle(point, normal, color) {
const ring = new THREE.Mesh(new THREE.RingGeometry(0.12, 0.18, 32),
new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, side: THREE.DoubleSide }));
ring.position.copy(point).addScaledVector(normal, 0.03);
ring.lookAt(point.clone().add(normal));
scene.add(ring);
fx.push({ mesh: ring, t: 0 });
}
function updateFx(dt) {
for (let i = fx.length - 1; i >= 0; i--) {
const f = fx[i];
f.t += dt;
const k = f.t / 0.45;
f.mesh.scale.setScalar(0.6 + k * 1.6);
f.mesh.material.opacity = Math.max(0, 1 - k);
if (k >= 1) {
scene.remove(f.mesh);
f.mesh.geometry.dispose();
f.mesh.material.dispose();
fx.splice(i, 1);
}
}
}
// ---------- shooting ----------
const raycaster = new THREE.Raycaster();
const CENTER = new THREE.Vector2(0, 0);
function shoot(name) {
if (mode !== 'play') return;
camera.updateMatrixWorld();
raycaster.setFromCamera(CENTER, camera);
const hit = raycaster.intersectObjects(solids, false)[0];
const P = portals[name];
if (hit && hit.object.userData.panel && placePortal(P, hit.point, hit.object.userData.panel)) return;
const at = hit ? hit.point : raycaster.ray.at(6, new THREE.Vector3());
const nrm = hit
? hit.face.normal.clone().transformDirection(hit.object.matrixWorld)
: raycaster.ray.direction.clone().negate();
fizzle(at, nrm, P.material.uniforms.glowColor.value);
}
// ---------- player ----------
const player = { pos: START.clone(), vel: new THREE.Vector3(), yaw: 0, pitch: 0, grounded: true, viewLift: 0 };
function teleport(P, Q, feet) {
const R = portalRotation(P, Q);
player.pos.copy(feet).applyMatrix4(portalTransform(P, Q));
// Never come out below the exit's bottom edge, so the body is not left inside the ledge.
// The eye keeps its mapped height and eases down to the real one, so the view does not jump.
const mappedY = player.pos.y;
player.pos.y = Math.max(mappedY, Q.c.y - PORTAL_HH);
player.viewLift += mappedY - player.pos.y;
player.vel.applyMatrix4(R);
const f = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw)).applyMatrix4(R);
player.yaw = Math.atan2(-f.x, -f.z);
if (mode === 'demo') { demo.teleported = true; demo.t0 = demo.t; }
}
function inLedgeFootprint(x, z) {
return x > LEDGE.x0 && x < LEDGE.x1 && z > LEDGE.z0 && z < LEDGE.z1;
}
function pushOutOfLedge(p) {
const x0 = LEDGE.x0 - BODY_R, x1 = LEDGE.x1 + BODY_R, z1 = LEDGE.z1 + BODY_R;
if (p.x <= x0 || p.x >= x1 || p.z <= LEDGE.z0 || p.z >= z1) return;
const left = p.x - x0, right = x1 - p.x, front = z1 - p.z;
if (left <= right && left <= front) p.x = x0;
else if (right <= front) p.x = x1;
else p.z = z1;
}
// The chest may pass a wall only through a placed portal's opening.
function inOpening(c, inward) {
for (const P of PORTAL_LIST) {
if (!P.placed || P.n.dot(inward) < 0.99) continue;
const d = c.clone().sub(P.c);
if (Math.abs(d.dot(P.n)) > 0.9) continue;
if ((d.dot(P.r) / (PORTAL_HW + 0.15)) ** 2 + (d.dot(P.u) / (PORTAL_HH + 0.15)) ** 2 < 1) return true;
}
return false;
}
const WALLS = [
{ axis: 'x', sign: 1, inward: V(-1, 0, 0) },
{ axis: 'x', sign: -1, inward: V(1, 0, 0) },
{ axis: 'z', sign: 1, inward: V(0, 0, -1) },
{ axis: 'z', sign: -1, inward: V(0, 0, 1) },
];
const LIMIT = ROOM.hx - BODY_R;
function clampToRoom(p) {
for (const w of WALLS) {
if (p[w.axis] * w.sign <= LIMIT) continue;
if (inOpening(p.clone().add(CHEST_V), w.inward)) continue;
p[w.axis] = w.sign * LIMIT;
}
}
function stepPlayer(sx, sy, sz) {
const pos = player.pos;
const prev = pos.clone();
const next = prev.clone().add(V(sx, sy, sz));
// 1) Crossing a placed portal's opening (judged by the chest) teleports the player.
const cPrev = prev.clone().add(CHEST_V), cNext = next.clone().add(CHEST_V);
for (const P of PORTAL_LIST) {
const Q = other(P);
if (!P.placed || !Q.placed) continue;
const aPrev = cPrev.clone().sub(P.c).dot(P.n);
const aNext = cNext.clone().sub(P.c).dot(P.n);
if (aPrev < 0 || aNext >= 0) continue;
const X = cPrev.clone().lerp(cNext, aPrev / (aPrev - aNext)).sub(P.c);
if ((X.dot(P.r) / PORTAL_HW) ** 2 + (X.dot(P.u) / PORTAL_HH) ** 2 < 1) {
teleport(P, Q, next);
return;
}
}
// 2) The ledge is solid while the player is below its top; they cannot step up onto it.
if (prev.y < LEDGE.top - STEP) pushOutOfLedge(next);
// 3) Room walls.
clampToRoom(next);
// 4) Ground: the floor, or the ledge top when the player is already at that height.
const onLedge = prev.y >= LEDGE.top - STEP && inLedgeFootprint(next.x, next.z);
const ground = onLedge ? LEDGE.top : 0;
if (next.y <= ground) {
next.y = ground;
player.vel.y = 0;
player.grounded = true;
} else {
player.grounded = false;
}
pos.copy(next);
}
function update(input, dt) {
const fwd = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(-fwd.z, 0, fwd.x);
const wish = fwd.multiplyScalar(input.f - input.s).addScaledVector(right, input.r - input.l);
if (wish.lengthSq() > 0) wish.normalize().multiplyScalar(WALK);
player.vel.x = wish.x;
player.vel.z = wish.z;
if (input.jump && player.grounded) {
player.vel.y = JUMP;
player.grounded = false;
}
player.vel.y -= GRAVITY * dt;
const steps = Math.max(1, Math.ceil(player.vel.length() * dt / 0.04));
for (let i = 0; i < steps; i++) {
stepPlayer(player.vel.x * dt / steps, player.vel.y * dt / steps, player.vel.z * dt / steps);
}
}
function syncCamera() {
camera.position.set(player.pos.x, player.pos.y + EYE + player.viewLift, player.pos.z);
camera.rotation.set(player.pitch, player.yaw, 0);
camera.updateMatrixWorld();
}
// ---------- modes and UI ----------
const overlay = document.getElementById('overlay');
const overlayMsg = document.getElementById('overlayMsg');
const wonCard = document.getElementById('won');
let mode = 'demo'; // demo | play | paused | won
function showOverlay(on, msg) {
overlay.classList.toggle('hidden', !on);
if (msg) overlayMsg.textContent = msg;
}
function lock() {
const p = renderer.domElement.requestPointerLock();
// Chrome refuses a re-lock right after Esc; fall back to the paused overlay so the next click works.
if (p && p.catch) p.catch(() => {
if (mode === 'play') {
mode = 'paused';
showOverlay(true, 'Click to resume');
}
});
}
function resetChamber() {
for (const P of PORTAL_LIST) { P.placed = false; P.mesh.visible = false; }
player.pos.copy(START);
player.vel.set(0, 0, 0);
player.yaw = 0;
player.pitch = 0;
player.grounded = true;
player.viewLift = 0;
}
function startGame() {
resetChamber();
mode = 'play';
wonCard.classList.add('hidden');
showOverlay(false);
lock();
}
function resumeGame() {
mode = 'play';
showOverlay(false);
lock();
}
function checkWin() {
const p = player.pos;
if (mode === 'play' && Math.abs(p.x - DOOR.x) < 0.8 && p.z < -6.0 && Math.abs(p.y - LEDGE.top) < 0.3) {
mode = 'won';
showOverlay(false);
wonCard.classList.remove('hidden');
if (document.pointerLockElement) document.exitPointerLock();
}
}
// ---------- attract demo: walk into blue, come out of orange ----------
const demo = { t: 0, teleported: false, t0: 0 };
function resetDemo() {
player.pos.copy(DEMO_START);
player.vel.set(0, 0, 0);
player.yaw = DEMO_YAW;
player.pitch = 0;
player.grounded = true;
player.viewLift = 0;
demo.t = 0;
demo.teleported = false;
}
function demoInput(dt) {
demo.t += dt;
if (!demo.teleported) return { f: 1, s: 0, l: 0, r: 0, jump: false };
player.yaw += (Math.PI / 2 - player.yaw) * Math.min(1, dt * 2); // turn toward the door
if (demo.t - demo.t0 > 3.5) resetDemo();
return NO_INPUT;
}
// ---------- input ----------
const keys = new Set();
const held = c => (keys.has(c) ? 1 : 0);
function liveInput() {
return { f: held('KeyW'), s: held('KeyS'), l: held('KeyA'), r: held('KeyD'), jump: keys.has('Space') };
}
// Some synthetic key events arrive with an empty e.code, so fall back to e.key.
const codeOf = e => e.code || (e.key === ' ' ? 'Space' : 'Key' + e.key.toUpperCase());
addEventListener('keydown', e => {
keys.add(codeOf(e));
if (codeOf(e) === 'Space') e.preventDefault();
});
addEventListener('keyup', e => keys.delete(codeOf(e)));
addEventListener('blur', () => keys.clear());
addEventListener('mousemove', e => {
if (document.pointerLockElement !== renderer.domElement) return;
player.yaw -= e.movementX * 0.0022;
player.pitch = THREE.MathUtils.clamp(player.pitch - e.movementY * 0.0022, -1.45, 1.45);
});
addEventListener('mousedown', e => {
if (mode !== 'play') return;
if (e.button === 0) shoot('blue');
else if (e.button === 2) shoot('orange');
});
addEventListener('contextmenu', e => e.preventDefault());
overlay.addEventListener('click', () => {
if (mode === 'demo') startGame();
else if (mode === 'paused') resumeGame();
});
document.getElementById('restart').addEventListener('click', startGame);
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement === renderer.domElement) return;
if (mode === 'play') {
mode = 'paused';
showOverlay(true, 'Paused: click to resume');
}
});
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
syncSize();
});
// ---------- start ----------
resetDemo();
placePortal(portals.blue, V(7, 1.2, 0), findPanel(V(7, 1.2, 0)));
placePortal(portals.orange, V(0.8, 4.2, -7), findPanel(V(0.8, 4.2, -7)));
syncCamera();
let last = performance.now(), time = 0;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
time += dt;
if (mode === 'demo') update(demoInput(dt), dt);
else if (mode === 'play') update(liveInput(), dt);
checkWin();
player.viewLift *= Math.exp(-dt * 12);
syncCamera();
updateFx(dt);
for (const P of PORTAL_LIST) P.material.uniforms.time.value = time;
renderFrame();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Read-only handle for automated testing.
window.chamber = {
player,
portals,
get mode() { return mode; },
shoot,
setLook(yaw, pitch) { player.yaw = yaw; player.pitch = pitch; },
};
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.