๐ŸŽฎArcadeLab

Subway Dash 3D

by LunarGalaxy87
723 lines25.1 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.0, viewport-fit=cover">
<title>Subway Dash 3D</title>
<style>
  html,body{margin:0;padding:0;overflow:hidden;background:#0a0f1e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;touch-action:none;}
  canvas{display:block;touch-action:none;}
  #hud{position:fixed;top:0;left:0;width:100%;padding:14px 18px;box-sizing:border-box;display:flex;justify-content:space-between;align-items:flex-start;pointer-events:none;z-index:10;}
  #score{color:#fff;font-size:28px;font-weight:800;text-shadow:0 2px 8px rgba(0,0,0,.6);letter-spacing:1px;}
  #coins{color:#ffd54a;font-size:20px;font-weight:700;text-shadow:0 2px 8px rgba(0,0,0,.6);margin-top:4px;}
  #fps{color:#7effc0;font-size:13px;font-weight:600;text-shadow:0 2px 6px rgba(0,0,0,.6);}
  #speedbar{position:fixed;top:14px;left:50%;transform:translateX(-50%);color:#9fd8ff;font-size:12px;font-weight:600;text-shadow:0 2px 6px rgba(0,0,0,.6);z-index:10;}
  #startScreen,#gameOverScreen{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:radial-gradient(ellipse at 50% 30%,rgba(30,40,70,.95),rgba(5,8,18,.98));z-index:20;color:#fff;text-align:center;padding:20px;box-sizing:border-box;}
  #startScreen h1,#gameOverScreen h1{font-size:42px;margin:0 0 6px;background:linear-gradient(90deg,#ffd54a,#ff6a6a,#7effc0);-webkit-background-clip:text;background-clip:text;color:transparent;font-weight:900;letter-spacing:1px;}
  #startScreen p,#gameOverScreen p{color:#b9c6e0;font-size:15px;margin:4px 0;max-width:320px;}
  .btn{margin-top:22px;padding:16px 44px;font-size:19px;font-weight:800;color:#0a0f1e;background:linear-gradient(135deg,#ffd54a,#ffb84a);border:none;border-radius:40px;box-shadow:0 8px 24px rgba(255,180,50,.35);cursor:pointer;letter-spacing:.5px;}
  .btn:active{transform:scale(.96);}
  #finalScore{font-size:52px;font-weight:900;color:#ffd54a;margin:10px 0;}
  #controls{position:fixed;bottom:0;left:0;width:100%;display:flex;z-index:10;}
  .zone{flex:1;height:220px;}
  .hidden{display:none !important;}
  #swipeHint{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,.55);font-size:12px;z-index:10;pointer-events:none;letter-spacing:.5px;}
</style>
</head>
<body>

<div id="hud">
  <div>
    <div id="score">0 m</div>
    <div id="coins">๐Ÿช™ 0</div>
  </div>
  <div id="fps">120 fps</div>
</div>
<div id="swipeHint">Swipe left/right to switch lanes ยท up to jump ยท down to slide</div>
<div id="controls">
  <div class="zone" id="zoneL"></div>
  <div class="zone" id="zoneM"></div>
  <div class="zone" id="zoneR"></div>
</div>

<div id="startScreen">
  <h1>SUBWAY DASH 3D</h1>
  <p>Sprint down the tracks, dodge trains and barriers, and grab every coin you can. Swipe or use arrow keys.</p>
  <button class="btn" id="startBtn">START RUN</button>
</div>

<div id="gameOverScreen" class="hidden">
  <h1>CRASHED</h1>
  <div id="finalScore">0 m</div>
  <p id="finalCoins">๐Ÿช™ 0 collected</p>
  <button class="btn" id="retryBtn">RUN AGAIN</button>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
"use strict";

// ---------- Core setup ----------
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x9fcfff, 0.018);

const camera = new THREE.PerspectiveCamera(62, window.innerWidth/window.innerHeight, 0.1, 500);
const renderer = new THREE.WebGLRenderer({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.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);

window.addEventListener('resize', ()=>{
  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// ---------- Sky / lighting ----------
const skyGeo = new THREE.SphereGeometry(400, 24, 16);
const skyMat = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  uniforms:{
    top:{value:new THREE.Color(0x4fa8ff)},
    bottom:{value:new THREE.Color(0xdcefff)}
  },
  vertexShader:`varying vec3 vPos; void main(){ vPos=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
  fragmentShader:`varying vec3 vPos; uniform vec3 top; uniform vec3 bottom; void main(){ float h=normalize(vPos).y*0.5+0.5; gl_FragColor=vec4(mix(bottom,top,h),1.0);}`
});
scene.add(new THREE.Mesh(skyGeo, skyMat));

const hemi = new THREE.HemisphereLight(0xbfe0ff, 0x3a2d20, 0.65);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.15);
sun.position.set(-10, 22, -8);
sun.castShadow = true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.left=-18; sun.shadow.camera.right=18; sun.shadow.camera.top=18; sun.shadow.camera.bottom=-18;
sun.shadow.camera.near=1; sun.shadow.camera.far=60;
sun.shadow.bias=-0.0015;
scene.add(sun);
sun.target.position.set(0,0,-10);
scene.add(sun.target);

// ---------- Materials ----------
const skinMat = new THREE.MeshStandardMaterial({color:0xd9a679, roughness:0.65, metalness:0.02});
const shirtMat = new THREE.MeshStandardMaterial({color:0xe0433e, roughness:0.55, metalness:0.05});
const pantsMat = new THREE.MeshStandardMaterial({color:0x2c3550, roughness:0.6});
const shoeMat = new THREE.MeshStandardMaterial({color:0x1c1c1c, roughness:0.4});
const hairMat = new THREE.MeshStandardMaterial({color:0x3b2a20, roughness:0.7});

// r128 has no CapsuleGeometry (added in r142+) โ€” build an equivalent from a cylinder + two end caps
function makeCapsule(radius, length, mat){
  const g = new THREE.Group();
  const cyl = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 8), mat);
  cyl.castShadow = true;
  g.add(cyl);
  const capTop = new THREE.Mesh(new THREE.SphereGeometry(radius, 8, 6, 0, Math.PI*2, 0, Math.PI/2), mat);
  capTop.position.y = length/2;
  capTop.castShadow = true;
  g.add(capTop);
  const capBottom = new THREE.Mesh(new THREE.SphereGeometry(radius, 8, 6, 0, Math.PI*2, 0, Math.PI/2), mat);
  capBottom.position.y = -length/2;
  capBottom.rotation.x = Math.PI;
  capBottom.castShadow = true;
  g.add(capBottom);
  return g;
}

// ---------- Build realistic-proportioned jointed character ----------
function buildCharacter(){
  const g = new THREE.Group();
  const rig = {};

  // Hips pivot (root for legs+spine)
  const hips = new THREE.Group();
  hips.position.y = 1.0;
  g.add(hips);
  rig.hips = hips;

  // Torso (tapered via two stacked boxes for a human-like ribcage/waist)
  const torso = new THREE.Group();
  torso.position.y = 0.02;
  hips.add(torso);
  const waist = new THREE.Mesh(new THREE.CylinderGeometry(0.145,0.165,0.28,10), pantsMat);
  waist.position.y = 0.16;
  waist.castShadow = true;
  torso.add(waist);
  const chest = new THREE.Mesh(new THREE.CylinderGeometry(0.185,0.155,0.42,10), shirtMat);
  chest.position.y = 0.5;
  chest.castShadow = true;
  torso.add(chest);
  // shoulders cap
  const shoulders = new THREE.Mesh(new THREE.SphereGeometry(0.2,12,10), shirtMat);
  shoulders.scale.set(1.15,0.6,0.85);
  shoulders.position.y = 0.72;
  shoulders.castShadow = true;
  torso.add(shoulders);

  // Neck + head
  const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.07,0.1,8), skinMat);
  neck.position.y = 0.8;
  torso.add(neck);
  const head = new THREE.Group();
  head.position.y = 0.93;
  torso.add(head);
  const skull = new THREE.Mesh(new THREE.SphereGeometry(0.135,14,12), skinMat);
  skull.scale.set(0.88,1,0.92);
  skull.castShadow = true;
  head.add(skull);
  const jaw = new THREE.Mesh(new THREE.SphereGeometry(0.1,10,8), skinMat);
  jaw.position.set(0,-0.08,0.02);
  jaw.scale.set(0.8,0.65,0.8);
  head.add(jaw);
  const hair = new THREE.Mesh(new THREE.SphereGeometry(0.14,12,10,0,Math.PI*2,0,Math.PI*0.62), hairMat);
  hair.position.y = 0.035;
  head.add(hair);
  // simple face hints
  const noseGeo = new THREE.ConeGeometry(0.018,0.05,6);
  const nose = new THREE.Mesh(noseGeo, skinMat);
  nose.rotation.x = Math.PI/2.3;
  nose.position.set(0,-0.01,0.13);
  head.add(nose);

  // Arm builder (upper+lower with elbow joint, shoulder joint pivot)
  function buildArm(sign){
    const shoulder = new THREE.Group();
    shoulder.position.set(sign*0.225, 0.68, 0);
    torso.add(shoulder);
    const upperArm = makeCapsule(0.055,0.26,shirtMat);
    upperArm.position.y = -0.15;
    shoulder.add(upperArm);
    const elbow = new THREE.Group();
    elbow.position.y = -0.3;
    shoulder.add(elbow);
    const lowerArm = makeCapsule(0.045,0.24,skinMat);
    lowerArm.position.y = -0.13;
    elbow.add(lowerArm);
    const hand = new THREE.Mesh(new THREE.SphereGeometry(0.05,8,8), skinMat);
    hand.position.y = -0.27;
    elbow.add(hand);
    return {shoulder, elbow};
  }
  const armL = buildArm(1);
  const armR = buildArm(-1);

  // Leg builder (thigh+shin with hip and knee pivot)
  function buildLeg(sign){
    const hipJoint = new THREE.Group();
    hipJoint.position.set(sign*0.11, 0.03, 0);
    hips.add(hipJoint);
    const thigh = makeCapsule(0.075,0.32,pantsMat);
    thigh.position.y = -0.18;
    hipJoint.add(thigh);
    const knee = new THREE.Group();
    knee.position.y = -0.36;
    hipJoint.add(knee);
    const shin = makeCapsule(0.06,0.3,pantsMat);
    shin.position.y = -0.16;
    knee.add(shin);
    const foot = new THREE.Mesh(new THREE.BoxGeometry(0.09,0.06,0.2), shoeMat);
    foot.position.set(0,-0.33,0.04);
    foot.castShadow = true;
    knee.add(foot);
    return {hipJoint, knee};
  }
  const legL = buildLeg(1);
  const legR = buildLeg(-1);

  rig.torso = torso;
  rig.armL = armL; rig.armR = armR;
  rig.legL = legL; rig.legR = legR;
  rig.head = head;

  return {group:g, rig};
}

const player = buildCharacter();
player.group.position.set(0, 0, 0);
scene.add(player.group);

// ---------- Lane / track setup ----------
const LANES = [-2.2, 0, 2.2];
let laneIndex = 1;
let targetX = 0;

const trackGroup = new THREE.Group();
scene.add(trackGroup);

const groundMat = new THREE.MeshStandardMaterial({color:0x4a4f57, roughness:0.95});
const railMat = new THREE.MeshStandardMaterial({color:0x9a9a9a, roughness:0.4, metalness:0.6});
const tieMat = new THREE.MeshStandardMaterial({color:0x3a2c22, roughness:0.9});
const sideMat = new THREE.MeshStandardMaterial({color:0x6b7280, roughness:0.85});

const SEGMENT_LEN = 20;
const segments = [];
function buildSegment(zPos){
  const seg = new THREE.Group();
  seg.position.z = zPos;

  const floor = new THREE.Mesh(new THREE.BoxGeometry(8, 0.4, SEGMENT_LEN), groundMat);
  floor.position.y = -0.2;
  floor.receiveShadow = true;
  seg.add(floor);

  const sideL = new THREE.Mesh(new THREE.BoxGeometry(0.6,1.4,SEGMENT_LEN), sideMat);
  sideL.position.set(-4.3,0.5,0);
  sideL.receiveShadow = true; sideL.castShadow = true;
  seg.add(sideL);
  const sideR = sideL.clone();
  sideR.position.x = 4.3;
  seg.add(sideR);

  for(let t=-SEGMENT_LEN/2; t<SEGMENT_LEN/2; t+=1){
    const tie = new THREE.Mesh(new THREE.BoxGeometry(2.6,0.08,0.5), tieMat);
    tie.position.set(0,-0.001,t);
    tie.receiveShadow = true;
    seg.add(tie);
  }
  LANES.forEach(lx=>{
    [-0.35,0.35].forEach(off=>{
      const rail = new THREE.Mesh(new THREE.BoxGeometry(0.07,0.07,SEGMENT_LEN), railMat);
      rail.position.set(lx+off, 0.03, 0);
      rail.castShadow = true;
      seg.add(rail);
    });
  });

  trackGroup.add(seg);
  segments.push(seg);
}
for(let i=0;i<6;i++) buildSegment(-i*SEGMENT_LEN + SEGMENT_LEN/2);

// Buildings for atmosphere
const buildingMats = [0x556b8f,0x6b5a7a,0x4a7a6b,0x8a6b4a].map(c=>new THREE.MeshStandardMaterial({color:c, roughness:0.8}));
const buildings = [];
function spawnBuilding(z, side){
  const w = 3+Math.random()*3, h = 5+Math.random()*14, d = 3+Math.random()*3;
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(w,h,d), buildingMats[Math.floor(Math.random()*buildingMats.length)]);
  mesh.position.set(side*(7+Math.random()*6), h/2-0.3, z);
  mesh.castShadow = true; mesh.receiveShadow = true;
  scene.add(mesh);
  buildings.push(mesh);
}
for(let i=0;i<20;i++){
  spawnBuilding(-i*10+10, -1);
  spawnBuilding(-i*10+15, 1);
}

// ---------- Obstacles / coins / trains ----------
const obstacles = [];
const coins = [];

const barrierMat = new THREE.MeshStandardMaterial({color:0xffb020, roughness:0.5, metalness:0.3});
const trainMat = new THREE.MeshStandardMaterial({color:0x2f7bd6, roughness:0.35, metalness:0.5});
const trainMat2 = new THREE.MeshStandardMaterial({color:0xe8e8ee, roughness:0.5});
const coinMat = new THREE.MeshStandardMaterial({color:0xffd54a, roughness:0.25, metalness:0.85, emissive:0x664400, emissiveIntensity:0.3});
const coinGeo = new THREE.CylinderGeometry(0.22,0.22,0.06,16);

function makeLowBarrier(lane){
  const m = new THREE.Mesh(new THREE.BoxGeometry(1.6,0.7,0.35), barrierMat);
  m.position.set(LANES[lane], 0.35, 0);
  m.castShadow = true;
  return {mesh:m, type:'jump', lane};
}
function makeHighBarrier(lane){
  const g = new THREE.Group();
  const bar = new THREE.Mesh(new THREE.BoxGeometry(1.6,0.18,0.3), barrierMat);
  bar.position.y = 1.15;
  bar.castShadow = true;
  g.add(bar);
  const postL = new THREE.Mesh(new THREE.BoxGeometry(0.1,1.15,0.1), barrierMat);
  postL.position.set(-0.65,0.575,0);
  const postR = postL.clone(); postR.position.x = 0.65;
  g.add(postL, postR);
  g.position.set(LANES[lane], 0, 0);
  return {mesh:g, type:'duck', lane};
}
function makeTrain(lane){
  const g = new THREE.Group();
  const body = new THREE.Mesh(new THREE.BoxGeometry(1.9,1.9,5.5), trainMat);
  body.position.y = 0.95;
  body.castShadow = true;
  g.add(body);
  const stripe = new THREE.Mesh(new THREE.BoxGeometry(1.95,0.35,5.55), trainMat2);
  stripe.position.y = 0.5;
  g.add(stripe);
  for(let i=-2;i<=2;i++){
    const win = new THREE.Mesh(new THREE.BoxGeometry(0.05,0.5,0.7), new THREE.MeshStandardMaterial({color:0xbfe6ff, roughness:0.2, metalness:0.3}));
    win.position.set(0.96,1.15,i*0.9);
    g.add(win);
    const win2 = win.clone(); win2.position.x = -0.96;
    g.add(win2);
  }
  g.position.set(LANES[lane], 0, 0);
  return {mesh:g, type:'block', lane};
}

function spawnObstacleSet(z){
  const r = Math.random();
  const lane = Math.floor(Math.random()*3);
  if(r < 0.35){
    const o = makeLowBarrier(lane);
    o.mesh.position.z = z;
    trackGroup.add(o.mesh);
    o.z = z;
    obstacles.push(o);
  } else if(r < 0.6){
    const o = makeHighBarrier(lane);
    o.mesh.position.z = z;
    trackGroup.add(o.mesh);
    o.z = z;
    obstacles.push(o);
  } else {
    const takenLanes = new Set();
    const numTrains = Math.random()<0.4 ? 2 : 1;
    for(let n=0;n<numTrains;n++){
      let l = Math.floor(Math.random()*3);
      let tries=0;
      while(takenLanes.has(l) && tries<5){ l=Math.floor(Math.random()*3); tries++; }
      takenLanes.add(l);
      const o = makeTrain(l);
      o.mesh.position.z = z;
      trackGroup.add(o.mesh);
      o.z = z;
      obstacles.push(o);
    }
  }
  // coins in a free lane
  if(Math.random() < 0.75){
    const freeLanes = [0,1,2];
    const coinLane = freeLanes[Math.floor(Math.random()*3)];
    const count = 4;
    for(let i=0;i<count;i++){
      const c = new THREE.Mesh(coinGeo, coinMat);
      c.rotation.x = Math.PI/2;
      c.position.set(LANES[coinLane], 0.9, z - i*1.1 - 2);
      c.castShadow = true;
      trackGroup.add(c);
      coins.push({mesh:c, z:c.position.z, taken:false});
    }
  }
}

let nextSpawnZ = -25;
function ensureSpawns(playerZ){
  while(nextSpawnZ > playerZ - 140){
    spawnObstacleSet(nextSpawnZ);
    nextSpawnZ -= 12 + Math.random()*6;
  }
}
ensureSpawns(0);

// ---------- Input ----------
let touchStartX=0, touchStartY=0, touchActive=false;
function onDown(x,y){ touchStartX=x; touchStartY=y; touchActive=true; }
function onUp(x,y){
  if(!touchActive) return;
  touchActive=false;
  const dx = x-touchStartX, dy = y-touchStartY;
  if(Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 30){
    if(dx>0) changeLane(1); else changeLane(-1);
  } else if(Math.abs(dy) > 30){
    if(dy<0) doJump(); else doSlide();
  }
}
document.getElementById('controls').addEventListener('touchstart', e=>{
  const t=e.touches[0]; onDown(t.clientX,t.clientY);
},{passive:true});
document.getElementById('controls').addEventListener('touchend', e=>{
  const t=e.changedTouches[0]; onUp(t.clientX,t.clientY);
},{passive:true});
document.addEventListener('mousedown', e=>{ if(gameState!=='playing') return; onDown(e.clientX,e.clientY); });
document.addEventListener('mouseup', e=>{ if(gameState!=='playing') return; onUp(e.clientX,e.clientY); });

window.addEventListener('keydown', e=>{
  if(gameState!=='playing') return;
  if(e.key==='ArrowLeft'||e.key==='a') changeLane(-1);
  else if(e.key==='ArrowRight'||e.key==='d') changeLane(1);
  else if(e.key==='ArrowUp'||e.key==='w'||e.key===' ') doJump();
  else if(e.key==='ArrowDown'||e.key==='s') doSlide();
});

function changeLane(dir){
  laneIndex = Math.max(0, Math.min(2, laneIndex+dir));
  targetX = LANES[laneIndex];
}

// ---------- Player physics state ----------
let velY = 0;
let isJumping = false;
let isSliding = false;
let slideTimer = 0;
const GRAVITY = -28;
const JUMP_V = 9.5;
const GROUND_Y = 0;

function doJump(){
  if(!isJumping && !isSliding){
    isJumping = true;
    velY = JUMP_V;
  }
}
function doSlide(){
  if(!isJumping && !isSliding){
    isSliding = true;
    slideTimer = 0.55;
  }
}

// ---------- Game state ----------
let gameState = 'menu'; // menu, playing, over
let distance = 0;
let coinCount = 0;
let baseSpeed = 9;
let speed = baseSpeed;
let runTime = 0;
let gameOverAnim = 0;

const scoreEl = document.getElementById('score');
const coinsEl = document.getElementById('coins');
const fpsEl = document.getElementById('fps');
const startScreen = document.getElementById('startScreen');
const gameOverScreen = document.getElementById('gameOverScreen');
const finalScoreEl = document.getElementById('finalScore');
const finalCoinsEl = document.getElementById('finalCoins');

document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('retryBtn').addEventListener('click', startGame);

function resetWorld(){
  laneIndex = 1; targetX = 0;
  velY = 0; isJumping=false; isSliding=false; slideTimer=0;
  distance = 0; coinCount = 0; speed = baseSpeed; runTime = 0;
  player.group.position.set(0,0,0);
  player.group.rotation.z = 0;
  player.rig.torso.rotation.x = 0;

  obstacles.forEach(o=>trackGroup.remove(o.mesh));
  obstacles.length = 0;
  coins.forEach(c=>trackGroup.remove(c.mesh));
  coins.length = 0;
  nextSpawnZ = -25;
  ensureSpawns(0);
}

function startGame(){
  resetWorld();
  gameState = 'playing';
  startScreen.classList.add('hidden');
  gameOverScreen.classList.add('hidden');
}

function endGame(){
  gameState = 'over';
  finalScoreEl.textContent = Math.floor(distance) + ' m';
  finalCoinsEl.textContent = '๐Ÿช™ ' + coinCount + ' collected';
  setTimeout(()=> gameOverScreen.classList.remove('hidden'), 500);
}

// ---------- Camera rig ----------
camera.position.set(0, 3.2, 6);
let camShake = 0;

// ---------- Collision ----------
function checkCollisions(){
  const pz = player.group.position.z;
  for(let i=obstacles.length-1;i>=0;i--){
    const o = obstacles[i];
    if(o.mesh.position.z - pz > -1.4 && o.mesh.position.z - pz < 1.0){
      const dx = Math.abs(player.group.position.x - LANES[o.lane]);
      if(dx < 0.9){
        if(o.type==='jump'){
          if(!isJumping || player.group.position.y < 0.55){ crash(); return; }
        } else if(o.type==='duck'){
          if(!isSliding){ crash(); return; }
        } else if(o.type==='block'){
          crash(); return;
        }
      }
    }
  }
  for(let i=coins.length-1;i>=0;i--){
    const c = coins[i];
    if(c.taken) continue;
    const dz = c.mesh.position.z - pz;
    if(dz > -1.0 && dz < 1.0){
      const dx = Math.abs(player.group.position.x - c.mesh.position.x);
      if(dx < 0.7){
        c.taken = true;
        c.mesh.visible = false;
        coinCount++;
        coinsEl.textContent = '๐Ÿช™ ' + coinCount;
      }
    }
  }
}

function crash(){
  if(gameState!=='playing') return;
  gameState = 'crashing';
  camShake = 1;
  setTimeout(()=> endGame(), 550);
}

// ---------- Animation loop ----------
let lastTime = performance.now();
let fpsAccum = 0, fpsFrames = 0, fpsLast = performance.now();
let runCycle = 0;

function animate(now){
  requestAnimationFrame(animate);
  let dt = (now - lastTime) / 1000;
  lastTime = now;
  dt = Math.min(dt, 0.05);

  fpsFrames++;
  if(now - fpsLast > 500){
    fpsEl.textContent = Math.round(fpsFrames / ((now-fpsLast)/1000)) + ' fps';
    fpsFrames = 0; fpsLast = now;
  }

  if(gameState==='playing' || gameState==='crashing'){
    runTime += dt;
    if(gameState==='crashing'){
      // keep falling under gravity instead of freezing mid-air, and topple over
      if(player.group.position.y > GROUND_Y){
        velY += GRAVITY*dt;
        player.group.position.y += velY*dt;
        if(player.group.position.y <= GROUND_Y){
          player.group.position.y = GROUND_Y;
          velY = 0;
        }
      }
      player.rig.torso.rotation.x += dt*4;
      if(player.rig.torso.rotation.x > 1.4) player.rig.torso.rotation.x = 1.4;
      player.group.rotation.z += dt*2.2;
    }
    if(gameState==='playing'){
      speed = baseSpeed + Math.min(runTime*0.15, 9);
      distance += speed*dt;
      player.group.position.z -= speed*dt;
      scoreEl.textContent = Math.floor(distance) + ' m';

      // lane movement (smooth)
      player.group.position.x += (targetX - player.group.position.x) * Math.min(1, dt*12);

      // jump physics
      if(isJumping){
        velY += GRAVITY*dt;
        player.group.position.y += velY*dt;
        if(player.group.position.y <= GROUND_Y){
          player.group.position.y = GROUND_Y;
          isJumping = false; velY = 0;
        }
      }
      // slide timer
      if(isSliding){
        slideTimer -= dt;
        if(slideTimer<=0) isSliding=false;
      }

      checkCollisions();
      ensureSpawns(player.group.position.z);

      // cull passed obstacles/coins
      const pz = player.group.position.z;
      for(let i=obstacles.length-1;i>=0;i--){
        if(obstacles[i].mesh.position.z - pz > 8){
          trackGroup.remove(obstacles[i].mesh);
          obstacles.splice(i,1);
        }
      }
      for(let i=coins.length-1;i>=0;i--){
        if(coins[i].mesh.position.z - pz > 8){
          trackGroup.remove(coins[i].mesh);
          coins.splice(i,1);
        }
      }

      // recycle track segments
      segments.forEach(seg=>{
        if(seg.position.z - pz > SEGMENT_LEN){
          seg.position.z -= SEGMENT_LEN*segments.length;
        }
      });
      // recycle buildings
      buildings.forEach(b=>{
        if(b.position.z - pz > 15){
          b.position.z -= 200;
        }
      });
    }

    // ---- run animation (cycle legs/arms) ----
    const moving = gameState==='playing';
    if(moving && !isSliding){
      runCycle += dt * (7 + speed*0.35);
    }
    const cyc = runCycle;
    const swing = isSliding ? 0 : Math.sin(cyc)*0.65;
    const swingOpp = isSliding ? 0 : Math.sin(cyc+Math.PI)*0.65;

    if(isSliding){
      player.rig.torso.rotation.x = 1.05;
      player.rig.hips.position.y = 0.55;
      player.rig.legL.hipJoint.rotation.x = -0.3;
      player.rig.legR.hipJoint.rotation.x = 0.9;
      player.rig.legL.knee.rotation.x = 0.2;
      player.rig.legR.knee.rotation.x = 1.1;
      player.rig.armL.shoulder.rotation.x = -1.2;
      player.rig.armR.shoulder.rotation.x = -1.2;
    } else if(isJumping){
      player.rig.torso.rotation.x = 0.12;
      player.rig.hips.position.y = 1.0;
      player.rig.legL.hipJoint.rotation.x = 0.7;
      player.rig.legR.hipJoint.rotation.x = -0.4;
      player.rig.legL.knee.rotation.x = 1.2;
      player.rig.legR.knee.rotation.x = 0.3;
      player.rig.armL.shoulder.rotation.x = -0.9;
      player.rig.armR.shoulder.rotation.x = 0.6;
    } else {
      player.rig.torso.rotation.x = 0.08;
      player.rig.hips.position.y = 1.0 + Math.abs(Math.sin(cyc*2))*0.03;
      player.rig.legL.hipJoint.rotation.x = swing;
      player.rig.legR.hipJoint.rotation.x = swingOpp;
      player.rig.legL.knee.rotation.x = Math.max(0, -swing*1.3) + 0.15;
      player.rig.legR.knee.rotation.x = Math.max(0, -swingOpp*1.3) + 0.15;
      player.rig.armL.shoulder.rotation.x = swingOpp*0.8;
      player.rig.armR.shoulder.rotation.x = swing*0.8;
      player.rig.armL.elbow.rotation.x = Math.max(0,swingOpp)*0.6+0.2;
      player.rig.armR.elbow.rotation.x = Math.max(0,swing)*0.6+0.2;
    }

    // coin spin
    coins.forEach(c=>{ if(!c.taken) c.mesh.rotation.z += dt*3; });

    // camera follow
    const pz2 = player.group.position.z;
    let camShakeX=0, camShakeY=0;
    if(camShake>0){
      camShake -= dt*2;
      camShakeX = (Math.random()-0.5)*0.3*Math.max(0,camShake);
      camShakeY = (Math.random()-0.5)*0.2*Math.max(0,camShake);
    }
    camera.position.x += ((player.group.position.x*0.5)+camShakeX - camera.position.x)*Math.min(1,dt*5);
    camera.position.y += (3.2 + player.group.position.y*0.3 + camShakeY - camera.position.y)*Math.min(1,dt*5);
    camera.position.z = pz2 + 6;
    camera.lookAt(player.group.position.x*0.5, 1.1+player.group.position.y*0.3, pz2-4);

    player.group.rotation.y = (targetX - player.group.position.x)*-0.15;
  }

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

})();
</script>
</body>
</html>

Game Source: Subway Dash 3D

Creator: LunarGalaxy87

Libraries: three

Complexity: complex (723 lines, 25.1 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: subway-dash-3d-lunargalaxy87" to link back to the original. Then publish at arcadelab.ai/publish.