🎮ArcadeLab

经典大战 · 选人与决斗

by ApexFalcon66
1029 lines31.6 KB
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>经典大战 · 选人与决斗</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    background: radial-gradient(ellipse at 50% 40%, #0b1a24 0%, #05090d 55%, #010204 100%);
    font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
    color: #d7f6ff;
    overflow: hidden;
    user-select: none;
    -webkit-user-select: none;
  }

  .screen {
    position: fixed; inset: 0;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    transition: opacity .35s ease, transform .35s ease;
  }
  .screen.hidden {
    opacity: 0; pointer-events: none;
    transform: scale(1.02);
  }

  #screen-start { gap: 28px; }
  .title-main {
    font-size: clamp(32px, 7vw, 64px);
    letter-spacing: 10px;
    font-weight: 900;
    color: #eafffd;
    text-shadow: 0 0 18px rgba(0,255,224,.6), 0 0 60px rgba(0,200,180,.35);
  }
  .title-sub { font-size: 13px; letter-spacing: 4px; color: rgba(0,255,224,.55); }
  .start-btn {
    margin-top: 8px; padding: 16px 64px;
    font-size: 18px; letter-spacing: 6px;
    color: #031412;
    background: linear-gradient(180deg, #9ffff2, #22e3c5);
    border: none; border-radius: 6px;
    box-shadow: 0 0 22px rgba(0,255,224,.45), inset 0 1px 0 rgba(255,255,255,.6);
    cursor: pointer; transition: transform .15s, box-shadow .15s;
  }
  .start-btn:hover { transform: translateY(-2px); box-shadow: 0 0 34px rgba(0,255,224,.7); }

  #screen-select { gap: 16px; padding: 20px; }
  .select-title { font-size: 15px; letter-spacing: 5px; color: rgba(0,255,224,.7); }
  .select-hint  { font-size: 12px; color: rgba(215,246,255,.45); margin-top: -6px; }

  .roster {
    display: flex; gap: 10px;
    flex-wrap: wrap; justify-content: center;
    max-width: 760px;
  }

  .card {
    width: 128px;
    background: linear-gradient(170deg, rgba(14,32,44,.95), rgba(6,14,20,.98));
    border: 1px solid rgba(0,255,224,.16);
    border-radius: 8px; padding: 9px 10px 8px;
    cursor: pointer; position: relative;
    transition: border-color .18s, box-shadow .18s, transform .18s;
  }
  .card:hover { border-color: rgba(0,255,224,.45); transform: translateY(-2px); }
  .card.selected {
    border-color: #3dffe4;
    box-shadow: 0 0 0 1px rgba(61,255,228,.5), 0 0 18px rgba(0,255,224,.25);
  }
  .card.selected::after {
    content: "✓";
    position: absolute; top: 5px; right: 6px;
    width: 14px; height: 14px;
    font-size: 10px; line-height: 14px; text-align: center;
    color: #031412; background: #3dffe4;
    border-radius: 50%; font-weight: 700;
  }

  .char-name { font-size: 15px; font-weight: 800; letter-spacing: 1px; }
  .char-source { font-size: 9px; color: rgba(215,246,255,.4); margin-top: 1px; }

  .tag-row { display: flex; gap: 4px; margin: 6px 0 7px; }
  .tag {
    font-size: 9px; letter-spacing: 1px;
    padding: 1px 5px; border-radius: 3px;
    border: 1px solid rgba(0,255,224,.25); color: rgba(0,255,224,.8);
  }
  .tag.ranged { border-color: rgba(255,190,90,.35); color: rgba(255,200,120,.9); }

  .stats {
    display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px;
    font-size: 9px; color: rgba(215,246,255,.5);
  }
  .stats b { color: rgba(215,246,255,.85); font-weight: 600; margin-left: 3px; }

  .select-actions { display: flex; gap: 14px; margin-top: 6px; }
  .btn {
    padding: 12px 34px; font-size: 14px; letter-spacing: 3px;
    border-radius: 5px; border: 1px solid rgba(0,255,224,.3);
    background: rgba(0,255,224,.06); color: rgba(0,255,224,.8);
    cursor: pointer; transition: .18s;
  }
  .btn:hover:not(:disabled) { background: rgba(0,255,224,.14); }
  .btn:disabled { opacity: .28; cursor: not-allowed; }
  .btn-primary {
    background: linear-gradient(180deg, #9ffff2, #22e3c5);
    color: #031412; border: none; font-weight: 800;
  }
  .btn-primary:disabled { background: rgba(120,180,170,.2); color: rgba(215,246,255,.3); }

  #screen-map { gap: 12px; }
  .map-hud {
    display: flex; gap: 40px; align-items: center;
    font-size: 12px; letter-spacing: 1px; color: rgba(215,246,255,.6);
  }
  .hud-name { font-weight: 700; color: #eafffd; font-size: 14px; }
  .hud-hp { color: #3dffe4; font-weight: 700; }
  .hud-vs { color: rgba(255,255,255,.2); font-size: 16px; letter-spacing: 4px; }
  canvas { display: block; touch-action: none; }

  .back-link {
    font-size: 11px; color: rgba(0,255,224,.4);
    cursor: pointer; letter-spacing: 2px; margin-top: 4px;
  }
  .back-link:hover { color: rgba(0,255,224,.8); }

  #screen-result {
    gap: 18px;
    background: radial-gradient(ellipse at 50% 50%, rgba(0,40,36,.85), rgba(1,2,4,.96) 70%);
  }
  .result-label {
    font-size: 13px; letter-spacing: 8px; color: rgba(0,255,224,.55);
    animation: fadeUp .5s ease;
  }
  .result-name {
    font-size: clamp(40px, 10vw, 76px); font-weight: 900; letter-spacing: 8px;
    color: #eafffd;
    text-shadow: 0 0 24px rgba(0,255,224,.75), 0 0 90px rgba(0,200,180,.45);
    animation: resultPop .55s cubic-bezier(.2,1.5,.5,1);
  }
  .result-sub {
    font-size: 12px; letter-spacing: 3px; color: rgba(215,246,255,.35);
    margin-bottom: 8px; animation: fadeUp .7s ease .1s both;
  }
  #btn-result-back { animation: fadeUp .7s ease .18s both; }
  @keyframes resultPop {
    0% { transform: scale(.6); opacity: 0; }
    60% { transform: scale(1.08); opacity: 1; }
    100% { transform: scale(1); opacity: 1; }
  }
  @keyframes fadeUp {
    0% { transform: translateY(10px); opacity: 0; }
    100% { transform: translateY(0); opacity: 1; }
  }
</style>
</head>
<body>

<section class="screen" id="screen-start">
  <div class="title-main">经典大战</div>
  <div class="title-sub">CLASSIC ARENA · 文本作证 · 规则统一</div>
  <button class="start-btn" id="btn-to-select">开 始 游 戏</button>
</section>

<section class="screen hidden" id="screen-select">
  <div class="select-title">选择参战角色</div>
  <div class="select-hint">至少选中 2 名角色,点击“开始决斗”进入地图</div>
  <div class="roster" id="roster"></div>
  <div class="select-actions">
    <button class="btn" id="btn-back-start">返回</button>
    <button class="btn btn-primary" id="btn-start-duel" disabled>开始决斗</button>
  </div>
</section>

<section class="screen hidden" id="screen-map">
  <div class="map-hud" id="map-hud"></div>
  <canvas id="game"></canvas>
  <div class="back-link" id="btn-back-select">← 返回选人</div>
</section>

<section class="screen hidden" id="screen-result">
  <div class="result-label">胜 者</div>
  <div class="result-name" id="result-name">—</div>
  <div class="result-sub" id="result-sub">经典大战</div>
  <button class="btn btn-primary" id="btn-result-back">返回重新选择</button>
</section>

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

  /* ============================================================
     0. 角色数据
     ============================================================ */
  const CHARACTERS = [
    {
      id: 'xiangzi', name: '祥子', source: '《骆驼祥子》',
      type: 'melee', typeLabel: '近战',
      hp: 100, sta: 100, mp: 0,
      speed: 1.6, atk: 9,
      atkRange: 1.4, detectRange: 5,
      atkCd: 0.7, color: '#3dffe4',
    },
    {
      id: 'ruifeng', name: '瑞丰', source: '《四世同堂》',
      type: 'ranged', typeLabel: '远程',
      hp: 72, sta: 80, mp: 100,
      speed: 1.15, atk: 6,
      atkRange: 6.5, detectRange: 0,
      atkCd: 1.1, color: '#ffd76a',
    },
    {
      id: 'fushan', name: '富善先生', source: '《四世同堂》',
      type: 'ranged', typeLabel: '远程',
      hp: 85, sta: 70, mp: 100,
      speed: 1.0, atk: 7,
      atkRange: 7.2, detectRange: 0,
      atkCd: 1.25, color: '#7ec8ff',
    },
    {
      id: 'baoeer', name: '保尔·柯察金', source: '《钢铁是怎样炼成的》',
      type: 'melee', typeLabel: '近战',
      hp: 110, sta: 100, mp: 0,
      speed: 1.5, atk: 10,
      atkRange: 1.5, detectRange: 5.5,
      atkCd: 0.75, color: '#e05a3a',
      passive: 'steel_will',
    },
    {
      id: 'zhuhelai', name: '朱赫来', source: '《钢铁是怎样炼成的》',
      type: 'melee', typeLabel: '近战',
      hp: 115, sta: 100, mp: 0,
      speed: 1.45, atk: 11,
      atkRange: 1.3, detectRange: 5,
      atkCd: 0.95, color: '#c9a227',
      passive: 'lead_fist',
    },
    {
      id: 'victor', name: '维克托', source: '《钢铁是怎样炼成的》',
      type: 'ranged', typeLabel: '远程',
      hp: 68, sta: 60, mp: 100,
      speed: 1.3, atk: 5,
      atkRange: 6.0, detectRange: 0,
      atkCd: 1.0, color: '#b07ac9',
      passive: 'inform',
    },
  ];

  /* ============================================================
     1. 地图常量与物理
     ============================================================ */
  const COLS = 20, ROWS = 20, CELL = 32, WALL = 16;
  const GRID_W = COLS * CELL, GRID_H = ROWS * CELL;
  const WORLD_W = GRID_W + WALL * 2, WORLD_H = GRID_H + WALL * 2;

  const WALLS = [
    { x: 0, y: 0, w: WORLD_W, h: WALL },
    { x: 0, y: WORLD_H - WALL, w: WORLD_W, h: WALL },
    { x: 0, y: 0, w: WALL, h: WORLD_H },
    { x: WORLD_W - WALL, y: 0, w: WALL, h: WORLD_H },
  ];

  function aabbHit(a, b) {
    return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
  }
  function moveBody(body, dx, dy) {
    body.x += dx;
    for (const wall of WALLS) {
      if (!aabbHit(body, wall)) continue;
      if (dx > 0) body.x = wall.x - body.w;
      else if (dx < 0) body.x = wall.x + wall.w;
    }
    body.y += dy;
    for (const wall of WALLS) {
      if (!aabbHit(body, wall)) continue;
      if (dy > 0) body.y = wall.y - body.h;
      else if (dy < 0) body.y = wall.y + wall.h;
    }
    return body;
  }
  function cellCenter(col, row) {
    return { x: WALL + col * CELL + CELL / 2, y: WALL + row * CELL + CELL / 2 };
  }

  /* ============================================================
     2. 选人界面
     ============================================================ */
  const rosterEl = document.getElementById('roster');
  const btnStartDuel = document.getElementById('btn-start-duel');
  const selected = new Set();

  function renderRoster() {
    rosterEl.innerHTML = CHARACTERS.map(c => {
      const typeClass = c.type === 'ranged' ? 'tag ranged' : 'tag';
      return `
        <div class="card" data-id="${c.id}">
          <div class="char-name">${c.name}</div>
          <div class="char-source">${c.source}</div>
          <div class="tag-row"><span class="${typeClass}">${c.typeLabel}</span></div>
          <div class="stats">
            <span>生命<b>${c.hp}</b></span>
            <span>攻击<b>${c.atk}</b></span>
            <span>速度<b>${c.speed}</b></span>
            <span>射程<b>${c.atkRange}</b></span>
          </div>
        </div>
      `;
    }).join('');

    rosterEl.querySelectorAll('.card').forEach(card => {
      card.addEventListener('click', () => {
        const id = card.dataset.id;
        if (selected.has(id)) { selected.delete(id); card.classList.remove('selected'); }
        else { selected.add(id); card.classList.add('selected'); }
        btnStartDuel.disabled = selected.size < 2;
      });
    });
  }

  /* ============================================================
     3. 界面切换
     ============================================================ */
  const screens = {
    start: document.getElementById('screen-start'),
    select: document.getElementById('screen-select'),
    map: document.getElementById('screen-map'),
    result: document.getElementById('screen-result'),
  };
  function show(name) {
    Object.values(screens).forEach(s => s.classList.add('hidden'));
    screens[name].classList.remove('hidden');
  }

  document.getElementById('btn-to-select').addEventListener('click', () => {
    renderRoster();
    selected.clear();
    btnStartDuel.disabled = true;
    show('select');
  });
  document.getElementById('btn-back-start').addEventListener('click', () => show('start'));
  document.getElementById('btn-back-select').addEventListener('click', () => {
    stopDuel();
    show('select');
  });

  /* ============================================================
     4. 地图绘制
     ============================================================ */
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const mapHud = document.getElementById('map-hud');

  function resizeCanvas() {
    const size = Math.min(window.innerWidth * 0.9, window.innerHeight * 0.72, 640);
    const dpr = window.devicePixelRatio || 1;
    canvas.style.width = size + 'px';
    canvas.style.height = size + 'px';
    canvas.width = Math.round(size * dpr);
    canvas.height = Math.round(size * dpr);
  }

  function drawArena(time) {
    const scale = canvas.width / WORLD_W;
    ctx.setTransform(scale, 0, 0, scale, 0, 0);
    ctx.clearRect(0, 0, WORLD_W, WORLD_H);

    const bg = ctx.createLinearGradient(WALL, WALL, WALL + GRID_W, WALL + GRID_H);
    bg.addColorStop(0, '#0b1927');
    bg.addColorStop(0.5, '#0d1e2e');
    bg.addColorStop(1, '#061018');
    ctx.fillStyle = bg;
    ctx.fillRect(WALL, WALL, GRID_W, GRID_H);

    ctx.lineWidth = 1;
    ctx.strokeStyle = 'rgba(80,200,255,.10)';
    ctx.beginPath();
    for (let c = 0; c <= COLS; c++) {
      const x = WALL + c * CELL;
      ctx.moveTo(x, WALL); ctx.lineTo(x, WALL + GRID_H);
    }
    for (let r = 0; r <= ROWS; r++) {
      const y = WALL + r * CELL;
      ctx.moveTo(WALL, y); ctx.lineTo(WALL + GRID_W, y);
    }
    ctx.stroke();

    const pulse = 0.5 + 0.5 * Math.sin(time * 0.002);
    ctx.save();
    ctx.shadowColor = 'rgba(0,255,224,.85)';
    ctx.shadowBlur = 8 + pulse * 16;
    ctx.beginPath();
    ctx.rect(0, 0, WORLD_W, WORLD_H);
    ctx.rect(WALL, WALL, GRID_W, GRID_H);
    ctx.fillStyle = '#00cdb2';
    ctx.fill('evenodd');
    ctx.restore();

    ctx.strokeStyle = `rgba(190,255,248,${0.4 + pulse * 0.45})`;
    ctx.lineWidth = 2;
    ctx.strokeRect(WALL + 1, WALL + 1, GRID_W - 2, GRID_H - 2);
  }

  /* ============================================================
     5. 决斗逻辑
     ============================================================ */
  const AGGRO_DURATION = 3.5;   // 受击激怒持续时间(秒)

  let fighters = [];
  let projectiles = [];
  let effects = [];
  let floatingTexts = [];
  let duelActive = false;
  let rafId = null;
  let lastTime = 0;
  let duelSession = 0;

  function assignWanderLeg(f) {
    f.wanderAngle = Math.random() * Math.PI * 2;
    f.wanderSpeedBoost = 1.15 + Math.random() * 1.05;
    f.wanderDistLeft = (2.5 + Math.random() * 5) * CELL;
  }

  function stopDuel() {
    duelActive = false;
    if (rafId) cancelAnimationFrame(rafId);
    rafId = null;
    fighters = [];
    projectiles = [];
    effects = [];
    floatingTexts = [];
  }

  function startDuel() {
    stopDuel();
    duelSession++;
    resizeCanvas();
    show('map');

    const chosen = CHARACTERS.filter(c => selected.has(c.id));
    const duo = chosen.slice(0, 2);

    const spawnA = cellCenter(3, 17);
    const spawnB = cellCenter(16, 2);

    fighters = duo.map((c, i) => {
      const f = {
        def: c,
        x: (i === 0 ? spawnA : spawnB).x - 13,
        y: (i === 0 ? spawnA : spawnB).y - 13,
        w: 26, h: 26,
        hp: c.hp, maxHp: c.hp,
        sta: c.sta, maxSta: c.sta,
        mp: c.mp, maxMp: c.mp,
        atkTimer: 0,
        shield: 0, shieldTimer: 0, shieldInit: false,
        marked: 0,
        informMark: 0,
        atkCount: 0,
        alive: true,
        speedMult: 1,
        wanderAngle: 0,
        wanderSpeedBoost: 1,
        wanderDistLeft: 0,
        dashTimer: 0,
        dashCooldown: 0,
        hitFlash: 0,
        atkFlash: 0,
        // 受击激怒
        aggroTarget: null,
        aggroTimer: 0,
      };
      assignWanderLeg(f);
      return f;
    });

    updateHud();
    duelActive = true;
    lastTime = performance.now();
    rafId = requestAnimationFrame(duelLoop);
  }

  function duelLoop(now) {
    if (!duelActive) return;
    const dt = Math.min((now - lastTime) / 1000, 0.05);
    lastTime = now;

    update(dt);
    render(now);

    if (duelActive) rafId = requestAnimationFrame(duelLoop);
  }

  function fireProjectile(owner, target, dmg, opts) {
    const ox = owner.x + owner.w / 2;
    const oy = owner.y + owner.h / 2;
    const tx = target.x + target.w / 2;
    const ty = target.y + target.h / 2;
    const ang = Math.atan2(ty - oy, tx - ox);
    const speed = opts.speed || 420;

    projectiles.push({
      owner,
      x: ox, y: oy,
      vx: Math.cos(ang) * speed,
      vy: Math.sin(ang) * speed,
      dmg,
      color: opts.color || '#fff',
      radius: opts.radius || 5,
      life: 2.5,
      isRuifeng: !!opts.isRuifeng,
      isVictor: !!opts.isVictor,
      trail: [],
    });
    owner.atkFlash = 0.12;
  }

  /* ---- 核心伤害结算 ---- */
  function applyDamage(attacker, target, dmg, meta) {
    let finalDmg = dmg;

    if (meta.isRuifeng) {
      if (target.marked > 0) finalDmg *= 1.2;
      target.marked = 3;
    }

    if (target.informMark > 0) {
      finalDmg *= 1.25;
    }

    if (target.shield > 0) {
      finalDmg *= 0.4;
      target.shield = 0;
      target.shieldTimer = 0;
    }

    if (target.def.passive === 'steel_will') {
      const missing = 1 - target.hp / target.maxHp;
      const reduction = Math.min(0.3, missing * 0.5);
      finalDmg *= (1 - reduction);
    }

    target.hp = Math.max(0, target.hp - finalDmg);
    target.hitFlash = 0.15;

    // —— 近战受击自动激怒:锁定伤害来源 ——
    if (target.def.type === 'melee' && attacker && attacker !== target && attacker.alive) {
      target.aggroTarget = attacker;
      target.aggroTimer = AGGRO_DURATION;
    }

    floatingTexts.push({
      x: target.x + target.w / 2 + (Math.random() - 0.5) * 18,
      y: target.y - 2,
      text: '-' + Math.round(finalDmg),
      life: 0.9, maxLife: 0.9,
      color: '#ff3b3b',
    });

    if (target.hp <= 0) {
      target.alive = false;
      endDuel(attacker);
      return;
    }
    updateHud();
  }

  function update(dt) {
    const aliveList = fighters.filter(f => f.alive);
    if (aliveList.length < 2) return;

    /* ---- 祥子:体力透支 ---- */
    const xz = fighters.find(f => f.def.id === 'xiangzi' && f.alive);
    if (xz) {
      if (xz.hp / xz.maxHp < 0.4 && xz.sta > 0) {
        xz.sta = Math.max(0, xz.sta - 2 * dt);
        xz.speedMult = xz.sta > 0 ? 1.3 : 0.6;
      } else {
        xz.speedMult = 1;
      }
    }

    /* ---- 富善先生:旧物屏障 ---- */
    const fs = fighters.find(f => f.def.id === 'fushan' && f.alive);
    if (fs) {
      if (!fs.shieldInit) { fs.shield = 1; fs.shieldInit = true; }
      fs.shieldTimer += dt;
      if (fs.shieldTimer >= 12) { fs.shieldTimer = 0; fs.shield = 1; }
    }

    /* ---- 冷却/标记/激怒倒计时 ---- */
    for (const f of fighters) {
      if (!f.alive) continue;
      f.atkTimer = Math.max(0, f.atkTimer - dt);
      if (f.marked > 0) f.marked = Math.max(0, f.marked - dt);
      if (f.informMark > 0) f.informMark = Math.max(0, f.informMark - dt);
      if (f.dashTimer > 0) f.dashTimer = Math.max(0, f.dashTimer - dt);
      if (f.dashCooldown > 0) f.dashCooldown = Math.max(0, f.dashCooldown - dt);
      if (f.hitFlash > 0) f.hitFlash = Math.max(0, f.hitFlash - dt);
      if (f.atkFlash > 0) f.atkFlash = Math.max(0, f.atkFlash - dt);

      // 激怒计时与失效清理
      if (f.aggroTimer > 0) {
        f.aggroTimer = Math.max(0, f.aggroTimer - dt);
        if (f.aggroTimer === 0 || !f.aggroTarget || !f.aggroTarget.alive) {
          f.aggroTarget = null;
          f.aggroTimer = 0;
        }
      }
    }

    /* ---- 单位移动与攻击 ---- */
    for (const f of fighters) {
      if (!f.alive) continue;

      // 选目标:近战优先追激怒目标(无视探测范围),否则走原逻辑
      let target = null;
      let tracking = false;
      let isAggro = false;

      if (
        f.def.type === 'melee' &&
        f.aggroTimer > 0 &&
        f.aggroTarget &&
        f.aggroTarget.alive
      ) {
        target = f.aggroTarget;
        tracking = true;
        isAggro = true;
      } else {
        target = fighters.find(o => o !== f && o.alive);
      }

      if (!target) continue;

      const dx = (target.x + target.w / 2) - (f.x + f.w / 2);
      const dy = (target.y + target.h / 2) - (f.y + f.h / 2);
      const targetDist = Math.hypot(dx, dy);

      let moveAngle = f.wanderAngle;
      let shouldMove = true;

      if (isAggro) {
        // 激怒追击:直接朝伤害来源走
        moveAngle = Math.atan2(dy, dx);
        if (targetDist <= f.def.atkRange * CELL) shouldMove = false;
      } else if (f.def.type === 'melee' && targetDist <= f.def.detectRange * CELL) {
        moveAngle = Math.atan2(dy, dx);
        tracking = true;
        if (targetDist <= f.def.atkRange * CELL) shouldMove = false;
      }

      if (!tracking && f.dashCooldown <= 0 && Math.random() < 0.008) {
        f.dashTimer = 0.15 + Math.random() * 0.2;
        f.dashCooldown = 0.8 + Math.random() * 1.0;
      }

      // 移动
      if (shouldMove) {
        let speedMul = tracking ? 1.0 : f.wanderSpeedBoost;
        if (f.dashTimer > 0) speedMul *= 2.5;

        const speed = f.def.speed * CELL * (f.speedMult || 1) * speedMul * dt;
        const oldX = f.x, oldY = f.y;
        moveBody(f, Math.cos(moveAngle) * speed, Math.sin(moveAngle) * speed);

        const moved = Math.hypot(f.x - oldX, f.y - oldY);

        if (moved < speed * 0.4) {
          if (!tracking) { assignWanderLeg(f); f.dashTimer = 0; }
        } else if (!tracking) {
          f.wanderDistLeft -= moved;
          if (f.wanderDistLeft <= 0) assignWanderLeg(f);
        }
      }

      // 攻击
      if (f.def.type === 'melee') {
        if (targetDist <= f.def.atkRange * CELL && f.atkTimer <= 0) {
          f.atkTimer = f.def.atkCd;
          f.atkFlash = 0.15;

          let meleeDmg = f.def.atk;
          if (f.def.passive === 'lead_fist') {
            f.atkCount++;
            if (f.atkCount % 3 === 0) meleeDmg *= 1.8;
          }

          effects.push({
            x: target.x + target.w / 2,
            y: target.y + target.h / 2,
            angle: Math.atan2(dy, dx),
            life: 0.22, maxLife: 0.22,
            color: f.def.color,
            heavy: f.def.passive === 'lead_fist' && f.atkCount % 3 === 0,
          });

          applyDamage(f, target, meleeDmg, { isRuifeng: false });
        }
      } else {
        if (f.atkTimer <= 0) {
          f.atkTimer = f.def.atkCd;

          let dmg = f.def.atk;
          const isRuifeng = f.def.id === 'ruifeng';
          const isVictor = f.def.id === 'victor';

          if (isRuifeng && targetDist < CELL * 2) dmg *= 0.65;
          if (isVictor && targetDist > CELL * 4) dmg *= 0.8;

          fireProjectile(f, target, dmg, {
            speed: 420,
            color: f.def.color,
            radius: 5,
            isRuifeng,
            isVictor,
          });
        }
      }
    }

    /* ---- 投掷物 ---- */
    for (let i = projectiles.length - 1; i >= 0; i--) {
      const p = projectiles[i];
      p.life -= dt;
      if (p.life <= 0) { projectiles.splice(i, 1); continue; }

      p.x += p.vx * dt;
      p.y += p.vy * dt;

      p.trail.push({ x: p.x, y: p.y });
      if (p.trail.length > 7) p.trail.shift();

      if (p.x < WALL || p.x > WORLD_W - WALL || p.y < WALL || p.y > WORLD_H - WALL) {
        projectiles.splice(i, 1);
        continue;
      }

      let hit = false;
      for (const f of fighters) {
        if (!f.alive || f === p.owner) continue;
        const fx = f.x + f.w / 2;
        const fy = f.y + f.h / 2;
        const d = Math.hypot(p.x - fx, p.y - fy);
        if (d <= f.w / 2 + p.radius) {
          if (p.isVictor) f.informMark = 4;
          applyDamage(p.owner, f, p.dmg, { isRuifeng: p.isRuifeng });
          hit = true;
          break;
        }
      }
      if (hit) projectiles.splice(i, 1);
    }

    /* ---- 特效与伤害数字 ---- */
    for (let i = effects.length - 1; i >= 0; i--) {
      effects[i].life -= dt;
      if (effects[i].life <= 0) effects.splice(i, 1);
    }
    for (let i = floatingTexts.length - 1; i >= 0; i--) {
      floatingTexts[i].life -= dt;
      if (floatingTexts[i].life <= 0) floatingTexts.splice(i, 1);
    }
  }

  function endDuel(winner) {
    duelActive = false;
    if (rafId) cancelAnimationFrame(rafId);
    rafId = null;

    const mySession = duelSession;
    setTimeout(() => {
      if (mySession !== duelSession) return;
      if (screens.map.classList.contains('hidden')) return;

      document.getElementById('result-name').textContent = winner.def.name;
      document.getElementById('result-sub').textContent = winner.def.source + ' · 经典大战';
      show('result');
    }, 550);
  }

  function updateHud() {
    if (fighters.length < 2) return;
    const [a, b] = fighters;
    mapHud.innerHTML = `
      <span><span class="hud-name">${a.def.name}</span> <span class="hud-hp">${Math.ceil(a.hp)}</span>/${a.maxHp}</span>
      <span class="hud-vs">VS</span>
      <span><span class="hud-name">${b.def.name}</span> <span class="hud-hp">${Math.ceil(b.hp)}</span>/${b.maxHp}</span>
    `;
  }

  /* ---- 渲染 ---- */
  function render(time) {
    drawArena(time);

    for (const f of fighters) {
      if (!f.alive) continue;
      const cx = f.x + f.w / 2;
      const cy = f.y + f.h / 2;

      const isAggro = f.def.type === 'melee' && f.aggroTimer > 0 && f.aggroTarget && f.aggroTarget.alive;

      // 近战探测范围(激怒时改成红色)
      if (f.def.type === 'melee' && f.def.detectRange > 0) {
        ctx.save();
        ctx.setLineDash([5, 5]);
        ctx.strokeStyle = isAggro
          ? 'rgba(255,70,70,.28)'
          : 'rgba(61,255,228,.18)';
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(cx, cy, f.def.detectRange * CELL, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      }

      const dashing = f.dashTimer > 0;
      const hit = f.hitFlash > 0;
      const atk = f.atkFlash > 0;

      let steelRing = false;
      if (f.def.passive === 'steel_will' && f.hp / f.maxHp < 0.5) steelRing = true;

      // 激怒脉冲环
      if (isAggro) {
        const pulse = 0.5 + 0.5 * Math.sin(time * 0.012);
        ctx.save();
        ctx.strokeStyle = `rgba(255,70,70,${0.35 + pulse * 0.4})`;
        ctx.lineWidth = 2.5;
        ctx.shadowColor = 'rgba(255,70,70,.8)';
        ctx.shadowBlur = 10;
        ctx.beginPath();
        ctx.arc(cx, cy, f.w / 2 + 9 + pulse * 2, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      }

      ctx.save();
      ctx.shadowColor = f.def.color;
      ctx.shadowBlur = hit ? 28 : (atk ? 22 : (dashing ? 26 : 12));
      ctx.fillStyle = hit ? '#ffffff' : (atk ? '#ffffff' : (dashing ? '#ffffff' : f.def.color));
      ctx.beginPath();
      ctx.arc(cx, cy, f.w / 2, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();

      if (steelRing) {
        ctx.save();
        ctx.strokeStyle = 'rgba(224,90,58,.65)';
        ctx.lineWidth = 2.5;
        ctx.setLineDash([4, 4]);
        ctx.beginPath();
        ctx.arc(cx, cy, f.w / 2 + 7, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      }

      if (f.def.type === 'melee' && atk) {
        ctx.save();
        ctx.strokeStyle = 'rgba(255,255,255,.9)';
        ctx.lineWidth = 2;
        ctx.shadowColor = f.def.color;
        ctx.shadowBlur = 14;
        ctx.beginPath();
        ctx.arc(cx, cy, f.w / 2 + 6, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      }

      ctx.fillStyle = 'rgba(255,255,255,.85)';
      ctx.font = '11px -apple-system, "PingFang SC", sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(f.def.name, cx, f.y - 8);

      // 血条
      const barW = 34, barH = 4;
      const bx = cx - barW / 2, by = f.y - 20;
      ctx.fillStyle = 'rgba(0,0,0,.5)';
      ctx.fillRect(bx, by, barW, barH);
      ctx.fillStyle = f.def.color;
      ctx.fillRect(bx, by, barW * (f.hp / f.maxHp), barH);

      if (f.shield > 0) {
        ctx.strokeStyle = 'rgba(126,200,255,.8)';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.arc(cx, cy, f.w / 2 + 5, 0, Math.PI * 2);
        ctx.stroke();
      }

      if (f.marked > 0) {
        ctx.fillStyle = 'rgba(255,215,106,.9)';
        ctx.beginPath();
        ctx.moveTo(cx, f.y - 28);
        ctx.lineTo(cx - 4, f.y - 34);
        ctx.lineTo(cx + 4, f.y - 34);
        ctx.closePath();
        ctx.fill();
      }

      if (f.informMark > 0) {
        ctx.fillStyle = 'rgba(176,122,201,.95)';
        ctx.fillRect(cx - 4, f.y - 35, 8, 8);
      }

      // 朱赫来铅拳就绪提示
      if (f.def.passive === 'lead_fist' && f.atkCount % 3 === 2) {
        ctx.save();
        ctx.strokeStyle = 'rgba(255,220,80,.8)';
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(cx, cy, f.w / 2 + 4, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      }

      // 激怒感叹号(头顶)
      if (isAggro) {
        ctx.save();
        ctx.fillStyle = '#ff4646';
        ctx.font = 'bold 16px -apple-system, "PingFang SC", sans-serif';
        ctx.textAlign = 'center';
        ctx.shadowColor = 'rgba(255,70,70,.9)';
        ctx.shadowBlur = 8;
        ctx.fillText('!', cx, f.y - 40);
        ctx.restore();
      }
    }

    // 投掷物
    for (const p of projectiles) {
      for (let i = 0; i < p.trail.length; i++) {
        const t = p.trail[i];
        const k = (i + 1) / p.trail.length;
        ctx.globalAlpha = k * 0.45;
        ctx.fillStyle = p.color;
        ctx.beginPath();
        ctx.arc(t.x, t.y, p.radius * (0.25 + k * 0.75), 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;

      ctx.save();
      ctx.shadowColor = p.color;
      ctx.shadowBlur = 16;
      ctx.fillStyle = '#ffffff';
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();

      ctx.strokeStyle = p.color;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
      ctx.stroke();
    }

    // 近战命中特效
    for (const e of effects) {
      const k = e.life / e.maxLife;
      const progress = 1 - k;

      ctx.save();
      ctx.globalAlpha = k * 0.95;

      ctx.strokeStyle = '#ffffff';
      ctx.shadowColor = e.color;
      ctx.shadowBlur = e.heavy ? 28 : 20;
      ctx.lineWidth = e.heavy ? 4 : 3;
      ctx.beginPath();
      ctx.arc(e.x, e.y, (e.heavy ? 16 : 12) + progress * (e.heavy ? 38 : 28), 0, Math.PI * 2);
      ctx.stroke();

      ctx.strokeStyle = e.color;
      ctx.lineWidth = e.heavy ? 9 : 6;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.arc(e.x, e.y, (e.heavy ? 14 : 10) + progress * 20, e.angle - 0.9, e.angle + 0.9);
      ctx.stroke();

      ctx.restore();
    }

    // 伤害数字
    for (const t of floatingTexts) {
      const k = t.life / t.maxLife;
      const age = 1 - k;
      const alpha = Math.min(1, k * 1.8);

      ctx.save();
      ctx.globalAlpha = alpha;
      ctx.font = 'bold 15px -apple-system, "PingFang SC", sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillStyle = t.color;
      ctx.shadowColor = 'rgba(255,0,0,.9)';
      ctx.shadowBlur = 10;
      ctx.fillText(t.text, t.x, t.y - age * 38);
      ctx.restore();
    }
  }

  /* ============================================================
     6. 启动
     ============================================================ */
  document.getElementById('btn-start-duel').addEventListener('click', startDuel);

  document.getElementById('btn-result-back').addEventListener('click', () => {
    stopDuel();
    duelSession++;
    renderRoster();
    selected.clear();
    btnStartDuel.disabled = true;
    show('select');
  });

  window.addEventListener('resize', () => {
    if (!screens.map.classList.contains('hidden')) resizeCanvas();
  });

  show('start');

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

Game Source: 经典大战 · 选人与决斗

Creator: ApexFalcon66

Libraries: none

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