У человечка на нашей странице со стикменом нет ни спрайт-листа, ни файлов анимации. Каждый кадр несколько сотен строк JavaScript вычисляют, где должны оказаться таз, колени, кисти и голова, и рисуют результат на HTML <canvas>. В лаборатории движения он ходит, бегает, делает бросок без оружия, прыгает и выполняет комплекс с копьём из 17 поз — и переключаться между ними можно в любой момент без заметного рывка.
Теперь эта страница — небольшая игра, Stickman Spear Run: раннер про стикмена, где человечек несёт длинное копьё и на бегу атакует прямым выпадом двумя руками (Пробел или ↑ — прыжок, F или → — удар). Исходная лаборатория движения осталась вторым режимом, так что движения по-прежнему можно перелистывать — включая бросок без оружия, который есть только там, — и смотреть, как работает скелет. В этой статье мы разбираем стоящие за ним приёмы на реальных фрагментах исходного кода: двухкостную обратную кинематику по теореме косинусов, дорожки ключевых кадров с плавностью, постановку стоп, сжатие и растяжение и немного verlet-физики для вторичного движения.
Скелет из чисел
Вся фигура — это несколько длин костей в единицах canvas:
const SPINE=54, NECK=13, HEADR=12;
const UARM=27, FARM=25, THIGH=37, SHIN=37;
const HIP_H=66; // standing hip height: knees always slightly bent
Обратите внимание: HIP_H (66) меньше, чем THIGH + SHIN (74). Фигура никогда не стоит на прямых, «запертых» коленях, и именно поэтому выглядит живой даже в покое. Раз размеры фиксированы, масштабируется сам canvas: скрипт подбирает масштаб так, чтобы пространство рисования всегда было около 440 единиц в высоту, каким бы ни был элемент на экране. Композиция и отступы одинаковы и на телефоне, и на 4K-мониторе.
Каждый кадр функция buildPose(dt) возвращает набор точек в экранных координатах — таз, грудь, голова, плечо, бедро, оба колена, обе стопы, оба локтя и обе кисти, — а отдельный шаг отрисовки их рисует. Разделение «где суставы» и «как их рисовать» позволяет менять стиль графики, не трогая движение.
Двухкостная IK по теореме косинусов
Обратная кинематика отвечает на вопрос: если бедро здесь, а стопа должна быть там, где окажется колено? Для цепочки всего из двух костей есть точный ответ в замкнутой форме, и он занимает лишь несколько строк:
function ik(root, target, L1, L2, sign){
let dx=target.x-root.x, dy=target.y-root.y;
let d=Math.hypot(dx,dy);
d=clamp(d, Math.abs(L1-L2)+.01, L1+L2-.01);
const base=Math.atan2(dy,dx);
let ca=clamp((L1*L1+d*d-L2*L2)/(2*L1*d),-1,1);
const a=Math.acos(ca);
const jointAng=base+sign*a;
return V(root.x+Math.cos(jointAng)*L1, root.y+Math.sin(jointAng)*L1);
}
Полный двухкостный решатель из stickman.js. Он возвращает средний сустав (колено или локоть).
Логика такая. Бедренная кость (L1), голень (L2) и прямая от бедра до стопы (d) образуют треугольник. Все три стороны известны, значит, теорема косинусов даёт любой угол:
L2² = L1² + d² − 2·L1·d·cos(a) ⇒ cos(a) = (L1² + d² − L2²) / (2·L1·d)
a — угол у бедра между линией бедро–стопа и бедренной костью. Линия бедро–стопа направлена под углом base = atan2(dy, dx), значит, бедренная кость направлена под углом base ± a. Отложите от бедра L1 в этом направлении — и вы в колене. Аргумент sign выбирает одно из двух зеркальных решений: колени сгибаются вперёд, а когда копьё держат обе руки, ближний и дальний локти получают противоположные знаки и сгибаются в разные стороны.
Два ограничения (clamp) делают решатель надёжным:
dограничивается чуть внутри диапазона[|L1 − L2|, L1 + L2]. Если цель недостижима, треугольника не существует, иacosвернул быNaN. Ограничение означает «тянись к цели, насколько можешь».- Косинус ограничивается диапазоном
[−1, 1]. Погрешность чисел с плавающей точкой может дать 1.0000001, а это тоже территорияNaN.
Код ног добавляет ещё одну страховку перед вызовом ik(): если цель стопы дальше от бедра, чем THIGH + SHIN − 1.5, она подтягивается назад вдоль той же линии. Нога может почти выпрямиться, но никогда не «запирается» полностью — так исчезает заметный «щелчок», который бывает, когда двухкостная цепочка доходит до полного разгибания.
Ноги — на IK, руки — в основном на FK
Хочется посчитать через IK вообще всё. Мы так не делаем. Стопы должны стоять в точных местах — на земле, — поэтому ноги решаются через IK. А у рук, которые качаются при ходьбе, нет цели — у них есть ритм. Для этого прямая кинематика (задаём углы, вычисляем положения) проще и выглядит естественнее:
function armFK(aUp, flex){
const e=add(shoulder,{x:Math.sin(aUp)*UARM, y:Math.cos(aUp)*UARM});
const h=add(e, {x:Math.sin(aUp+flex)*FARM, y:Math.cos(aUp+flex)*FARM});
return {elbow:e, hand:h};
}
Соглашение об углах здесь такое: «0 — прямо вниз, положительные значения — мах вперёд», и о позах ходьбы так думать проще. При ходьбе каждая рука качается в противофазе с ногой своей стороны, а локоть сильнее сгибается при махе вперёд и выпрямляется при махе назад — мелочь, которая убирает роботизированный «маятниковый» вид. На IK руки переключаются только тогда, когда кисти должны оказаться в конкретном месте: на древке копья.
Цикл походки в одной переменной фазы
Ходьбой и бегом управляет одно число — phase, которое циклически идёт от 0 до 1. Ближняя стопа использует phase, дальняя — phase + 0.5, поэтому они всегда разнесены на полцикла. Второе число, gait, плавно меняется от 0 (ходьба) до 1 (бег), и по нему интерполируется каждый параметр походки:
| Параметр | Ходьба (gait = 0) | Бег (gait = 1) |
|---|---|---|
Каденс cyc (циклов в секунду) | 1.5 | 2.55 |
| Длина шага | 44 | 60 |
| Высота подъёма стопы | 15 | 26 |
| Доля опоры (часть цикла, когда стопа на земле) | 0.62 | 0.40 |
| Амплитуда покачивания таза | 5 | 12 |
Самый важный параметр — доля опоры. Если она больше 0.5, часть каждого цикла обе стопы стоят на земле — это и есть определение ходьбы. Если меньше 0.5, бывают моменты, когда на земле нет ни одной стопы: фаза полёта, благодаря которой бег выглядит бегом. Поскольку gait меняется плавно (каждый кадр он мягко подтягивается к цели), переход между ходьбой и бегом никогда не дёргается.
Траектория каждой стопы делится на две части. В фазе опоры (первые duty её цикла) стопа линейно скользит от половины шага впереди бедра до половины шага позади, ровно по земле. В фазе переноса она снова движется вперёд по кривой smoothstep, приподнятая половиной синусоиды:
if(pph<duty){ const u=pph/duty; fx=lerp(stride/2,-stride/2,u); fy=groundY; }
else{ const u=(pph-duty)/(1-duty); fx=lerp(-stride/2,stride/2,smooth(u));
fy=groundY - stepH*Math.sin(PI*u); }
Таз поднимается и опускается дважды за цикл (раз на каждый шаг) по формуле -|sin(phase·2π)|; при беге эта кривая сдвинута по фазе, чтобы верхняя точка совпадала с фазой полёта. Кроме того, стопы немного выносятся вперёд пропорционально наклону корпуса, чтобы оставаться под центром масс.
Постановка стоп: как избавиться от лунной походки
Фигура стоит на месте экрана, а мимо неё прокручивается земля. Если скорость прокрутки неверна, опорная стопа скользит по земле — классический баг «лунной походки». Исправление умещается в одну строку:
const scroll = stride*cyc/duty; // ground speed = stride × cadence ÷ duty factor
Почему это работает? В фазе опоры стопа проходит относительно бедра ровно один stride. Опора длится duty цикла, а цикл — 1/cyc секунд, значит, опора длится duty/cyc секунд. Следовательно, стопа движется назад относительно тела со скоростью
stride ÷ (duty ÷ cyc) = stride × cyc ÷ duty
Если земля уезжает назад ровно с этой скоростью, стопа и земля движутся вместе — стопа стоит. С числами из таблицы ходьба прокручивается со скоростью 44 × 1.5 ÷ 0.62 ≈ 106 единиц в секунду, а бег — 60 × 2.55 ÷ 0.40 ≈ 383. Заметьте: бег быстрее более чем втрое, хотя шаг длиннее лишь на треть, — основную работу делает меньшая доля опоры. Частая ошибка — брать stride × cadence: доля опоры игнорируется, и опорная стопа уплывает.
Земля в сцене — ряд мягких точек, который каждый кадр смещается на scroll × dt. Когда фигура перестаёт перемещаться, чтобы бросить, прыгнуть или выполнить комплекс с копьём, прокрутка падает до нуля и точки тоже останавливаются.
Дорожки ключевых кадров с плавностью
Бросок и прыжок — не циклы, а разовые движения с замахом, действием и восстановлением. Для них мы используем крошечные дорожки ключевых кадров: массивы пар [время, значение], которые сэмплируются в нормализованное время p от 0 до 1:
function track(t, keys, ease){
ease=ease||smooth;
if(t<=keys[0][0]) return keys[0][1];
for(let i=0;i<keys.length-1;i++){
const [t0,v0]=keys[i],[t1,v1]=keys[i+1];
if(t<=t1){ let u=(t-t0)/((t1-t0)||1); return lerp(v0,v1,ease(u)); }
}
return keys[keys.length-1][1];
}
У каждого анимируемого свойства своя дорожка. Для прыжка вертикальное смещение тела такое:
rootY = track(p,[[0,0],[0.2,10],[0.32,-6],[0.5,-160],[0.68,-70],[0.82,4],[0.9,-2],[1,0]],
(p<0.5?easeOutCubic:easeInCubic));
Читайте это как историю: присесть (+10 — помните, что ось y в canvas направлена вниз), оттолкнуться, подняться на 160 единиц в верхней точке, упасть, погасить приземление чуть ниже уровня земли, выпрямиться. Функция плавности меняется на полпути: easeOutCubic на подъёме (резкий старт, замедление к вершине) и easeInCubic на спуске (медленное начало, ускорение). Это дешёвая, но убедительная имитация параболы под действием гравитации.
Комплекс с копьём развивает ту же идею: 18 опорных точек (17 поз плюс завершающий ключ, совпадающий с начальным, чтобы комплекс зацикливался без шва), каждая описывает всё тело — угол копья, положение хвата, наклон, перенос веса, присед, ширину стойки, подъём и сжатие, — с необязательной функцией плавности для каждого отрезка. Читаемость дают два приёма. Две одинаковые опорные точки подряд создают фиксацию — тот самый стоп-кадр «позы» в комплексе боевых искусств. А выпады используют easeOutCubic, поэтому копьё выстреливает быстро и замедляется, входя в позу, — это читается как чёткий и мощный удар.
Сжатие и растяжение
Прыжок анимирует ещё и вертикальный масштаб sy: сжатие до 0.84 в приседе, растяжение до 1.14 при отрыве и снова сжатие до 0.82 при приземлении. Горизонтальный масштаб выводится из него, чтобы фигура будто сохраняла объём:
sx = 1+(1-sy)*0.7; // squash down → get wider; stretch up → get thinner
const pivot=V(anchorX, groundY);
const S=p=>V(pivot.x+(p.x-pivot.x)*sx, pivot.y+(p.y-pivot.y)*sy);
В конце buildPose каждый сустав пропускается через S(). Масштабирование относительно точки на земле под фигурой (а не её центра) удерживает стопы на полу, пока тело сжимается, — ровно так нарисовал бы аниматор вручную. Радиус головы масштабируется по среднему от sx и sy.
Вторичное движение: пружина и verlet-цепочка
Первичное движение — то, что делает персонаж. Вторичное — то, что следует за ним: волосы, ткань, голова, на миг отстающая от тела. Добавить его дёшево, а разница огромна. У нашей фигуры два вида такого движения.
Затухающая пружина на голове
Голова не сидит на шее жёстко. Её притягивает к идеальному положению пружина (жёсткость 260, затухание 26), поэтому, когда тело резко дёргается, голова отстаёт и устанавливается с лёгким перелётом. Затухание выбрано достаточно низким, чтобы осталось немного мультяшной упругости.
Волосы на verlet
Хохолок — это цепочка из четырёх точек с шагом 9 единиц. Интегрирование Верле хранит текущее и предыдущее положение каждой точки; скорость — просто их разность. Поэтому обновление получается очень коротким:
const s=scarf[i], vx=(s.x-s.px)*0.86, vy=(s.y-s.py)*0.86;
s.px=s.x; s.py=s.y;
s.x+=vx+windX*dt*dt*60; s.y+=vy+g*dt*dt*60;
Множитель 0.86 — сопротивление воздуха. windX — толчок назад, который растёт со скоростью бега (и получает дополнительный импульс в прыжках), а g — гравитация. После перемещения точек шесть итераций ограничения расстояния возвращают каждую пару точно на 9 единиц друг от друга, причём первая точка прикреплена к затылку. Закрепления и ограничений достаточно: на бегу волосы развеваются назад, при ходьбе свисают, в прыжках взлетают — и ничего из этого не анимировалось вручную.
Рисуем мягкие конечности
Человечек из прямых линий выглядит деревянным. У нашего три точки каждой конечности (например, бедро, колено, стопа) проходят через сплайн Катмулла — Рома, уплотняются в гладкую кривую, а вокруг неё заливается сужающаяся лента — толстая у бедра, тоньше у стопы — со скруглёнными концами. Получается конечность в духе мультфильмов «rubber hose», которая гнётся, а не ломается в шарнире. Конечности дальней стороны рисуются первыми — тоньше, темнее и с непрозрачностью 42%, — и это создаёт ощущение глубины безо всякого 3D.
Наконец, цикл ведёт себя вежливо. dt каждого кадра ограничен 33 мс, чтобы случайная заминка не отшвырнула волосы через весь экран. Анимация ставится на паузу, когда сцена уходит за пределы видимости или вкладка скрыта, а если система посетителя просит уменьшить движение, всё начинается на паузе с одного статичного кадра.
Что стоит у нас утащить
- Двухкостная IK — это десять строк. Ограничьте расстояние и косинус — и она никогда не выдаст
NaN. - IK — там, где есть цель, FK — там, где есть ритм.
- Скорость земли = длина шага × каденс ÷ доля опоры. Сделайте это правильно — и стопы будут прилипать к полу при любой походке.
- Дорожек ключевых кадров с плавностью на каждом отрезке хватает для выразительных разовых движений. Продублируйте ключ — получите фиксацию.
- Сжимайте относительно точки контакта, а не центра, и выводите ширину из высоты, чтобы сохранить объём.
- Пружина и verlet-цепочка из четырёх точек дают бо́льшую часть ощущения «живого» почти без кода.
Всё это можно увидеть в движении на странице со стикменом — теперь это Stickman Spear Run: бегите, прыгайте и бейте копьём, — а лаборатория движения стала её вторым режимом. Если вам по душе человечки, то и в нашей будущей физической головоломке Ink Left главный герой — человечек, нарисованный в недорисованном скетчбуке.