🎮ArcadeLab

Subway Runner

by LaserLegend64
532 lines14.6 KB
▶ Play
<!DOCTYPE html>
<html lang="fa">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#0b1020">
<title>Subway Runner</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box;}
  html,body{
    height:100%;overflow:hidden;background:#0b1020;
    font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
    touch-action:none;-webkit-user-select:none;user-select:none;
    -webkit-tap-highlight-color:transparent;
  }
  canvas{display:block;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(function(){
'use strict';

/* ================== SETUP ================== */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha:false });

let W=0, H=0, DPR=1, HORIZON=0, focal=0;
const CAM_Z = -6, CAM_Y = 2.3;

function resize(){
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth;
  H = window.innerHeight;
  canvas.width  = Math.round(W * DPR);
  canvas.height = Math.round(H * DPR);
  canvas.style.width  = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  HORIZON = H * 0.40;
  focal   = H * 0.92;
}
resize();
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', ()=>setTimeout(resize,200));

/* ================== PROJECTION ================== */
function project(x, y, z){
  const dz = z - CAM_Z;
  if (dz < 0.7) return null;
  const s = focal / dz;
  return { x: W*0.5 + x*s, y: HORIZON + (CAM_Y - y)*s, s:s };
}

/* ================== BOX RENDERER ================== */
function drawBox(x, y, z, w, h, d, hue, sat, lit){
  const hw=w/2, hd=d/2;
  const zn = z-hd, zf = z+hd;
  const yb = y, yt = y+h;

  const A = project(x-hw, yb, zn);
  const B = project(x+hw, yb, zn);
  const C = project(x+hw, yt, zn);
  const D = project(x-hw, yt, zn);
  const E = project(x-hw, yb, zf);
  const F = project(x+hw, yb, zf);
  const G = project(x+hw, yt, zf);
  const I = project(x-hw, yt, zf);
  if(!A||!B||!C||!D||!E||!F||!G||!I) return;

  // face side
  if (x > 0.02){
    ctx.fillStyle = `hsl(${hue},${sat}%,${lit*0.62}%)`;
    ctx.beginPath();
    ctx.moveTo(A.x,A.y); ctx.lineTo(D.x,D.y);
    ctx.lineTo(I.x,I.y); ctx.lineTo(E.x,E.y);
    ctx.closePath(); ctx.fill();
  } else if (x < -0.02){
    ctx.fillStyle = `hsl(${hue},${sat}%,${lit*0.62}%)`;
    ctx.beginPath();
    ctx.moveTo(B.x,B.y); ctx.lineTo(C.x,C.y);
    ctx.lineTo(G.x,G.y); ctx.lineTo(F.x,F.y);
    ctx.closePath(); ctx.fill();
  }

  // face top
  if (yt < CAM_Y - 0.05){
    ctx.fillStyle = `hsl(${hue},${sat}%,${Math.min(88, lit*1.42)}%)`;
    ctx.beginPath();
    ctx.moveTo(D.x,D.y); ctx.lineTo(C.x,C.y);
    ctx.lineTo(G.x,G.y); ctx.lineTo(I.x,I.y);
    ctx.closePath(); ctx.fill();
  }

  // face front
  ctx.fillStyle = `hsl(${hue},${sat}%,${lit}%)`;
  ctx.beginPath();
  ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y);
  ctx.lineTo(C.x,C.y); ctx.lineTo(D.x,D.y);
  ctx.closePath(); ctx.fill();
}

/* ================== GROUND QUAD ================== */
function groundQuad(x1,x2,z1,z2,color){
  const a = project(x1,0,z1), b = project(x2,0,z1);
  const c = project(x2,0,z2), d = project(x1,0,z2);
  if(!a||!b||!c||!d) return;
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);
  ctx.lineTo(c.x,c.y); ctx.lineTo(d.x,d.y);
  ctx.closePath(); ctx.fill();
}

/* ================== GAME STATE ================== */
const LANE_X   = [-1.65, 0, 1.65];
const GRAV     = -26;
const JUMP_V   = 8.2;
const SLIDE_T  = 0.72;

let player, items, speed, distance, score, coins, best;
let gameOver, spawnAccum, gap, time, shakeT, lastT;

best = parseInt(localStorage.getItem('sr_best') || '0', 10) || 0;

function reset(){
  player = { lane:1, x:0, y:0, vy:0, sliding:false, slideT:0 };
  items = [];
  speed = 14;
  distance = 0;
  score = 0;
  coins = 0;
  gameOver = false;
  spawnAccum = 0;
  gap = 18;
  time = 0;
  shakeT = 0;
  lastT = performance.now();
}

