---
title: "Спрайтовые листы и покадровая анимация: атлас, опорная точка, частота"
description: "Как устроена покадровая 2D-анимация в браузерной игре: расчёт прямоугольника кадра в атласе с полями и зазорами, опорная точка при развороте, своя частота клипа 8–24 к/с при экране 60 или 144 Гц и события на кадрах. Код на JavaScript, проверенный в Node.js 24."
slug: sprajty-kadry-i-2d-animaciya-v-brauzere
date: 2026-10-31
updated: 2026-11-02
scheduled_at: "2026-10-31T09:00:00+03:00"
rubric: ustrojstvo-2d-igr
tags: ["sprajty", "animaciya", "atlas", "novichkam", "brauzernye-igry"]
reading_time: 9
draft: false
summary: "Покадровая 2D-анимация хранит фазы движения в одном изображении — спрайтовом листе (атласе), откуда по индексу кадра вычисляется прямоугольник для рисования. Чтобы персонаж не дёргался при смене кадров и не смещался при развороте, у всех кадров задают общую опорную точку (pivot), обычно у стоп, и отражают её вместе с кадром. Клип идёт со своей частотой, например 12 к/с, независимо от частоты экрана, а звуки шагов и удары привязывают к событиям конкретных кадров так, чтобы просадка FPS их не пропускала."
---

## Коротко

Покадровая анимация — это атлас с кадрами, формула прямоугольника кадра по индексу, общая опорная точка и своя частота клипа. Если считать номер кадра по времени, а не по числу обновлений экрана, анимация одинаково идёт на мониторах 60 и 144 Гц. События кадров («шаг», «удар») нужно запускать для всех пройденных кадров, иначе просадка FPS их проглотит.

## Содержание

1. Спрайтовый лист: как найти прямоугольник кадра
2. Опорная точка: почему персонаж дёргается и уезжает при развороте
3. Частота клипа против частоты экрана
4. События на кадрах: шаги, удары, звуки
5. Таблица: параметры клипов персонажа
6. Код на JavaScript: атлас, разворот и проигрыватель клипа
7. Практический сценарий: готовим лист бега к игре
8. Когда вам это не подойдёт

## Спрайтовый лист: как найти прямоугольник кадра

Прямоугольник кадра в атласе вычисляется из индекса, числа столбцов, размера кадра, поля по краю листа и зазора между кадрами. Столбец — это остаток от деления индекса на число столбцов, строка — целая часть деления. Формула:

```text
столбец = индекс mod столбцов
строка  = floor(индекс / столбцов)
x = поле + столбец × (ширина_кадра + зазор)
y = поле + строка  × (высота_кадра + зазор)
```

На холсте браузера этот прямоугольник передают в 9-аргументную форму `drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)`: первые четыре числа после изображения выбирают область источника, последние четыре — место и размер на экране. Поле и зазор в 1–2 пикселя нужны, чтобы при масштабировании соседний кадр не «подмешивался» по краю; о чётких краях пиксель-арта при масштабе — отдельная тема.

## Опорная точка: почему персонаж дёргается и уезжает при развороте

Опорная точка (pivot) — это точка кадра, которая совпадает с позицией объекта в мире; у всех кадров клипа она должна быть одна и та же. Если художник нарисовал кадры бега со смещением на 2–3 пикселя, а движок рисует кадр от левого верхнего угла, персонаж «пляшет» на месте. Если опорная точка не по центру фигуры, при отражении по горизонтали он прыгает в сторону.

Решение — хранить pivot в координатах кадра и при развороте отражать и его:

```text
pivot_x при развороте = ширина_кадра − pivot_x
угол рисования = позиция − pivot
```

Для героя платформера pivot ставят у стоп: на нижней строке кадра, посередине между ногами. Хитбокс считается от той же точки, поэтому разворот не задвигает героя в стену.

## Частота клипа против частоты экрана

Клип анимации идёт со своей частотой, а экран обновляется со своей, и связывать их нельзя. Если менять кадр «каждые 5 обновлений экрана», на мониторе 60 Гц бег идёт 12 к/с, а на 144 Гц — 28,8 к/с, то есть в 2,4 раза быстрее. Номер кадра нужно считать по накопленному времени:

