Ethan: Last Light — 3D
by BlazeMaker301139 lines39.2 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>Ethan: Last Light — 3D</title>
<style>
:root { color-scheme: dark; font-family: system-ui, -apple-system, sans-serif; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin:0; width:100%; height:100%; overflow:hidden; background:#87b8e0; user-select:none; -webkit-user-select:none; }
canvas { display:block; width:100%; height:100%; }
#hud { position:fixed; inset:0; pointer-events:none; font-variant-numeric:tabular-nums; }
#topLeft { position:absolute; top:max(14px,env(safe-area-inset-top)); left:max(14px,env(safe-area-inset-left)); }
#topRight { position:absolute; top:max(14px,env(safe-area-inset-top)); right:max(14px,env(safe-area-inset-right)); text-align:right; }
.hpBar { width:180px; height:12px; background:#00000055; border:1px solid #ffffff33; border-radius:3px; overflow:hidden; }
.hpBar > div { height:100%; width:100%; background:#5cd48a; transition:width .15s,background .2s; }
.label { font-size:11px; letter-spacing:2px; font-weight:800; color:#fff; text-shadow:0 1px 3px #0008; margin-bottom:4px; }
.big { font-size:20px; font-weight:900; letter-spacing:1px; color:#fff; text-shadow:0 2px 6px #000a; }
.small { font-size:13px; color:#fff; text-shadow:0 1px 3px #000a; font-weight:700; }
#crosshair { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:24px; height:24px; }
#crosshair::before, #crosshair::after { content:""; position:absolute; background:#fff; box-shadow:0 0 3px #000a; }
#crosshair::before { left:11px; top:0; width:2px; height:9px; }
#crosshair::after { left:11px; bottom:0; width:2px; height:9px; }
.cross-h { position:absolute; top:11px; height:2px; width:9px; background:#fff; box-shadow:0 0 3px #000a; }
.cross-h.left { left:0; }
.cross-h.right { right:0; }
#ammo { position:absolute; bottom:max(18px,env(safe-area-inset-bottom)); right:max(18px,env(safe-area-inset-right)); text-align:right; color:#fff; text-shadow:0 2px 6px #000a; }
#ammo .num { font-size:38px; font-weight:900; line-height:1; }
#ammo .sub { font-size:12px; font-weight:800; letter-spacing:2px; opacity:0.85; }
#banner { position:absolute; top:22%; left:0; width:100%; text-align:center; font-size:clamp(24px,6vw,44px); font-weight:900; letter-spacing:6px; color:#fff; text-shadow:0 3px 20px #000, 0 0 40px #fff4; opacity:0; transition:opacity .3s,letter-spacing .3s; }
#banner.on { opacity:1; letter-spacing:10px; }
#overlay { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background:linear-gradient(180deg,#0a1a2e,#04090f); z-index:10; padding:20px; }
.card { width:min(100%, 460px); text-align:center; color:#d4e6ec; }
.card h1 { font-size:clamp(44px,12vw,72px); font-weight:900; letter-spacing:-2px; line-height:.95; margin:0; color:#fff; }
.card h1 small { display:block; font-size:14px; letter-spacing:8px; color:#ffb673; font-weight:800; margin-top:10px; }
.card p { font-size:14px; line-height:1.7; color:#9db3bd; margin:22px auto; max-width:360px; }
.keys { display:flex; flex-wrap:wrap; gap:6px; justify-content:center; margin-bottom:22px; }
.keys span { font-size:10px; letter-spacing:1.5px; font-weight:800; color:#8fb0b8; border:1px solid #ffffff20; border-radius:20px; padding:5px 10px; }
#startBtn { width:100%; padding:16px; border:none; border-radius:10px; background:linear-gradient(180deg,#7fe6b0,#4fd48a); color:#0d241c; font-weight:900; letter-spacing:2px; font-size:15px; cursor:pointer; }
#startBtn:active { transform:scale(.98); }
.hint { font-size:11px; color:#607a84; margin-top:14px; }
#touchUI { position:fixed; inset:0; pointer-events:none; display:none; }
#touchUI.on { display:block; }
#fireBtn { position:absolute; right:24px; bottom:24px; width:88px; height:88px; border-radius:50%; background:radial-gradient(circle at 35% 30%,#c94f3a,#8a2c1e); border:2px solid #ffffff40; color:#fff; font-weight:900; font-size:11px; letter-spacing:1px; pointer-events:auto; box-shadow:0 6px 20px #0008; touch-action:none; }
#fireBtn:active { transform:scale(.94); }
#jumpBtn { position:absolute; right:24px; bottom:130px; width:64px; height:64px; border-radius:50%; background:#2a4a55cc; border:2px solid #ffffff30; color:#fff; font-weight:900; font-size:10px; letter-spacing:1px; pointer-events:auto; touch-action:none; }
#jumpBtn:active { transform:scale(.94); }
</style>
</head>
<body>
<div id="hud">
<div id="topLeft">
<div class="label">SURVIVOR</div>
<div class="big">ETHAN</div>
<div class="hpBar"><div id="hpFill"></div></div>
</div>
<div id="topRight">
<div class="label">WAVE <span id="waveNum">01</span></div>
<div class="small"><span id="scoreNum">0</span> PTS · <span id="killsNum">0</span> KILLS</div>
</div>
<div id="crosshair">
<div class="cross-h left"></div>
<div class="cross-h right"></div>
</div>
<div id="ammo">
<div class="num"><span id="ammoCur">30</span><span style="font-size:20px;opacity:.7"> / </span><span id="ammoMax" style="font-size:22px;opacity:.7">120</span></div>
<div class="sub">AK-47</div>
</div>
<div id="banner"></div>
</div>
<div id="touchUI">
<button id="fireBtn">FIRE</button>
<button id="jumpBtn">JUMP</button>
</div>
<div id="overlay">
<div class="card">
<h1>ETHAN<small>LAST LIGHT</small></h1>
<p>The city is silent. Something moves between the buildings.<br>Clear the streets. Watch your ammo.</p>
<div class="keys">
<span>WASD MOVE</span><span>MOUSE LOOK</span><span>CLICK FIRE</span>
<span>SPACE JUMP</span><span>R RELOAD</span><span>P PAUSE</span>
</div>
<button id="startBtn">ENTER THE CITY</button>
<div class="hint">Click canvas to lock mouse. ESC to release.</div>
</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';
/* =================================================================
SETUP — scene, camera, renderer
================================================================= */
const canvas = document.createElement('canvas');
document.body.insertBefore(canvas, document.getElementById('hud'));
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8ec1e8);
scene.fog = new THREE.Fog(0x8ec1e8, 40, 160);
const camera = new THREE.PerspectiveCamera(72, window.innerWidth/window.innerHeight, 0.05, 300);
camera.rotation.order = 'YXZ';
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* =================================================================
LIGHTING — sun + hemisphere + shadows
================================================================= */
const hemi = new THREE.HemisphereLight(0xbcd8ff, 0x6b5a48, 0.85);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d8, 2.2);
sun.position.set(40, 60, 20);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 200;
sun.shadow.camera.left = -80;
sun.shadow.camera.right = 80;
sun.shadow.camera.top = 80;
sun.shadow.camera.bottom = -80;
sun.shadow.bias = -0.0005;
scene.add(sun);
scene.add(sun.target);
/* =================================================================
WORLD — ground, buildings, props
================================================================= */
const groundMat = new THREE.MeshStandardMaterial({ color: 0x8a8a80, roughness: 0.95, metalness: 0.02 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
ground.rotation.x = -Math.PI/2;
ground.receiveShadow = true;
scene.add(ground);
// grid lines to add depth to the ground
const grid = new THREE.GridHelper(400, 200, 0x5a5a52, 0x6a6a62);
grid.position.y = 0.01;
grid.material.opacity = 0.35;
grid.material.transparent = true;
scene.add(grid);
// Obstacle list for collision
const obstacles = []; // { minX, maxX, minZ, maxZ, height }
const buildingMats = [
new THREE.MeshStandardMaterial({ color: 0xd8d5c8, roughness: 0.85 }), // pale concrete
new THREE.MeshStandardMaterial({ color: 0xb8b5a4, roughness: 0.9 }), // darker concrete
new THREE.MeshStandardMaterial({ color: 0xa8a8a0, roughness: 0.9 }), // gray
new THREE.MeshStandardMaterial({ color: 0xc4a880, roughness: 0.85 }), // tan
new THREE.MeshStandardMaterial({ color: 0x6a7a82, roughness: 0.7, metalness: 0.15 }) // blue-gray
];
function addBuilding(cx, cz, w, d, h, matIndex) {
const mat = buildingMats[matIndex % buildingMats.length];
const geo = new THREE.BoxGeometry(w, h, d);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(cx, h/2, cz);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
// Darker top band for architectural feel
const topBand = new THREE.Mesh(
new THREE.BoxGeometry(w + 0.06, h*0.08, d + 0.06),
new THREE.MeshStandardMaterial({ color: 0x2a2e33, roughness: 0.7 })
);
topBand.position.set(cx, h - h*0.04, cz);
topBand.castShadow = true;
scene.add(topBand);
// Windows — small dark boxes on faces
const winMat = new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.3, metalness: 0.4, emissive: 0x0a1520, emissiveIntensity: 0.4 });
const rows = Math.max(1, Math.floor(h / 3));
const cols = Math.max(1, Math.floor(w / 3));
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const wy = 1.6 + r * 3;
if (wy > h - 1) continue;
const wx = -w/2 + 1.2 + c * (w / cols);
const windowMesh = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.1), winMat);
windowMesh.position.set(cx + wx, wy, cz + d/2 + 0.06);
scene.add(windowMesh);
const windowMesh2 = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.1), winMat);
windowMesh2.position.set(cx + wx, wy, cz - d/2 - 0.06);
scene.add(windowMesh2);
}
}
obstacles.push({
minX: cx - w/2, maxX: cx + w/2,
minZ: cz - d/2, maxZ: cz + d/2,
height: h
});
}
// Generate a small city block layout
const rng = (() => {
let s = 1337;
return () => { s = (s*1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
})();
const blockPositions = [];
for (let bx = -2; bx <= 2; bx++) {
for (let bz = -2; bz <= 2; bz++) {
if (bx === 0 && bz === 0) continue; // leave player spawn open
blockPositions.push([bx, bz]);
}
}
for (const [bx, bz] of blockPositions) {
const centerX = bx * 24;
const centerZ = bz * 24;
// 2 buildings per block
const count = 1 + Math.floor(rng() * 2);
for (let i = 0; i < count; i++) {
const w = 6 + rng() * 6;
const d = 6 + rng() * 6;
const h = 4 + rng() * 12;
const ox = (rng() - 0.5) * 10;
const oz = (rng() - 0.5) * 10;
addBuilding(centerX + ox, centerZ + oz, w, d, h, Math.floor(rng() * 5));
}
}
// Add some low crates and barriers scattered around
const crateMat = new THREE.MeshStandardMaterial({ color: 0x6a8a5a, roughness: 0.9 });
const barrelMat = new THREE.MeshStandardMaterial({ color: 0xa04828, roughness: 0.7, metalness: 0.1 });
const concreteMat = new THREE.MeshStandardMaterial({ color: 0xa8a8a2, roughness: 0.9 });
function addCrate(x, z) {
const size = 1.4;
const crate = new THREE.Mesh(new THREE.BoxGeometry(size, size, size), crateMat);
crate.position.set(x, size/2, z);
crate.castShadow = true;
crate.receiveShadow = true;
scene.add(crate);
obstacles.push({ minX: x - size/2, maxX: x + size/2, minZ: z - size/2, maxZ: z + size/2, height: size });
}
function addBarrel(x, z) {
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 1.5, 12), barrelMat);
barrel.position.set(x, 0.75, z);
barrel.castShadow = true;
barrel.receiveShadow = true;
scene.add(barrel);
obstacles.push({ minX: x - 0.5, maxX: x + 0.5, minZ: z - 0.5, maxZ: z + 0.5, height: 1.5 });
}
function addConcrete(x, z, w, d) {
const c = new THREE.Mesh(new THREE.BoxGeometry(w, 1.2, d), concreteMat);
c.position.set(x, 0.6, z);
c.castShadow = true;
c.receiveShadow = true;
scene.add(c);
obstacles.push({ minX: x - w/2, maxX: x + w/2, minZ: z - d/2, maxZ: z + d/2, height: 1.2 });
}
for (let i = 0; i < 40; i++) {
const a = rng() * Math.PI * 2;
const r = 15 + rng() * 60;
const x = Math.cos(a) * r;
const z = Math.sin(a) * r;
// make sure it's not inside a building
let inside = false;
for (const o of obstacles) {
if (x > o.minX - 1 && x < o.maxX + 1 && z > o.minZ - 1 && z < o.maxZ + 1) { inside = true; break; }
}
if (inside) continue;
const kind = rng();
if (kind < 0.4) addCrate(x, z);
else if (kind < 0.7) addBarrel(x, z);
else addConcrete(x, z, 2 + rng()*3, 0.6);
}
/* =================================================================
PLAYER — position, velocity, camera
================================================================= */
const player = {
pos: new THREE.Vector3(0, 1.7, 0),
vel: new THREE.Vector3(),
yaw: 0,
pitch: 0,
onGround: true,
health: 100,
maxHealth: 100
};
/* =================================================================
WEAPON VIEWMODEL — attached to camera
================================================================= */
const weaponGroup = new THREE.Group();
camera.add(weaponGroup);
scene.add(camera);
// Build an AK-47 style rifle from primitives
function makeRifle() {
const g = new THREE.Group();
// Materials
const woodMat = new THREE.MeshStandardMaterial({ color: 0xb8853e, roughness: 0.55 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c1c1e, roughness: 0.55, metalness: 0.35 });
const metalMat = new THREE.MeshStandardMaterial({ color: 0x3a3a3e, roughness: 0.4, metalness: 0.65 });
const magMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.6, metalness: 0.3 });
// Receiver
const receiver = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.09, 0.5), darkMat);
receiver.position.set(0, 0, -0.05);
g.add(receiver);
// Barrel
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.018, 0.55, 8), metalMat);
barrel.rotation.x = Math.PI / 2;
barrel.position.set(0, 0.015, -0.55);
g.add(barrel);
// Wooden handguard
const handguard = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.08, 0.22), woodMat);
handguard.position.set(0, 0.005, -0.3);
g.add(handguard);
// Magazine (curved, made of 3 segments)
for (let i = 0; i < 3; i++) {
const seg = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.12, 0.09), magMat);
seg.position.set(0, -0.10 - i * 0.1, 0.02 + i * 0.04);
seg.rotation.x = 0.28;
g.add(seg);
}
// Pistol grip
const grip = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.16, 0.07), darkMat);
grip.position.set(0, -0.11, 0.18);
grip.rotation.x = -0.22;
g.add(grip);
// Stock
const stock = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.09, 0.24), woodMat);
stock.position.set(0, -0.01, 0.32);
g.add(stock);
// Rear sight
const rearSight = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.08), darkMat);
rearSight.position.set(0, 0.075, -0.08);
g.add(rearSight);
// Peep hole
const hole = new THREE.Mesh(new THREE.TorusGeometry(0.014, 0.006, 6, 10), darkMat);
hole.rotation.y = Math.PI/2;
hole.position.set(0, 0.075, -0.08);
g.add(hole);
// Front sight post
const frontSight = new THREE.Mesh(new THREE.BoxGeometry(0.012, 0.05, 0.02), metalMat);
frontSight.position.set(0, 0.06, -0.7);
g.add(frontSight);
const frontBase = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.02, 0.04), metalMat);
frontBase.position.set(0, 0.035, -0.7);
g.add(frontBase);
// Trigger guard
const guard = new THREE.Mesh(new THREE.TorusGeometry(0.025, 0.006, 6, 12, Math.PI*1.2), darkMat);
guard.rotation.z = Math.PI;
guard.rotation.y = Math.PI/2;
guard.position.set(0, -0.055, 0.08);
g.add(guard);
// Muzzle brake
const muzzle = new THREE.Mesh(new THREE.CylinderGeometry(0.026, 0.026, 0.05, 8), metalMat);
muzzle.rotation.x = Math.PI/2;
muzzle.position.set(0, 0.015, -0.8);
g.add(muzzle);
return g;
}
const rifle = makeRifle();
weaponGroup.add(rifle);
// Position weapon in front of camera, lower-right
weaponGroup.position.set(0.22, -0.20, -0.42);
weaponGroup.rotation.set(0, 0.04, 0.02);
// Muzzle flash light + sprite
const muzzleLight = new THREE.PointLight(0xffd080, 0, 8, 2);
muzzleLight.position.set(0, 0.015, -0.9);
weaponGroup.add(muzzleLight);
const muzzleFlashMat = new THREE.MeshBasicMaterial({ color: 0xffeeaa, transparent: true, opacity: 0 });
const muzzleFlash = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), muzzleFlashMat);
muzzleFlash.position.set(0, 0.015, -0.9);
weaponGroup.add(muzzleFlash);
/* =================================================================
HAND / ARM meshes so it doesn't look like a floating gun
================================================================= */
const skinMat = new THREE.MeshStandardMaterial({ color: 0xc99168, roughness: 0.75 });
const sleeveMat = new THREE.MeshStandardMaterial({ color: 0x2a3a42, roughness: 0.9 });
// Right forearm
const rArm = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.24, 0.11), sleeveMat);
rArm.position.set(0.02, -0.24, 0.24);
rArm.rotation.x = -0.35;
weaponGroup.add(rArm);
// Right hand
const rHand = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.11, 0.11), skinMat);
rHand.position.set(0, -0.12, 0.19);
weaponGroup.add(rHand);
// Left forearm (supporting handguard)
const lArm = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.24, 0.11), sleeveMat);
lArm.position.set(-0.02, -0.26, -0.28);
lArm.rotation.x = -0.55;
lArm.rotation.z = 0.15;
weaponGroup.add(lArm);
// Left hand
const lHand = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.11, 0.13), skinMat);
lHand.position.set(0, -0.09, -0.30);
weaponGroup.add(lHand);
/* =================================================================
ZOMBIES — low-poly capsule humanoids
================================================================= */
const zombies = [];
const zombieBodyMat = new THREE.MeshStandardMaterial({ color: 0x6a7a5a, roughness: 0.85 });
const zombieHeadMat = new THREE.MeshStandardMaterial({ color: 0x9aa880, roughness: 0.8 });
const zombieShirtMat = new THREE.MeshStandardMaterial({ color: 0x5a4a4a, roughness: 0.9 });
const zombieEyeMat = new THREE.MeshBasicMaterial({ color: 0xffcc44 });
function makeZombie() {
const g = new THREE.Group();
// Torso (box)
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.75, 0.32), zombieShirtMat);
torso.position.y = 1.05;
torso.castShadow = true;
g.add(torso);
// Hips
const hips = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.4, 0.3), zombieBodyMat);
hips.position.y = 0.6;
hips.castShadow = true;
g.add(hips);
// Legs (upper + lower)
for (const side of [-1, 1]) {
const upperLeg = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.45, 0.2), zombieBodyMat);
upperLeg.position.set(side * 0.14, 0.35, 0);
upperLeg.castShadow = true;
g.add(upperLeg);
const lowerLeg = new THREE.Mesh(new THREE.BoxGeometry(0.19, 0.4, 0.19), zombieBodyMat);
lowerLeg.position.set(side * 0.14, -0.05, 0);
lowerLeg.castShadow = true;
g.add(lowerLeg);
}
// Arms (reaching forward)
for (const side of [-1, 1]) {
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.16, 0.6), zombieBodyMat);
arm.position.set(side * 0.36, 1.2, -0.25);
arm.castShadow = true;
g.add(arm);
}
// Neck
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.1, 0.15), zombieHeadMat);
neck.position.y = 1.48;
g.add(neck);
// Head
const head = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.32, 0.28), zombieHeadMat);
head.position.y = 1.7;
head.castShadow = true;
g.add(head);
// Eyes (glowing)
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.04, 0.02), zombieEyeMat);
eye.position.set(side * 0.07, 1.73, -0.15);
g.add(eye);
}
return g;
}
function spawnZombie() {
// Pick a spawn position away from player
let x, z;
for (let tries = 0; tries < 60; tries++) {
const a = Math.random() * Math.PI * 2;
const d = 25 + Math.random() * 15;
x = player.pos.x + Math.cos(a) * d;
z = player.pos.z + Math.sin(a) * d;
// bounds
if (Math.abs(x) > 90 || Math.abs(z) > 90) continue;
// not inside a building
let bad = false;
for (const o of obstacles) {
if (x > o.minX - 0.5 && x < o.maxX + 0.5 && z > o.minZ - 0.5 && z < o.maxZ + 0.5) { bad = true; break; }
}
if (!bad) break;
}
const mesh = makeZombie();
mesh.position.set(x, 0, z);
scene.add(mesh);
zombies.push({
mesh,
hp: 5,
speed: 1.6 + Math.random() * 0.9,
attackCd: 0,
hitFlash: 0,
stepPhase: Math.random() * Math.PI * 2
});
}
/* =================================================================
SHOOTING
================================================================= */
const raycaster = new THREE.Raycaster();
let ammo = 30, maxAmmo = 120, reserve = 90;
let fireCd = 0;
let reloading = 0;
function updateAmmoHUD() {
document.getElementById('ammoCur').textContent = ammo;
document.getElementById('ammoMax').textContent = reserve;
}
function shoot() {
if (ammo <= 0 || reloading > 0) return;
ammo--;
updateAmmoHUD();
fireCd = 0.1;
muzzleFlashMat.opacity = 1;
muzzleLight.intensity = 6;
// Slight gun kick
gunKickZ = 0.06;
gunKickY = 0.02;
// Screen shake
shake = 0.35;
// Raycast
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
raycaster.far = 200;
// Check zombies first
const zombieMeshes = zombies.map(z => z.mesh);
const zombieHits = raycaster.intersectObjects(zombieMeshes, true);
const wallHits = raycaster.intersectObjects(scene.children.filter(o => o.isMesh && obstacles.some(ob => o.position.x >= ob.minX && o.position.x <= ob.maxX && o.position.z >= ob.minZ && o.position.z <= ob.maxZ)), false);
let nearestZ = zombieHits[0];
let nearestW = wallHits[0];
if (nearestZ && (!nearestW || nearestZ.distance < nearestW.distance)) {
// Find which zombie was hit
const hitObj = nearestZ.object;
let parent = hitObj;
while (parent && !zombies.find(z => z.mesh === parent)) parent = parent.parent;
const target = zombies.find(z => z.mesh === parent);
if (target) {
target.hp -= 1;
target.hitFlash = 0.15;
// Blood splatter
spawnBlood(nearestZ.point);
if (target.hp <= 0) {
killZombie(target);
}
}
} else if (nearestW) {
// Wall impact dust
spawnDust(nearestW.point);
}
}
// Blood particles
const bloodParts = [];
function spawnBlood(pos) {
for (let i = 0; i < 10; i++) {
const mat = new THREE.MeshBasicMaterial({ color: 0xa81018, transparent: true, opacity: 1 });
const p = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.06), mat);
p.position.copy(pos);
const vel = new THREE.Vector3(
(Math.random() - 0.5) * 4,
Math.random() * 3 + 1,
(Math.random() - 0.5) * 4
);
scene.add(p);
bloodParts.push({ mesh: p, vel, life: 0.9 });
}
}
function spawnDust(pos) {
for (let i = 0; i < 6; i++) {
const mat = new THREE.MeshBasicMaterial({ color: 0xc9c0a8, transparent: true, opacity: 1 });
const p = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.05, 0.05), mat);
p.position.copy(pos);
const vel = new THREE.Vector3((Math.random()-0.5)*2, Math.random()*2, (Math.random()-0.5)*2);
scene.add(p);
bloodParts.push({ mesh: p, vel, life: 0.5 });
}
}
function killZombie(z) {
scene.remove(z.mesh);
const i = zombies.indexOf(z);
if (i >= 0) zombies.splice(i, 1);
kills++;
score += 10;
updateHUD();
}
/* =================================================================
INPUT
================================================================= */
const keys = {};
let pointerLocked = false;
let mouseDown = false;
document.addEventListener('pointerlockchange', () => {
pointerLocked = document.pointerLockElement === canvas;
});
canvas.addEventListener('click', () => {
if (state === 'playing' && !pointerLocked) canvas.requestPointerLock();
});
document.addEventListener('mousemove', (e) => {
if (!pointerLocked || state !== 'playing') return;
player.yaw -= e.movementX * 0.002;
player.pitch -= e.movementY * 0.002;
player.pitch = Math.max(-Math.PI/2 + 0.05, Math.min(Math.PI/2 - 0.05, player.pitch));
});
window.addEventListener('mousedown', (e) => { if (e.button === 0) mouseDown = true; });
window.addEventListener('mouseup', (e) => { if (e.button === 0) mouseDown = false; });
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code === 'KeyR') reload();
if (e.code === 'KeyP') togglePause();
if (e.code === 'Space' && state === 'playing') e.preventDefault();
});
window.addEventListener('keyup', (e) => keys[e.code] = false);
/* =================================================================
MOBILE TOUCH CONTROLS
================================================================= */
const isTouch = ('ontouchstart' in window);
const touchUI = document.getElementById('touchUI');
const fireBtn = document.getElementById('fireBtn');
const jumpBtn = document.getElementById('jumpBtn');
if (isTouch) touchUI.classList.add('on');
const touchMove = { id: null, ox: 0, oy: 0, x: 0, y: 0 };
const touchLook = { id: null, lastX: 0, lastY: 0 };
canvas.addEventListener('touchstart', (e) => {
if (state !== 'playing') return;
for (const t of e.changedTouches) {
if (t.clientX < window.innerWidth * 0.45) {
if (touchMove.id === null) { touchMove.id = t.identifier; touchMove.ox = t.clientX; touchMove.oy = t.clientY; touchMove.x = 0; touchMove.y = 0; }
} else {
if (touchLook.id === null) { touchLook.id = t.identifier; touchLook.lastX = t.clientX; touchLook.lastY = t.clientY; }
}
}
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (state !== 'playing') return;
for (const t of e.changedTouches) {
if (t.identifier === touchMove.id) {
let dx = t.clientX - touchMove.ox, dy = t.clientY - touchMove.oy;
const l = Math.hypot(dx, dy);
const max = 60;
if (l > max) { dx *= max/l; dy *= max/l; }
touchMove.x = dx / max;
touchMove.y = dy / max;
} else if (t.identifier === touchLook.id) {
const dx = t.clientX - touchLook.lastX;
const dy = t.clientY - touchLook.lastY;
touchLook.lastX = t.clientX;
touchLook.lastY = t.clientY;
player.yaw -= dx * 0.005;
player.pitch -= dy * 0.005;
player.pitch = Math.max(-Math.PI/2 + 0.05, Math.min(Math.PI/2 - 0.05, player.pitch));
}
}
}, { passive: true });
canvas.addEventListener('touchend', (e) => {
for (const t of e.changedTouches) {
if (t.identifier === touchMove.id) { touchMove.id = null; touchMove.x = 0; touchMove.y = 0; }
if (t.identifier === touchLook.id) touchLook.id = null;
}
});
fireBtn.addEventListener('touchstart', (e) => { e.preventDefault(); mouseDown = true; }, { passive: false });
fireBtn.addEventListener('touchend', (e) => { e.preventDefault(); mouseDown = false; }, { passive: false });
jumpBtn.addEventListener('touchstart', (e) => { e.preventDefault(); if (player.onGround) { player.vel.y = 5.2; player.onGround = false; } }, { passive: false });
/* =================================================================
PLAYER UPDATE — movement + collision
================================================================= */
const PLAYER_RADIUS = 0.35;
const PLAYER_HEIGHT = 1.7;
function collideMove(dx, dz) {
let nx = player.pos.x + dx;
let nz = player.pos.z + dz;
// Check X separately
let blockedX = false;
for (const o of obstacles) {
if (o.height < 0.2) continue;
if (nx + PLAYER_RADIUS > o.minX && nx - PLAYER_RADIUS < o.maxX &&
player.pos.z + PLAYER_RADIUS > o.minZ && player.pos.z - PLAYER_RADIUS < o.maxZ) {
blockedX = true; break;
}
}
if (!blockedX) player.pos.x = nx;
let blockedZ = false;
for (const o of obstacles) {
if (o.height < 0.2) continue;
if (player.pos.x + PLAYER_RADIUS > o.minX && player.pos.x - PLAYER_RADIUS < o.maxX &&
nz + PLAYER_RADIUS > o.minZ && nz - PLAYER_RADIUS < o.maxZ) {
blockedZ = true; break;
}
}
if (!blockedZ) player.pos.z = nz;
}
let gunKickZ = 0;
let gunKickY = 0;
let shake = 0;
let bobPhase = 0;
function updatePlayer(dt) {
// Movement input
let mx = 0, mz = 0;
if (keys['KeyW'] || keys['ArrowUp']) mz -= 1;
if (keys['KeyS'] || keys['ArrowDown']) mz += 1;
if (keys['KeyA'] || keys['ArrowLeft']) mx -= 1;
if (keys['KeyD'] || keys['ArrowRight']) mx += 1;
if (touchMove.id !== null) {
mx += touchMove.x;
mz += touchMove.y;
}
const len = Math.hypot(mx, mz);
if (len > 1) { mx /= len; mz /= len; }
// Rotate movement by yaw
const cos = Math.cos(player.yaw);
const sin = Math.sin(player.yaw);
const worldX = mx * cos - mz * sin;
const worldZ = mx * sin + mz * cos;
const SPEED = 5.5;
const accel = player.onGround ? 30 : 6;
player.vel.x += (worldX * SPEED - player.vel.x) * Math.min(1, accel * dt);
player.vel.z += (worldZ * SPEED - player.vel.z) * Math.min(1, accel * dt);
// Gravity
player.vel.y -= 22 * dt;
collideMove(player.vel.x * dt, player.vel.z * dt);
player.pos.y += player.vel.y * dt;
// Ground collision
if (player.pos.y < PLAYER_HEIGHT) {
player.pos.y = PLAYER_HEIGHT;
player.vel.y = 0;
player.onGround = true;
}
// Jump
if ((keys['Space'] || false) && player.onGround && state === 'playing') {
player.vel.y = 5.2;
player.onGround = false;
}
// Camera
camera.position.copy(player.pos);
camera.rotation.y = player.yaw;
camera.rotation.x = player.pitch;
// Bob
const speed = Math.hypot(player.vel.x, player.vel.z);
if (speed > 0.5 && player.onGround) {
bobPhase += dt * speed * 1.6;
const bobX = Math.sin(bobPhase) * 0.006;
const bobY = Math.abs(Math.cos(bobPhase)) * 0.008;
weaponGroup.position.set(0.22 + bobX, -0.20 - bobY, -0.42);
} else {
weaponGroup.position.set(0.22, -0.20, -0.42);
}
// Recoil recovery
gunKickZ *= Math.exp(-14 * dt);
gunKickY *= Math.exp(-14 * dt);
weaponGroup.rotation.x = gunKickY * 4;
weaponGroup.position.z = -0.42 + gunKickZ;
// Muzzle flash decay
muzzleFlashMat.opacity *= Math.exp(-22 * dt);
muzzleLight.intensity *= Math.exp(-22 * dt);
shake *= Math.exp(-8 * dt);
}
/* =================================================================
ZOMBIE UPDATE
================================================================= */
function updateZombies(dt) {
for (let i = zombies.length - 1; i >= 0; i--) {
const z = zombies[i];
const dx = player.pos.x - z.mesh.position.x;
const dz = player.pos.z - z.mesh.position.z;
const dist = Math.hypot(dx, dz);
if (dist > 0.001) {
z.mesh.rotation.y = Math.atan2(dx, dz);
}
if (dist > 0.8) {
// Walk toward player
const nx = dx / dist;
const nz = dz / dist;
let mx = nx * z.speed * dt;
let mz = nz * z.speed * dt;
// Simple obstacle avoidance — try direct, then slide
const testX = z.mesh.position.x + mx;
const testZ = z.mesh.position.z + mz;
let blocked = false;
for (const o of obstacles) {
if (testX > o.minX - 0.4 && testX < o.maxX + 0.4 && testZ > o.minZ - 0.4 && testZ < o.maxZ + 0.4) {
blocked = true; break;
}
}
if (blocked) {
// try X only
let bx = false;
for (const o of obstacles) {
if (testX > o.minX - 0.4 && testX < o.maxX + 0.4 && z.mesh.position.z > o.minZ - 0.4 && z.mesh.position.z < o.maxZ + 0.4) { bx = true; break; }
}
if (!bx) mz = 0;
else {
let bz = false;
for (const o of obstacles) {
if (z.mesh.position.x > o.minX - 0.4 && z.mesh.position.x < o.maxX + 0.4 && testZ > o.minZ - 0.4 && testZ < o.maxZ + 0.4) { bz = true; break; }
}
if (!bz) mx = 0;
else { mx = 0; mz = 0; }
}
}
z.mesh.position.x += mx;
z.mesh.position.z += mz;
// Walk animation
z.stepPhase += dt * 6;
const swing = Math.sin(z.stepPhase) * 0.3;
// Bob
z.mesh.position.y = Math.abs(Math.sin(z.stepPhase)) * 0.04;
z.mesh.rotation.z = swing * 0.15;
} else {
// Attack
z.attackCd -= dt;
if (z.attackCd <= 0) {
z.attackCd = 1.0;
damagePlayer(12);
}
}
// Hit flash
if (z.hitFlash > 0) {
z.hitFlash -= dt;
z.mesh.traverse(c => {
if (c.isMesh && c.material.emissive) {
c.material.emissive.setHex(z.hitFlash > 0 ? 0xff2222 : 0x000000);
c.material.emissiveIntensity = z.hitFlash > 0 ? 1 : 0;
}
});
}
}
}
/* =================================================================
BLOOD PARTICLE UPDATE
================================================================= */
function updateBloodParts(dt) {
for (let i = bloodParts.length - 1; i >= 0; i--) {
const b = bloodParts[i];
b.life -= dt;
if (b.life <= 0) {
scene.remove(b.mesh);
b.mesh.geometry.dispose();
b.mesh.material.dispose();
bloodParts.splice(i, 1);
continue;
}
b.vel.y -= 18 * dt;
b.mesh.position.x += b.vel.x * dt;
b.mesh.position.y += b.vel.y * dt;
b.mesh.position.z += b.vel.z * dt;
if (b.mesh.position.y < 0.03) {
b.mesh.position.y = 0.03;
b.vel.set(0, 0, 0);
}
b.mesh.material.opacity = b.life / 0.9;
}
}
/* =================================================================
GAME STATE
================================================================= */
let state = 'menu';
let wave = 1;
let score = 0;
let kills = 0;
let waveTimer = 0;
let waveCleared = false;
let bannerTimer = 0;
function updateHUD() {
document.getElementById('hpFill').style.width = Math.max(0, player.health) + '%';
document.getElementById('hpFill').style.background = player.health < 30 ? '#e05548' : '#5cd48a';
document.getElementById('waveNum').textContent = String(wave).padStart(2, '0');
document.getElementById('scoreNum').textContent = score;
document.getElementById('killsNum').textContent = kills;
}
function damagePlayer(dmg) {
player.health -= dmg;
shake = 1.0;
updateHUD();
if (player.health <= 0) {
player.health = 0;
gameOver();
}
}
function announce(text, long) {
const b = document.getElementById('banner');
b.textContent = text;
b.classList.add('on');
bannerTimer = long ? 2.6 : 1.8;
}
function nextWave() {
wave++;
waveCleared = false;
const count = 5 + wave * 2;
for (let i = 0; i < count; i++) spawnZombie();
announce('WAVE ' + String(wave).padStart(2, '0'));
updateHUD();
}
function reload() {
if (reloading > 0) return;
if (ammo >= 30) return;
if (reserve <= 0) return;
reloading = 1.6;
}
function startGame() {
// Reset
for (const z of zombies) scene.remove(z.mesh);
zombies.length = 0;
player.pos.set(0, 1.7, 0);
player.vel.set(0, 0, 0);
player.yaw = 0;
player.pitch = 0;
player.health = 100;
ammo = 30;
reserve = 90;
wave = 0;
score = 0;
kills = 0;
state = 'playing';
document.getElementById('overlay').style.display = 'none';
canvas.requestPointerLock && canvas.requestPointerLock();
updateAmmoHUD();
updateHUD();
// Start first wave
setTimeout(() => nextWave(), 300);
}
function gameOver() {
state = 'over';
if (document.exitPointerLock) document.exitPointerLock();
const overlay = document.getElementById('overlay');
overlay.style.display = 'flex';
overlay.querySelector('h1').innerHTML = 'OVERRUN<small>YOU DIED</small>';
overlay.querySelector('p').innerHTML = `You survived ${wave} waves and eliminated ${kills} zombies.<br>Final score: ${score}`;
document.getElementById('startBtn').textContent = 'TRY AGAIN';
}
function togglePause() {
if (state === 'playing') {
state = 'paused';
if (document.exitPointerLock) document.exitPointerLock();
const overlay = document.getElementById('overlay');
overlay.style.display = 'flex';
overlay.querySelector('h1').innerHTML = 'PAUSED<small>BREATHE</small>';
overlay.querySelector('p').innerHTML = 'Take your time. The city isn\'t going anywhere.';
document.getElementById('startBtn').textContent = 'RESUME';
} else if (state === 'paused') {
state = 'playing';
document.getElementById('overlay').style.display = 'none';
canvas.requestPointerLock && canvas.requestPointerLock();
}
}
document.getElementById('startBtn').addEventListener('click', () => {
if (state === 'paused') togglePause();
else startGame();
});
/* =================================================================
MAIN LOOP
================================================================= */
let lastTime = 0;
let firstZombieSound = 0;
function frame(now) {
requestAnimationFrame(frame);
if (!lastTime) lastTime = now;
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
if (state === 'playing') {
updatePlayer(dt);
updateZombies(dt);
updateBloodParts(dt);
// Firing
if (mouseDown && fireCd <= 0 && reloading <= 0) shoot();
fireCd -= dt;
if (reloading > 0) {
reloading -= dt;
if (reloading <= 0) {
const need = 30 - ammo;
const give = Math.min(need, reserve);
ammo += give;
reserve -= give;
updateAmmoHUD();
}
}
// Wave logic
if (zombies.length === 0 && !waveCleared) {
waveCleared = true;
waveTimer = 4;
announce('AREA CLEARED');
}
if (waveCleared) {
waveTimer -= dt;
if (waveTimer <= 0) nextWave();
}
// Banner
if (bannerTimer > 0) {
bannerTimer -= dt;
if (bannerTimer <= 0) document.getElementById('banner').classList.remove('on');
}
// Update sun target to follow player (for shadow map)
sun.position.x = player.pos.x + 40;
sun.position.z = player.pos.z + 20;
sun.target.position.set(player.pos.x, 0, player.pos.z);
sun.target.updateMatrixWorld();
// Camera shake
if (shake > 0.01) {
camera.position.x += (Math.random() - 0.5) * shake * 0.06;
camera.position.y += (Math.random() - 0.5) * shake * 0.06;
camera.position.z += (Math.random() - 0.5) * shake * 0.06;
}
}
renderer.render(scene, camera);
}
requestAnimationFrame(frame);
updateAmmoHUD();
updateHUD();
/* Zombie ambience — random groan sound via WebAudio */
let audioCtx = null;
function initAudio() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
document.getElementById('startBtn').addEventListener('click', initAudio);
function playGroan() {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const o = audioCtx.createOscillator();
const g = audioCtx.createGain();
o.type = 'sawtooth';
o.frequency.setValueAtTime(80 + Math.random() * 40, t);
o.frequency.exponentialRampToValueAtTime(50, t + 0.8);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.04, t + 0.05);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
o.connect(g).connect(audioCtx.destination);
o.start(t); o.stop(t + 1);
}
setInterval(() => { if (state === 'playing' && Math.random() < 0.4) playGroan(); }, 3000);
</script>
</body>
</html>Game Source: Ethan: Last Light — 3D
Creator: BlazeMaker30
Libraries: three
Complexity: complex (1139 lines, 39.2 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: ethan-last-light-3d-blazemaker30" to link back to the original. Then publish at arcadelab.ai/publish.