/* ================== SPAWNING ================== */
function addBarrier(lane, z){
  items.push({
    type:'barrier', x:LANE_X[lane], y:0, z:z,
    w:1.25, h:0.85, d:1.0, hue:28, sat:85, lit:55
  });
}
function addGate(lane, z){
  items.push({
    type:'gate', x:LANE_X[lane], y:1.15, z:z,
    w:1.35, h:1.05, d:0.7, hue:352, sat:75, lit:52
  });
}
function addTrain(lane, z){
  const hue = [205, 268, 320][Math.floor(Math.random()*3)];
  items.push({
    type:'train', x:LANE_X[lane], y:0, z:z+3.2,
    w:1.42, h:2.15, d:8.0, hue:hue, sat:60, lit:48
  });
}
function addCoin(lane, z){
  items.push({
    type:'coin', x:LANE_X[lane], y:0.75, z:z,
    w:0.6, h:0.6, d:0.6, seed: Math.random()*6.28
  });
}

function spawnRow(z){
  const lanes = [0,1,2];
  for(let i=lanes.length-1;i>0;i--){
    const j = Math.floor(Math.random()*(i+1));
    const t = lanes[i]; lanes[i]=lanes[j]; lanes[j]=t;
  }
  const two = Math.random() < 0.32;
  const nBlock = two ? 2 : 1;
  let hasTrain = false;

  for(let i=0;i<nBlock;i++){
    const l = lanes[i];
    // اگه دو خط بسته‌ست، قطار نمی‌ذاریم تا راه فرار بمونه
    const r = two ? Math.random()*0.9 : Math.random();
    if (r < 0.38)       addBarrier(l, z);
    else if (r < 0.72)  addGate(l, z);
    else { addTrain(l, z); hasTrain = true; }
  }

  const free = lanes.slice(nBlock);
  if (free.length && Math.random() < 0.8){
    const l = free[Math.floor(Math.random()*free.length)];
    const n = 3 + Math.floor(Math.random()*4);
    for(let i=0;i<n;i++) addCoin(l, z + i*1.4);
  }
  return hasTrain;
}

/* ================== CONTROLS ================== */
function moveLeft(){  if(!gameOver && player.lane > 0) player.lane--; }
function moveRight(){ if(!gameOver && player.lane < 2) player.lane++; }

function jump(){
  if(gameOver) return;
  if(player.y <= 0.02){
    player.vy = JUMP_V;
    player.sliding = false;
    player.slideT = 0;
  }
}
function slide(){
  if(gameOver) return;
  player.sliding = true;
  player.slideT = SLIDE_T;
  if(player.y > 0.02) player.vy = Math.min(player.vy, -14);
}

window.addEventListener('keydown', e=>{
  const k = e.key;
  if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(k)) e.preventDefault();
  if(gameOver){
    if(k===' '||k==='Enter'||k==='ArrowUp') reset();
    return;
  }
  if(k==='ArrowLeft'  || k==='a' || k==='A') moveLeft();
  else if(k==='ArrowRight' || k==='d' || k==='D') moveRight();
  else if(k==='ArrowUp' || k==='w' || k==='W' || k===' ') jump();
  else if(k==='ArrowDown' || k==='s' || k==='S') slide();
});

let ptr = null;
canvas.addEventListener('pointerdown', e=>{
  ptr = { x:e.clientX, y:e.clientY, t:performance.now() };
  canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointerup', e=>{
  if(!ptr) return;
  const dx = e.clientX - ptr.x;
  const dy = e.clientY - ptr.y;
  const ax = Math.abs(dx), ay = Math.abs(dy);
  ptr = null;

  if(gameOver){ reset(); return; }

  if(Math.max(ax,ay) < 28){ jump(); return; }
  if(ax > ay)  (dx > 0) ? moveRight() : moveLeft();
  else         (dy > 0) ? slide() : jump();
});
canvas.addEventListener('pointercancel', ()=>{ ptr=null; });
canvas.addEventListener('contextmenu', e=>e.preventDefault());

