🎮ArcadeLab

跑外卖 · 进阶版

by CyberFox46
820 lines24.3 KB
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<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>跑外卖 · 进阶版</title>
<style>
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%;overflow:hidden;background:#e6edf2;
    font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
    -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
  #game{display:block;width:100%;height:100%;touch-action:none}

  #hud{position:fixed;inset:0;pointer-events:none;padding:max(12px,env(safe-area-inset-top)) 14px 0;z-index:4}
  .topbar{display:flex;gap:8px;justify-content:space-between}
  .pill{background:rgba(255,255,255,.9);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
    border-radius:999px;padding:8px 15px;font-size:15px;font-weight:700;color:#334155;
    box-shadow:0 2px 10px rgba(0,0,0,.06);white-space:nowrap}
  #money{color:#ea580c}
  #hearts{font-size:13px;letter-spacing:1px}

  #orders{position:fixed;left:50%;transform:translateX(-50%);
    bottom:max(16px,env(safe-area-inset-bottom));width:min(92%,420px);
    display:flex;flex-direction:column;gap:8px;pointer-events:none;z-index:5}
  .order-card{background:rgba(255,255,255,.95);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
    border-radius:14px;padding:10px 14px;box-shadow:0 4px 16px rgba(0,0,0,.08);
    pointer-events:auto;cursor:pointer;transition:transform .1s}
  .order-card:active{transform:scale(.98)}
  .order-head{font-size:11px;color:#94a3b8;font-weight:700;margin-bottom:3px}
  .order-phase{font-size:14px;font-weight:700;color:#1e293b;margin-bottom:6px}
  .order-bar{height:5px;border-radius:3px;background:#e2e8f0;overflow:hidden}
  .order-bar>div{height:100%;border-radius:3px;transition:width .15s linear}
  .order-time{font-size:11px;color:#94a3b8;text-align:right;margin-top:3px}

  .overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
    background:rgba(230,237,242,.95);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
    z-index:10;padding:24px}
  .card{background:#fff;border-radius:24px;padding:30px 24px;text-align:center;
    max-width:340px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.12)}
  .card h1{margin:0 0 12px;font-size:24px;color:#1e293b}
  .card p{margin:6px 0;color:#64748b;font-size:14px;line-height:1.7}
  .tip{background:#f1f5f9;border-radius:12px;padding:12px;margin:14px 0!important;font-size:13px;text-align:left}
  .stat{font-size:15px;color:#475569;margin:4px 0}
  .stat b{color:#ea580c;font-size:20px}
  button{border:0;background:#ff6b35;color:#fff;font-size:17px;font-weight:700;
    padding:14px 44px;border-radius:16px;cursor:pointer;margin-top:20px;
    box-shadow:0 8px 20px rgba(255,107,53,.35);transition:transform .1s}
  button:active{transform:scale(.97)}
</style>
</head>
<body>
<canvas id="game"></canvas>

<div id="hud">
  <div class="topbar">
    <div class="pill">收入 <span id="money">¥0</span></div>
    <div class="pill" id="hearts">❤️❤️❤️</div>
    <div class="pill"><span id="done">0</span> 单</div>
  </div>
</div>

<div id="orders"></div>

<div id="start" class="overlay">
  <div class="card">
    <h1>🛵 跑外卖 · 进阶版</h1>
    <p>点击地图移动,自动寻路</p>
    <p class="tip">
      🟡 黄点:商家 · 取餐<br>
      🟣 紫点:便利店 · 帮买<br>
      🔵 蓝点:顾客 · 送达<br>
      🟨 黄门:小区唯一入口<br>
      <br>点击底部订单卡片可自动导航
    </p>
    <p style="font-size:13px;color:#94a3b8">超时 3 次收工</p>
    <button id="btnStart">开始接单</button>
  </div>
</div>

<div id="over" class="overlay" style="display:none">
  <div class="card">
    <h1>收工了</h1>
    <div id="overBody"></div>
    <button id="btnAgain">再来一单</button>
  </div>
</div>

<script>
(function () {
  'use strict';

  // ===== 常量 =====
  var GRID = 26;
  var TILE = 40;
  var WORLD = GRID * TILE;
  var SPEED = 4.5;

  var T_ROAD = 0, T_BUILDING = 1, T_WALL = 2, T_GATE = 3, T_YARD = 4;

  var SHOP_NAMES = ['兰州拉面','沙县小吃','黄焖鸡','蜜雪冰城','华莱士','麻辣烫',
                    '煲仔饭','猪脚饭','螺蛳粉','牛肉面','炸鸡汉堡','小碗菜'];
  var ERRAND_ITEMS = ['一包烟','一瓶水','一袋盐','两瓶啤酒','一包纸巾','一盒鸡蛋'];

  // ===== DOM =====
  var cvs = document.getElementById('game');
  var ctx = cvs.getContext('2d');
  var $money = document.getElementById('money');
  var $hearts = document.getElementById('hearts');
  var $done = document.getElementById('done');
  var $orders = document.getElementById('orders');
  var $start = document.getElementById('start');
  var $over = document.getElementById('over');
  var $overBody = document.getElementById('overBody');
  var $btnStart = document.getElementById('btnStart');
  var $btnAgain = document.getElementById('btnAgain');

  // ===== 尺寸 =====
  var W = 0, H = 0, DPR = 1;
  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    W = window.innerWidth;
    H = window.innerHeight;
    cvs.width = Math.round(W * DPR);
    cvs.height = Math.round(H * DPR);
    cvs.style.width = W + 'px';
    cvs.style.height = H + 'px';
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener('resize', function(){
    resize();
    if (state === 'playing') updateCamera(1);
  });

  // ===== 状态 =====
  var map = [];
  var compounds = [];
  var shops = [];
  var player = { x: 1, y: 1, path: [] };
  var cam = { x: 0, y: 0 };
  var orders = [];
  var money = 0, hearts = 3, done = 0, combo = 0;
  var state = 'ready';
  var floaters = [];
  var orderIdCounter = 0;
  var needNewOrder = false;
  var lastUIUpdate = 0;
  var lastT = 0;

  // ===== 工具 =====
  function randInt(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
  function shuffle(a) {
    for (var i = a.length - 1; i > 0; i--) {
      var j = (Math.random() * (i + 1)) | 0;
      var t = a[i]; a[i] = a[j]; a[j] = t;
    }
    return a;
  }
  function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }

  function isWalk(x, y) {
    if (x < 0 || y < 0 || x >= GRID || y >= GRID) return false;
    var t = map[y][x];
    return t === T_ROAD || t === T_GATE || t === T_YARD;
  }

  function nearestWalkable(x, y) {
    if (isWalk(x, y)) return { x: x, y: y };
    for (var r = 1; r <= 10; r++) {
      for (var dy = -r; dy <= r; dy++) {
        for (var dx = -r; dx <= r; dx++) {
          if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
          var nx = x + dx, ny = y + dy;
          if (isWalk(nx, ny)) return { x: nx, y: ny };
        }
      }
    }
    return null;
  }

  function nearCompound(x, y) {
    for (var i = 0; i < compounds.length; i++) {
      var c = compounds[i];
      if (x >= c.x0 - 1 && x <= c.x0 + 4 && y >= c.y0 - 1 && y <= c.y0 + 4) return true;
    }
    return false;
  }

  // ===== A* 寻路 =====
  function findPath(sx, sy, tx, ty) {
    var start = nearestWalkable(Math.round(sx), Math.round(sy));
    var goal = nearestWalkable(Math.round(tx), Math.round(ty));
    if (!start || !goal) return [];
    if (start.x === goal.x && start.y === goal.y) return [goal];

    var open = [{ x: start.x, y: start.y, g: 0, f: 0, parent: null }];
    var closed = {};
    var best = {};
    var sk = start.y * GRID + start.x;
    best[sk] = open[0];

    var iter = 0;
    while (open.length > 0 && iter++ < 8000) {
      var bi = 0;
      for (var i = 1; i < open.length; i++) {
        if (open[i].f < open[bi].f) bi = i;
      }
      var node = open.splice(bi, 1)[0];
      var k = node.y * GRID + node.x;
      if (closed[k]) continue;
      closed[k] = true;

      if (node.x === goal.x && node.y === goal.y) {
        var path = [];
        var cur = node;
        while (cur) { path.push({ x: cur.x, y: cur.y }); cur = cur.parent; }
        path.reverse();
        return path;
      }

      var dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
      for (var d = 0; d < 4; d++) {
        var nx = node.x + dirs[d][0];
        var ny = node.y + dirs[d][1];
        if (!isWalk(nx, ny)) continue;
        var nk = ny * GRID + nx;
        if (closed[nk]) continue;
        var g = node.g + 1;
        var h = Math.abs(nx - goal.x) + Math.abs(ny - goal.y);
        var old = best[nk];
        if (old && old.g <= g) continue;
        var n = { x: nx, y: ny, g: g, f: g + h, parent: node };
        best[nk] = n;
        open.push(n);
      }
    }
    return [];
  }

  function pathDist(a, b) {
    var p = findPath(a.x, a.y, b.x, b.y);
    if (p.length > 1) return p.length - 1;
    return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
  }

  // ===== 地图生成 =====
  function generateMap() {
    map = [];
    for (var y = 0; y < GRID; y++) {
      map[y] = [];
      for (var x = 0; x < GRID; x++) map[y][x] = T_BUILDING;
    }

    // 道路网格
    for (var y2 = 0; y2 < GRID; y2++) {
      for (var x2 = 0; x2 < GRID; x2++) {
        if (x2 % 5 === 0 || y2 % 5 === 0) map[y2][x2] = T_ROAD;
      }
    }

    // 小区
    compounds = [];
    var blocks = [];
    for (var by = 0; by < 5; by++) {
      for (var bx = 0; bx < 5; bx++) blocks.push({ bx: bx, by: by });
    }
    shuffle(blocks);

    for (var i = 0; i < 4; i++) {
      var b = blocks[i];
      var x0 = b.bx * 5 + 1, y0 = b.by * 5 + 1;

      for (var yy = y0; yy < y0 + 4; yy++) {
        for (var xx = x0; xx < x0 + 4; xx++) {
          var border = (xx === x0 || xx === x0 + 3 || yy === y0 || yy === y0 + 3);
          map[yy][xx] = border ? T_WALL : T_YARD;
        }
      }

      // 门
      var candidates = [];
      for (var gx = x0 + 1; gx <= x0 + 2; gx++) {
        candidates.push({ x: gx, y: y0 });
        candidates.push({ x: gx, y: y0 + 3 });
      }
      for (var gy = y0 + 1; gy <= y0 + 2; gy++) {
        candidates.push({ x: x0, y: gy });
        candidates.push({ x: x0 + 3, y: gy });
      }
      shuffle(candidates);
      var gates = candidates.slice(0, 2);
      for (var gi = 0; gi < gates.length; gi++) {
        map[gates[gi].y][gates[gi].x] = T_GATE;
      }
      compounds.push({ x0: x0, y0: y0, gates: gates });
    }

    // 便利店
    shops = [];
    for (var si = 0; si < 5; si++) {
      var sx, sy, tries = 0;
      do {
        sx = randInt(1, GRID - 2);
        sy = randInt(1, GRID - 2);
        tries++;
      } while ((map[sy][sx] !== T_ROAD || nearCompound(sx, sy)) && tries < 300);
      shops.push({ x: sx, y: sy });
    }
  }

  // ===== 订单 =====
  function randomMerchantPos() {
    var x, y, tries = 0;
    do {
      x = randInt(1, GRID - 2);
      y = randInt(1, GRID - 2);
      tries++;
    } while ((map[y][x] !== T_ROAD || nearCompound(x, y)) && tries < 300);
    return { x: x, y: y };
  }

  function randomCustomerPos() {
    var yards = [];
    for (var y = 0; y < GRID; y++) {
      for (var x = 0; x < GRID; x++) {
        if (map[y][x] === T_YARD) yards.push({ x: x, y: y });
      }
    }
    if (yards.length > 0 && Math.random() < 0.75) {
      return yards[randInt(0, yards.length - 1)];
    }
    return randomMerchantPos();
  }

  function calcOrderTime(o) {
    var d = 0;
    var from = { x: Math.round(player.x), y: Math.round(player.y) };

    if (o.phase === 'pickup') {
      d += pathDist(from, o.merchant);
      if (o.errandShop) d += pathDist(o.merchant, o.errandShop);
      var last = o.errandShop || o.merchant;
      d += pathDist(last, o.customer);
    } else if (o.phase === 'errand') {
      d += pathDist(from, o.errandShop);
      d += pathDist(o.errandShop, o.customer);
    } else {
      d += pathDist(from, o.customer);
    }

    o.totalDist = d;
    o.maxTime = Math.max(12, d / SPEED * 1.8 + 8);
    o.timeLeft = o.maxTime;
  }

  function makeOrder(type) {
    var merchant = randomMerchantPos();
    merchant.name = SHOP_NAMES[randInt(0, SHOP_NAMES.length - 1)];
    var customer = randomCustomerPos();

    var errandShop = null, errandItem = null;
    if (type === 'errand' && shops.length > 0) {
      errandShop = shops[randInt(0, shops.length - 1)];
      errandItem = ERRAND_ITEMS[randInt(0, ERRAND_ITEMS.length - 1)];
    }

    var o = {
      id: ++orderIdCounter,
      type: type,
      merchant: merchant,
      merchantName: merchant.name,
      customer: customer,
      errandShop: errandShop,
      errandItem: errandItem,
      phase: 'pickup',
      timeLeft: 0,
      maxTime: 0,
      totalDist: 0
    };

    calcOrderTime(o);
    return o;
  }

  function spawnNextOrder() {
    if (state !== 'playing') return;
    if (orders.length > 0) return;

    var isCombo = done >= 2 && Math.random() < 0.35;
    var count = isCombo ? 2 : 1;

    for (var i = 0; i < count; i++) {
      var type = 'normal';
      if (done >= 1 && Math.random() < 0.4) type = 'errand';
      orders.push(makeOrder(type));
    }
  }

  // ===== 完成 / 超时 =====
  function completeOrder(o, idx) {
    var ratio = o.timeLeft / o.maxTime;
    var base = 8 + Math.round(o.totalDist * 0.3);
    var speedBonus = Math.round(ratio * 10);
    combo++;
    var comboBonus = Math.min(combo, 5) * 2;
    var earn = base + speedBonus + comboBonus;

    money += earn;
    done++;
    $money.textContent = money;
    $done.textContent = done;

    addFloater('+¥' + earn, o.customer.x, o.customer.y, '#16a34a');

    orders.splice(idx, 1);
    if (orders.length === 0) needNewOrder = true;
  }

  function updateOrders(dt) {
    for (var i = orders.length - 1; i >= 0; i--) {
      var o = orders[i];
      o.timeLeft -= dt;
      if (o.timeLeft <= 0) {
        hearts--;
        combo = 0;
        money = Math.max(0, money - 5);
        $money.textContent = money;
        updateHearts();
        addFloater('超时 -¥5', player.x, player.y, '#dc2626');
        orders.splice(i, 1);
        if (orders.length === 0) needNewOrder = true;
        if (hearts <= 0) {
          gameOver();
          return;
        }
      }
    }
  }

  // ===== 交互判定 =====
  function adjacent(x1, y1, x2, y2) {
    return Math.abs(x1 - x2) <= 1 && Math.abs(y1 - y2) <= 1;
  }

  function checkInteractions() {
    var px = Math.round(player.x);
    var py = Math.round(player.y);

    for (var i = orders.length - 1; i >= 0; i--) {
      var o = orders[i];

      if (o.phase === 'pickup' && adjacent(px, py, o.merchant.x, o.merchant.y)) {
        o.phase = o.errandShop ? 'errand' : 'deliver';
        addFloater('取餐成功', o.merchant.x, o.merchant.y, '#f59e0b');
        calcOrderTime(o);
      } else if (o.phase === 'errand' && o.errandShop && adjacent(px, py, o.errandShop.x, o.errandShop.y)) {
        o.phase = 'deliver';
        addFloater('买好了:' + o.errandItem, o.errandShop.x, o.errandShop.y, '#8b5cf6');
        calcOrderTime(o);
      } else if (o.phase === 'deliver' && adjacent(px, py, o.customer.x, o.customer.y)) {
        completeOrder(o, i);
      }
    }
  }

  // ===== 玩家移动 =====
  function updatePlayer(dt) {
    if (player.path.length === 0) return;
    var next = player.path[0];
    var dx = next.x - player.x;
    var dy = next.y - player.y;
    var d = Math.sqrt(dx * dx + dy * dy);
    var step = SPEED * dt;

    if (d <= step) {
      player.x = next.x;
      player.y = next.y;
      player.path.shift();
    } else {
      player.x += dx / d * step;
      player.y += dy / d * step;
    }
  }

  // ===== 相机 =====
  function updateCamera(lerp) {
    var px = player.x * TILE + TILE / 2;
    var py = player.y * TILE + TILE / 2;
    var targetX = px - W / 2;
    var targetY = py - H / 2;

    targetX = clamp(targetX, 0, Math.max(0, WORLD - W));
    targetY = clamp(targetY, 0, Math.max(0, WORLD - H));

    cam.x += (targetX - cam.x) * lerp;
    cam.y += (targetY - cam.y) * lerp;
  }

  // ===== 浮动文字 =====
  function addFloater(text, gx, gy, color) {
    floaters.push({ x: gx, y: gy, text: text, color: color, life: 1.2 });
  }

  function updateFloaters(dt) {
    for (var i = floaters.length - 1; i >= 0; i--) {
      floaters[i].life -= dt;
      if (floaters[i].life <= 0) floaters.splice(i, 1);
    }
  }

  // ===== 渲染 =====
  function drawTile(x, y, sx, sy) {
    var t = map[y][x];
    var color;

    if (t === T_ROAD) {
      color = '#dae3ee';
    } else if (t === T_BUILDING) {
      var shade = ((x * 7 + y * 13) % 5) * 5;
      color = 'rgb(' + (176 - shade) + ',' + (190 - shade) + ',' + (210 - shade) + ')';
    } else if (t === T_WALL) {
      color = '#8794a8';
    } else if (t === T_GATE) {
      color = '#fbbf24';
    } else if (t === T_YARD) {
      color = '#b5e0a8';
    } else {
      color = '#fff';
    }

    ctx.fillStyle = color;
    ctx.fillRect(sx, sy, TILE, TILE);

    if (t === T_BUILDING) {
      ctx.fillStyle = 'rgba(255,255,255,0.08)';
      ctx.fillRect(sx + 5, sy + 5, TILE - 10, TILE - 10);
    }

    if (t === T_GATE) {
      ctx.fillStyle = '#fff';
      ctx.font = 'bold 14px -apple-system,sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText('门', sx + TILE / 2, sy + TILE / 2 + 1);
    }

    ctx.strokeStyle = 'rgba(0,0,0,0.04)';
    ctx.lineWidth = 1;
    ctx.strokeRect(sx + 0.5, sy + 0.5, TILE - 1, TILE - 1);
  }

  function drawMarker(p, color, label, active) {
    var sx = p.x * TILE - cam.x + TILE / 2;
    var sy = p.y * TILE - cam.y + TILE / 2;
    if (sx < -60 || sx > W + 60 || sy < -60 || sy > H + 60) return;

    if (active) {
      var pulse = 0.5 + 0.5 * Math.sin(Date.now() / 250);
      ctx.save();
      ctx.globalAlpha = 0.18 + pulse * 0.2;
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.arc(sx, sy, 22 + pulse * 10, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
    }

    ctx.beginPath();
    ctx.arc(sx, sy, 13, 0, Math.PI * 2);
    ctx.fillStyle = color;
    ctx.fill();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2.5;
    ctx.stroke();

    ctx.fillStyle = '#fff';
    ctx.font = 'bold 12px -apple-system,sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(label, sx, sy + 1);
  }

  function drawOrderMarkers() {
    for (var i = 0; i < orders.length; i++) {
      var o = orders[i];
      drawMarker(o.merchant, '#f59e0b', '店', o.phase === 'pickup');
      if (o.errandShop) drawMarker(o.errandShop, '#8b5cf6', '买', o.phase === 'errand');
      drawMarker(o.customer, '#3b82f6', '家', o.phase === 'deliver');
    }
  }

  function drawPlayer() {
    var sx = player.x * TILE - cam.x + TILE / 2;
    var sy = player.y * TILE - cam.y + TILE / 2;

    if (player.path.length > 0) {
      ctx.beginPath();
      ctx.moveTo(sx, sy);
      for (var i = 0; i < player.path.length; i++) {
        var px = player.path[i].x * TILE - cam.x + TILE / 2;
        var py = player.path[i].y * TILE - cam.y + TILE / 2;
        ctx.lineTo(px, py);
      }
      ctx.strokeStyle = 'rgba(255,107,53,0.35)';
      ctx.lineWidth = 3;
      ctx.setLineDash([6, 6]);
      ctx.stroke();
      ctx.setLineDash([]);
    }

    ctx.beginPath();
    ctx.ellipse(sx, sy + 9, 11, 5, 0, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(0,0,0,0.15)';
    ctx.fill();

    ctx.beginPath();
    ctx.arc(sx, sy, 12, 0, Math.PI * 2);
    ctx.fillStyle = '#ff6b35';
    ctx.fill();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2.5;
    ctx.stroke();

    ctx.beginPath();
    ctx.arc(sx, sy - 2, 6, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(255,255,255,0.55)';
    ctx.fill();

    ctx.fillStyle = '#334155';
    ctx.fillRect(sx - 6, sy + 5, 12, 8);
  }

  function render() {
    ctx.fillStyle = '#e6edf2';
    ctx.fillRect(0, 0, W, H);

    var startX = Math.floor(cam.x / TILE);
    var startY = Math.floor(cam.y / TILE);
    var endX = Math.ceil((cam.x + W) / TILE);
    var endY = Math.ceil((cam.y + H) / TILE);

    startX = clamp(startX, 0, GRID - 1);
    startY = clamp(startY, 0, GRID - 1);
    endX = clamp(endX, 0, GRID);
    endY = clamp(endY, 0, GRID);

    for (var y = startY; y < endY; y++) {
      for (var x = startX; x < endX; x++) {
        var sx = x * TILE - cam.x;
        var sy = y * TILE - cam.y;
        drawTile(x, y, sx, sy);
      }
    }

    drawOrderMarkers();
    drawPlayer();

    for (var i = 0; i < floaters.length; i++) {
      var f = floaters[i];
      var fx = f.x * TILE - cam.x + TILE / 2;
      var fy = f.y * TILE - cam.y + TILE / 2 - (1.2 - f.life) * 40;
      ctx.save();
      ctx.globalAlpha = Math.max(0, Math.min(1, f.life));
      ctx.fillStyle = f.color;
      ctx.font = 'bold 15px -apple-system,sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(f.text, fx, fy);
      ctx.restore();
    }
  }

  // ===== UI 更新 =====
  function updateHearts() {
    var s = '';
    for (var i = 0; i < 3; i++) s += i < hearts ? '❤️' : '🖤';
    $hearts.textContent = s;
  }

  function updateOrderUI() {
    var now = Date.now();
    if (now - lastUIUpdate < 150) return;
    lastUIUpdate = now;

    var html = '';
    for (var i = 0; i < orders.length; i++) {
      var o = orders[i];
      var pct = Math.max(0, o.timeLeft / o.maxTime) * 100;
      var barColor = pct > 50 ? '#22c55e' : (pct > 25 ? '#f59e0b' : '#ef4444');
      var phaseText = '';
      if (o.phase === 'pickup') phaseText = '去「' + o.merchantName + '」取餐';
      else if (o.phase === 'errand') phaseText = '帮买:' + o.errandItem;
      else phaseText = '送到顾客';

      html += '<div class="order-card" data-id="' + o.id + '">' +
        '<div class="order-head">' + (o.type === 'errand' ? '帮买单' : '外卖单') + '</div>' +
        '<div class="order-phase">' + phaseText + '</div>' +
        '<div class="order-bar"><div style="width:' + pct + '%;background:' + barColor + '"></div></div>' +
        '<div class="order-time">' + Math.ceil(o.timeLeft) + 's</div>' +
        '</div>';
    }
    $orders.innerHTML = html;
  }

  // ===== 开始 / 结束 =====
  function reset() {
    resize();
    state = 'playing';
    money = 0; hearts = 3; done = 0; combo = 0;
    orders = [];
    floaters = [];
    orderIdCounter = 0;
    needNewOrder = false;

    $money.textContent = '0';
    $done.textContent = '0';
    updateHearts();
    $start.style.display = 'none';
    $over.style.display = 'none';

    generateMap();

    var spawn = nearestWalkable(1, 1) || { x: 1, y: 1 };
    player.x = spawn.x;
    player.y = spawn.y;
    player.path = [];

    cam.x = clamp(player.x * TILE + TILE / 2 - W / 2, 0, Math.max(0, WORLD - W));
    cam.y = clamp(player.y * TILE + TILE / 2 - H / 2, 0, Math.max(0, WORLD - H));

    spawnNextOrder();
    updateOrderUI();
  }

  function gameOver() {
    state = 'over';
    orders = [];
    $orders.innerHTML = '';
    $overBody.innerHTML =
      '<p class="stat">总收入 <b>¥' + money + '</b></p>' +
      '<p class="stat">完成 <b>' + done + '</b> 单</p>';
    $over.style.display = 'flex';
  }

  // ===== 主循环 =====
  function loop(t) {
    if (!lastT) lastT = t;
    var dt = (t - lastT) / 1000;
    lastT = t;
    if (dt > 0.05) dt = 0.05;

    if (state === 'playing') {
      updatePlayer(dt);
      checkInteractions();
      updateOrders(dt);
      updateFloaters(dt);
      updateCamera(Math.min(1, dt * 8));

      if (needNewOrder) {
        needNewOrder = false;
        spawnNextOrder();
      }
      updateOrderUI();
    }

    render();
    requestAnimationFrame(loop);
  }

  // ===== 事件绑定 =====
  $btnStart.addEventListener('click', function (e) {
    e.stopPropagation();
    reset();
  });

  $btnAgain.addEventListener('click', function (e) {
    e.stopPropagation();
    reset();
  });

  cvs.addEventListener('pointerdown', function (e) {
    if (state !== 'playing') return;
    e.preventDefault();
    var wx = (e.clientX + cam.x) / TILE;
    var wy = (e.clientY + cam.y) / TILE;
    var path = findPath(player.x, player.y, wx, wy);
    if (path.length > 1) {
      player.path = path.slice(1);
    }
  });

  // 点击订单卡片自动导航
  $orders.addEventListener('click', function (e) {
    var card = e.target.closest('.order-card');
    if (!card || state !== 'playing') return;
    var id = parseInt(card.getAttribute('data-id'), 10);
    var o = null;
    for (var i = 0; i < orders.length; i++) {
      if (orders[i].id === id) { o = orders[i]; break; }
    }
    if (!o) return;

    var target = o.merchant;
    if (o.phase === 'errand') target = o.errandShop;
    else if (o.phase === 'deliver') target = o.customer;

    if (target) {
      var path = findPath(player.x, player.y, target.x, target.y);
      if (path.length > 1) {
        player.path = path.slice(1);
      }
    }
  });

  // 初始渲染
  resize();
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>

Game Source: 跑外卖 · 进阶版

Creator: CyberFox46

Libraries: none

Complexity: complex (820 lines, 24.3 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: game-cyberfox46" to link back to the original. Then publish at arcadelab.ai/publish.