One Portal test chamber, built by Gemini 4 Argon
Hostedvia DaBenchOpenRouter
$0.24
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.0" />
<title>Aperture Test Chamber 01</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #0d1117;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
color: #f0f4f8;
}
#game-canvas {
display: block;
width: 100vw;
height: 100vh;
cursor: pointer;
}
/* Crosshair & Portal Indicators */
#hud {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
#hud.visible {
opacity: 1;
}
#crosshair {
position: absolute;
top: 50%;
left: 50%;
width: 48px;
height: 48px;
transform: translate(-50%, -50%);
}
#hud-top-left {
position: absolute;
top: 24px;
left: 28px;
background: rgba(14, 20, 28, 0.72);
border-left: 3px solid #00a8ff;
padding: 10px 16px;
backdrop-filter: blur(6px);
border-radius: 0 6px 6px 0;
letter-spacing: 0.08em;
font-size: 12px;
text-transform: uppercase;
color: #c8d6e5;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
#hud-top-left strong {
display: block;
font-size: 15px;
color: #ffffff;
margin-bottom: 2px;
letter-spacing: 0.12em;
}
#hud-controls {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(14, 20, 28, 0.75);
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 8px 20px;
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.06em;
color: #d2dae2;
backdrop-filter: blur(6px);
display: flex;
gap: 18px;
align-items: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
white-space: nowrap;
}
.key-badge {
display: inline-block;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 4px;
padding: 2px 7px;
font-weight: 600;
font-size: 11px;
color: #fff;
margin-right: 4px;
}
.dot-blue {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
background: #00a8ff;
box-shadow: 0 0 8px #00a8ff;
margin-right: 5px;
}
.dot-orange {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 50%;
background: #ff7700;
box-shadow: 0 0 8px #ff7700;
margin-right: 5px;
}
/* Demo Overlay (Shown before first click) */
#demo-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 20;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 34px 42px;
background: radial-gradient(circle at center, transparent 55%, rgba(6, 10, 15, 0.45) 100%);
transition: opacity 0.35s ease;
}
#demo-overlay.hidden {
opacity: 0;
visibility: hidden;
}
.demo-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.chamber-brand {
background: rgba(12, 17, 24, 0.82);
border: 1px solid rgba(255, 255, 255, 0.15);
border-left: 4px solid #ff7700;
padding: 16px 24px;
border-radius: 0 10px 10px 0;
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
}
.chamber-brand .sub {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: #8fa4b8;
margin-bottom: 4px;
}
.chamber-brand h1 {
font-size: 26px;
font-weight: 800;
letter-spacing: 0.08em;
color: #ffffff;
}
.demo-pill {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(12, 17, 24, 0.82);
border: 1px solid rgba(0, 168, 255, 0.4);
padding: 8px 16px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #70d6ff;
backdrop-filter: blur(8px);
}
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #00a8ff;
box-shadow: 0 0 10px #00a8ff;
animation: pulseGlow 1.4s infinite ease-in-out;
}
@keyframes pulseGlow {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.35); opacity: 0.55; }
}
.demo-footer {
align-self: center;
text-align: center;
background: rgba(12, 17, 24, 0.86);
border: 1px solid rgba(255, 255, 255, 0.16);
padding: 20px 36px;
border-radius: 14px;
backdrop-filter: blur(12px);
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
}
.click-prompt {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #ffffff;
margin-bottom: 10px;
}
.demo-controls-row {
display: flex;
gap: 20px;
justify-content: center;
font-size: 13px;
color: #b8c7d6;
}
/* Pause Banner when pointer lock is exited during gameplay */
#pause-banner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(12, 18, 26, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 24px 38px;
border-radius: 12px;
text-align: center;
pointer-events: none;
z-index: 25;
backdrop-filter: blur(10px);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease;
}
#pause-banner.visible {
opacity: 1;
visibility: visible;
}
#pause-banner h2 {
font-size: 20px;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 8px;
}
#pause-banner p {
font-size: 13px;
color: #9fb3c8;
}
/* Chamber Complete Modal Card */
#complete-modal {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
background: rgba(6, 10, 16, 0.72);
backdrop-filter: blur(10px);
opacity: 0;
visibility: hidden;
transition: opacity 0.35s ease;
}
#complete-modal.visible {
opacity: 1;
visibility: visible;
}
.complete-card {
background: linear-gradient(145deg, rgba(20, 28, 38, 0.96), rgba(12, 17, 24, 0.98));
border: 1px solid rgba(255, 255, 255, 0.18);
border-top: 4px solid #00a8ff;
border-radius: 14px;
padding: 36px 44px;
width: 440px;
max-width: 90vw;
text-align: center;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7);
transform: translateY(12px);
transition: transform 0.35s ease;
}
#complete-modal.visible .complete-card {
transform: translateY(0);
}
.complete-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
background: rgba(0, 168, 255, 0.15);
border: 1px solid rgba(0, 168, 255, 0.4);
color: #5cd1ff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
margin-bottom: 14px;
}
.complete-card h2 {
font-size: 30px;
font-weight: 800;
letter-spacing: 0.06em;
color: #ffffff;
margin-bottom: 8px;
}
.complete-card p.desc {
font-size: 14px;
color: #a5b8cc;
margin-bottom: 24px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 28px;
}
.stat-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 12px 8px;
}
.stat-val {
font-size: 20px;
font-weight: 700;
color: #ffffff;
}
.stat-lbl {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #8395a7;
margin-top: 3px;
}
#restart-btn {
width: 100%;
padding: 14px 24px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #ffffff;
background: linear-gradient(90deg, #0097e6, #00a8ff);
border: none;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 6px 20px rgba(0, 168, 255, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
#restart-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 26px rgba(0, 168, 255, 0.6);
background: linear-gradient(90deg, #00a8ff, #25baff);
}
#restart-btn:active {
transform: translateY(0);
}
</style>
</head>
<body>
<canvas id="game-canvas"></canvas>
<!-- In-Game HUD -->
<div id="hud">
<div id="hud-top-left">
<strong>CHAMBER 01</strong>
<span>Objective: Reach Upper Exit</span>
</div>
<svg id="crosshair" viewBox="0 0 48 48">
<!-- Left Arc (Blue Portal State) -->
<path id="xhair-blue" d="M 19 11 A 14 14 0 0 0 19 37" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.6" stroke-linecap="round" />
<!-- Right Arc (Orange Portal State) -->
<path id="xhair-orange" d="M 29 11 A 14 14 0 0 1 29 37" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.6" stroke-linecap="round" />
<!-- Center Reticle Dot -->
<circle cx="24" cy="24" r="2.2" fill="#ffffff" />
</svg>
<div id="hud-controls">
<span><span class="dot-blue"></span><span class="key-badge">L-Click</span>Blue Portal</span>
<span><span class="dot-orange"></span><span class="key-badge">R-Click</span>Orange Portal</span>
<span><span class="key-badge">WASD</span>Move</span>
<span><span class="key-badge">SPACE</span>Jump</span>
<span><span class="key-badge">R</span>Reset Portals</span>
</div>
</div>
<!-- Attract / Demo Overlay -->
<div id="demo-overlay">
<div class="demo-header">
<div class="chamber-brand">
<div class="sub">Aperture Laboratories // Enrichment Center</div>
<h1>TEST CHAMBER 01</h1>
</div>
<div class="demo-pill">
<span class="pulse-dot"></span>
<span>Live Portal Demo Loop</span>
</div>
</div>
<div class="demo-footer">
<div class="click-prompt">Click Anywhere to Start Chamber</div>
<div class="demo-controls-row">
<span><span class="key-badge">Mouse</span>Look</span>
<span><span class="key-badge">WASD</span>Move</span>
<span><span class="key-badge">Space</span>Jump</span>
<span><span class="dot-blue"></span><span class="key-badge">LMB</span>Blue</span>
<span><span class="dot-orange"></span><span class="key-badge">RMB</span>Orange</span>
</div>
</div>
</div>
<!-- Pause Banner -->
<div id="pause-banner">
<h2>Simulation Paused</h2>
<p>Click anywhere on the chamber view to lock mouse and resume</p>
</div>
<!-- Chamber Complete Card -->
<div id="complete-modal">
<div class="complete-card">
<div class="complete-badge">Testing Protocol Complete</div>
<h2>Chamber complete</h2>
<p class="desc">Exemplary portal placement and spatial traversal recorded.</p>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-val" id="stat-time">0.0s</div>
<div class="stat-lbl">Time</div>
</div>
<div class="stat-box">
<div class="stat-val" id="stat-shots">0</div>
<div class="stat-lbl">Portals Placed</div>
</div>
<div class="stat-box">
<div class="stat-val" id="stat-steps">0</div>
<div class="stat-lbl">Traversals</div>
</div>
</div>
<button id="restart-btn" type="button">Restart Chamber</button>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// =========================================================================
// CONSTANTS & CONFIGURATION
// =========================================================================
const PORTAL_RX = 0.68; // Horizontal semi-axis of portal (1.36m wide)
const PORTAL_RY = 1.18; // Vertical semi-axis of portal (2.36m tall)
const PLAYER_EYE_HEIGHT = 1.55;
const PLAYER_RADIUS = 0.36;
const GRAVITY = 15.0;
const JUMP_SPEED = 5.4;
const MOVE_SPEED = 5.5;
const CAMERA_NEAR = 0.035;
const CAMERA_FAR = 65.0;
const CAMERA_FOV = 72;
const COLOR_BLUE = new THREE.Color(0x00a8ff);
const COLOR_ORANGE = new THREE.Color(0xff7700);
// =========================================================================
// PROCEDURAL WEB AUDIO SYNTHESIZER
// =========================================================================
class SoundSystem {
constructor() {
this.ctx = null;
}
init() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
}
playShoot(isBlue) {
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
const baseFreq = isBlue ? 520 : 420;
osc.frequency.setValueAtTime(baseFreq, now);
osc.frequency.exponentialRampToValueAtTime(baseFreq * 1.9, now + 0.06);
osc.frequency.exponentialRampToValueAtTime(140, now + 0.22);
gain.gain.setValueAtTime(0.16, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.23);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.24);
} catch (e) {}
}
playPortalOpen(isBlue) {
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const freqs = isBlue ? [330, 495, 660] : [293.66, 440, 587.33];
freqs.forEach((f, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(f * 0.85, now);
osc.frequency.exponentialRampToValueAtTime(f, now + 0.14);
gain.gain.setValueAtTime(0.05 / (idx + 1), now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.26);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.27);
});
} catch (e) {}
}
playFizzle() {
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const bufferSize = this.ctx.sampleRate * 0.2;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.35));
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1600, now);
filter.frequency.exponentialRampToValueAtTime(420, now + 0.19);
filter.Q.value = 2.5;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.22, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
noise.start(now);
} catch (e) {}
}
playTraverse() {
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(160, now);
osc.frequency.exponentialRampToValueAtTime(320, now + 0.08);
osc.frequency.exponentialRampToValueAtTime(110, now + 0.22);
gain.gain.setValueAtTime(0.14, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.23);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.24);
} catch (e) {}
}
playFootstep() {
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(115 + Math.random() * 25, now);
osc.frequency.exponentialRampToValueAtTime(45, now + 0.055);
gain.gain.setValueAtTime(0.045, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.065);
} catch (e) {}
}
playVictory() {
if (!this.ctx) return;
try {
const now = this.ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5];
notes.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
const t = now + i * 0.11;
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.55);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(t);
osc.stop(t + 0.56);
});
} catch (e) {}
}
}
const sounds = new SoundSystem();
// =========================================================================
// RENDERER, SCENE & CAMERAS
// =========================================================================
const canvas = document.getElementById('game-canvas');
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
powerPreference: 'high-performance'
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.NoToneMapping;
renderer.localClippingEnabled = true;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x161c24);
scene.fog = new THREE.FogExp2(0x161c24, 0.015);
const mainCamera = new THREE.PerspectiveCamera(
CAMERA_FOV,
window.innerWidth / window.innerHeight,
CAMERA_NEAR,
CAMERA_FAR
);
const virtualCamera = new THREE.PerspectiveCamera(
CAMERA_FOV,
window.innerWidth / window.innerHeight,
CAMERA_NEAR,
CAMERA_FAR
);
const recursiveCamera = new THREE.PerspectiveCamera(
CAMERA_FOV,
window.innerWidth / window.innerHeight,
CAMERA_NEAR,
CAMERA_FAR
);
// First-person Portal Gun overlay scene so gun never clips into walls
const weaponScene = new THREE.Scene();
const weaponCamera = new THREE.PerspectiveCamera(
50,
window.innerWidth / window.innerHeight,
0.05,
10
);
// =========================================================================
// PROCEDURAL TEXTURE GENERATORS
// =========================================================================
function createWhitePanelTexture() {
const size = 512;
const c = document.createElement('canvas');
c.width = size;
c.height = size;
const ctx = c.getContext('2d');
// Matte off-white ceramic base
ctx.fillStyle = '#eaeeef';
ctx.fillRect(0, 0, size, size);
// Subtle panel variation
const tiles = 2;
const step = size / tiles;
for (let y = 0; y < tiles; y++) {
for (let x = 0; x < tiles; x++) {
const shade = 232 + ((x + y) % 2) * 5;
ctx.fillStyle = `rgb(${shade}, ${shade + 3}, ${shade + 5})`;
ctx.fillRect(x * step + 4, y * step + 4, step - 8, step - 8);
// Inner subtle bevel highlight
ctx.strokeStyle = 'rgba(255, 255, 255, 0.75)';
ctx.lineWidth = 2;
ctx.strokeRect(x * step + 5, y * step + 5, step - 10, step - 10);
// Subtle corner pin marks
ctx.fillStyle = 'rgba(130, 145, 160, 0.35)';
const m = 14;
ctx.fillRect(x * step + m, y * step + m, 3, 3);
ctx.fillRect((x + 1) * step - m - 3, y * step + m, 3, 3);
ctx.fillRect(x * step + m, (y + 1) * step - m - 3, 3, 3);
ctx.fillRect((x + 1) * step - m - 3, (y + 1) * step - m - 3, 3, 3);
}
}
// Crisp dark seams
ctx.strokeStyle = '#9aa6b2';
ctx.lineWidth = 6;
for (let i = 0; i <= tiles; i++) {
ctx.beginPath();
ctx.moveTo(i * step, 0);
ctx.lineTo(i * step, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * step);
ctx.lineTo(size, i * step);
ctx.stroke();
}
// Fine seam core
ctx.strokeStyle = '#6c7885';
ctx.lineWidth = 2;
for (let i = 0; i <= tiles; i++) {
ctx.beginPath();
ctx.moveTo(i * step, 0);
ctx.lineTo(i * step, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * step);
ctx.lineTo(size, i * step);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
function createDarkMetalTexture() {
const size = 512;
const c = document.createElement('canvas');
c.width = size;
c.height = size;
const ctx = c.getContext('2d');
ctx.fillStyle = '#262d36';
ctx.fillRect(0, 0, size, size);
const tiles = 2;
const step = size / tiles;
for (let y = 0; y < tiles; y++) {
for (let x = 0; x < tiles; x++) {
const base = 37 + ((x + y) % 2) * 4;
ctx.fillStyle = `rgb(${base}, ${base + 6}, ${base + 14})`;
ctx.fillRect(x * step + 6, y * step + 6, step - 12, step - 12);
// Recessed inner plate
ctx.strokeStyle = '#36404d';
ctx.lineWidth = 2;
ctx.strokeRect(x * step + 14, y * step + 14, step - 28, step - 28);
}
}
// Deep dark metal seams
ctx.strokeStyle = '#15191f';
ctx.lineWidth = 8;
for (let i = 0; i <= tiles; i++) {
ctx.beginPath();
ctx.moveTo(i * step, 0);
ctx.lineTo(i * step, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * step);
ctx.lineTo(size, i * step);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
function createFloorTexture() {
const size = 512;
const c = document.createElement('canvas');
c.width = size;
c.height = size;
const ctx = c.getContext('2d');
ctx.fillStyle = '#1f252d';
ctx.fillRect(0, 0, size, size);
const tiles = 4;
const step = size / tiles;
for (let y = 0; y < tiles; y++) {
for (let x = 0; x < tiles; x++) {
const v = 32 + ((x + y) % 2) * 3;
ctx.fillStyle = `rgb(${v}, ${v + 6}, ${v + 12})`;
ctx.fillRect(x * step + 3, y * step + 3, step - 6, step - 6);
}
}
ctx.strokeStyle = '#13171c';
ctx.lineWidth = 4;
for (let i = 0; i <= tiles; i++) {
ctx.beginPath();
ctx.moveTo(i * step, 0);
ctx.lineTo(i * step, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * step);
ctx.lineTo(size, i * step);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
function createChamberSignTexture() {
const c = document.createElement('canvas');
c.width = 512;
c.height = 768;
const ctx = c.getContext('2d');
// Backlit white display background
const grad = ctx.createLinearGradient(0, 0, 0, 768);
grad.addColorStop(0, '#f4f7f9');
grad.addColorStop(1, '#dce4ec');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 512, 768);
// Outer border
ctx.strokeStyle = '#222a34';
ctx.lineWidth = 18;
ctx.strokeRect(9, 9, 494, 750);
// Giant "01"
ctx.fillStyle = '#182028';
ctx.font = 'bold 250px "Segoe UI", Arial, sans-serif';
ctx.fillText('01', 42, 265);
// Divider line & progress ticks
ctx.fillRect(46, 310, 420, 8);
for (let i = 0; i < 10; i++) {
ctx.fillStyle = i < 3 ? '#182028' : 'rgba(24, 32, 40, 0.22)';
ctx.fillRect(46 + i * 43, 334, 30, 16);
}
ctx.fillStyle = '#182028';
ctx.font = 'bold 28px "Segoe UI", Arial, sans-serif';
ctx.fillText('PORTAL TRAVERSAL', 46, 405);
ctx.font = '600 20px "Segoe UI", Arial, sans-serif';
ctx.fillStyle = '#485665';
ctx.fillText('CHAMBER 01 // HIGH LEDGE EXIT', 46, 438);
// Icon boxes at bottom
const iconsY = 500;
const boxS = 124;
for (let i = 0; i < 3; i++) {
const bx = 46 + i * 148;
ctx.strokeStyle = '#182028';
ctx.lineWidth = 6;
ctx.strokeRect(bx, iconsY, boxS, boxS);
}
// Icon 1: Portal entry
ctx.strokeStyle = '#0088dd';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.ellipse(46 + 62, iconsY + 62, 26, 42, 0, 0, Math.PI * 2);
ctx.stroke();
// Icon 2: Portal exit
ctx.strokeStyle = '#ee6600';
ctx.lineWidth = 8;
ctx.beginPath();
ctx.ellipse(46 + 148 + 62, iconsY + 62, 26, 42, 0, 0, Math.PI * 2);
ctx.stroke();
// Icon 3: High ledge person reaching door
ctx.fillStyle = '#182028';
ctx.fillRect(46 + 296 + 20, iconsY + 78, 84, 10);
ctx.beginPath();
ctx.arc(46 + 296 + 62, iconsY + 38, 11, 0, Math.PI * 2);
ctx.fill();
ctx.fillRect(46 + 296 + 54, iconsY + 52, 16, 24);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
const whitePanelBaseTex = createWhitePanelTexture();
const darkMetalBaseTex = createDarkMetalTexture();
const floorBaseTex = createFloorTexture();
// =========================================================================
// LEVEL / TEST CHAMBER CONSTRUCTION
// =========================================================================
const shootableMeshes = [];
const boxColliders = [];
const whitePanelRegistry = {};
function addWallBox({
x, y, z, w, h, d,
material,
collider = true,
shootable = true,
castShadow = true,
receiveShadow = true
}) {
const geo = new THREE.BoxGeometry(w, h, d);
const mesh = new THREE.Mesh(geo, material);
mesh.position.set(x, y, z);
mesh.castShadow = castShadow;
mesh.receiveShadow = receiveShadow;
mesh.userData.portalable = false;
scene.add(mesh);
if (shootable) shootableMeshes.push(mesh);
if (collider) {
boxColliders.push({
minX: x - w / 2,
maxX: x + w / 2,
minY: y - h / 2,
maxY: y + h / 2,
minZ: z - d / 2,
maxZ: z + d / 2,
panelId: null
});
}
return mesh;
}
function makeTiledMaterial(baseTex, repeatX, repeatY, roughness = 0.55, metalness = 0.25) {
const tex = baseTex.clone();
tex.needsUpdate = true;
tex.repeat.set(repeatX, repeatY);
return new THREE.MeshStandardMaterial({
map: tex,
roughness,
metalness
});
}
// Add a white portalable panel flush on a wall surface
function addWhitePanel({
id,
center,
normal,
width,
height,
hostColliderIndex = -1
}) {
const thickness = 0.08;
const geo = new THREE.BoxGeometry(width, height, thickness);
const mat = makeTiledMaterial(whitePanelBaseTex, width / 1.9, height / 1.9, 0.35, 0.06);
const mesh = new THREE.Mesh(geo, mat);
// Orient mesh so local +Z points along `normal`
const up = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(up, normal).normalize();
const rotMat = new THREE.Matrix4().makeBasis(right, up, normal);
mesh.quaternion.setFromRotationMatrix(rotMat);
// Position slightly recessed so front face is at `center`
mesh.position.copy(center).addScaledVector(normal, -thickness * 0.5);
mesh.castShadow = true;
mesh.receiveShadow = true;
// Dark metallic border frame around the white panel
const frameMat = new THREE.MeshStandardMaterial({
color: 0x1a2028,
roughness: 0.4,
metalness: 0.7
});
const borderT = 0.08;
const topBar = new THREE.Mesh(new THREE.BoxGeometry(width + borderT * 2, borderT, thickness + 0.02), frameMat);
topBar.position.set(0, height / 2 + borderT / 2, 0);
const botBar = new THREE.Mesh(new THREE.BoxGeometry(width + borderT * 2, borderT, thickness + 0.02), frameMat);
botBar.position.set(0, -height / 2 - borderT / 2, 0);
const leftBar = new THREE.Mesh(new THREE.BoxGeometry(borderT, height, thickness + 0.02), frameMat);
leftBar.position.set(-width / 2 - borderT / 2, 0, 0);
const rightBar = new THREE.Mesh(new THREE.BoxGeometry(borderT, height, thickness + 0.02), frameMat);
rightBar.position.set(width / 2 + borderT / 2, 0, 0);
mesh.add(topBar, botBar, leftBar, rightBar);
const panelInfo = {
id,
center: center.clone(),
normal: normal.clone(),
up: up.clone(),
right: right.clone(),
halfWidth: width * 0.5,
halfHeight: height * 0.5,
hostColliderIndex
};
mesh.userData.portalable = true;
mesh.userData.panelInfo = panelInfo;
whitePanelRegistry[id] = panelInfo;
scene.add(mesh);
shootableMeshes.push(mesh);
return mesh;
}
// Build Test Chamber 01
let exitDoorLeft, exitDoorRight, exitLightMat;
let doorOpenAmount = 0;
function buildTestChamber() {
// 1. Lower Floor (z from -3.5 to +9.0, y = 0)
addWallBox({
x: 0, y: -0.25, z: 2.75,
w: 14, h: 0.5, d: 12.5,
material: makeTiledMaterial(floorBaseTex, 7, 6.25, 0.45, 0.35)
});
// 2. High Ledge Floor (z from -9.0 to -3.5, y = 4.4)
addWallBox({
x: 0, y: 4.15, z: -6.25,
w: 14, h: 0.5, d: 5.5,
material: makeTiledMaterial(floorBaseTex, 7, 2.75, 0.45, 0.35)
});
// 3. High Ledge Cliff Wall (at z = -3.5, rising from y = 0 to y = 4.4)
const cliffColliderIdx = boxColliders.length;
addWallBox({
x: 0, y: 2.2, z: -3.75,
w: 14, h: 4.4, d: 0.5,
material: makeTiledMaterial(darkMetalBaseTex, 7, 2.2, 0.5, 0.45)
});
// Yellow/black industrial edge trim along the top of the high ledge (z = -3.5, y = 4.4)
const edgeTrimMat = new THREE.MeshStandardMaterial({
color: 0xd49b27,
roughness: 0.4,
metalness: 0.5
});
addWallBox({
x: 0, y: 4.38, z: -3.48,
w: 14, h: 0.08, d: 0.08,
material: edgeTrimMat,
collider: false,
shootable: false
});
// Glass safety railings on the left and right edges of the High Ledge (leaving center open)
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0x99d8ff,
transparent: true,
opacity: 0.26,
roughness: 0.1,
metalness: 0.1,
transmission: 0.5
});
const postMat = new THREE.MeshStandardMaterial({ color: 0x485460, metalness: 0.7, roughness: 0.3 });
// Left railing
addWallBox({ x: -5.3, y: 4.95, z: -3.52, w: 3.2, h: 1.05, d: 0.04, material: glassMat });
addWallBox({ x: -3.7, y: 4.95, z: -3.52, w: 0.08, h: 1.1, d: 0.08, material: postMat });
// Right railing
addWallBox({ x: 5.3, y: 4.95, z: -3.52, w: 3.2, h: 1.05, d: 0.04, material: glassMat });
addWallBox({ x: 3.7, y: 4.95, z: -3.52, w: 0.08, h: 1.1, d: 0.08, material: postMat });
// 4. Outer Room Walls (x = -7, x = +7, z = +9, z = -9, ceiling y = 9.2)
const westWallIdx = boxColliders.length;
addWallBox({
x: -7.25, y: 4.6, z: 0,
w: 0.5, h: 9.2, d: 18,
material: makeTiledMaterial(darkMetalBaseTex, 9, 4.6, 0.5, 0.45)
});
const eastWallIdx = boxColliders.length;
addWallBox({
x: 7.25, y: 4.6, z: 0,
w: 0.5, h: 9.2, d: 18,
material: makeTiledMaterial(darkMetalBaseTex, 9, 4.6, 0.5, 0.45)
});
// South wall (behind player start)
addWallBox({
x: 0, y: 4.6, z: 9.25,
w: 14, h: 9.2, d: 0.5,
material: makeTiledMaterial(darkMetalBaseTex, 7, 4.6, 0.5, 0.45)
});
// North wall on the High Ledge (with doorway opening around x = 3.2, y = 4.4..7.0)
// Left section of North Wall (x from -7 to +1.85)
const northWallLeftIdx = boxColliders.length;
addWallBox({
x: -2.575, y: 6.8, z: -9.25,
w: 8.85, h: 4.8, d: 0.5,
material: makeTiledMaterial(darkMetalBaseTex, 4.4, 2.4, 0.5, 0.45)
});
// Right section of North Wall (x from +4.55 to +7)
addWallBox({
x: 5.775, y: 6.8, z: -9.25,
w: 2.45, h: 4.8, d: 0.5,
material: makeTiledMaterial(darkMetalBaseTex, 1.2, 2.4, 0.5, 0.45)
});
// Top section above Exit Door (x from +1.85 to +4.55, y from 7.0 to 9.2)
addWallBox({
x: 3.2, y: 8.1, z: -9.25,
w: 2.7, h: 2.2, d: 0.5,
material: makeTiledMaterial(darkMetalBaseTex, 1.35, 1.1, 0.5, 0.45)
});
// Ceiling (y = 9.2)
addWallBox({
x: 0, y: 9.45, z: 0,
w: 14, h: 0.5, d: 18,
material: makeTiledMaterial(darkMetalBaseTex, 7, 9, 0.6, 0.4)
});
// 5. White Portalable Wall Panels
// Panel A: Lower Cliff Wall Panel (used in Demo & convenient for player)
addWhitePanel({
id: 'lower_cliff',
center: new THREE.Vector3(-3.0, 1.44, -3.45),
normal: new THREE.Vector3(0, 0, 1),
width: 3.8,
height: 2.72,
hostColliderIndex: cliffColliderIdx
});
// Panel B: Lower West Wall Panel (used in Demo & convenient for player)
addWhitePanel({
id: 'lower_west',
center: new THREE.Vector3(-6.95, 1.44, 2.4),
normal: new THREE.Vector3(1, 0, 0),
width: 3.8,
height: 2.72,
hostColliderIndex: westWallIdx
});
// Panel C: Lower East Wall Panel (faces West panel for fun portal recursion!)
addWhitePanel({
id: 'lower_east',
center: new THREE.Vector3(6.95, 1.44, 2.4),
normal: new THREE.Vector3(-1, 0, 0),
width: 3.8,
height: 2.72,
hostColliderIndex: eastWallIdx
});
// Panel D: High Ledge North Wall Panel (The Puzzle Target!)
addWhitePanel({
id: 'upper_ledge',
center: new THREE.Vector3(-3.0, 5.84, -8.95),
normal: new THREE.Vector3(0, 0, 1),
width: 4.2,
height: 2.72,
hostColliderIndex: northWallLeftIdx
});
// 6. Exit Door & Exit Vestibule on the High Ledge (x = 3.2, y = 4.4, z = -9.0)
buildExitDoorAndVestibule();
// 7. Architectural Details: Observation Window, Chamber Sign, Indicator Lights, Ceiling Strips
buildArchitecturalDetails();
}
function buildExitDoorAndVestibule() {
const doorX = 3.2;
const doorY = 4.4;
const doorZ = -9.0;
// Vestibule floor, ceiling, walls beyond z = -9.0
const vestMat = new THREE.MeshStandardMaterial({
color: 0xdce4ec,
roughness: 0.35,
metalness: 0.15
});
// Floor
addWallBox({
x: doorX, y: 4.15, z: -11.0,
w: 3.6, h: 0.5, d: 4.0,
material: makeTiledMaterial(floorBaseTex, 2, 2, 0.35, 0.3)
});
// Ceiling
addWallBox({ x: doorX, y: 7.25, z: -11.0, w: 3.6, h: 0.4, d: 4.0, material: vestMat });
// Left wall
addWallBox({ x: doorX - 1.7, y: 5.7, z: -11.0, w: 0.3, h: 2.8, d: 4.0, material: vestMat });
// Right wall
addWallBox({ x: doorX + 1.7, y: 5.7, z: -11.0, w: 0.3, h: 2.8, d: 4.0, material: vestMat });
// Back wall of vestibule
addWallBox({ x: doorX, y: 5.7, z: -12.9, w: 3.6, h: 2.8, d: 0.3, material: vestMat });
// Glowing cyan exit portal pad at back of vestibule
const padMat = new THREE.MeshBasicMaterial({ color: 0x5cd1ff });
const backGlow = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 2.2), padMat);
backGlow.position.set(doorX, 5.6, -12.72);
scene.add(backGlow);
const vestLight = new THREE.PointLight(0x66d9ff, 3.5, 7);
vestLight.position.set(doorX, 6.4, -10.8);
scene.add(vestLight);
// Heavy Aperture-style Door Frame
const frameMat = new THREE.MeshStandardMaterial({
color: 0x181e26,
roughness: 0.35,
metalness: 0.75
});
addWallBox({ x: doorX - 1.35, y: doorY + 1.35, z: doorZ + 0.05, w: 0.22, h: 2.7, d: 0.55, material: frameMat });
addWallBox({ x: doorX + 1.35, y: doorY + 1.35, z: doorZ + 0.05, w: 0.22, h: 2.7, d: 0.55, material: frameMat });
addWallBox({ x: doorX, y: doorY + 2.65, z: doorZ + 0.05, w: 2.9, h: 0.24, d: 0.55, material: frameMat });
// Overhead Exit Sign Badge
exitLightMat = new THREE.MeshBasicMaterial({ color: 0x00e676 });
const signBadge = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.28, 0.1), exitLightMat);
signBadge.position.set(doorX, doorY + 2.95, doorZ + 0.26);
scene.add(signBadge);
// Sliding Door Halves
const doorLeafMat = new THREE.MeshStandardMaterial({
color: 0x3b4654,
roughness: 0.35,
metalness: 0.65
});
const trimMat = new THREE.MeshBasicMaterial({ color: 0x00d8ff });
exitDoorLeft = new THREE.Group();
const leftMesh = new THREE.Mesh(new THREE.BoxGeometry(1.25, 2.55, 0.18), doorLeafMat);
leftMesh.castShadow = true;
leftMesh.receiveShadow = true;
const leftStripe = new THREE.Mesh(new THREE.BoxGeometry(0.06, 2.2, 0.2), trimMat);
leftStripe.position.set(0.56, 0, 0);
exitDoorLeft.add(leftMesh, leftStripe);
exitDoorLeft.position.set(doorX - 0.62, doorY + 1.27, doorZ);
scene.add(exitDoorLeft);
exitDoorRight = new THREE.Group();
const rightMesh = new THREE.Mesh(new THREE.BoxGeometry(1.25, 2.55, 0.18), doorLeafMat);
rightMesh.castShadow = true;
rightMesh.receiveShadow = true;
const rightStripe = new THREE.Mesh(new THREE.BoxGeometry(0.06, 2.2, 0.2), trimMat);
rightStripe.position.set(-0.56, 0, 0);
exitDoorRight.add(rightMesh, rightStripe);
exitDoorRight.position.set(doorX + 0.62, doorY + 1.27, doorZ);
scene.add(exitDoorRight);
}
function buildArchitecturalDetails() {
// 1. Illuminated Test Chamber 01 Sign on the right side of the Cliff Wall
const signTex = createChamberSignTexture();
const signMat = new THREE.MeshBasicMaterial({ map: signTex });
const signMesh = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 2.55), signMat);
signMesh.position.set(2.9, 2.2, -3.47);
scene.add(signMesh);
// Frame around sign
const signFrameMat = new THREE.MeshStandardMaterial({ color: 0x151a21, metalness: 0.7, roughness: 0.4 });
addWallBox({
x: 2.9, y: 2.2, z: -3.5,
w: 1.82, h: 2.67, d: 0.04,
material: signFrameMat,
collider: false,
shootable: false
});
// 2. High Observation Window on the East Wall (x = 6.95, y = 6.4, z = 1.5)
const obsBackMat = new THREE.MeshBasicMaterial({ color: 0xcfe9f7 });
const obsWindow = new THREE.Mesh(new THREE.PlaneGeometry(6.8, 2.4), obsBackMat);
obsWindow.rotation.y = -Math.PI / 2;
obsWindow.position.set(6.96, 6.5, 1.5);
scene.add(obsWindow);
// Mullions over Observation Window
const mullionMat = new THREE.MeshStandardMaterial({ color: 0x1a2028, metalness: 0.7, roughness: 0.35 });
for (let i = -3; i <= 3; i++) {
addWallBox({
x: 6.94, y: 6.5, z: 1.5 + i * 1.1,
w: 0.1, h: 2.5, d: 0.08,
material: mullionMat,
collider: false,
shootable: false
});
}
addWallBox({ x: 6.94, y: 7.72, z: 1.5, w: 0.12, h: 0.12, d: 7.0, material: mullionMat, collider: false, shootable: false });
addWallBox({ x: 6.94, y: 5.28, z: 1.5, w: 0.12, h: 0.12, d: 7.0, material: mullionMat, collider: false, shootable: false });
// 3. Glowing Indicator Dots on the High Ledge leading from Upper White Panel to Exit Door
const dotGeo = new THREE.BoxGeometry(0.16, 0.02, 0.16);
const dotMat = new THREE.MeshBasicMaterial({ color: 0x00c8ff });
const ledgePathPoints = [
[-3.0, -8.3], [-3.0, -7.7], [-3.0, -7.1], [-2.4, -7.1], [-1.6, -7.1],
[-0.8, -7.1], [0.0, -7.1], [0.8, -7.1], [1.6, -7.1], [2.4, -7.1],
[3.2, -7.1], [3.2, -7.7], [3.2, -8.3]
];
ledgePathPoints.forEach(([px, pz]) => {
const dot = new THREE.Mesh(dotGeo, dotMat);
dot.position.set(px, 4.41, pz);
scene.add(dot);
});
// 4. Recessed Ceiling Light Strips
const lightStripMat = new THREE.MeshBasicMaterial({ color: 0xeaf6ff });
const stripPositions = [-6.2, -1.2, 4.2];
stripPositions.forEach((sz) => {
const strip = new THREE.Mesh(new THREE.BoxGeometry(9.2, 0.08, 0.55), lightStripMat);
strip.position.set(0, 9.18, sz);
scene.add(strip);
});
// 5. Starting Pad Ring at Player Spawn (x = 0, z = 6.2)
const padRingMat = new THREE.MeshStandardMaterial({
color: 0x34404d,
metalness: 0.65,
roughness: 0.35
});
const padCyl = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.45, 0.06, 32), padRingMat);
padCyl.position.set(0, 0.03, 6.2);
padCyl.receiveShadow = true;
scene.add(padCyl);
const padGlow = new THREE.Mesh(
new THREE.RingGeometry(1.05, 1.16, 32),
new THREE.MeshBasicMaterial({ color: 0x00a8ff, side: THREE.DoubleSide })
);
padGlow.rotation.x = -Math.PI / 2;
padGlow.position.set(0, 0.065, 6.2);
scene.add(padGlow);
}
// =========================================================================
// LIGHTING WITH SOFT SHADOWS
// =========================================================================
function setupLighting() {
const hemiLight = new THREE.HemisphereLight(0xddeeff, 0x26303d, 0.85);
scene.add(hemiLight);
// Main overhead directional light casting soft shadows across the chamber
const dirLight = new THREE.DirectionalLight(0xf5faff, 1.35);
dirLight.position.set(4.5, 8.8, 3.5);
dirLight.target.position.set(-1.5, 0, -2.5);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 22;
const d = 10.5;
dirLight.shadow.camera.left = -d;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = -d;
dirLight.shadow.bias = -0.0005;
dirLight.shadow.radius = 3.5;
scene.add(dirLight);
scene.add(dirLight.target);
// Secondary fill light illuminating the High Ledge & White Panel
const ledgeLight = new THREE.SpotLight(0xeef7ff, 18.0, 16, Math.PI / 3, 0.6, 1.2);
ledgeLight.position.set(-1.5, 8.7, -4.5);
ledgeLight.target.position.set(-2.5, 5.2, -8.9);
scene.add(ledgeLight);
scene.add(ledgeLight.target);
// Observation window warm/cyan glow fill
const obsLight = new THREE.PointLight(0xbbe8ff, 4.5, 14);
obsLight.position.set(5.8, 6.2, 1.5);
scene.add(obsLight);
}
// =========================================================================
// PORTAL GUN VIEWMODEL
// =========================================================================
let gunGroup;
let gunCoreMat;
let gunMuzzleLight;
let gunRecoil = 0;
function buildPortalGun() {
gunGroup = new THREE.Group();
const wHemi = new THREE.HemisphereLight(0xffffff, 0x334455, 1.5);
weaponScene.add(wHemi);
const wDir = new THREE.DirectionalLight(0xffffff, 1.2);
wDir.position.set(1, 2, 2);
weaponScene.add(wDir);
const shellMat = new THREE.MeshStandardMaterial({
color: 0xf2f5f8,
roughness: 0.2,
metalness: 0.15
});
const darkMetal = new THREE.MeshStandardMaterial({
color: 0x1b2129,
roughness: 0.4,
metalness: 0.75
});
gunCoreMat = new THREE.MeshBasicMaterial({ color: COLOR_BLUE });
// Rear bulbous white shell
const rearShell = new THREE.Mesh(new THREE.SphereGeometry(0.11, 24, 18), shellMat);
rearShell.scale.set(0.95, 0.85, 1.75);
rearShell.position.set(0, 0, 0.12);
gunGroup.add(rearShell);
// Inner black barrel rod
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.038, 0.045, 0.56, 20), darkMetal);
barrel.rotation.x = Math.PI / 2;
barrel.position.set(0, -0.01, -0.08);
gunGroup.add(barrel);
// Glowing energy chamber core
const coreTube = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.028, 0.18, 16), gunCoreMat);
coreTube.rotation.x = Math.PI / 2;
coreTube.position.set(0, 0.045, -0.05);
gunGroup.add(coreTube);
// Front white cowling collar
const frontCowl = new THREE.Mesh(new THREE.CylinderGeometry(0.068, 0.078, 0.15, 20), shellMat);
frontCowl.rotation.x = Math.PI / 2;
frontCowl.position.set(0, 0, -0.24);
gunGroup.add(frontCowl);
// 3 iconic articulatedemitter claws at muzzle
for (let i = 0; i < 3; i++) {
const angle = (i * Math.PI * 2) / 3 + Math.PI / 6;
const claw = new THREE.Group();
claw.rotation.z = angle;
const arm1 = new THREE.Mesh(new THREE.BoxGeometry(0.012, 0.055, 0.09), darkMetal);
arm1.position.set(0, 0.075, -0.33);
arm1.rotation.x = 0.25;
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.015, 0.08), darkMetal);
tip.position.set(0, 0.09, -0.39);
tip.rotation.x = -0.35;
claw.add(arm1, tip);
gunGroup.add(claw);
}
// Muzzle point light in main scene
gunMuzzleLight = new THREE.PointLight(0x00a8ff, 0, 3.5);
scene.add(gunMuzzleLight);
gunGroup.position.set(0.24, -0.22, -0.42);
weaponScene.add(gunGroup);
}
// =========================================================================
// PORTAL OBJECTS, RENDER TARGETS & SHADERS
// =========================================================================
function createPortalRenderTarget() {
const dpr = renderer.getPixelRatio();
const rt = new THREE.WebGLRenderTarget(
Math.floor(window.innerWidth * dpr),
Math.floor(window.innerHeight * dpr),
{
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
stencilBuffer: false,
depthBuffer: true
}
);
return rt;
}
const rtBlue = createPortalRenderTarget();
const rtOrange = createPortalRenderTarget();
const rtRecursion = createPortalRenderTarget();
// Build custom geometry for the portal screen window:
// Front ellipse cap + rear recessed pocket so the camera's near plane NEVER clips a hole when crossing!
function createPortalPocketGeometry(rx, ry, depth = 0.34, segments = 48) {
const positions = [];
const uvs = [];
const indices = [];
// 1. Front ellipse disk at z = +0.006 (facing +Z)
const frontCenterIdx = 0;
positions.push(0, 0, 0.006);
uvs.push(0.5, 0.5);
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
const c = Math.cos(theta);
const s = Math.sin(theta);
positions.push(c * rx, s * ry, 0.006);
uvs.push(c * 0.5 + 0.5, s * 0.5 + 0.5);
}
for (let i = 1; i <= segments; i++) {
indices.push(frontCenterIdx, i, i + 1);
}
// 2. Rear pocket walls & back cap (for seamless near-plane crossing)
const ringStartFront = positions.length / 3;
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
const c = Math.cos(theta);
const s = Math.sin(theta);
positions.push(c * rx, s * ry, 0.006);
uvs.push(c * 0.5 + 0.5, s * 0.5 + 0.5);
}
const ringStartBack = positions.length / 3;
for (let i = 0; i <= segments; i++) {
const theta = (i / segments) * Math.PI * 2;
const c = Math.cos(theta);
const s = Math.sin(theta);
positions.push(c * rx, s * ry, -depth);
uvs.push(c * 0.5 + 0.5, s * 0.5 + 0.5);
}
for (let i = 0; i < segments; i++) {
const a = ringStartFront + i;
const b = ringStartFront + i + 1;
const c = ringStartBack + i;
const d = ringStartBack + i + 1;
// Inward facing triangles
indices.push(a, c, b);
indices.push(b, c, d);
}
// Back cap at z = -depth (facing +Z)
const backCenterIdx = positions.length / 3;
positions.push(0, 0, -depth);
uvs.push(0.5, 0.5);
for (let i = 0; i < segments; i++) {
indices.push(backCenterIdx, ringStartBack + i, ringStartBack + i + 1);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices);
geo.computeVertexNormals();
return geo;
}
// Build flat rim quad geometry around the portal ellipse
function createPortalRimMaterial(portalColor) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
clipping: true,
uniforms: {
uColor: { value: portalColor.clone() },
uTime: { value: 0 }
},
vertexShader: `
varying vec2 vUv;
#include <clipping_planes_pars_vertex>
void main() {
vUv = uv * 2.0 - 1.0;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <clipping_planes_vertex>
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
float r = length(vUv);
float angle = atan(vUv.y, vUv.x);
// Animated plasma waves along the elliptical border
float wave1 = sin(angle * 7.0 - uTime * 4.5 + r * 6.0) * 0.025;
float wave2 = cos(angle * 11.0 + uTime * 3.2 - r * 8.0) * 0.018;
float dist = abs(r - 0.80 + wave1 + wave2);
float core = smoothstep(0.085, 0.008, dist);
float halo = smoothstep(0.22, 0.0, dist) * 0.68;
// Mask out inner opening and outer square corners
float innerCut = smoothstep(0.735, 0.77, r);
float outerCut = 1.0 - smoothstep(0.92, 1.0, r);
float alpha = (core + halo) * innerCut * outerCut;
if (alpha < 0.01) discard;
vec3 finalColor = mix(uColor, vec3(1.0, 0.98, 0.95), core * 0.72);
gl_FragColor = vec4(finalColor, alpha);
}
`
});
}
// Screen-space portal window shader (samples live virtual camera render target, or shows vortex when unlinked)
function createPortalScreenMaterial(portalColor, renderTarget) {
return new THREE.ShaderMaterial({
side: THREE.DoubleSide,
clipping: true,
uniforms: {
uPortalTex: { value: renderTarget.texture },
uColor: { value: portalColor.clone() },
uLinked: { value: 0.0 },
uTime: { value: 0.0 }
},
vertexShader: `
varying vec4 vClipPos;
varying vec2 vUv;
#include <clipping_planes_pars_vertex>
void main() {
vUv = uv * 2.0 - 1.0;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vClipPos = projectionMatrix * mvPosition;
gl_Position = vClipPos;
#include <clipping_planes_vertex>
}
`,
fragmentShader: `
uniform sampler2D uPortalTex;
uniform vec3 uColor;
uniform float uLinked;
uniform float uTime;
varying vec4 vClipPos;
varying vec2 vUv;
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
float r = length(vUv);
if (uLinked > 0.5) {
vec2 screenUV = (vClipPos.xy / vClipPos.w) * 0.5 + 0.5;
vec4 viewColor = texture2D(uPortalTex, clamp(screenUV, 0.001, 0.999));
// Subtle glowing inner edge feathering
float edgeGlow = smoothstep(0.88, 1.02, r);
vec3 col = mix(viewColor.rgb, uColor * 1.35, edgeGlow * 0.35);
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
} else {
// Unlinked swirling vortex effect
float angle = atan(vUv.y, vUv.x);
float swirl = sin(r * 14.0 - uTime * 5.0 + angle * 4.0) * 0.5 + 0.5;
float swirl2 = cos(r * 9.0 + uTime * 3.5 - angle * 3.0) * 0.5 + 0.5;
vec3 darkCore = uColor * 0.18;
vec3 brightCore = mix(uColor, vec3(1.0), 0.45);
vec3 col = mix(darkCore, brightCore, (swirl * 0.6 + swirl2 * 0.4) * smoothstep(0.0, 0.95, r));
gl_FragColor = vec4(col, 1.0);
}
}
`
});
}
class Portal {
constructor(isBlue, color, renderTarget) {
this.isBlue = isBlue;
this.color = color;
this.renderTarget = renderTarget;
this.active = false;
this.panelId = null;
this.hostColliderIndex = -1;
this.openScale = 1.0;
this.position = new THREE.Vector3();
this.normal = new THREE.Vector3(0, 0, 1);
this.up = new THREE.Vector3(0, 1, 0);
this.right = new THREE.Vector3(1, 0, 0);
this.group = new THREE.Group();
this.group.visible = false;
// 1. Portal screen window mesh (with anti-near-clip rear pocket)
const pocketGeo = createPortalPocketGeometry(PORTAL_RX, PORTAL_RY, 0.35, 56);
this.screenMat = createPortalScreenMaterial(color, renderTarget);
this.screenMesh = new THREE.Mesh(pocketGeo, this.screenMat);
this.group.add(this.screenMesh);
// 2. Glowing animated plasma rim mesh
const rimGeo = new THREE.PlaneGeometry(PORTAL_RX * 2.55, PORTAL_RY * 2.55);
this.rimMat = createPortalRimMaterial(color);
this.rimMesh = new THREE.Mesh(rimGeo, this.rimMat);
this.rimMesh.position.z = 0.012;
this.group.add(this.rimMesh);
// 3. Ambient point light emitted by the portal rim
this.light = new THREE.PointLight(color, 2.2, 5.5);
this.light.position.set(0, 0, 0.35);
this.group.add(this.light);
scene.add(this.group);
}
place(pos, normal, panelInfo, animateOpen = true) {
this.active = true;
this.panelId = panelInfo.id;
this.hostColliderIndex = panelInfo.hostColliderIndex;
this.position.copy(pos);
this.normal.copy(normal).normalize();
this.up.set(0, 1, 0);
this.right.crossVectors(this.up, this.normal).normalize();
const rotMat = new THREE.Matrix4().makeBasis(this.right, this.up, this.normal);
this.group.quaternion.setFromRotationMatrix(rotMat);
this.group.position.copy(this.position);
this.openScale = animateOpen ? 0.08 : 1.0;
this.group.scale.setScalar(this.openScale);
this.group.visible = true;
this.group.updateMatrixWorld(true);
}
clear() {
this.active = false;
this.panelId = null;
this.hostColliderIndex = -1;
this.group.visible = false;
}
update(dt, elapsed, isLinked) {
if (!this.active) return;
if (this.openScale < 1.0) {
this.openScale = Math.min(1.0, this.openScale + dt * 7.5);
const eased = 1 - Math.pow(1 - this.openScale, 3);
this.group.scale.setScalar(eased);
this.group.updateMatrixWorld(true);
}
this.rimMat.uniforms.uTime.value = elapsed;
this.screenMat.uniforms.uTime.value = elapsed;
this.screenMat.uniforms.uLinked.value = isLinked ? 1.0 : 0.0;
}
}
const bluePortal = new Portal(true, COLOR_BLUE, rtBlue);
const orangePortal = new Portal(false, COLOR_ORANGE, rtOrange);
// =========================================================================
// PORTAL TRANSFORMATION & REAL-TIME VIRTUAL CAMERA RENDERING
// =========================================================================
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
const invSourceMat = new THREE.Matrix4();
const destMat = new THREE.Matrix4();
const portalTransformMat = new THREE.Matrix4();
// Compute 4x4 matrix that maps a pose relative to `srcPortal` (+Z in, -Z into wall)
// to the corresponding pose emerging out of `dstPortal` (+Z out of wall)
function computePortalTransform(srcPortal, dstPortal, targetMat) {
// Build unscaled world matrices for src and dst so opening scale animation never warps perspective
const srcBasis = new THREE.Matrix4().makeBasis(srcPortal.right, srcPortal.up, srcPortal.normal);
srcBasis.setPosition(srcPortal.position);
invSourceMat.copy(srcBasis).invert();
const dstBasis = new THREE.Matrix4().makeBasis(dstPortal.right, dstPortal.up, dstPortal.normal);
dstBasis.setPosition(dstPortal.position);
destMat.copy(dstBasis);
targetMat.multiplyMatrices(destMat, rotY180).multiply(invSourceMat);
return targetMat;
}
function renderSinglePortalPass(srcPortal, dstPortal, eyeCamera, outRenderTarget, camObj) {
computePortalTransform(srcPortal, dstPortal, portalTransformMat);
// Virtual camera world matrix = T_src_to_dst * eyeCamera.matrixWorld
camObj.matrixWorld.multiplyMatrices(portalTransformMat, eyeCamera.matrixWorld);
camObj.matrixWorld.decompose(camObj.position, camObj.quaternion, camObj.scale);
camObj.matrixWorldInverse.copy(camObj.matrixWorld).invert();
camObj.projectionMatrix.copy(eyeCamera.projectionMatrix);
camObj.projectionMatrixInverse.copy(eyeCamera.projectionMatrixInverse);
// Hardware clipping plane at dstPortal's wall surface so walls behind dstPortal never block the view
const clipPlane = new THREE.Plane().setFromNormalAndCoplanarPoint(
dstPortal.normal,
dstPortal.position
);
// Slight offset so the host white panel at dstPortal is cleanly clipped away
clipPlane.constant -= 0.015;
const prevScreenVis = dstPortal.screenMesh.visible;
const prevRimVis = dstPortal.rimMesh.visible;
dstPortal.screenMesh.visible = false;
dstPortal.rimMesh.visible = false;
renderer.clippingPlanes = [clipPlane];
renderer.setRenderTarget(outRenderTarget);
renderer.clear();
renderer.render(scene, camObj);
dstPortal.screenMesh.visible = prevScreenVis;
dstPortal.rimMesh.visible = prevRimVis;
}
function renderPortals(activeEyeCamera) {
if (!bluePortal.active || !orangePortal.active) {
renderer.clippingPlanes = [];
renderer.setRenderTarget(null);
return;
}
// 1. Render Blue Portal's live view (looking out of Orange Portal)
// First do 1 recursive bounce in case Orange can see Blue (e.g. facing walls)
renderSinglePortalPass(bluePortal, orangePortal, activeEyeCamera, rtRecursion, virtualCamera);
bluePortal.screenMat.uniforms.uPortalTex.value = rtRecursion.texture;
renderSinglePortalPass(bluePortal, orangePortal, activeEyeCamera, rtBlue, recursiveCamera);
bluePortal.screenMat.uniforms.uPortalTex.value = rtBlue.texture;
// 2. Render Orange Portal's live view (looking out of Blue Portal)
renderSinglePortalPass(orangePortal, bluePortal, activeEyeCamera, rtRecursion, virtualCamera);
orangePortal.screenMat.uniforms.uPortalTex.value = rtRecursion.texture;
renderSinglePortalPass(orangePortal, bluePortal, activeEyeCamera, rtOrange, recursiveCamera);
orangePortal.screenMat.uniforms.uPortalTex.value = rtOrange.texture;
renderer.clippingPlanes = [];
renderer.setRenderTarget(null);
}
// =========================================================================
// PROJECTILE TRACERS & DARK-METAL FIZZLE PARTICLES
// =========================================================================
const activeEffects = [];
function spawnBeamAndImpact(startPos, endPos, normal, isBlue, didStick) {
const color = isBlue ? COLOR_BLUE : COLOR_ORANGE;
// 1. Energy tracer beam from portal gun to target
const dist = startPos.distanceTo(endPos);
const beamGeo = new THREE.CylinderGeometry(0.018, 0.028, dist, 8);
beamGeo.rotateX(Math.PI / 2);
const beamMat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.9
});
const beamMesh = new THREE.Mesh(beamGeo, beamMat);
beamMesh.position.lerpVectors(startPos, endPos, 0.5);
beamMesh.lookAt(endPos);
scene.add(beamMesh);
activeEffects.push({
type: 'beam',
mesh: beamMesh,
age: 0,
maxAge: 0.14
});
// 2. If shot hit dark metal / non-portalable surface, spawn Fizzle Particle Burst!
if (!didStick) {
const count = 34;
const group = new THREE.Group();
group.position.copy(endPos).addScaledVector(normal, 0.03);
// Fizzle shockwave ring on the dark metal surface
const ringGeo = new THREE.RingGeometry(0.04, 0.12, 24);
const ringMat = new THREE.MeshBasicMaterial({
color,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.95
});
const ringMesh = new THREE.Mesh(ringGeo, ringMat);
const targetPt = endPos.clone().add(normal);
ringMesh. position.copy(endPos).addScaledVector(normal, 0.02);
ringMesh.lookAt(targetPt);
scene.add(ringMesh);
activeEffects.push({
type: 'ring',
mesh: ringMesh,
age: 0,
maxAge: 0.32
});
// Sparks scattering off the dark metal surface
const sparkGeo = new THREE.SphereGeometry(0.032, 6, 6);
const particles = [];
for (let i = 0; i < count; i++) {
const pMat = new THREE.MeshBasicMaterial({
color: i % 3 === 0 ? 0xffffff : color,
transparent: true,
opacity: 1.0
});
const pMesh = new THREE.Mesh(sparkGeo, pMat);
// Random tangent + normal velocity
const randVec = new THREE.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
).normalize();
const vel = normal.clone().multiplyScalar(1.2 + Math.random() * 2.2)
.addScaledVector(randVec, 2.4);
group.add(pMesh);
particles.push({ mesh: pMesh, vel });
}
scene.add(group);
activeEffects.push({
type: 'fizzle',
group,
particles,
age: 0,
maxAge: 0.42
});
}
}
function updateEffects(dt) {
for (let i = activeEffects.length - 1; i >= 0; i--) {
const fx = activeEffects[i];
fx.age += dt;
const t = fx.age / fx.maxAge;
if (t >= 1) {
if (fx.mesh) {
scene.remove(fx.mesh);
fx.mesh.geometry.dispose();
fx.mesh.material.dispose();
}
if (fx.group) {
scene.remove(fx.group);
}
activeEffects.splice(i, 1);
continue;
}
if (fx.type === 'beam') {
fx.mesh.material.opacity = (1 - t) * 0.9;
fx.mesh.scale.set(1 - t * 0.7, 1 - t * 0.7, 1);
} else if (fx.type === 'ring') {
const s = 1 + t * 5.2;
fx.mesh.scale.set(s, s, 1);
fx.mesh.material.opacity = (1 - t) * 0.9;
} else if (fx.type === 'fizzle') {
fx.particles.forEach((p) => {
p.vel.y -= 7.5 * dt;
p.mesh.position.addScaledVector(p.vel, dt);
p.mesh.material.opacity = 1 - t;
p.mesh.scale.setScalar(1 - t * 0.6);
});
}
}
}
// =========================================================================
// PORTAL FIRING & PLACEMENT LOGIC
// =========================================================================
const raycaster = new THREE.Raycaster();
function firePortal(isBlue) {
const targetPortal = isBlue ? bluePortal : orangePortal;
const otherPortal = isBlue ? orangePortal : bluePortal;
const color = isBlue ? COLOR_BLUE : COLOR_ORANGE;
// Update Portal Gun visual state
gunCoreMat.color.copy(color);
gunMuzzleLight.color.copy(color);
gunMuzzleLight.intensity = 4.0;
gunRecoil = 1.0;
// Muzzle world origin for beam visual
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(mainCamera.quaternion);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(mainCamera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(mainCamera.quaternion);
const muzzlePos = mainCamera.position.clone()
.addScaledVector(forward, 0.45)
.addScaledVector(right, 0.22)
.addScaledVector(up, -0.18);
raycaster.setFromCamera(new THREE.Vector2(0, 0), mainCamera);
const hits = raycaster.intersectObjects(shootableMeshes, false);
if (hits.length === 0) return;
const hit = hits[0];
const hitMesh = hit.object;
const hitNormal = hit.face.normal.clone().transformDirection(hitMesh.matrixWorld).normalize();
if (hitMesh.userData.portalable && hitMesh.userData.panelInfo) {
const panel = hitMesh.userData.panelInfo;
// Ensure player shot the front face of the white panel
if (hitNormal.dot(panel.normal) < 0.7) {
sounds.playFizzle();
spawnBeamAndImpact(muzzlePos, hit.point, hitNormal, isBlue, false);
return;
}
// Project hit point onto the white panel's 2D plane and clamp so portal stays inside panel bounds
const rel = hit.point.clone().sub(panel.center);
let u = rel.dot(panel.right);
let v = rel.dot(panel.up);
const maxU = Math.max(0, panel.halfWidth - PORTAL_RX - 0.08);
const maxV = Math.max(0, panel.halfHeight - PORTAL_RY - 0.06);
u = THREE.MathUtils.clamp(u, -maxU, maxU);
v = THREE.MathUtils.clamp(v, -maxV, maxV);
// If the other portal is on the same white panel, prevent overlapping it
if (otherPortal.active && otherPortal.panelId === panel.id) {
const otherRel = otherPortal.position.clone().sub(panel.center);
const otherU = otherRel.dot(panel.right);
const minSep = PORTAL_RX * 2 + 0.14;
if (Math.abs(u - otherU) < minSep) {
const shiftedRight = otherU + minSep;
const shiftedLeft = otherU - minSep;
if (Math.abs(u - shiftedRight) < Math.abs(u - shiftedLeft) && shiftedRight <= maxU) {
u = shiftedRight;
} else if (shiftedLeft >= -maxU) {
u = shiftedLeft;
} else if (shiftedRight <= maxU) {
u = shiftedRight;
} else {
// No room on this panel without overlapping the other portal -> fizzle
sounds.playFizzle();
spawnBeamAndImpact(muzzlePos, hit.point, panel.normal, isBlue, false);
return;
}
}
}
const finalPos = panel.center.clone()
.addScaledVector(panel.right, u)
.addScaledVector(panel.up, v);
targetPortal.place(finalPos, panel.normal, panel, true);
sounds.playShoot(isBlue);
sounds.playPortalOpen(isBlue);
spawnBeamAndImpact(muzzlePos, finalPos, panel.normal, isBlue, true);
gameStats.shots++;
updateCrosshairHUD();
} else {
// Hit dark metal or glass -> fizzle!
sounds.playShoot(isBlue);
sounds.playFizzle();
spawnBeamAndImpact(muzzlePos, hit.point, hitNormal, isBlue, false);
}
}
function updateCrosshairHUD() {
const xBlue = document.getElementById('xhair-blue');
const xOrange = document.getElementById('xhair-orange');
xBlue.setAttribute('stroke', bluePortal.active ? '#00a8ff' : 'rgba(255,255,255,0.28)');
xOrange.setAttribute('stroke', orangePortal.active ? '#ff7700' : 'rgba(255,255,255,0.28)');
}
// =========================================================================
// PLAYER STATE, PHYSICS & SEAMLESS PORTAL TRAVERSAL
// =========================================================================
const GAME_MODE = {
DEMO: 'DEMO',
PLAYING: 'PLAYING',
COMPLETE: 'COMPLETE'
};
let currentMode = GAME_MODE.DEMO;
const player = {
eyePos: new THREE.Vector3(0, PLAYER_EYE_HEIGHT, 6.2),
vel: new THREE.Vector3(),
yaw: 0,
pitch: 0,
onGround: true,
stepTimer: 0
};
const keys = {
KeyW: false,
KeyS: false,
KeyA: false,
KeyD: false,
ArrowUp: false,
ArrowDown: false,
ArrowLeft: false,
ArrowRight: false,
Space: false
};
const gameStats = {
startTime: 0,
shots: 0,
traversals: 0
};
// Check if a point `eyePos` is aligned within the walkable opening of `portal`
function getPortalLocalCoords(eyePos, portal) {
const rel = eyePos.clone().sub(portal.position);
return {
x: rel.dot(portal.right),
y: rel.dot(portal.up),
z: rel.dot(portal.normal)
};
}
function isInsidePortalAperture(localCoords, marginX = 0.16, marginY = 0.45) {
const nx = localCoords.x / (PORTAL_RX - marginX);
const ny = localCoords.y / (PORTAL_RY - marginY);
return (nx * nx + ny * ny) <= 1.0;
}
// Transform player pose and velocity seamlessly across `srcPortal` -> `dstPortal`
function traversePortal(srcPortal, dstPortal) {
computePortalTransform(srcPortal, dstPortal, portalTransformMat);
// 1. Transform exact eye position so camera matches virtual camera with 0-pixel jump
player.eyePos.applyMatrix4(portalTransformMat);
// 2. Transform velocity vector by the portal rotation
const rotMat3 = new THREE.Matrix4().extractRotation(portalTransformMat);
player.vel.applyMatrix4(rotMat3);
// Ensure minimum outward velocity so player smoothly exits the destination portal
const outDot = player.vel.dot(dstPortal.normal);
if (outDot < 1.8) {
player.vel.addScaledVector(dstPortal.normal, 1.8 - outDot);
}
// 3. Transform horizontal look direction (yaw)
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
forward.applyMatrix4(rotMat3);
player.yaw = Math.atan2(-forward.x, -forward.z);
sounds.playTraverse();
gameStats.traversals++;
}
function getFloorHeightAt(x, y, z) {
// Check if in high ledge region (z <= -3.5) and high enough to be on or stepping onto the ledge
if (z <= -3.45 && y >= 4.0 + PLAYER_EYE_HEIGHT - 0.55) {
return 4.4;
}
return 0.0;
}
function updatePlayerPhysics(dt) {
// 1. Horizontal movement input
const moveForward = (keys.KeyW || keys.ArrowUp ? 1 : 0) - (keys.KeyS || keys.ArrowDown ? 1 : 0);
const moveRight = (keys.KeyD || keys.ArrowRight ? 1 : 0) - (keys.KeyA || keys.ArrowLeft ? 1 : 0);
const forwardDir = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const rightDir = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wishDir = new THREE.Vector3()
.addScaledVector(forwardDir, moveForward)
.addScaledVector(rightDir, moveRight);
if (wishDir.lengthSq() > 0) wishDir.normalize();
const accel = player.onGround ? 14.0 : 4.5;
const targetVx = wishDir.x * MOVE_SPEED;
const targetVz = wishDir.z * MOVE_SPEED;
player.vel.x = THREE.MathUtils.lerp(player.vel.x, targetVx, Math.min(1, accel * dt));
player.vel.z = THREE.MathUtils.lerp(player.vel.z, targetVz, Math.min(1, accel * dt));
// Jump & Gravity
if (player.onGround && keys.Space) {
player.vel.y = JUMP_SPEED;
player.onGround = false;
sounds.playFootstep();
}
player.vel.y -= GRAVITY * dt;
// Save previous signed distance to each portal before stepping position
const bothPortalsOpen = bluePortal.active && orangePortal.active;
const prevBlueLocal = bothPortalsOpen ? getPortalLocalCoords(player.eyePos, bluePortal) : null;
const prevOrangeLocal = bothPortalsOpen ? getPortalLocalCoords(player.eyePos, orangePortal) : null;
// Advance position
player.eyePos.addScaledVector(player.vel, dt);
// 2. Check seamless portal plane crossing BEFORE wall collision pushback!
if (bothPortalsOpen) {
const currBlueLocal = getPortalLocalCoords(player.eyePos, bluePortal);
if (
prevBlueLocal.z >= 0 &&
currBlueLocal.z < 0 &&
player.vel.dot(bluePortal.normal) < 0 &&
isInsidePortalAperture(currBlueLocal, 0.12, 0.48)
) {
traversePortal(bluePortal, orangePortal);
} else {
const currOrangeLocal = getPortalLocalCoords(player.eyePos, orangePortal);
if (
prevOrangeLocal.z >= 0 &&
currOrangeLocal.z < 0 &&
player.vel.dot(orangePortal.normal) < 0 &&
isInsidePortalAperture(currOrangeLocal, 0.12, 0.48)
) {
traversePortal(orangePortal, bluePortal);
}
}
}
// 3. Floor & Ceiling collision
const floorY = getFloorHeightAt(player.eyePos.x, player.eyePos.y, player.eyePos.z);
const minEyeY = floorY + PLAYER_EYE_HEIGHT;
if (player.eyePos.y <= minEyeY) {
// Smoothly settle eye height if emerging slightly below/above floor from a portal
if (minEyeY - player.eyePos.y > 0.02 && minEyeY - player.eyePos.y < 0.35) {
player.eyePos.y = THREE.MathUtils.lerp(player.eyePos.y, minEyeY, Math.min(1, dt * 18));
} else {
player.eyePos.y = minEyeY;
}
if (player.vel.y < 0) player.vel.y = 0;
player.onGround = true;
} else {
player.onGround = false;
}
// Ceiling clamp
if (player.eyePos.y > 8.95) {
player.eyePos.y = 8.95;
if (player.vel.y > 0) player.vel.y = 0;
}
// 4. Horizontal Wall Collisions (with automatic pass-through when aligned with an active portal)
let bypassColliderIdx = -1;
if (bothPortalsOpen) {
for (const p of [bluePortal, orangePortal]) {
const loc = getPortalLocalCoords(player.eyePos, p);
if (loc.z > -0.45 && loc.z < PLAYER_RADIUS + 0.25 && isInsidePortalAperture(loc, 0.14, 0.48)) {
bypassColliderIdx = p.hostColliderIndex;
// Guide player cleanly inside the portal rim horizontally when inside the threshold
if (loc.z < PLAYER_RADIUS) {
const maxLocalX = PORTAL_RX - 0.20;
if (Math.abs(loc.x) > maxLocalX) {
const clampedX = Math.sign(loc.x) * maxLocalX;
player.eyePos.addScaledVector(p.right, clampedX - loc.x);
}
}
break;
}
}
}
const feetY = player.eyePos.y - PLAYER_EYE_HEIGHT + 0.15;
const headY = player.eyePos.y + 0.15;
for (let i = 0; i < boxColliders.length; i++) {
if (i === bypassColliderIdx) continue;
const b = boxColliders[i];
if (headY < b.minY || feetY > b.maxY) continue;
const closestX = THREE.MathUtils.clamp(player.eyePos.x, b.minX, b.maxX);
const closestZ = THREE.MathUtils.clamp(player.eyePos.z, b.minZ, b.maxZ);
const dx = player.eyePos.x - closestX;
const dz = player.eyePos.z - closestZ;
const distSq = dx * dx + dz * dz;
if (distSq < PLAYER_RADIUS * PLAYER_RADIUS) {
const dist = Math.sqrt(distSq);
if (dist > 0.0001) {
const push = (PLAYER_RADIUS - dist) / dist;
player.eyePos.x += dx * push;
player.eyePos.z += dz * push;
}
}
}
// Keep player blocked by closed Exit Door until it opens
if (doorOpenAmount < 0.75 && player.eyePos.y > 4.0 && Math.abs(player.eyePos.x - 3.2) < 1.3) {
if (player.eyePos.z < -8.62) {
player.eyePos.z = -8.62;
}
}
// Footsteps cadence
const horizSpeed = Math.hypot(player.vel.x, player.vel.z);
if (player.onGround && horizSpeed > 1.2) {
player.stepTimer += dt * horizSpeed * 0.42;
if (player.stepTimer >= 1.0) {
player.stepTimer = 0;
sounds.playFootstep();
}
}
// 5. Update Exit Door proximity & Check Chamber Completion
const distToDoor = Math.hypot(player.eyePos.x - 3.2, player.eyePos.z - (-9.0));
const targetDoorOpen = (player.eyePos.y > 4.0 && distToDoor < 4.6) ? 1.0 : 0.0;
doorOpenAmount = THREE.MathUtils.lerp(doorOpenAmount, targetDoorOpen, Math.min(1, dt * 6.0));
exitDoorLeft.position.x = 3.2 - 0.62 - doorOpenAmount * 1.15;
exitDoorRight.position.x = 3.2 + 0.62 + doorOpenAmount * 1.15;
// Trigger "Chamber complete" when player walks through the Exit Door on the high ledge
if (
player.eyePos.y > 4.0 &&
player.eyePos.z < -9.35 &&
Math.abs(player.eyePos.x - 3.2) < 1.45
) {
completeChamber();
}
// Apply player pose to mainCamera
mainCamera.position.copy(player.eyePos);
mainCamera.rotation.order = 'YXZ';
mainCamera.rotation.y = player.yaw;
mainCamera.rotation.x = player.pitch;
mainCamera.rotation.z = 0;
mainCamera.updateMatrixWorld(true);
}
// =========================================================================
// SEAMLESS INFINITE DEMO LOOP (Before First Click)
// =========================================================================
// In Demo Mode:
// - Blue Portal is placed on the Lower Cliff White Panel at (-3.0, 1.38, -3.45), normal (0, 0, +1)
// - Orange Portal is placed on the Lower West White Panel at (-6.95, 1.38, 2.40), normal (+1, 0, 0)
// The demo camera walks along a smooth closed path from the Orange Portal exit (-6.95, 2.40) facing +X,
// curves across the chamber floor toward (-3.0, -3.45) facing -Z, and walks straight through the Blue Portal,
// where T_Blue_to_Orange maps (-3.0, y, -3.45) facing -Z to (-6.95, y, 2.40) facing +X with C2 continuity!
let demoProgress = 0.68;
function setupDemoPortals() {
const cliffPanel = whitePanelRegistry['lower_cliff'];
const westPanel = whitePanelRegistry['lower_west'];
bluePortal.place(
new THREE.Vector3(-3.0, 1.38, -3.45),
cliffPanel.normal,
cliffPanel,
false
);
orangePortal.place(
new THREE.Vector3(-6.95, 1.38, 2.40),
westPanel.normal,
westPanel,
false
);
updateCrosshairHUD();
}
function evaluateDemoSpline(u) {
// u in [0, 1):
// Segment 1 (u in [0, 0.16]): straight exit out of Orange Portal along +X from x = -6.95 to x = -5.55
// Segment 2 (u in [0.16, 0.84]): smooth cubic Hermite curve from (-5.55, 2.40) tangent (+1, 0)
// to (-3.00, -2.05) tangent (0, -1)
// Segment 3 (u in [0.84, 1.00]): straight approach into Blue Portal along -Z from z = -2.05 to z = -3.45
const straightLen = 1.40;
const x0 = -6.95;
const z0 = 2.40;
const x1 = -3.00;
const z1 = -3.45;
let px, pz, tx, tz;
if (u < 0.16) {
const s = u / 0.16;
px = x0 + s * straightLen;
pz = z0;
tx = 1;
tz = 0;
} else if (u > 0.84) {
const s = (u - 0.84) / 0.16;
px = x1;
pz = (z1 + straightLen) - s * straightLen;
tx = 0;
tz = -1;
} else {
const t = (u - 0.16) / 0.68;
const p0x = x0 + straightLen, p0z = z0;
const p1x = x1, p1z = z1 + straightLen;
const mScale = 5.95; // Matches arc speed of straight segments
const m0x = mScale, m0z = 0;
const m1x = 0, m1z = -mScale;
const t2 = t * t;
const t3 = t2 * t;
const h00 = 2 * t3 - 3 * t2 + 1;
const h10 = t3 - 2 * t2 + t;
const h01 = -2 * t3 + 3 * t2;
const h11 = t3 - t2;
px = h00 * p0x + h10 * m0x + h01 * p1x + h11 * m1x;
pz = h00 * p0z + h10 * m0z + h01 * p1z + h11 * m1z;
// Derivative for tangent look direction
const dh00 = 6 * t2 - 6 * t;
const dh10 = 3 * t2 - 4 * t + 1;
const dh01 = -6 * t2 + 6 * t;
const dh11 = 3 * t2 - 2 * t;
tx = dh00 * p0x + dh10 * m0x + dh01 * p1x + dh11 * m1x;
tz = dh00 * p0z + dh10 * m0z + dh01 * p1z + dh11 * m1z;
}
const yaw = Math.atan2(-tx, -tz);
// Gentle head bob that has an integer number of cycles (4 cycles) over [0, 1] so bob is also continuous at u = 0/1!
const bob = Math.sin(u * Math.PI * 8) * 0.028;
// Subtle upward glance toward the high ledge in the middle of the room, level when entering/exiting portal
const midRoomFactor = Math.pow(Math.sin(u * Math.PI), 2);
const pitch = midRoomFactor * 0.14;
return {
x: px,
y: 1.50 + bob,
z: pz,
yaw,
pitch
};
}
function updateDemoLoop(dt) {
demoProgress = (demoProgress + dt * 0.115) % 1.0;
const pose = evaluateDemoSpline(demoProgress);
mainCamera.position.set(pose.x, pose.y, pose.z);
mainCamera.rotation.order = 'YXZ';
mainCamera.rotation.y = pose.yaw;
mainCamera.rotation.x = pose.pitch;
mainCamera.rotation.z = 0;
mainCamera.updateMatrixWorld(true);
}
// =========================================================================
// GAME FLOW, POINTER LOCK & INPUT HANDLERS
// =========================================================================
const demoOverlay = document.getElementById('demo-overlay');
const hudEl = document.getElementById('hud');
const pauseBanner = document.getElementById('pause-banner');
const completeModal = document.getElementById('complete-modal');
const restartBtn = document.getElementById('restart-btn');
function startPlayerChamber() {
currentMode = GAME_MODE.PLAYING;
demoOverlay.classList.add('hidden');
completeModal.classList.remove('visible');
hudEl.classList.add('visible');
// Reset player at chamber entrance pad facing the High Ledge & Exit Door
player.eyePos.set(0, PLAYER_EYE_HEIGHT, 6.2);
player.vel.set(0, 0, 0);
player.yaw = 0;
player.pitch = 0.08;
player.onGround = true;
// Clear demo portals so the player solves the chamber
bluePortal.clear();
orangePortal.clear();
updateCrosshairHUD();
doorOpenAmount = 0;
exitDoorLeft.position.x = 3.2 - 0.62;
exitDoorRight.position.x = 3.2 + 0.62;
gameStats.startTime = performance.now();
gameStats.shots = 0;
gameStats.traversals = 0;
}
function completeChamber() {
if (currentMode === GAME_MODE.COMPLETE) return;
currentMode = GAME_MODE.COMPLETE;
sounds.playVictory();
const elapsedSec = ((performance.now() - gameStats.startTime) / 1000).toFixed(1);
document.getElementById('stat-time').textContent = `${elapsedSec}s`;
document.getElementById('stat-shots').textContent = `${gameStats.shots}`;
document.getElementById('stat-steps').textContent = `${gameStats.traversals}`;
hudEl.classList.remove('visible');
pauseBanner.classList.remove('visible');
completeModal.classList.add('visible');
if (document.pointerLockElement === canvas) {
document.exitPointerLock();
}
}
canvas.addEventListener('mousedown', (e) => {
sounds.init();
if (currentMode === GAME_MODE.DEMO) {
startPlayerChamber();
canvas.requestPointerLock();
return;
}
if (currentMode === GAME_MODE.PLAYING) {
if (document.pointerLockElement !== canvas) {
canvas.requestPointerLock();
return;
}
if (e.button === 0) {
firePortal(true); // Left click -> Blue Portal
} else if (e.button === 2) {
firePortal(false); // Right click -> Orange Portal
}
}
});
window.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('pointerlockchange', () => {
if (currentMode === GAME_MODE.PLAYING) {
if (document.pointerLockElement === canvas) {
pauseBanner.classList.remove('visible');
} else {
pauseBanner.classList.add('visible');
// Clear held movement keys when unlocked
Object.keys(keys).forEach((k) => { keys[k] = false; });
}
}
});
document.addEventListener('mousemove', (e) => {
if (currentMode !== GAME_MODE.PLAYING || document.pointerLockElement !== canvas) return;
const sensitivity = 0.0022;
player.yaw -= e.movementX * sensitivity;
player.pitch -= e.movementY * sensitivity;
const maxPitch = Math.PI / 2 - 0.04;
player.pitch = THREE.MathUtils.clamp(player.pitch, -maxPitch, maxPitch);
});
window.addEventListener('keydown', (e) => {
if (e.code in keys) {
keys[e.code] = true;
}
if (e.code === 'KeyR' && currentMode === GAME_MODE.PLAYING) {
bluePortal.clear();
orangePortal.clear();
sounds.playFizzle();
updateCrosshairHUD();
}
});
window.addEventListener('keyup', (e) => {
if (e.code in keys) {
keys[e.code] = false;
}
});
restartBtn.addEventListener('click', () => {
sounds.init();
startPlayerChamber();
canvas.requestPointerLock();
});
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
const dpr = renderer.getPixelRatio();
renderer.setSize(w, h);
mainCamera.aspect = w / h;
mainCamera.updateProjectionMatrix();
virtualCamera.aspect = w / h;
virtualCamera.updateProjectionMatrix();
recursiveCamera.aspect = w / h;
recursiveCamera.updateProjectionMatrix();
weaponCamera.aspect = w / h;
weaponCamera.updateProjectionMatrix();
rtBlue.setSize(Math.floor(w * dpr), Math.floor(h * dpr));
rtOrange.setSize(Math.floor(w * dpr), Math.floor(h * dpr));
rtRecursion.setSize(Math.floor(w * dpr), Math.floor(h * dpr));
});
// =========================================================================
// INITIALIZE SCENE & MAIN ANIMATION LOOP
// =========================================================================
setupLighting();
buildTestChamber();
buildPortalGun();
setupDemoPortals();
let lastTime = performance.now();
function animate(now) {
requestAnimationFrame(animate);
const dt = Math.min(0.05, (now - lastTime) / 1000);
lastTime = now;
const elapsed = now * 0.001;
// 1. Update camera / player movement BEFORE portal rendering so portal views have zero lag
if (currentMode === GAME_MODE.DEMO) {
updateDemoLoop(dt);
} else if (currentMode === GAME_MODE.PLAYING) {
updatePlayerPhysics(dt);
}
// 2. Update portals & particle effects
const isLinked = bluePortal.active && orangePortal.active;
bluePortal.update(dt, elapsed, isLinked);
orangePortal.update(dt, elapsed, isLinked);
updateEffects(dt);
// 3. Update first-person Portal Gun sway & recoil
if (gunGroup) {
gunRecoil = Math.max(0, gunRecoil - dt * 6.5);
gunMuzzleLight.intensity = THREE.MathUtils.lerp(gunMuzzleLight.intensity, 0, dt * 10);
gunMuzzleLight.position.copy(mainCamera.position);
const moveSpeed = currentMode === GAME_MODE.PLAYING
? Math.hypot(player.vel.x, player.vel.z)
: 2.5;
const bobX = Math.cos(elapsed * 7.0) * 0.006 * (moveSpeed / MOVE_SPEED);
const bobY = Math.sin(elapsed * 14.0) * 0.005 * (moveSpeed / MOVE_SPEED);
gunGroup.position.set(
0.24 + bobX,
-0.22 + bobY + gunRecoil * 0.018,
-0.42 + gunRecoil * 0.065
);
gunGroup.rotation.x = gunRecoil * 0.16;
}
// 4. Render live portal windows to offscreen render targets
renderPortals(mainCamera);
// 5. Render main chamber view
renderer.autoClear = true;
renderer.render(scene, mainCamera);
// 6. Render first-person Portal Gun overlay on top of depth buffer
renderer.autoClear = false;
renderer.clearDepth();
renderer.render(weaponScene, weaponCamera);
renderer.autoClear = true;
}
requestAnimationFrame(animate);
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.