/* ================== UPDATE ================== */
function update(dt){
  if(gameOver) return;
  time += dt;

  speed = Math.min(34, 14 + distance * 0.011);
  distance += speed * dt;
  score = Math.floor(distance) + coins * 10;

  // حرکت افقی بازیکن
  const tx = LANE_X[player.lane];
  player.x += (tx - player.x) * Math.min(1, dt * 16);

  // پرش
  if(player.y > 0 || player.vy !== 0){
    player.vy += GRAV * dt;
    player.y  += player.vy * dt;
    if(player.y <= 0){ player.y = 0; player.vy = 0; }
  }

  // سُر خوردن
  if(player.sliding){
    player.slideT -= dt;
    if(player.slideT <= 0) player.sliding = false;
  }

  // spawn
  spawnAccum += speed * dt;
  if(spawnAccum >= gap){
    spawnAccum -= gap;
    const hasTrain = spawnRow(95);
    gap = 12 + speed * 0.42 + (hasTrain ? 7 : 0);
  }

  // برخوردها
  const ph = player.sliding ? 0.72 : 1.35;
  const pw = 0.72, pd = 0.5;

  for(let i=items.length-1; i>=0; i--){
    const it = items[i];
    const pz = it.z;
    it.z -= speed * dt;

    if(it.z < -12){ items.splice(i,1); continue; }

    const tz = (it.d + pd) * 0.5;
    if(pz >= -tz && it.z <= tz){
      const tx = (it.w + pw) * 0.5;
      if(Math.abs(it.x - player.x) < tx){
        if(it.type === 'coin'){
          coins++;
          items.splice(i,1);
          continue;
        }
        const yHit = !(it.y + it.h < player.y || it.y > player.y + ph);
        if(yHit){
          gameOver = true;
          shakeT = 0.45;
          if(score > best){
            best = score;
            localStorage.setItem('sr_best', String(best));
          }
        }
      }
    }
  }
}

/* ================== RENDER ================== */
function drawPlayer(){
  const ph = player.sliding ? 0.72 : 1.35;
  const pw = 0.82, pd = 0.5;

  // سایه
  const g = project(player.x, 0, 0);
  if(g){
    ctx.save();
    ctx.globalAlpha = 0.38 - Math.min(0.25, player.y*0.15);
    ctx.fillStyle = '#000';
    ctx.beginPath();
    ctx.ellipse(g.x, g.y, 0.55*g.s, 0.20*g.s, 0, 0, Math.PI*2);
    ctx.fill();
    ctx.restore();
  }

  // بدن
  drawBox(player.x, player.y, 0, pw, ph, pd, 198, 82, 55);

  // سر
  if(player.sliding){
    drawBox(player.x, player.y + 0.30, 0.42, 0.42, 0.40, 0.40, 32, 78, 66);
  } else {
    drawBox(player.x, player.y + ph, 0, 0.48, 0.44, 0.44, 32, 78, 66);
    // کلاه
    drawBox(player.x, player.y + ph + 0.34, 0, 0.52, 0.10, 0.48, 210, 75, 45);
  }
}

function drawCoin(it){
  const p = project(it.x, it.y, it.z);
  if(!p) return;
  const baseR = 0.30 * p.s;
  const ph = Math.abs(Math.cos(time*6 + it.seed));
  const rx = Math.max(1, baseR * (0.22 + 0.78*ph));
  const ry = Math.max(1, baseR);
  const grad = ctx.createLinearGradient(p.x-rx, p.y-ry, p.x+rx, p.y+ry);
  grad.addColorStop(0,   '#fff2a8');
  grad.addColorStop(0.45,'#f7c500');
  grad.addColorStop(1,   '#a86e00');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.ellipse(p.x, p.y, rx, ry, 0, 0, Math.PI*2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.55)';
  ctx.lineWidth = Math.max(0.8, p.s*0.02);
  ctx.stroke();
}

function drawItem(it){
  if(it.type === 'coin'){ drawCoin(it); return; }
  drawBox(it.x, it.y, it.z, it.w, it.h, it.d, it.hue, it.sat, it.lit);
}

function drawScene(){
  // آسمان
  const sky = ctx.createLinearGradient(0,0,0,HORIZON+30);
  sky.addColorStop(0,   '#080c1c');
  sky.addColorStop(0.55,'#1a1436');
  sky.addColorStop(1,   '#3a2260');
  ctx.fillStyle = sky;
  ctx.fillRect(0,0,W,HORIZON+30);

  // زمین
  ctx.fillStyle = '#141a2e';
  ctx.fillRect(0,HORIZON,W,H-HORIZON);

  // خطوط مسیر
  const bounds = [-2.45,-0.82,0.82,2.45];
  for(const bx of bounds){
    groundQuad(bx-0.045, bx+0.045, -4, 100, 'rgba(255,255,255,0.07)');
  }

  // نوارهای متحرک سرعت
  const SP = 5;
  const off = distance % SP;
  for(let i=0;i<26;i++){
    const z = i*SP - off - 4;
    if(z < -4) continue;
    groundQuad(-2.45, 2.45, z, z+0.22, 'rgba(255,255,255,0.045)');
  }

  // مه افق
  const fog = ctx.createLinearGradient(0,HORIZON-4,0,HORIZON+H*0.14);
  fog.addColorStop(0,'rgba(58,34,96,0.95)');
  fog.addColorStop(1,'rgba(58,34,96,0)');
  ctx.fillStyle = fog;
  ctx.fillRect(0,HORIZON-4,W,H*0.14+4);

  // آیتم‌ها: دور به نزدیک
  items.sort((a,b)=> b.z - a.z);
  let i = 0;
  for(; i<items.length; i++){
    if(items[i].z < 0) break;
    drawItem(items[i]);
  }

  // بازیکن
  drawPlayer();

  // آیتم‌های پشت بازیکن
  for(; i<items.length; i++){
    drawItem(items[i]);
  }
}