```text
номер = floor(время_клипа × частота_клипа)
кадр  = номер mod длина_клипа          (для зацикленного)
кадр  = min(номер, длина_клипа − 1)    (для однократного)
```

При накоплении времени шагами вида 1/144 с сумма может оказаться чуть меньше целого из-за погрешности дробей, поэтому в коде ниже к произведению добавлена поправка `1e-9`.

## События на кадрах: шаги, удары, звуки

Событие кадра — это метка «на кадре N произошло X», и запускать её нужно для каждого пройденного кадра, а не только для текущего. При 12 к/с кадр длится 83 мс; если обновление игры однажды заняло 250 мс, за него пройдут три кадра клипа, и кадр со звуком шага может оказаться в середине. Проигрыватель должен помнить номер последнего обработанного кадра и перебрать все номера до текущего.

Удары связывают с событием кадра «активный кадр»: урон включается на нём, а не в момент нажатия атаки. Так замах остаётся видимым, а попадание совпадает с картинкой.

## Таблица: параметры клипов персонажа

Значения ниже — рабочие стартовые параметры для персонажа 48×48 пикселей, которые затем подбирают на глаз.

| Клип | Кадров | Частота, к/с | Длительность цикла | Повтор | События кадров |
|---|---|---|---|---|---|
| Бездействие | 4 | 6 | 0,67 с | да | — |
| Бег | 8 | 12 | 0,67 с | да | шаг на 2 и 6 |
| Прыжок вверх | 3 | 12 | 0,25 с | нет | пыль на 0 |
| Атака | 6 | 18 | 0,33 с | нет | урон на 3, конец на 5 |
| Получение урона | 2 | 10 | 0,2 с | нет | — |

Длительность цикла считается как число кадров, делённое на частоту: 8 кадров при 12 к/с дают 0,67 с.

## Код на JavaScript: атлас, разворот и проигрыватель клипа

Код считает прямоугольники кадров, точку рисования с учётом разворота и проигрывает клип бега на экранах 60 и 144 Гц. Он проверен в Node.js 24: числа в комментариях совпадают с выводом.

```js
// Прямоугольник кадра в атласе по индексу: поля (margin) и зазоры (spacing) учитываются явно
function frameRect(index, sheet) {
  const col = index % sheet.columns;
  const row = Math.floor(index / sheet.columns);
  return {
    x: sheet.margin + col * (sheet.frameW + sheet.spacing),
    y: sheet.margin + row * (sheet.frameH + sheet.spacing),
    w: sheet.frameW,
    h: sheet.frameH,
  };
}

// Куда рисовать кадр, чтобы опорная точка (pivot) совпала с позицией объекта.
// flip = true — отражение по горизонтали: pivot тоже отражается внутри кадра.
function drawCorner(pos, sheet, flip) {
  const px = flip ? sheet.frameW - sheet.pivotX : sheet.pivotX;
  return { x: pos.x - px, y: pos.y - sheet.pivotY };
}

// Проигрыватель клипа: своя частота кадров, не зависит от частоты экрана.
// Возвращает события кадров, которые были пройдены за этот шаг (даже если кадр пропущен).
function createPlayer(clip) {
  let time = 0;
  let last = -1;
  return {
    update(dt) {
      time += dt;
      // 1e-9 гасит ошибку сложения дробей: 144 × (1/144) даёт чуть меньше 1
      const total = Math.floor(time * clip.fps + 1e-9);
      const frame = clip.loop ? total % clip.frames.length : Math.min(total, clip.frames.length - 1);
      const fired = [];
      for (let t = last + 1; t <= total; t++) {
        const f = clip.loop ? t % clip.frames.length : Math.min(t, clip.frames.length - 1);
        if (!clip.loop && t >= clip.frames.length) break;
        if (clip.events[f]) fired.push(clip.events[f] + "@" + f);
      }
      last = total;
      return { frame: clip.frames[frame], fired };
    },
  };
}

const sheet = { columns: 8, frameW: 48, frameH: 48, margin: 1, spacing: 2, pivotX: 20, pivotY: 46 };
console.log(frameRect(0, sheet));  // { x: 1, y: 1, w: 48, h: 48 }
console.log(frameRect(11, sheet)); // { x: 151, y: 51, w: 48, h: 48 }

console.log(drawCorner({ x: 300, y: 200 }, sheet, false)); // { x: 280, y: 154 }
console.log(drawCorner({ x: 300, y: 200 }, sheet, true));  // { x: 272, y: 154 }

// Бег: 8 кадров, 12 к/с, шаги на кадрах 2 и 6. Экран 60 Гц и 144 Гц
const run = { frames: [8, 9, 10, 11, 12, 13, 14, 15], fps: 12, loop: true, events: { 2: "step", 6: "step" } };
for (const hz of [60, 144]) {
  const p = createPlayer(run);
  let steps = 0;
  let frame = 0;
  for (let i = 0; i < hz; i++) {
    const r = p.update(1 / hz);
    steps += r.fired.length;
    frame = r.frame;
  }
  console.log(hz + " Гц: за 1 с шагов", steps, "кадр атласа", frame);
}
// 60 Гц: за 1 с шагов 3 кадр атласа 12
// 144 Гц: за 1 с шагов 3 кадр атласа 12

// Просадка: один кадр длиной 0,25 с не съедает событие шага
const p2 = createPlayer(run);
p2.update(0.01);
console.log(p2.update(0.25).fired); // [ 'step@2' ]
```

