🎮ArcadeLab

Ethan: Last Light — 3D

by BlazeMaker30
1139 lines39.2 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, 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.