function drawHUD(){
  const pad = Math.max(14, W*0.04);
  ctx.textBaseline = 'top';

  // امتیاز
  ctx.font = '700 ' + Math.round(H*0.032) + 'px system-ui, sans-serif';
  ctx.textAlign = 'left';
  ctx.fillStyle = 'rgba(0,0,0,0.45)';
  ctx.fillText(String(score), pad+2, pad+2);
  ctx.fillStyle = '#ffffff';
  ctx.fillText(String(score), pad, pad);

  // سکه‌ها
  const cR = H*0.016;
  const cx = W - pad - cR;
  const cy = pad + cR;
  const grad = ctx.createLinearGradient(cx-cR,cy-cR,cx+cR,cy+cR);
  grad.addColorStop(0,'#fff2a8');
  grad.addColorStop(0.5,'#f7c500');
  grad.addColorStop(1,'#a86e00');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(cx, cy, cR, 0, Math.PI*2);
  ctx.fill();

  ctx.font = '700 ' + Math.round(H*0.03) + 'px system-ui, sans-serif';
  ctx.textAlign = 'right';
  ctx.fillStyle = 'rgba(0,0,0,0.45)';
  ctx.fillText(String(coins), W-pad-2*cR-10+2, pad+2);
  ctx.fillStyle = '#ffffff';
  ctx.fillText(String(coins), W-pad-2*cR-10, pad);

  // بهترین رکورد
  if(best > 0){
    ctx.textAlign = 'center';
    ctx.font = '600 ' + Math.round(H*0.019) + 'px system-ui, sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.42)';
    ctx.fillText('BEST  ' + best, W*0.5, pad + H*0.045);
  }
}

function drawGameOver(){
  ctx.fillStyle = 'rgba(6,9,20,0.78)';
  ctx.fillRect(0,0,W,H);

  const cx = W*0.5, cy = H*0.5;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  ctx.fillStyle = '#ff5c7a';
  ctx.font = '800 ' + Math.round(H*0.062) + 'px system-ui, sans-serif';
  ctx.fillText('GAME OVER', cx, cy - H*0.13);

  ctx.fillStyle = '#ffffff';
  ctx.font = '700 ' + Math.round(H*0.05) + 'px system-ui, sans-serif';
  ctx.fillText(String(score), cx, cy - H*0.035);

  ctx.fillStyle = 'rgba(255,255,255,0.55)';
  ctx.font = '600 ' + Math.round(H*0.022) + 'px system-ui, sans-serif';
  ctx.fillText('امتیاز', cx, cy + H*0.015);

  ctx.fillStyle = '#f7c500';
  ctx.font = '700 ' + Math.round(H*0.03) + 'px system-ui, sans-serif';
  ctx.fillText('🪙 ' + coins + '     🏆 ' + best, cx, cy + H*0.075);

  const pulse = 0.6 + 0.4*Math.sin(time*4);
  ctx.fillStyle = 'rgba(255,255,255,' + pulse.toFixed(2) + ')';
  ctx.font = '600 ' + Math.round(H*0.026) + 'px system-ui, sans-serif';
  ctx.fillText('برای شروع دوباره ضربه بزن', cx, cy + H*0.15);

  // راهنما
  ctx.fillStyle = 'rgba(255,255,255,0.30)';
  ctx.font = '500 ' + Math.round(H*0.018) + 'px system-ui, sans-serif';
  ctx.fillText('◀ ▶ تغییر خط   •   ▲ پرش   •   ▼ سُر خوردن', cx, H - H*0.08);
}

function render(){
  ctx.save();
  if(shakeT > 0){
    const k = shakeT * 22;
    ctx.translate((Math.random()-0.5)*k, (Math.random()-0.5)*k);
  }
  drawScene();
  ctx.restore();

  drawHUD();
  if(gameOver) drawGameOver();
}

/* ================== LOOP ================== */
function frame(now){
  let dt = (now - lastT) / 1000;
  lastT = now;
  if(dt > 0.05) dt = 0.05;
  if(shakeT > 0) shakeT -= dt;
  update(dt);
  render();
  requestAnimationFrame(frame);
}

/* ================== START ================== */
reset();
requestAnimationFrame(frame);

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

Game Source: Subway Runner

Creator: LaserLegend64

Libraries: none

Complexity: complex (532 lines, 14.6 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-runner-laserlegend64" to link back to the original. Then publish at arcadelab.ai/publish.