За 1 секунду на обоих экранах прозвучали 3 шага и показан один и тот же кадр атласа. После обновления длиной 0,25 с событие шага на кадре 2 не потерялось, хотя проигрыватель сразу перескочил на кадр 3.

## Практический сценарий: готовим лист бега к игре

Подготовка листа занимает около 15 минут и избавляет от дрожания и сдвигов на этапе игры:

1. **Единый размер кадра.** Экспортируйте все 8 кадров бега в одинаковой ячейке, например 48×48 пикселей, даже если фигура занимает меньше.
2. **Поле и зазор.** Задайте поле 1 пиксель по краю листа и зазор 2 пикселя между кадрами, запишите эти числа рядом с листом.
3. **Опорная точка.** Отметьте pivot у стоп на одном кадре и проверьте, что на всех остальных стопы стоят на той же строке пикселей.
4. **Частота.** Начните с 12 к/с, затем сравните бег со скоростью перемещения героя: если ноги «скользят», поднимайте частоту или снижайте скорость.
5. **События.** Поставьте метку «шаг» на кадры касания ногой земли, обычно на 2 и 6 из 8.
6. **Проверка.** Запустите игру с принудительно заниженной частотой обновления и убедитесь, что звуков шагов за секунду столько же, сколько при нормальной.

## Когда вам это не подойдёт

Покадровая анимация на атласе — не единственный способ оживить 2D-персонажа:

- **Скелетная анимация:** если персонаж собран из частей с костями и плавными переходами между движениями, кадры в атласе заменяются ключами поворота костей, а формулы этой статьи нужны только для отдельных эффектов.
- **Сотни уникальных кадров на персонажа:** когда клипов десятки, а кадров сотни, один лист перестаёт помещаться в разумный размер текстуры; придётся делить его по клипам и подгружать частями.
- **Чисто процедурная графика:** если объекты рисуются фигурами и шейдерами, атлас и опорные точки кадров не нужны.

---

Мы разрабатываем **J2DRU** — браузерный 2D игровой движок; он в разработке и публично пока не открыт, заявку можно оставить на [странице раннего доступа](/early-access?utm_source=blog&utm_medium=article&utm_campaign=sprajty-kadry-i-2d-animaciya-v-brauzere) (подача заявки не гарантирует приглашение).

**Источники (проверено: ноябрь 2026):** [MDN — CanvasRenderingContext2D.drawImage()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage).

**Читайте также:**
- [Тайловые сетки и устройство 2D-миров](/blog/tajlovye-setki-i-ustrojstvo-2d-mirov)
- [Игровой цикл и физика столкновений AABB](/blog/igrovoj-cikl-i-fizika-stolknovenij-aabb)
- [Камера, параллакс и game feel в 2D-игре](/blog/kamera-parallaks-i-game-feel-v-2d-igre)
