🎮ArcadeLab

METRO VELOCITY

by CyberFalcon70
1608 lines38.4 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,user-scalable=no">
<title>METRO VELOCITY</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#08111b;font-family:Arial,Helvetica,sans-serif}
body{touch-action:none}
#game{position:fixed;inset:0;width:100%;height:100%;display:block}
#hud{position:fixed;inset:0;pointer-events:none;color:#fff}
.top{
  position:absolute;top:14px;left:14px;right:14px;
  display:flex;justify-content:space-between;align-items:flex-start
}
.stats{display:flex;gap:8px;flex-wrap:wrap}
.stat{
  min-width:95px;padding:8px 13px;border-radius:12px;
  background:rgba(7,15,25,.62);border:1px solid rgba(255,255,255,.15);
  backdrop-filter:blur(8px);box-shadow:0 5px 20px #0005;
  font-weight:bold;text-shadow:0 2px 3px #000
}
#actions{display:flex;gap:7px;pointer-events:auto}
.uiButton{
  border:1px solid #ffffff33;border-radius:12px;background:#101c2bdb;
  color:white;padding:10px 14px;font-size:16px;font-weight:bold;cursor:pointer
}
.uiButton:hover{background:#19304a}
#powerStatus{
  position:absolute;top:77px;left:50%;transform:translateX(-50%);
  padding:7px 14px;border-radius:20px;background:#071321b8;
  font-weight:bold;text-shadow:0 2px 3px #000
}
#mobile{
  position:absolute;left:0;right:0;bottom:15px;
  display:none;justify-content:space-between;padding:0 18px;
  pointer-events:auto
}
.pad{display:flex;gap:8px}
.mobileBtn{
  width:58px;height:58px;border-radius:50%;
  border:1px solid #ffffff40;background:#071321b5;color:#fff;
  font-size:23px;font-weight:bold;backdrop-filter:blur(5px)
}
#overlay{
  position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
  background:linear-gradient(#02081288,#020812cc);
  backdrop-filter:blur(7px);z-index:20
}
.panel{
  width:min(92vw,540px);padding:34px;border-radius:25px;text-align:center;
  background:linear-gradient(145deg,#132538f5,#07111ef7);
  border:1px solid #ffffff25;box-shadow:0 25px 80px #000b
}
.panel h1{
  margin:0;font-size:48px;letter-spacing:4px;
  background:linear-gradient(90deg,#64d9ff,#a98cff);
  color:transparent;background-clip:text;-webkit-background-clip:text
}
.panel h2{margin:0 0 10px;font-size:30px}
.sub{color:#9fb4c9;margin:8px 0 25px}
.mainBtn{
  width:100%;padding:15px;border:0;border-radius:13px;
  background:linear-gradient(90deg,#168dff,#805cff);
  color:white;font-size:19px;font-weight:bold;cursor:pointer
}
.mainBtn:hover{filter:brightness(1.15)}
.menuRow{display:flex;gap:10px;margin-top:10px}
.menuRow button{flex:1}
.controls{
  margin-top:23px;color:#b8c8d9;line-height:1.8;font-size:14px
}
.hidden{display:none!important}
.setting{
  display:flex;justify-content:space-between;align-items:center;
  padding:12px 0;border-bottom:1px solid #ffffff15;color:#d8e4ef
}
select{
  background:#101c2b;color:white;border:1px solid #ffffff30;
  border-radius:8px;padding:7px
}
#loading{
  position:fixed;inset:0;background:#06101b;display:flex;
  align-items:center;justify-content:center;color:white;z-index:50;
  font-size:18px
}
@media(max-width:700px){
  .stat{min-width:auto;padding:7px 9px;font-size:13px}
  #actions .uiButton{padding:9px}
  #mobile{display:flex}
  .panel{padding:25px}
  .panel h1{font-size:37px}
}
</style>
</head>
<body>

<div id="loading">Loading Metro Velocity...</div>
<canvas id="game"></canvas>

<div id="hud">
  <div class="top">
    <div class="stats">
      <div class="stat">SCORE <span id="score">0</span></div>
      <div class="stat">DIST <span id="distance">0</span> m</div>
      <div class="stat">COINS <span id="coins">0</span></div>
      <div class="stat">BEST <span id="best">0</span></div>
    </div>
    <div id="actions">
      <button class="uiButton" id="pauseBtn">Ⅱ</button>
      <button class="uiButton" id="soundBtn">🔊</button>
      <button class="uiButton" id="settingsBtn">⚙</button>
    </div>
  </div>
  <div id="powerStatus"></div>

  <div id="mobile">
    <div class="pad">
      <button class="mobileBtn" id="leftBtn">←</button>
      <button class="mobileBtn" id="rightBtn">→</button>
    </div>
    <div class="pad">
      <button class="mobileBtn" id="slideBtn">↓</button>
      <button class="mobileBtn" id="jumpBtn">↑</button>
    </div>
  </div>
</div>

<div id="overlay">
  <div class="panel" id="mainPanel">
    <h1>METRO VELOCITY</h1>
    <p class="sub">A high-speed run through the city.</p>
    <p>BEST RUN: <b id="menuBest">0</b></p>
    <button class="mainBtn" id="playBtn">START RUN</button>
    <div class="controls">
      <b>Keyboard</b><br>
      A / D or ← / → — Change lane<br>
      W / Space / ↑ — Jump<br>
      S / ↓ — Slide<br>
      P — Pause
    </div>
  </div>

  <div class="panel hidden" id="pausePanel">
    <h2>PAUSED</h2>
    <p class="sub">The city is waiting.</p>
    <button class="mainBtn" id="resumeBtn">RESUME</button>
  </div>

  <div class="panel hidden" id="overPanel">
    <h2>RUN COMPLETE</h2>
    <p class="sub">You made it this far.</p>
    <p>SCORE: <b id="finalScore">0</b></p>
    <p>DISTANCE: <b id="finalDistance">0</b> m</p>
    <p>COINS: <b id="finalCoins">0</b></p>
    <p>BEST: <b id="finalBest">0</b></p>
    <button class="mainBtn" id="restartBtn">RUN AGAIN</button>
  </div>

  <div class="panel hidden" id="settingsPanel">
    <h2>SETTINGS</h2>
    <div class="setting">
      <span>Graphics Quality</span>
      <select id="quality">
        <option value="low">Low</option>
        <option value="medium" selected>Medium</option>
        <option value="high">High</option>
      </select>
    </div>
    <div class="setting">
      <span>Sound</span>
      <button class="uiButton" id="settingsSound">ON</button>
    </div>
    <br>
    <button class="mainBtn" id="closeSettings">DONE</button>
  </div>
</div>

<script type="module">
import * as THREE from
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js";

/* =========================================================
   METRO VELOCITY
   Complete procedural 3D endless runner
   ========================================================= */

const canvas=document.getElementById("game");

let renderer;
try{
  renderer=new THREE.WebGLRenderer({
    canvas,
    antialias:true,
    powerPreference:"high-performance"
  });
}catch(e){
  document.getElementById("loading").textContent="WebGL is not supported by this browser.";
  throw e;
}

renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.outputColorSpace=THREE.SRGBColorSpace;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.15;

const scene=new THREE.Scene();
scene.fog=new THREE.FogExp2(0x8295a8,.006);

const camera=new THREE.PerspectiveCamera(
  63,innerWidth/innerHeight,.1,650
);
camera.position.set(0,5.2,10);

const clock=new THREE.Clock();

let quality="medium";
let soundOn=true;
let state="menu";

let score=0;
let distance=0;
let coins=0;
let highScore=Number(localStorage.getItem("metroVelocityHigh")||0);

let speed=18;
let baseSpeed=18;
let elapsed=0;
let spawnDistance=0;
let sceneryDistance=0;

let lane=0;
let targetLane=0;

let jumpY=0;
let verticalVelocity=0;
let slideTimer=0;
let wasGrounded=true;

let magnetTimer=0;
let shieldTimer=0;
let boostTimer=0;

let shake=0;
let environmentTime=0;

const laneWidth=3.2;
const trackWidth=11;
const segmentLength=24;

const objects=[];
const activeObjects=[];
const activeScenery=[];
const particles=[];

const materials={};

function mat(color,roughness=.7,metalness=.05){
  return new THREE.MeshStandardMaterial({
    color,
    roughness,
    metalness
  });
}

materials.asphalt=mat(0x252a30,.88);
materials.sidewalk=mat(0x777b7d,.92);
materials.roadLine=mat(0xdce5e8,.5);
materials.concrete=mat(0x8c9093,.9);
materials.glass=mat(0x25465a,.2,.55);
materials.darkGlass=mat(0x111c25,.18,.7);
materials.tree=mat(0x1c542d,.9);
materials.trunk=mat(0x503b2a,.95);
materials.metal=mat(0x626a70,.35,.8);
materials.yellow=mat(0xffc928,.45,.25);
materials.red=mat(0xd7353d,.55,.2);
materials.blue=mat(0x2788c7,.5,.2);
materials.white=mat(0xe7e9e6,.5);
materials.orange=mat(0xf47c32,.5,.15);

const white=0xffffff;

const hemi=new THREE.HemisphereLight(0xdceaff,0x26323b,2.1);
scene.add(hemi);

const sun=new THREE.DirectionalLight(0xfff0d0,3.0);
sun.position.set(-70,100,80);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.left=-80;
sun.shadow.camera.right=80;
sun.shadow.camera.top=80;
sun.shadow.camera.bottom=-80;
scene.add(sun);

const ambient=new THREE.AmbientLight(0x506070,.35);
scene.add(ambient);

/* ---------- texture generators ---------- */

function canvasTexture(draw,w=256,h=256){
  const c=document.createElement("canvas");
  c.width=w;c.height=h;
  const x=c.getContext("2d");
  draw(x,w,h);
  const t=new THREE.CanvasTexture(c);
  t.colorSpace=THREE.SRGBColorSpace;
  t.wrapS=t.wrapT=THREE.RepeatWrapping;
  return t;
}

const asphaltTexture=canvasTexture((c,w,h)=>{
  c.fillStyle="#292d31";
  c.fillRect(0,0,w,h);
  for(let i=0;i<1800;i++){
    const v=30+Math.random()*35;
    c.fillStyle=`rgb(${v},${v+2},${v+3})`;
    c.fillRect(Math.random()*w,Math.random()*h,1+Math.random()*2,1+Math.random()*2);
  }
},512,512);
asphaltTexture.repeat.set(2,8);

materials.asphalt.map=asphaltTexture;

const concreteTexture=canvasTexture((c,w,h)=>{
  c.fillStyle="#7b7d7d";
  c.fillRect(0,0,w,h);
  for(let i=0;i<900;i++){
    const v=80+Math.random()*45;
    c.fillStyle=`rgb(${v},${v},${v})`;
    c.fillRect(Math.random()*w,Math.random()*h,1+Math.random()*5,1+Math.random()*2);
  }
},256,256);
materials.sidewalk.map=concreteTexture;

const buildingTexture=canvasTexture((c,w,h)=>{
  const grad=c.createLinearGradient(0,0,w,h);
  grad.addColorStop(0,"#77818a");
  grad.addColorStop(1,"#343d46");
  c.fillStyle=grad;c.fillRect(0,0,w,h);

  for(let y=10;y<h;y+=30){
    for(let x=8;x<w;x+=27){
      const lit=Math.random()>.38;
      c.fillStyle=lit?"#c9d4d2":"#1c2830";
      c.fillRect(x,y,14,18);
      if(lit){
        c.fillStyle="#eef2d0";
        c.fillRect(x+2,y+2,10,14);
      }
    }
  }
},256,512);
buildingTexture.repeat.set(1,1);

const grassTexture=canvasTexture((c,w,h)=>{
  c.fillStyle="#274d2e";c.fillRect(0,0,w,h);
  for(let i=0;i<1600;i++){
    c.fillStyle=Math.random()>.5?"#356438":"#1e4027";
    c.fillRect(Math.random()*w,Math.random()*h,1,3+Math.random()*5);
  }
},256,256);
grassTexture.repeat.set(5,20);

/* ---------- basic geometry helpers ---------- */

function box(w,h,d,material){
  const m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),material);
  m.castShadow=true;m.receiveShadow=true;
  return m;
}

function cyl(r,h,material,segments=12){
  const m=new THREE.Mesh(new THREE.CylinderGeometry(r,r,h,segments),material);
  m.castShadow=true;m.receiveShadow=true;
  return m;
}

function addMesh(parent,m,x,y,z){
  m.position.set(x,y,z);
  parent.add(m);
  return m;
}

/* ---------- player ---------- */

const player=new THREE.Group();
player.position.set(0,0,5);
scene.add(player);

const body=new THREE.Group();
player.add(body);

const skin=mat(0xc88665,.75);
const shirt=mat(0x197bc0,.62);
const pants=mat(0x202b39,.8);
const shoe=mat(0x202124,.65);
const hair=mat(0x202020,.7);

const torso=box(.95,1.35,.55,shirt);
torso.position.y=2.55;
body.add(torso);

const neck=cyl(.16,.22,skin,12);
neck.position.y=3.28;
body.add(neck);

const head=new THREE.Mesh(
  new THREE.SphereGeometry(.43,20,16),
  skin
);
head.position.y=3.72;
head.castShadow=true;
body.add(head);

const hairMesh=new THREE.Mesh(
  new THREE.SphereGeometry(.445,20,12,0,Math.PI*2,0,Math.PI*.55),
  hair
);
hairMesh.position.y=3.82;
hairMesh.scale.set(1,1,.95);
body.add(hairMesh);

const armL=new THREE.Group();
const armR=new THREE.Group();
armL.position.set(-.62,2.9,0);
armR.position.set(.62,2.9,0);
body.add(armL,armR);

const upperArmL=box(.25,.9,.27,shirt);
upperArmL.position.y=-.45;
armL.add(upperArmL);

const upperArmR=upperArmL.clone();
armR.add(upperArmR);

const handL=cyl(.17,.3,skin);
handL.position.y=-.98;
armL.add(handL);

const handR=handL.clone();
armR.add(handR);

const legL=new THREE.Group();
const legR=new THREE.Group();
legL.position.set(-.28,1.9,0);
legR.position.set(.28,1.9,0);
body.add(legL,legR);

const thighL=box(.31,.85,pants);
thighL.position.y=-.43;
legL.add(thighL);
const thighR=thighL.clone();
legR.add(thighR);

const shoeL=box(.38,.24,.75,shoe);
shoeL.position.set(0,-.93,.16);
legL.add(shoeL);
const shoeR=shoeL.clone();
legR.add(shoeR);

const shieldVisual=new THREE.Mesh(
  new THREE.SphereGeometry(2.25,24,16),
  new THREE.MeshBasicMaterial({
    color:0x40c9ff,
    transparent:true,
    opacity:.13,
    wireframe:true
  })
);
shieldVisual.position.y=2.15;
shieldVisual.visible=false;
player.add(shieldVisual);

/* ---------- city ---------- */

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

const roadSegments=[];
const sceneryPool=[];
const segmentCount=30;

function createRoadSegment(){
  const g=new THREE.Group();

  const road=box(trackWidth,.18,segmentLength,materials.asphalt);
  road.position.y=-.12;
  g.add(road);

  const leftSide=box(4,.28,segmentLength,materials.sidewalk);
  leftSide.position.set(-7.5,.02,0);
  g.add(leftSide);

  const rightSide=leftSide.clone();
  rightSide.position.x=7.5;
  g.add(rightSide);

  const grassL=box(12,.15,segmentLength, new THREE.MeshStandardMaterial({
    map:grassTexture,roughness:.95
  }));
  grassL.position.set(-15,-.05,0);
  g.add(grassL);

  const grassR=grassL.clone();
  grassR.position.x=15;
  g.add(grassR);

  for(let l=1;l<3;l++){
    const line=box(.09,.025,segmentLength*.72,materials.roadLine);
    line.position.set((l-1.5)*laneWidth+laneWidth/2,.015,0);
    line.material=materials.roadLine;
    g.add(line);
  }

  return g;
}

for(let i=0;i<segmentCount;i++){
  const s=createRoadSegment();
  s.position.z=-i*segmentLength;
  worldGroup.add(s);
  roadSegments.push(s);
}

/* ---------- city objects ---------- */

function building(){
  const g=new THREE.Group();
  const w=5+Math.random()*9;
  const h=10+Math.random()*30;
  const d=8+Math.random()*12;

  const b=box(w,h,d,new THREE.MeshStandardMaterial({
    map:buildingTexture,
    roughness:.72,
    metalness:.08
  }));
  b.position.y=h/2;
  g.add(b);

  if(Math.random()<.5){
    const roof=box(w+.2,.4,d+.2,materials.concrete);
    roof.position.y=h+.2;
    g.add(roof);
  }

  if(Math.random()<.3){
    const antenna=cyl(.08,4,materials.metal);
    antenna.position.y=h+2;
    g.add(antenna);
  }

  return g;
}

function streetLight(){
  const g=new THREE.Group();
  const pole=cyl(.09,5.3,materials.metal,10);
  pole.position.y=2.65;
  g.add(pole);

  const arm=box(1.4,.09,.09,materials.metal);
  arm.position.set(.65,5.15,0);
  g.add(arm);

  const lamp=new THREE.Mesh(
    new THREE.SphereGeometry(.18,10,8),
    new THREE.MeshStandardMaterial({
      color:0xffe6a0,emissive:0xffb12b,emissiveIntensity:2
    })
  );
  lamp.position.set(1.28,5.05,0);
  g.add(lamp);
  return g;
}

function tree(){
  const g=new THREE.Group();
  const trunk=cyl(.28,2.5,materials.trunk,10);
  trunk.position.y=1.25;
  g.add(trunk);

  const crown=new THREE.Mesh(
    new THREE.IcosahedronGeometry(1.6,1),
    materials.tree
  );
  crown.position.y=3.1;
  crown.scale.set(1.05,1.3,1.05);
  crown.castShadow=true;
  g.add(crown);
  return g;
}

function car(){
  const g=new THREE.Group();

  const colors=[0x233d57,0x9d2833,0xc6a32a,0xe4e7e9,0x242a30];
  const color=colors[Math.floor(Math.random()*colors.length)];

  const base=box(1.75,.5,3.5,mat(color,.38,.25));
  base.position.y=.55;
  g.add(base);

  const cabin=box(1.45,.62,1.65,materials.darkGlass);
  cabin.position.set(0,1.02,.1);
  g.add(cabin);

  const wheelMat=mat(0x151719,.65);
  for(const x of [-.95,.95]){
    for(const z of [-1.05,1.05]){
      const wh=new THREE.Mesh(
        new THREE.CylinderGeometry(.38,.38,.18,16),
        wheelMat
      );
      wh.rotation.z=Math.PI/2;
      wh.position.set(x,.42,z);
      g.add(wh);
    }
  }

  return g;
}

function sign(){
  const g=new THREE.Group();
  const pole=cyl(.06,3.5,materials.metal);
  pole.position.y=1.75;
  g.add(pole);
  const board=box(2.3,1,.12,mat(0x174f72,.55,.2));
  board.position.y=3.2;
  g.add(board);
  return g;
}

function populateScenery(){
  for(let i=0;i<65;i++){
    const z=-20-Math.random()*650;
    const side=Math.random()<.5?-1:1;
    let o;

    const r=Math.random();
    if(r<.42)o=building();
    else if(r<.64)o=tree();
    else if(r<.84)o=streetLight();
    else if(r<.94)o=car();
    else o=sign();

    o.position.z=z;
    o.position.x=side*(10+Math.random()*17);

    if(o.type==="Group"){
      const scale=.7+Math.random()*1.1;
      o.scale.setScalar(scale);
    }

    worldGroup.add(o);
    activeScenery.push(o);
  }
}
populateScenery();

/* ---------- obstacle/pickup creation ---------- */

const pool=[];

function createObject(){
  const g=new THREE.Group();
  g.userData.kind="";
  g.visible=false;
  scene.add(g);
  pool.push(g);
  return g;
}

for(let i=0;i<100;i++)createObject();

function getObject(){
  for(const o of pool){
    if(!o.visible)return o;
  }
  return createObject();
}

function clearGroup(g){
  while(g.children.length)g.remove(g.children[0]);
}

function setupObject(type,laneIndex,z){
  const g=getObject();
  clearGroup(g);
  g.visible=true;
  g.userData.kind=type;
  g.userData.lane=laneIndex;
  g.userData.hit=false;
  g.position.set(laneIndex*laneWidth,z,0);

  if(type==="crate"){
    const c=box(2.1,1.9,2.1,materials.orange);
    c.position.y=.95;
    g.add(c);

    const stripe=box(2.15,.16,2.15,materials.yellow);
    stripe.position.y=1.25;
    g.add(stripe);
  }

  if(type==="barrier"){
    const base=box(2.6,1.15,.65,materials.red);
    base.position.y=.58;
    g.add(base);

    const a=box(.22,1.8,.22,materials.yellow);
    a.position.set(-1.05,1.35,0);
    g.add(a);
    const b=a.clone();
    b.position.x=1.05;
    g.add(b);

    const rail=box(2.4,.18,.75,materials.yellow);
    rail.position.y=1.25;
    g.add(rail);
  }

  if(type==="overhead"){
    const left=box(.28,2.7,.35,materials.metal);
    left.position.set(-1.05,1.35,0);
    g.add(left);
    const right=left.clone();
    right.position.x=1.05;
    g.add(right);

    const top=box(2.5,.3,.5,materials.red);
    top.position.y=2.5;
    g.add(top);
  }

  if(type==="train"){
    const t=box(2.7,3.4,6.2,materials.blue);
    t.position.y=1.7;
    g.add(t);

    const windows=box(2.45,1.25,2.7,materials.darkGlass);
    windows.position.set(0,2.25,-.2);
    g.add(windows);

    const light=box(2.5,.22,.15,materials.yellow);
    light.position.set(0,.9,3.1);
    g.add(light);
  }

  if(type==="coin"){
    const coin=new THREE.Mesh(
      new THREE.CylinderGeometry(.46,.46,.13,20),
      new THREE.MeshStandardMaterial({
        color:0xffd22e,
        metalness:.8,
        roughness:.22,
        emissive:0x6e4200,
        emissiveIntensity:.18
      })
    );
    coin.rotation.x=Math.PI/2;
    coin.position.y=1.7;
    coin.castShadow=true;
    g.add(coin);
    g.userData.spin=coin;
  }

  if(type==="magnet"||type==="shield"||type==="boost"){
    const colors={
      magnet:0xd54dff,
      shield:0x3bc7ff,
      boost:0xff7043
    };
    const orb=new THREE.Mesh(
      new THREE.IcosahedronGeometry(.65,2),
      new THREE.MeshStandardMaterial({
        color:colors[type],
        emissive:colors[type],
        emissiveIntensity:.9,
        roughness:.2,
        metalness:.35
      })
    );
    orb.position.y=1.7;
    g.add(orb);
    g.userData.orb=orb;
  }

  activeObjects.push(g);
  return g;
}

/* ---------- spawning ---------- */

function spawnCoinLine(l,count=5,start=-70,spacing=4){
  for(let i=0;i<count;i++){
    setupObject("coin",l,start-i*spacing);
  }
}

function spawnPattern(){
  const d=Math.min(1,distance/2500);
  const r=Math.random();

  if(r<.24){
    const blocked=Math.floor(Math.random()*3);
    setupObject(Math.random()<.5?"crate":"barrier",blocked,-90);
    const safe=[0,1,2].filter(x=>x!==blocked)[
      Math.floor(Math.random()*2)
    ];
    spawnCoinLine(safe,5,-105,4);
  }
  else if(r<.42){
    const l=Math.floor(Math.random()*3);
    spawnCoinLine(l,7,-90,4);
  }
  else if(r<.58){
    const l=Math.floor(Math.random()*3);
    setupObject("overhead",l,-95);
    spawnCoinLine((l+1)%3,5,-112,5);
  }
  else if(r<.70){
    const l=Math.floor(Math.random()*3);
    setupObject("train",l,-100);
    spawnCoinLine((l+2)%3,6,-115,5);
  }
  else if(r<.83){
    const l=Math.floor(Math.random()*3);
    const powers=["magnet","shield","boost"];
    setupObject(powers[Math.floor(Math.random()*3)],l,-90);
    spawnCoinLine(l,5,-105,4);
  }
  else{
    const safe=Math.floor(Math.random()*3);
    for(let l=0;l<3;l++){
      if(l!==safe){
        setupObject("crate",l,-95);
      }
    }
    spawnCoinLine(safe,8,-110,4);
  }

  if(d>.45&&Math.random()<.35){
    const l=Math.floor(Math.random()*3);
    setupObject("crate",l,-145);
  }
}

/* ---------- particles ---------- */

function particle(x,y,z,color=0xffd83d){
  const p=new THREE.Mesh(
    new THREE.SphereGeometry(.055,6,6),
    new THREE.MeshBasicMaterial({color,transparent:true})
  );
  p.position.set(x,y,z);
  p.userData.life=.55;
  p.userData.v=new THREE.Vector3(
    (Math.random()-.5)*3,
    Math.random()*4+1,
    (Math.random()-.5)*3
  );
  scene.add(p);
  particles.push(p);
}

function burst(x,y,z,color,n=14){
  for(let i=0;i<n;i++)particle(x,y,z,color);
}

/* ---------- audio ---------- */

let audioCtx=null;

function audioInit(){
  if(!soundOn)return;
  if(!audioCtx){
    const AC=window.AudioContext||window.webkitAudioContext;
    if(!AC)return;
    audioCtx=new AC();
  }
  if(audioCtx.state==="suspended")audioCtx.resume();
}

function tone(freq,duration,type="sine",volume=.045){
  if(!soundOn)return;
  audioInit();
  if(!audioCtx)return;

  const o=audioCtx.createOscillator();
  const g=audioCtx.createGain();

  o.type=type;
  o.frequency.setValueAtTime(freq,audioCtx.currentTime);
  g.gain.setValueAtTime(volume,audioCtx.currentTime);
  g.gain.exponentialRampToValueAtTime(
    .001,audioCtx.currentTime+duration
  );

  o.connect(g);
  g.connect(audioCtx.destination);

  o.start();
  o.stop(audioCtx.currentTime+duration);
}

function coinSound(){
  tone(720,.07,"sine",.055);
  setTimeout(()=>tone(1000,.08,"sine",.035),45);
}

function jumpSound(){tone(430,.13,"triangle",.045)}
function powerSound(){
  tone(500,.12,"sine",.05);
  setTimeout(()=>tone(850,.18,"sine",.04),80);
}
function crashSound(){
  tone(90,.35,"sawtooth",.08);
  setTimeout(()=>tone(55,.4,"square",.04),100);
}

/* ---------- game controls ---------- */

function changeLane(dir){
  if(state!=="playing")return;
  targetLane=Math.max(-1,Math.min(1,targetLane+dir));
  tone(dir>0?360:280,.045,"square",.025);
}

function jump(){
  if(state!=="playing")return;
  if(jumpY<=.02&&slideTimer<=0){
    verticalVelocity=12.5;
    wasGrounded=false;
    jumpSound();
  }
}

function slide(){
  if(state!=="playing")return;
  if(jumpY<.08){
    slideTimer=.72;
    tone(180,.08,"triangle",.025);
  }
}

function pause(){
  if(state==="playing"){
    state="paused";
    showPanel("pause");
  }else if(state==="paused"){
    state="playing";
    hidePanels();
    clock.getDelta();
  }
}

addEventListener("keydown",e=>{
  const k=e.key.toLowerCase();

  if(["arrowleft","arrowright","arrowup","arrowdown"," "].includes(k))
    e.preventDefault();

  if(k==="arrowleft"||k==="a")changeLane(-1);
  if(k==="arrowright"||k==="d")changeLane(1);
  if(k==="arrowup"||k==="w"||k===" ")jump();
  if(k==="arrowdown"||k==="s")slide();
  if(k==="p")pause();
});

document.getElementById("leftBtn").onpointerdown=()=>changeLane(-1);
document.getElementById("rightBtn").onpointerdown=()=>changeLane(1);
document.getElementById("jumpBtn").onpointerdown=()=>jump();
document.getElementById("slideBtn").onpointerdown=()=>slide();

let touchX=0,touchY=0;

canvas.addEventListener("pointerdown",e=>{
  if(e.pointerType==="touch"){
    touchX=e.clientX;
    touchY=e.clientY;
  }
});

canvas.addEventListener("pointerup",e=>{
  if(e.pointerType!=="touch")return;

  const dx=e.clientX-touchX;
  const dy=e.clientY-touchY;

  if(Math.max(Math.abs(dx),Math.abs(dy))<30)return;

  if(Math.abs(dx)>Math.abs(dy)){
    changeLane(dx>0?1:-1);
  }else{
    if(dy<0)jump();
    else slide();
  }
});

/* ---------- UI ---------- */

const overlay=document.getElementById("overlay");
const panels=[
  document.getElementById("mainPanel"),
  document.getElementById("pausePanel"),
  document.getElementById("overPanel"),
  document.getElementById("settingsPanel")
];

function showPanel(name){
  overlay.classList.remove("hidden");
  panels.forEach(p=>p.classList.add("hidden"));

  if(name==="menu")document.getElementById("mainPanel").classList.remove("hidden");
  if(name==="pause")document.getElementById("pausePanel").classList.remove("hidden");
  if(name==="over")document.getElementById("overPanel").classList.remove("hidden");
  if(name==="settings")document.getElementById("settingsPanel").classList.remove("hidden");
}

function hidePanels(){
  overlay.classList.add("hidden");
}

document.getElementById("playBtn").onclick=startGame;
document.getElementById("restartBtn").onclick=startGame;
document.getElementById("pauseBtn").onclick=pause;
document.getElementById("resumeBtn").onclick=pause;

document.getElementById("soundBtn").onclick=()=>{
  soundOn=!soundOn;
  document.getElementById("soundBtn").textContent=soundOn?"🔊":"🔇";
  document.getElementById("settingsSound").textContent=soundOn?"ON":"OFF";
  if(soundOn)audioInit();
};

document.getElementById("settingsBtn").onclick=()=>{
  if(state==="playing"){
    state="paused";
  }
  showPanel("settings");
};

document.getElementById("closeSettings").onclick=()=>{
  if(state==="paused"){
    state="playing";
    hidePanels();
  }else{
    showPanel("menu");
  }
};

document.getElementById("settingsSound").onclick=()=>{
  soundOn=!soundOn;
  document.getElementById("settingsSound").textContent=soundOn?"ON":"OFF";
  document.getElementById("soundBtn").textContent=soundOn?"🔊":"🔇";
};

document.getElementById("quality").onchange=e=>{
  quality=e.target.value;
  applyQuality();
};

/* ---------- quality ---------- */

function applyQuality(){
  const high=quality==="high";
  const low=quality==="low";

  renderer.setPixelRatio(
    Math.min(devicePixelRatio,low?1:high?2:1.5)
  );

  renderer.shadowMap.enabled=!low;

  sun.shadow.mapSize.set(
    low?512:high?2048:1024,
    low?512:high?2048:1024
  );

  scene.fog.density=low?.009:quality==="medium"?.006:.0048;
}

/* ---------- game state ---------- */

function resetObjects(){
  for(const o of activeObjects)o.visible=false;
  activeObjects.length=0;
}

function resetScenery(){
  for(const o of activeScenery)worldGroup.remove(o);
  activeScenery.length=0;
  populateScenery();
}

function startGame(){
  audioInit();

  score=0;
  distance=0;
  coins=0;
  elapsed=0;
  spawnDistance=0;
  sceneryDistance=0;

  speed=18;
  baseSpeed=18;

  lane=0;
  targetLane=0;

  jumpY=0;
  verticalVelocity=0;
  slideTimer=0;

  magnetTimer=0;
  shieldTimer=0;
  boostTimer=0;

  shake=0;
  environmentTime=0;

  resetObjects();

  resetScenery();

  player.position.set(0,0,5);
  player.rotation.set(0,0,0);

  for(const p of particles)scene.remove(p);
  particles.length=0;

  state="playing";
  hidePanels();

  tone(300,.1,"triangle",.04);
  tone(500,.12,"triangle",.035);
}

function gameOver(){
  if(state!=="playing")return;

  state="over";
  crashSound();

  const final=Math.floor(score);

  if(final>highScore){
    highScore=final;
    localStorage.setItem("metroVelocityHigh",highScore);
  }

  document.getElementById("finalScore").textContent=final;
  document.getElementById("finalDistance").textContent=Math.floor(distance);
  document.getElementById("finalCoins").textContent=coins;
  document.getElementById("finalBest").textContent=highScore;

  document.getElementById("menuBest").textContent=highScore;
  document.getElementById("best").textContent=highScore;

  showPanel("over");
}

/* ---------- collisions ---------- */

function playerBox(){
  const height=slideTimer>0?1.7:3.8;
  const bottom=jumpY;
  return {
    minX:player.position.x-.55,
    maxX:player.position.x+.55,
    minY:bottom,
    maxY:bottom+height,
    minZ:player.position.z-1,
    maxZ:player.position.z+1
  };
}

function objectBounds(o){
  const p=o.position;

  if(o.userData.kind==="coin"||
     o.userData.kind==="magnet"||
     o.userData.kind==="shield"||
     o.userData.kind==="boost"){
    return {
      minX:p.x-.8,maxX:p.x+.8,
      minY:1,maxY:2.5,
      minZ:p.z-.8,maxZ:p.z+.8
    };
  }

  if(o.userData.kind==="crate"||
     o.userData.kind==="barrier"){
    return {
      minX:p.x-1.35,maxX:p.x+1.35,
      minY:0,maxY:o.userData.kind==="crate"?2:1.5,
      minZ:p.z-1.1,maxZ:p.z+1.1
    };
  }

  if(o.userData.kind==="overhead"){
    return {
      minX:p.x-1.3,maxX:p.x+1.3,
      minY:0,maxY:2.7,
      minZ:p.z-.8,maxZ:p.z+.8
    };
  }

  return {
    minX:p.x-1.45,maxX:p.x+1.45,
    minY:0,maxY:3.5,
    minZ:p.z-3.2,maxZ:p.z+3.2
  };
}

function intersects(a,b){
  return a.minX<b.maxX&&a.maxX>b.minX&&
         a.minY<b.maxY&&a.maxY>b.minY&&
         a.minZ<b.maxZ&&a.maxZ>b.minZ;
}

/* ---------- object updates ---------- */

function updateObjects(dt){
  const pbox=playerBox();

  for(let i=activeObjects.length-1;i>=0;i--){
    const o=activeObjects[i];

    o.position.z+=speed*dt;

    if(o.userData.spin){
      o.userData.spin.rotation.z+=dt*7;
      o.userData.spin.rotation.y+=dt*3;
    }

    if(o.userData.orb){
      o.userData.orb.rotation.x+=dt*2;
      o.userData.orb.rotation.y+=dt*3;
      o.position.y=1.65+Math.sin(environmentTime*4+o.id)*.18;
    }

    /* Magnet attraction */
    if(
      magnetTimer>0 &&
      o.userData.kind==="coin" &&
      o.position.z>0 &&
      o.position.z<12
    ){
      const dx=player.position.x-o.position.x;
      if(Math.abs(dx)<8){
        o.position.x+=dx*dt*8;
        o.position.y+=(2.1-o.position.y)*dt*5;
      }
    }

    const bounds=objectBounds(o);

    if(intersects(pbox,bounds)){
      const kind=o.userData.kind;

      if(kind==="coin"){
        coins++;
        score+=100;
        coinSound();
        burst(o.position.x,o.position.y,o.position.z,0xffd52f,12);
        o.visible=false;
        activeObjects.splice(i,1);
        continue;
      }

      if(kind==="magnet"||kind==="shield"||kind==="boost"){
        if(kind==="magnet")magnetTimer=9;
        if(kind==="shield")shieldTimer=10;
        if(kind==="boost")boostTimer=6;
        powerSound();
        burst(
          o.position.x,o.position.y,o.position.z,
          kind==="shield"?0x39cfff:
          kind==="magnet"?0xd65cff:0xff7040,
          22
        );
        o.visible=false;
        activeObjects.splice(i,1);
        continue;
      }

      /* Jump clears low obstacles */
      if((kind==="crate"||kind==="barrier")&&jumpY>1.45){
        continue;
      }

      /* Slide clears overhead bar */
      if(kind==="overhead"&&slideTimer>0){
        continue;
      }

      if(shieldTimer>0){
        shieldTimer=0;
        shake=.25;
        burst(o.position.x,2,o.position.z,0x54dfff,25);
        tone(170,.15,"sawtooth",.05);
        o.visible=false;
        activeObjects.splice(i,1);
        continue;
      }

      gameOver();
      return;
    }

    if(o.position.z>15){
      o.visible=false;
      activeObjects.splice(i,1);
    }
  }
}

/* ---------- scenery movement ---------- */

function updateScenery(dt){
  for(const o of activeScenery){
    o.position.z+=speed*dt;

    if(o.position.z>40){
      o.position.z=-650-Math.random()*250;
      o.position.x=(Math.random()<.5?-1:1)*(10+Math.random()*17);
    }
  }

  for(const s of roadSegments){
    s.position.z+=speed*dt;

    if(s.position.z>segmentLength){
      let min=Infinity;
      for(const r of roadSegments)min=Math.min(min,r.position.z);
      s.position.z=min-segmentLength;
    }
  }
}

/* ---------- player animation ---------- */

function updatePlayer(dt){
  const previousJump=jumpY;

  if(jumpY>0||verticalVelocity>0){
    jumpY+=verticalVelocity*dt;
    verticalVelocity-=30*dt;

    if(jumpY<=0){
      jumpY=0;
      verticalVelocity=0;
      if(!wasGrounded){
        tone(100,.06,"triangle",.025);
        wasGrounded=true;
      }
    }
  }

  if(jumpY>0.05)wasGrounded=false;

  if(slideTimer>0){
    slideTimer-=dt;
    body.rotation.x=THREE.MathUtils.lerp(body.rotation.x,-.8,dt*12);
    body.position.y=THREE.MathUtils.lerp(body.position.y,-.7,dt*12);
  }else{
    body.rotation.x=THREE.MathUtils.lerp(body.rotation.x,0,dt*12);
    body.position.y=THREE.MathUtils.lerp(body.position.y,0,dt*12);
  }

  const desiredX=targetLane*laneWidth;
  player.position.x=THREE.MathUtils.lerp(
    player.position.x,desiredX,1-Math.pow(.001,dt)
  );

  const run=Math.sin(environmentTime*speed*.55);
  const runFast=Math.sin(environmentTime*speed*.55+Math.PI);

  if(slideTimer<=0){
    legL.rotation.x=run*.75;
    legR.rotation.x=runFast*.75;

    armL.rotation.x=runFast*.65;
    armR.rotation.x=run*.65;

    armL.rotation.z=-.08;
    armR.rotation.z=.08;
  }else{
    legL.rotation.x=.9;
    legR.rotation.x=.9;
    armL.rotation.x=-.4;
    armR.rotation.x=-.4;
  }

  player.position.y=jumpY;

  player.rotation.z=THREE.MathUtils.lerp(
    player.rotation.z,
    (desiredX-player.position.x)*-.06,
    dt*8
  );

  if(previousJump>0&&jumpY===0){
    burst(player.position.x,.2,player.position.z,0xffffff,5);
  }
}

/* ---------- camera ---------- */

function updateCamera(dt){
  const targetX=player.position.x*.28;

  camera.position.x=THREE.MathUtils.lerp(
    camera.position.x,targetX,dt*5
  );

  camera.position.y=THREE.MathUtils.lerp(
    camera.position.y,
    5.1+jumpY*.15,
    dt*5
  );

  camera.position.z=THREE.MathUtils.lerp(
    camera.position.z,10,dt*4
  );

  camera.lookAt(
    player.position.x*.12,
    1.9+jumpY*.2,
    -10
  );

  if(shake>0){
    camera.position.x+=(Math.random()-.5)*shake;
    camera.position.y+=(Math.random()-.5)*shake;
    shake=Math.max(0,shake-dt*1.5);
  }
}

/* ---------- atmosphere ---------- */

function updateEnvironment(dt){
  environmentTime+=dt;

  const cycle=(environmentTime/100)%1;

  const sunAngle=cycle*Math.PI*2;

  sun.position.x=Math.cos(sunAngle)*100;
  sun.position.y=45+Math.sin(sunAngle)*75;
  sun.position.z=60;

  const daylight=Math.max(.15,Math.sin(sunAngle)*.5+.55);

  sun.intensity=1.1+daylight*2.4;
  hemi.intensity=.8+daylight*1.5;
  ambient.intensity=.2+daylight*.3;

  const skyTop=new THREE.Color().setHSL(
    .58,.32,.12+daylight*.34
  );

  scene.background=skyTop;

  scene.fog.color.copy(
    new THREE.Color().setHSL(.58,.25,.18+daylight*.28)
  );
}

/* ---------- HUD ---------- */

function updateHUD(){
  document.getElementById("score").textContent=Math.floor(score);
  document.getElementById("distance").textContent=Math.floor(distance);
  document.getElementById("coins").textContent=coins;
  document.getElementById("best").textContent=Math.max(
    highScore,Math.floor(score)
  );

  let text=[];

  if(magnetTimer>0)
    text.push("🧲 MAGNET "+magnetTimer.toFixed(1));

  if(shieldTimer>0)
    text.push("🛡 SHIELD "+shieldTimer.toFixed(1));

  if(boostTimer>0)
    text.push("⚡ BOOST "+boostTimer.toFixed(1));

  document.getElementById("powerStatus").textContent=text.join("   ");
}

/* ---------- main update ---------- */

function update(dt){
  elapsed+=dt;

  const difficulty=Math.min(1,distance/3000);

  baseSpeed=18+difficulty*13+Math.min(10,distance/1800);
  speed=baseSpeed+(boostTimer>0?10:0);

  distance+=speed*dt;
  score+=speed*dt*2.3;

  spawnDistance-=speed*dt;

  if(spawnDistance<=0){
    spawnPattern();

    const interval=Math.max(
      17,
      31-distance*.003
    );

    spawnDistance=interval*(.75+Math.random()*.3);
  }

  magnetTimer=Math.max(0,magnetTimer-dt);
  shieldTimer=Math.max(0,shieldTimer-dt);
  boostTimer=Math.max(0,boostTimer-dt);

  shieldVisual.visible=shieldTimer>0;

  updatePlayer(dt);
  updateObjects(dt);
  updateScenery(dt);
  updateEnvironment(dt);
  updateCamera(dt);
  updateParticles(dt);
  updateHUD();
}

/* ---------- particle update ---------- */

function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.position.addScaledVector(p.userData.v,dt);
    p.userData.v.y-=9*dt;
    p.userData.life-=dt;
    p.material.opacity=Math.max(0,p.userData.life/.55);

    if(p.userData.life<=0){
      scene.remove(p);
      particles.splice(i,1);
    }
  }
}

/* ---------- rendering ---------- */

function render(){
  renderer.render(scene,camera);
}

function animate(){
  requestAnimationFrame(animate);

  const dt=Math.min(clock.getDelta(),.05);

  if(state==="playing"){
    update(dt);
  }else{
    environmentTime+=dt*.15;
    updateEnvironment(dt*.15);
  }

  render();
}

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

/* ---------- initialization ---------- */

applyQuality();

document.getElementById("menuBest").textContent=highScore;
document.getElementById("best").textContent=highScore;
document.getElementById("settingsSound").textContent="ON";

scene.background=new THREE.Color(0x718ba4);

setTimeout(()=>{
  document.getElementById("loading").classList.add("hidden");
  showPanel("menu");
},500);

animate();

</script>
</body>
</html>

Game Source: METRO VELOCITY

Creator: CyberFalcon70

Libraries: three

Complexity: complex (1608 lines, 38.4 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: metro-velocity-cyberfalcon70" to link back to the original. Then publish at arcadelab.ai/publish.