Короткий ответ: ниже — готовый однофайловый 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>
Как это работает
- Игровой цикл на
requestAnimationFrameобновляет игру и рисует кадр. Шаг времени ограничен0.033секунды, поэтому на быстром и медленном телефоне скорость примерно одинаковая. - Экран делится на две половины. Левая половина — виртуальный джойстик движения, правая — прицел: пока палец удерживается, персонаж стреляет. Если не тянуть палец, включается автоприцел по боту.
- У бойцов есть круглые хитбоксы, у стен — прямоугольники. Пули сталкиваются и с бойцами, и со стенами. Поэтому укрытия реально работают.
- У каждого по 3000 HP, 3 патрона, урон одного попадания — 350. Патроны восстанавливаются автоматически.
- Бот проверяет линию видимости, обходит стены, старается держать дистанцию и стреляет только тогда, когда игрок не полностью закрыт стеной.
- Победа наступает при обнулении HP противника. Если за 90 секунд никто не погиб, сравнивается доля оставшегося здоровья.
Что проверить после запуска
- На телефоне удерживай левую половину и веди палец — герой должен двигаться.
- Удерживай правую половину — должны расходоваться патроны и идти стрельба.
- Спрячься за стену — пули бота не должны проходить сквозь неё.
- Дождись перезарядки: жёлтые точки под бойцом снова заполняются.
- После победы или поражения должна появиться кнопка «ЕЩЁ РАЗ».
Это локальный прототип: здесь нет онлайна, суперспособностей, кустов и боевого пропуска. Базовая механика 1×1, мобильное управление и бот уже готовы; параметры здоровья, урона, скорости и перезарядки легко менять в makeFighter и shoot.