Новая Школа - онлайн-школа подготовки к ЕГЭ
Задача Задача №3779077
А
Аноним
4 дня назад · публичный вопрос
А
Аноним

Создай штмл игру которая по геймплею будет как бравл старс 1|1 для смартфона

Ответ нейросети
4 дня назад

Короткий ответ: ниже — готовый однофайловый HTML-прототип арены 1×1 для смартфона. Механика в духе Brawl Stars: две сенсорные зоны, стены, здоровье, 3 патрона с перезарядкой, бот с простым ИИ и таймер. Это оригинальная игра без чужих ассетов, а не сетевой клон.

Сохрани код как arena.html и открой в браузере телефона.

<!doctype html>
<html lang="ru">
<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>Арена 1×1</title>
<style>
  * { box-sizing: border-box; }
  html, body {
    height: 100%;
    margin: 0;
    overflow: hidden;
    background: #070b14;
    color: #fff;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
    -webkit-user-select: none;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
  }
  #game {
    position: fixed;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    touch-action: none;
  }
  .overlay {
    position: fixed;
    inset: 0;
    z-index: 10;
    display: none;
    place-items: center;
    padding: 20px;
    background: rgba(3, 7, 16, .72);
    backdrop-filter: blur(7px);
    -webkit-backdrop-filter: blur(7px);
  }
  .overlay.show { display: grid; }
  .panel {
    width: min(430px, 100%);
    padding: 28px 22px 24px;
    border-radius: 26px;
    text-align: center;
    background: linear-gradient(160deg, rgba(26, 41, 71, .96), rgba(11, 17, 32, .98));
    border: 1px solid rgba(120, 190, 255, .28);
    box-shadow: 0 24px 80px rgba(0, 0, 0, .55), inset 0 1px 0 rgba(255, 255, 255, .08);
  }
  .badge {
    display: inline-block;
    padding: 7px 14px;
    border-radius: 999px;
    background: rgba(76, 160, 255, .14);
    border: 1px solid rgba(98, 184, 255, .35);
    color: #8dd7ff;
    font-size: 12px;
    font-weight: 800;
    letter-spacing: .16em;
  }
  h1 {
    margin: 16px 0 10px;
    font-size: clamp(34px, 9vw, 50px);
    line-height: .95;
    letter-spacing: .03em;
    text-shadow: 0 8px 30px rgba(60, 150, 255, .35);
    overflow-wrap: anywhere;
  }
  #subtitle {
    margin: 0 auto 18px;
    max-width: 330px;
    color: #b9c8df;
    font-size: 15px;
    line-height: 1.45;
  }
  .hint {
    margin: 0 auto 22px;
    max-width: 340px;
    padding: 12px 14px;
    border-radius: 14px;
    background: rgba(255, 255, 255, .04);
    border: 1px solid rgba(255, 255, 255, .06);
    color: #8fa3c2;
    font-size: 13px;
    line-height: 1.5;
  }
  button {
    width: 100%;
    max-width: 310px;
    border: 0;
    border-radius: 16px;
    padding: 16px 20px;
    background: linear-gradient(180deg, #43c6ff, #1977e8);
    color: #fff;
    font: 800 18px/1 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
    letter-spacing: .06em;
    box-shadow: 0 10px 26px rgba(20, 120, 240, .4), inset 0 1px 0 rgba(255, 255, 255, .45);
    cursor: pointer;
    touch-action: manipulation;
  }
  button:active {
    transform: translateY(1px) scale(.99);
    filter: brightness(1.08);
  }
</style>
</head>
<body>
<canvas id="game" aria-label="Игровая арена"></canvas>

<div id="overlay" class="overlay show">
  <div class="panel">
    <div class="badge">1 × 1</div>
    <h1 id="title">АРЕНА</h1>
    <p id="subtitle">Уничтожь бота раньше, чем он тебя.</p>
    <p class="hint">
      Телефон: левая половина экрана — движение, правая — прицел и огонь.<br>
      Компьютер: WASD и пробел.
    </p>
    <button id="startBtn">В БОЙ</button>
  </div>
</div>

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

  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const overlay = document.getElementById('overlay');
  const titleEl = document.getElementById('title');
  const subtitleEl = document.getElementById('subtitle');
  const startBtn = document.getElementById('startBtn');

  const TAU = Math.PI * 2;

  // Стены заданы в долях экрана, поэтому арена подстраивается под телефон.
  const LAYOUT = [
    [0.17, 0.17, 0.15, 0.16],
    [0.68, 0.17, 0.15, 0.16],
    [0.17, 0.67, 0.15, 0.16],
    [0.68, 0.67, 0.15, 0.16],
    [0.44, 0.41, 0.12, 0.18]
  ];

  let W = 0;
  let H = 0;
  let DPR = 1;
  let minDim = 1;

  let state = 'menu';
  let player = null;
  let enemy = null;
  let bullets = [];
  let particles = [];
  let obstacles = [];

  let matchTime = 90;
  let last = performance.now();
  let shake = 0;

  const touches = { left: null, right: null };
  const keys = new Set();

  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));

  function rr(x, y, w, h, r) {
    const rad = Math.max(0, Math.min(r, w / 2, h / 2));
    ctx.beginPath();
    ctx.moveTo(x + rad, y);
    ctx.arcTo(x + w, y, x + w, y + h, rad);
    ctx.arcTo(x + w, y + h, x, y + h, rad);
    ctx.arcTo(x, y + h, x, y, rad);
    ctx.arcTo(x, y, x + w, y, rad);
    ctx.closePath();
  }

  function resize() {
    const oldW = W;
    const oldH = H;

    W = Math.max(1, window.innerWidth);
    H = Math.max(1, window.innerHeight);
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    minDim = Math.min(W, H);

    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);

    obstacles = LAYOUT.map(([x, y, w, h]) => ({
      x: x * W,
      y: y * H,
      w: w * W,
      h: h * H
    }));

    if (state === 'playing' && player && enemy && oldW > 0 && oldH > 0) {
      const sx = W / oldW;
      const sy = H / oldH;

      for (const e of [player, enemy]) {
        e.x *= sx;
        e.y *= sy;
        e.r = Math.max(15, minDim * 0.038);
        e.speed = minDim * 0.55;
        e.range = minDim * 0.96;
        resolveCircleCollisions(e);
      }

      for (const b of bullets) {
        b.x *= sx;
        b.y *= sy;
        b.vx *= sx;
        b.vy *= sy;
        b.r = Math.max(3.5, minDim * 0.012);
        b.maxDist *= (sx + sy) * 0.5;
      }
    }
  }

  function makeFighter(isPlayer, x, y) {
    return {
      isPlayer,
      x,
      y,
      r: Math.max(15, minDim * 0.038),
      speed: minDim * 0.55,
      maxHp: 3000,
      hp: 3000,
      maxAmmo: 3,
      ammo: 3,
      reloadTime: isPlayer ? 1.05 : 1.2,
      reloadTimer: 0,
      fireTimer: 0,
      fireCooldown: isPlayer ? 0.24 : 0.46,
      range: minDim * 0.96,
      aimAngle: isPlayer ? 0 : Math.PI,
      strafeDir: Math.random() < 0.5 ? -1 : 1,
      strafeTimer: 1.2
    };
  }

  function initGame() {
    player = null;
    enemy = null;
    resize();

    bullets.length = 0;
    particles.length = 0;
    matchTime = 90;
    shake = 0;

    player = makeFighter(true, W * 0.12, H * 0.5);
    enemy = makeFighter(false, W * 0.88, H * 0.5);
    player.aimAngle = 0;
    enemy.aimAngle = Math.PI;

    resolveCircleCollisions(player);
    resolveCircleCollisions(enemy);

    touches.left = null;
    touches.right = null;
    keys.clear();

    overlay.classList.remove('show');
    state = 'playing';
    last = performance.now();
  }

  function endGame(winner) {
    if (state !== 'playing') return;
    state = 'over';
    touches.left = null;
    touches.right = null;
    shake = 0;

    if (winner === 'player') {
      titleEl.textContent = 'ПОБЕДА!';
      subtitleEl.textContent = 'Бот обезврежен. Перезарядка и укрытия решают.';
    } else if (winner === 'enemy') {
      titleEl.textContent = 'ПОРАЖЕНИЕ';
      subtitleEl.textContent = 'Бот оказался точнее. Попробуй снова.';
    } else {
      titleEl.textContent = 'НИЧЬЯ';
      subtitleEl.textContent = 'Время вышло: силы оказались равны.';
    }

    startBtn.textContent = 'ЕЩЁ РАЗ';
    overlay.classList.add('show');
  }

  function resolveCircleCollisions(e) {
    e.x = clamp(e.x, e.r + 8, W - e.r - 8);
    e.y = clamp(e.y, e.r + 8, H - e.r - 8);

    for (const o of obstacles) {
      const cx = clamp(e.x, o.x, o.x + o.w);
      const cy = clamp(e.y, o.y, o.y + o.h);
      const dx = e.x - cx;
      const dy = e.y - cy;
      const d2 = dx * dx + dy * dy;

      if (d2 < e.r * e.r) {
        if (d2 > 0.0001) {
          const d = Math.sqrt(d2);
          const push = (e.r - d) / d;
          e.x += dx * push;
          e.y += dy * push;
        } else {
          const left = e.x - o.x;
          const right = o.x + o.w - e.x;
          const top = e.y - o.y;
          const bottom = o.y + o.h - e.y;
          const m = Math.min(left, right, top, bottom);

          if (m === left) e.x = o.x - e.r;
          else if (m === right) e.x = o.x + o.w + e.r;
          else if (m === top) e.y = o.y - e.r;
          else e.y = o.y + o.h + e.r;
        }
      }
    }

    e.x = clamp(e.x, e.r + 8, W - e.r - 8);
    e.y = clamp(e.y, e.r + 8, H - e.r - 8);
  }

  function moveEntity(e, dx, dy) {
    e.x += dx;
    resolveCircleCollisions(e);
    e.y += dy;
    resolveCircleCollisions(e);
  }

  function canStand(x, y, r) {
    if (x < r + 8 || x > W - r - 8 || y < r + 8 || y > H - r - 8) {
      return false;
    }

    for (const o of obstacles) {
      const cx = clamp(x, o.x, o.x + o.w);
      const cy = clamp(y, o.y, o.y + o.h);
      const dx = x - cx;
      const dy = y - cy;
      if (dx * dx + dy * dy < r * r) return false;
    }

    return true;
  }

  function segmentRect(x1, y1, x2, y2, o, pad = 0) {
    const xmin = o.x - pad;
    const xmax = o.x + o.w + pad;
    const ymin = o.y - pad;
    const ymax = o.y + o.h + pad;

    const dx = x2 - x1;
    const dy = y2 - y1;
    const p = [-dx, dx, -dy, dy];
    const q = [x1 - xmin, xmax - x1, y1 - ymin, ymax - y1];

    let t0 = 0;
    let t1 = 1;

    for (let i = 0; i < 4; i++) {
      if (Math.abs(p[i]) < 1e-9) {
        if (q[i] < 0) return false;
      } else {
        const r = q[i] / p[i];
        if (p[i] < 0) {
          if (r > t1) return false;
          if (r > t0) t0 = r;
        } else {
          if (r < t0) return false;
          if (r < t1) t1 = r;
        }
      }
    }

    return true;
  }

  function hasLineOfSight(a, b) {
    for (const o of obstacles) {
      if (segmentRect(a.x, a.y, b.x, b.y, o, 2)) return false;
    }
    return true;
  }

  function burst(x, y, color, count, power) {
    for (let i = 0; i < count; i++) {
      const a = Math.random() * TAU;
      const s = power * (0.25 + Math.random() * 0.85);
      const life = 0.18 + Math.random() * 0.35;

      particles.push({
        x,
        y,
        vx: Math.cos(a) * s,
        vy: Math.sin(a) * s,
        life,
        maxLife: life,
        size: 2 + Math.random() * 3,
        color
      });
    }

    if (particles.length > 260) {
      particles.splice(0, particles.length - 260);
    }
  }

  function shoot(e, angle) {
    if (state !== 'playing' || e.ammo <= 0) return;

    e.ammo--;
    if (e.reloadTimer <= 0) e.reloadTimer = e.reloadTime;

    const speed = minDim * 1.35;
    const start = e.r + 8;

    bullets.push({
      x: e.x + Math.cos(angle) * start,
      y: e.y + Math.sin(angle) * start,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      r: Math.max(3.5, minDim * 0.012),
      owner: e === player ? 'player' : 'enemy',
      damage: 350,
      traveled: 0,
      maxDist: e.range
    });

    burst(
      e.x + Math.cos(angle) * (e.r + 5),
      e.y + Math.sin(angle) * (e.r + 5),
      e.isPlayer ? '#83d9ff' : '#ff9aa4',
      5,
      85
    );
  }

  function updateTimers(dt) {
    for (const e of [player, enemy]) {
      e.fireTimer = Math.max(0, e.fireTimer - dt);

      if (e.ammo < e.maxAmmo) {
        e.reloadTimer -= dt;
        while (e.reloadTimer <= 0 && e.ammo < e.maxAmmo) {
          e.ammo++;
          e.reloadTimer += e.reloadTime;
        }
      } else {
        e.reloadTimer = 0;
      }
    }
  }

  function updatePlayer(dt) {
    let mx = 0;
    let my = 0;

    const moveTouch = touches.left;
    if (moveTouch && moveTouch.mag > 0.08) {
      mx = moveTouch.nx;
      my = moveTouch.ny;
    } else {
      if (keys.has('KeyA') || keys.has('ArrowLeft')) mx -= 1;
      if (keys.has('KeyD') || keys.has('ArrowRight')) mx += 1;
      if (keys.has('KeyW') || keys.has('ArrowUp')) my -= 1;
      if (keys.has('KeyS') || keys.has('ArrowDown')) my += 1;

      const len = Math.hypot(mx, my);
      if (len > 1) {
        mx /= len;
        my /= len;
      }
    }

    if (mx || my) {
      moveEntity(player, mx * player.speed * dt, my * player.speed * dt);
    }

    let firing = false;
    const aimTouch = touches.right;

    if (aimTouch) {
      if (Math.hypot(aimTouch.nx, aimTouch.ny) > 0.18) {
        player.aimAngle = Math.atan2(aimTouch.ny, aimTouch.nx);
      } else {
        player.aimAngle = Math.atan2(enemy.y - player.y, enemy.x - player.x);
      }
      firing = true;
    } else if (keys.has('Space')) {
      player.aimAngle = Math.atan2(enemy.y - player.y, enemy.x - player.x);
      firing = true;
    }

    if (firing && player.fireTimer <= 0 && player.ammo > 0) {
      shoot(player, player.aimAngle);
      player.fireTimer = player.fireCooldown;
    }
  }

  function updateEnemy(dt) {
    if (enemy.hp <= 0 || player.hp <= 0) return;

    const dx = player.x - enemy.x;
    const dy = player.y - enemy.y;
    const dist = Math.hypot(dx, dy) || 1;
    const los = hasLineOfSight(enemy, player);

    enemy.strafeTimer -= dt;
    if (enemy.strafeTimer <= 0) {
      enemy.strafeDir *= -1;
      enemy.strafeTimer = 0.8 + Math.random() * 1.5;
    }

    const toward = Math.atan2(dy, dx);
    const preferred = minDim * 0.46;

    let moveAngle;

    if (!los) {
      moveAngle = toward + enemy.strafeDir * 0.55;
    } else if (dist > preferred * 1.25) {
      moveAngle = toward + enemy.strafeDir * 0.25;
    } else if (dist < preferred * 0.62) {
      moveAngle = toward + Math.PI;
    } else {
      moveAngle = toward + enemy.strafeDir * Math.PI / 2;
    }

    const step = enemy.speed * dt;

    if (!canStand(
      enemy.x + Math.cos(moveAngle) * step * 6,
      enemy.y + Math.sin(moveAngle) * step * 6,
      enemy.r + 2
    )) {
      const offsets = [0.65, -0.65, 1.25, -1.25, 1.9, -1.9, Math.PI];

      for (const off of offsets) {
        const a = moveAngle + off;
        if (canStand(
          enemy.x + Math.cos(a) * step * 6,
          enemy.y + Math.sin(a) * step * 6,
          enemy.r + 2
        )) {
          moveAngle = a;
          break;
        }
      }
    }

    moveEntity(
      enemy,
      Math.cos(moveAngle) * enemy.speed * dt,
      Math.sin(moveAngle) * enemy.speed * dt
    );

    enemy.aimAngle =
      Math.atan2(player.y - enemy.y, player.x - enemy.x) +
      Math.sin(performance.now() * 0.003) * 0.035;

    if (los && dist < enemy.range * 0.92 && enemy.fireTimer <= 0 && enemy.ammo > 0) {
      shoot(enemy, enemy.aimAngle);
      enemy.fireTimer = enemy.fireCooldown + Math.random() * 0.12;
    }
  }

  function updateBullets(dt) {
    for (let i = bullets.length - 1; i >= 0; i--) {
      const b = bullets[i];
      const nx = b.x + b.vx * dt;
      const ny = b.y + b.vy * dt;

      b.traveled += Math.hypot(nx - b.x, ny - b.y);
      b.x = nx;
      b.y = ny;

      if (
        b.traveled > b.maxDist ||
        b.x < -40 || b.x > W + 40 ||
        b.y < -40 || b.y > H + 40
      ) {
        bullets.splice(i, 1);
        continue;
      }

      let hitWall = false;

      for (const o of obstacles) {
        if (
          b.x + b.r > o.x &&
          b.x - b.r < o.x + o.w &&
          b.y + b.r > o.y &&
          b.y - b.r < o.y + o.h
        ) {
          hitWall = true;
          break;
        }
      }

      if (hitWall) {
        burst(b.x, b.y, '#9fb5d8', 5, 70);
        bullets.splice(i, 1);
        continue;
      }

      const target = b.owner === 'player' ? enemy : player;

      if (
        target.hp > 0 &&
        Math.hypot(b.x - target.x, b.y - target.y) < target.r + b.r
      ) {
        target.hp = Math.max(0, target.hp - b.damage);

        burst(
          b.x,
          b.y,
          b.owner === 'player' ? '#ffd166' : '#ff6b81',
          11,
          170
        );

        shake = target === player ? 10 : 6;

        if (navigator.vibrate) {
          navigator.vibrate(target === player ? 20 : 8);
        }

        bullets.splice(i, 1);

        if (target.hp <= 0) {
          endGame(b.owner === 'player' ? 'player' : 'enemy');
          break;
        }
      }
    }
  }

  function updateParticles(dt) {
    const damp = Math.pow(0.08, dt);

    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.vx *= damp;
      p.vy *= damp;
      p.life -= dt;

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

  function update(dt) {
    if (state !== 'playing') return;

    matchTime -= dt;

    if (matchTime <= 0) {
      const pr = player.hp / player.maxHp;
      const er = enemy.hp / enemy.maxHp;
      const winner = Math.abs(pr - er) < 0.001 ? 'draw' : (pr > er ? 'player' : 'enemy');
      endGame(winner);
      return;
    }

    updateTimers(dt);
    updatePlayer(dt);
    updateEnemy(dt);
    updateBullets(dt);
    updateParticles(dt);

    shake = Math.max(0, shake - dt * 55);
  }

  function formatTime(t) {
    const sec = Math.max(0, Math.ceil(t));
    return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
  }

  function drawBackground() {
    const g = ctx.createLinearGradient(0, 0, 0, H);
    g.addColorStop(0, '#111b34');
    g.addColorStop(0.55, '#0a1224');
    g.addColorStop(1, '#060a13');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, W, H);

    const glow = ctx.createRadialGradient(
      W * 0.5, H * 0.42, 0,
      W * 0.5, H * 0.42, Math.max(W, H) * 0.72
    );
    glow.addColorStop(0, 'rgba(35, 97, 190, .18)');
    glow.addColorStop(1, 'rgba(0, 0, 0, 0)');
    ctx.fillStyle = glow;
    ctx.fillRect(0, 0, W, H);

    const step = Math.max(34, minDim * 0.105);
    ctx.strokeStyle = 'rgba(103, 155, 235, .045)';
    ctx.lineWidth = 1;
    ctx.beginPath();

    for (let x = step; x < W; x += step) {
      ctx.moveTo(x, 0);
      ctx.lineTo(x, H);
    }

    for (let y = step; y < H; y += step) {
      ctx.moveTo(0, y);
      ctx.lineTo(W, y);
    }

    ctx.stroke();
  }

  function drawArenaFloor() {
    ctx.save();
    ctx.strokeStyle = 'rgba(114, 184, 255, .23)';
    ctx.lineWidth = 3;
    rr(7, 7, W - 14, H - 14, 20);
    ctx.stroke();

    ctx.strokeStyle = 'rgba(90, 160, 255, .10)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(W / 2, H / 2, minDim * 0.18, 0, TAU);
    ctx.stroke();

    ctx.setLineDash([5, 12]);
    ctx.strokeStyle = 'rgba(100, 170, 255, .09)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(W / 2, 10);
    ctx.lineTo(W / 2, H - 10);
    ctx.stroke();
    ctx.restore();
  }

  function drawObstacles() {
    for (const o of obstacles) {
      const rad = Math.min(14, o.w * 0.2, o.h * 0.2);

      ctx.save();
      ctx.shadowColor = 'rgba(0, 0, 0, .45)';
      ctx.shadowBlur = 12;
      ctx.shadowOffsetY = 6;

      const g = ctx.createLinearGradient(o.x, o.y, o.x, o.y + o.h);
      g.addColorStop(0, '#3d516f');
      g.addColorStop(1, '#202a3e');

      ctx.fillStyle = g;
      rr(o.x, o.y, o.w, o.h, rad);
      ctx.fill();
      ctx.restore();

      ctx.save();
      ctx.strokeStyle = 'rgba(145, 190, 255, .32)';
      ctx.lineWidth = 2;
      rr(o.x, o.y, o.w, o.h, rad);
      ctx.stroke();

      ctx.strokeStyle = 'rgba(255, 255, 255, .08)';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(o.x + 10, o.y + 4);
      ctx.lineTo(o.x + o.w - 10, o.y + 4);
      ctx.stroke();
      ctx.restore();
    }
  }

  function drawAimLine() {
    if (!(touches.right || keys.has('Space'))) return;

    const len = player.range * 0.95;
    const sx = player.x + Math.cos(player.aimAngle) * (player.r + 8);
    const sy = player.y + Math.sin(player.aimAngle) * (player.r + 8);
    const ex = player.x + Math.cos(player.aimAngle) * len;
    const ey = player.y + Math.sin(player.aimAngle) * len;

    ctx.save();
    ctx.setLineDash([7, 9]);
    ctx.strokeStyle = 'rgba(120, 220, 255, .38)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(ex, ey);
    ctx.stroke();

    ctx.setLineDash([]);
    ctx.strokeStyle = 'rgba(120, 220, 255, .5)';
    ctx.beginPath();
    ctx.arc(ex, ey, 5, 0, TAU);
    ctx.stroke();
    ctx.restore();
  }

  function drawBullets() {
    ctx.save();
    ctx.lineCap = 'round';

    for (const b of bullets) {
      const isP = b.owner === 'player';

      ctx.strokeStyle = isP
        ? 'rgba(110, 215, 255, .55)'
        : 'rgba(255, 110, 135, .55)';
      ctx.lineWidth = Math.max(3, b.r * 1.5);
      ctx.beginPath();
      ctx.moveTo(b.x - b.vx * 0.028, b.y - b.vy * 0.028);
      ctx.lineTo(b.x, b.y);
      ctx.stroke();

      ctx.shadowColor = isP ? '#51c7ff' : '#ff526d';
      ctx.shadowBlur = 14;
      ctx.fillStyle = isP ? '#e9fcff' : '#ffecef';
      ctx.beginPath();
      ctx.arc(b.x, b.y, b.r, 0, TAU);
      ctx.fill();
      ctx.shadowBlur = 0;
    }

    ctx.restore();
  }

  function drawFighter(e) {
    if (!e || e.hp <= 0) return;

    const isP = e.isPlayer;
    const r = e.r;

    ctx.save();
    ctx.globalAlpha = 0.24;
    ctx.fillStyle = '#000';
    ctx.beginPath();
    ctx.ellipse(e.x, e.y + r * 0.82, r * 0.9, r * 0.38, 0, 0, TAU);
    ctx.fill();
    ctx.restore();

    ctx.save();
    ctx.translate(e.x, e.y);
    ctx.rotate(e.aimAngle);
    ctx.fillStyle = isP ? '#a6e9ff' : '#ffb0b9';
    rr(-r * 0.12, -r * 0.22, r * 1.75, r * 0.44, r * 0.18);
    ctx.fill();
    ctx.strokeStyle = 'rgba(255, 255, 255, .8)';
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.restore();

    const grad = ctx.createRadialGradient(
      e.x - r * 0.3,
      e.y - r * 0.38,
      r * 0.08,
      e.x,
      e.y,
      r * 1.05
    );
    grad.addColorStop(0, isP ? '#9ae4ff' : '#ffb1b9');
    grad.addColorStop(1, isP ? '#2478d4' : '#d7314b');

    ctx.beginPath();
    ctx.arc(e.x, e.y, r, 0, TAU);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.lineWidth = Math.max(2, r * 0.12);
    ctx.strokeStyle = 'rgba(230, 248, 255, .85)';
    ctx.stroke();

    ctx.beginPath();
    ctx.arc(e.x, e.y, r * 0.55, 0, TAU);
    ctx.strokeStyle = 'rgba(255, 255, 255, .35)';
    ctx.lineWidth = 1.5;
    ctx.stroke();

    ctx.beginPath();
    ctx.arc(e.x, e.y, r * 0.18, 0, TAU);
    ctx.fillStyle = 'rgba(255, 255, 255, .9)';
    ctx.fill();

    const bw = Math.max(52, r * 3.1);
    const bh = Math.max(5, Math.min(8, r * 0.3));
    const bx = e.x - bw / 2;
    const by = e.y - r - bh - 13;

    ctx.fillStyle = 'rgba(3, 8, 17, .72)';
    rr(bx - 1, by - 1, bw + 2, bh + 2, bh);
    ctx.fill();

    const ratio = clamp(e.hp / e.maxHp, 0, 1);
    if (ratio > 0.002) {
      ctx.fillStyle = isP ? '#42df81' : '#ff6079';
      rr(bx, by, bw * ratio, bh, bh / 2);
      ctx.fill();
    }

    const pipGap = Math.max(7, r * 0.36);
    const py = e.y + r + 11;

    for (let i = 0; i < e.maxAmmo; i++) {
      ctx.beginPath();
      ctx.arc(
        e.x + (i - (e.maxAmmo - 1) / 2) * pipGap,
        py,
        Math.max(2, r * 0.09),
        0,
        TAU
      );
      ctx.fillStyle = i < e.ammo ? '#ffd46b' : 'rgba(255, 255, 255, .18)';
      ctx.fill();
    }
  }

  function drawParticles() {
    ctx.save();

    for (const p of particles) {
      const a = clamp(p.life / p.maxLife, 0, 1);
      ctx.globalAlpha = a;
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, Math.max(0.3, p.size * a), 0, TAU);
      ctx.fill();
    }

    ctx.restore();
  }

  function drawHudBar(x, y, w, hp, maxHp, label, color, fromRight) {
    ctx.save();
    ctx.textBaseline = 'top';
    ctx.font = '700 ' + Math.max(10, Math.min(14, minDim * 0.035)) + 'px system-ui, sans-serif';
    ctx.fillStyle = 'rgba(220, 235, 255, .82)';
    ctx.textAlign = fromRight ? 'right' : 'left';
    ctx.fillText(label, fromRight ? x + w : x, y);

    const bh = 9;
    const by = y + 18;

    ctx.fillStyle = 'rgba(0, 0, 0, .46)';
    rr(x, by, w, bh, bh / 2);
    ctx.fill();

    const ratio = clamp(hp / maxHp, 0, 1);
    const fw = w * ratio;

    if (fw > 0.5) {
      const fx = fromRight ? x + w - fw : x;
      ctx.fillStyle = color;
      rr(fx, by, fw, bh, bh / 2);
      ctx.fill();
    }

    ctx.strokeStyle = 'rgba(255, 255, 255, .16)';
    ctx.lineWidth = 1;
    rr(x, by, w, bh, bh / 2);
    ctx.stroke();
    ctx.restore();
  }

  function drawHUD() {
    const barW = Math.max(96, Math.min(210, W * 0.32));

    drawHudBar(12, 10, barW, player.hp, player.maxHp, 'ВЫ', '#42df81', false);
    drawHudBar(W - 12 - barW, 10, barW, enemy.hp, enemy.maxHp, 'БОТ', '#ff6079', true);

    const tw = Math.max(52, Math.min(68, W * 0.18));
    const tx = W / 2 - tw / 2;
    const ty = 8;
    const th = 42;

    ctx.save();
    ctx.fillStyle = 'rgba(4, 10, 22, .62)';
    rr(tx, ty, tw, th, 15);
    ctx.fill();
    ctx.strokeStyle = 'rgba(145, 200, 255, .22)';
    ctx.lineWidth = 1;
    ctx.stroke();

    ctx.fillStyle = '#fff';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.font = '800 ' + Math.max(15, Math.min(21, minDim * 0.055)) + 'px system-ui, sans-serif';
    ctx.fillText(formatTime(matchTime), W / 2, ty + th / 2 + 1);
    ctx.restore();
  }

  function drawStick(t, defX, defY, label, R) {
    const active = !!t;
    const bx = active ? t.sx : defX;
    const by = active ? t.sy : defY;

    ctx.save();
    ctx.globalAlpha = active ? 0.78 : 0.28;

    ctx.beginPath();
    ctx.arc(bx, by, R, 0, TAU);
    ctx.fillStyle = 'rgba(5, 14, 30, .48)';
    ctx.fill();
    ctx.strokeStyle = 'rgba(160, 220, 255, .7)';
    ctx.lineWidth = 2;
    ctx.stroke();

    const kx = active ? bx + t.nx * R * 0.62 : bx;
    const ky = active ? by + t.ny * R * 0.62 : by;

    ctx.beginPath();
    ctx.arc(kx, ky, R * 0.4, 0, TAU);
    ctx.fillStyle = active
      ? 'rgba(92, 205, 255, .92)'
      : 'rgba(160, 220, 255, .6)';
    ctx.fill();
    ctx.strokeStyle = 'rgba(255, 255, 255, .55)';
    ctx.lineWidth = 1.5;
    ctx.stroke();

    if (!active) {
      ctx.globalAlpha = 0.48;
      ctx.fillStyle = '#a9c7e8';
      ctx.font = '700 ' + Math.max(9, Math.min(12, minDim * 0.03)) + 'px system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(label, bx, by + R + 17);
    }

    ctx.restore();
  }

  function drawJoysticks() {
    const R = Math.min(58, Math.max(42, minDim * 0.135));
    const y = H - R - 28;

    drawStick(touches.left, R + 24, y, 'ХОДЬБА', R);
    drawStick(touches.right, W - R - 24, y, 'ОГОНЬ', R);
  }

  function render() {
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    ctx.clearRect(0, 0, W, H);

    drawBackground();

    if (player && enemy) {
      ctx.save();

      if (shake > 0) {
        ctx.translate(
          (Math.random() - 0.5) * shake,
          (Math.random() - 0.5) * shake
        );
      }

      drawArenaFloor();
      drawAimLine();
      drawObstacles();
      drawBullets();
      drawFighter(enemy);
      drawFighter(player);
      drawParticles();

      ctx.restore();

      drawHUD();

      if (state === 'playing') {
        drawJoysticks();
      }
    }
  }

  function loop(now) {
    const dt = Math.min(0.033, Math.max(0, (now - last) / 1000));
    last = now;

    if (state === 'playing') update(dt);
    render();

    requestAnimationFrame(loop);
  }

  canvas.addEventListener('pointerdown', (event) => {
    if (state !== 'playing') return;
    event.preventDefault();

    const side = event.clientX < W / 2 ? 'left' : 'right';
    if (touches[side]) return;

    touches[side] = {
      id: event.pointerId,
      sx: event.clientX,
      sy: event.clientY,
      nx: 0,
      ny: 0,
      mag: 0
    };

    if (canvas.setPointerCapture) {
      try {
        canvas.setPointerCapture(event.pointerId);
      } catch (_) {}
    }
  });

  canvas.addEventListener('pointermove', (event) => {
    const t =
      touches.left && touches.left.id === event.pointerId
        ? touches.left
        : touches.right && touches.right.id === event.pointerId
          ? touches.right
          : null;

    if (!t) return;

    event.preventDefault();

    const max = 58;
    let dx = event.clientX - t.sx;
    let dy = event.clientY - t.sy;
    const len = Math.hypot(dx, dy);

    if (len > max) {
      dx *= max / len;
      dy *= max / len;
    }

    t.nx = dx / max;
    t.ny = dy / max;
    t.mag = Math.min(1, len / max);
  }, { passive: false });

  function releasePointer(event) {
    if (touches.left && touches.left.id === event.pointerId) {
      touches.left = null;
    }
    if (touches.right && touches.right.id === event.pointerId) {
      touches.right = null;
    }
  }

  canvas.addEventListener('pointerup', releasePointer);
  canvas.addEventListener('pointercancel', releasePointer);
  canvas.addEventListener('lostpointercapture', releasePointer);

  canvas.addEventListener('contextmenu', (event) => {
    event.preventDefault();
  });

  window.addEventListener('blur', () => {
    touches.left = null;
    touches.right = null;
    keys.clear();
  });

  window.addEventListener('keydown', (event) => {
    if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(event.code)) {
      event.preventDefault();
    }
    keys.add(event.code);
  });

  window.addEventListener('keyup', (event) => {
    keys.delete(event.code);
  });

  document.addEventListener('gesturestart', (event) => {
    event.preventDefault();
  }, { passive: false });

  window.addEventListener('resize', resize);
  window.addEventListener('orientationchange', () => {
    setTimeout(resize, 150);
  });

  startBtn.addEventListener('click', initGame);

  resize();
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>

Как это работает

  1. Игровой цикл на requestAnimationFrame обновляет игру и рисует кадр. Шаг времени ограничен 0.033 секунды, поэтому на быстром и медленном телефоне скорость примерно одинаковая.
  2. Экран делится на две половины. Левая половина — виртуальный джойстик движения, правая — прицел: пока палец удерживается, персонаж стреляет. Если не тянуть палец, включается автоприцел по боту.
  3. У бойцов есть круглые хитбоксы, у стен — прямоугольники. Пули сталкиваются и с бойцами, и со стенами. Поэтому укрытия реально работают.
  4. У каждого по 3000 HP, 3 патрона, урон одного попадания — 350. Патроны восстанавливаются автоматически.
  5. Бот проверяет линию видимости, обходит стены, старается держать дистанцию и стреляет только тогда, когда игрок не полностью закрыт стеной.
  6. Победа наступает при обнулении HP противника. Если за 90 секунд никто не погиб, сравнивается доля оставшегося здоровья.

Что проверить после запуска

  • На телефоне удерживай левую половину и веди палец — герой должен двигаться.
  • Удерживай правую половину — должны расходоваться патроны и идти стрельба.
  • Спрячься за стену — пули бота не должны проходить сквозь неё.
  • Дождись перезарядки: жёлтые точки под бойцом снова заполняются.
  • После победы или поражения должна появиться кнопка «ЕЩЁ РАЗ».

Это локальный прототип: здесь нет онлайна, суперспособностей, кустов и боевого пропуска. Базовая механика 1×1, мобильное управление и бот уже готовы; параметры здоровья, урона, скорости и перезарядки легко менять в makeFighter и shoot.