Мышь и тач-управление

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

Зачем это нужно

В прошлом уроке мы научили цыплёнка слушаться клавиатуры — стрелки, WASD, всё как в больших играх. Но открой свой проект на телефоне — и упс: клавиш-то нет. А ведь большинство твоих друзей будут тыкать в игру именно пальцем. Да и на компьютере есть куча игр, где управляют не клавишами, а мышкой: тапаешь в точку — и герой бежит туда.

Представь: ты водишь пальцем по экрану телефона, а цыплёнок плавно скользит за пальцем, будто привязанный ниточкой. Двигаешь курсор мышкой на ноуте — он туда же. Вот к этому мы и придём за урок.

Смотри, к чему мы идём:

// цыплёнок плавно догоняет точку, куда ты ткнул мышью или пальцем
let chicken = { x: 200, y: 150 };
let target = { x: 200, y: 150 }; // куда бежать

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  chicken.x += (target.x - chicken.x) * 0.1; // догоняем по X
  chicken.y += (target.y - chicken.y) * 0.1; // догоняем по Y
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Результат: цыплёнок плавно скользит к той точке холста, куда указывает курсор или палец, и мягко тормозит рядом с ней — как будто едет на магните за твоей рукой. Если водить мышью, он всё время держится чуть позади и догоняет.

Чтобы это заработало, нам надо решить две задачи: во-первых, поймать события мыши и касаний, а во-вторых — и это самое коварное — правильно перевести координаты экрана в координаты холста. Разберём всё по шагам.

И ещё одна приятная новость: код, который мы напишем сегодня, не одноразовый. Объект chicken с его x и y, наш игровой цикл loop, спрайт chickenSprite — всё это те же самые куски, что мы собирали в прошлых уроках. Управление мышью мы просто докручиваем сверху, как новую кнопку на уже работающем пульте. В финальной аркаде про цыплёнка эти кусочки соберутся вместе, и тебе не придётся ничего переписывать — поэтому держим имена переменными одинаковыми от урока к уроку.

Как браузер сообщает о мыши: события

Браузер постоянно следит за мышью и пальцем и, когда что-то происходит, кидает событие — маленькую записку «вот тут кое-что случилось». Мы можем сказать: «когда на холсте случится такое-то событие — позови мою функцию». Это как подписка на уведомления: лайкнули твой пост — прилетела пушка. Подвинули мышь над холстом — прилетело событие mousemove.

Нам сегодня важны четыре события:

СобытиеКогда срабатывает
mousemoveМышь двигается над элементом
clickПо элементу кликнули (нажали и отпустили)
touchstartПалец коснулся экрана
touchmoveПалец двигается по экрану, не отрываясь

Подписываемся на событие методом addEventListener. Звучит длинно, а делает простое: «добавь слушателя такого-то события». Вот самый первый, голый пример:

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

// слушаем движение мыши прямо на холсте
canvas.addEventListener('mousemove', function (event) {
  console.log('мышь в окне:', event.clientX, event.clientY);
});

Результат: как только ты водишь мышью над холстом, в консоли разработчика бегут пары чисел — координаты курсора в окне браузера. Сам цыплёнок пока не двигается, мы просто подсматриваем за мышью.

В функцию-обработчик браузер сам передаёт объект события — мы назвали его event. Внутри него лежит всё про это событие: какие координаты, какая кнопка нажата и так далее. Координаты курсора лежат в event.clientX и event.clientY. Вот тут и притаилась первая ловушка.

Главная ловушка: экран — это не холст

Числа clientX и clientY — это координаты относительно всего окна браузера, а не относительно нашего холста. А холст почти никогда не прижат к левому верхнему углу окна: над ним есть шапка сайта, слева — отступы, поля. Если ты возьмёшь clientX как есть и нарисуешь там цыплёнка, он окажется смещённым — будет бежать не туда, куда ты тыкаешь, а левее и выше.

Это как сказать «третий ряд, пятое место» — но не уточнить, в каком зале кинотеатра. Адрес есть, а точки отсчёта нет.

Нам нужна точка отсчёта — где именно на странице стоит холст. Её даёт метод getBoundingClientRect(). Он возвращает прямоугольник холста: его left и top — это сколько пикселей от края окна до левого и верхнего края холста. Вычитаем их — и получаем честные координаты внутри холста.

Переводим координаты правильно

function getCanvasPos(event) {
  const rect = canvas.getBoundingClientRect(); // где холст в окне
  return {
    x: event.clientX - rect.left, // вычитаем левый край холста
    y: event.clientY - rect.top,  // вычитаем верхний край холста
  };
}

canvas.addEventListener('mousemove', function (event) {
  const pos = getCanvasPos(event);
  console.log('на холсте:', pos.x, pos.y);
});

Результат: теперь в консоли — координаты внутри холста. Если навести курсор в самый левый верхний угол холста, числа будут около 0 и 0, а не на величину шапки сайта. Где бы холст ни стоял на странице, отсчёт всегда идёт от его собственного угла.

Запомни эту маленькую функцию getCanvasPos — она будет в каждой игре с мышью. Один раз понял — и больше не путаешься.

Ведём цыплёнка за курсором

Теперь соберём первую рабочую версию. Сделаем глобальную точку target — «куда бежать цыплёнку», — и на каждый mousemove будем обновлять её координатами курсора. А в игровом цикле потихоньку двигать цыплёнка к этой точке.

Шаг 1. Цыплёнок прыгает прямо под курсор

Сначала самый простой вариант — пусть цыплёнок просто телепортируется туда, где курсор.

let chicken = { x: 200, y: 150 };

function getCanvasPos(event) {
  const rect = canvas.getBoundingClientRect();
  return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}

canvas.addEventListener('mousemove', function (event) {
  const pos = getCanvasPos(event);
  chicken.x = pos.x; // ставим цыплёнка прямо под курсор
  chicken.y = pos.y;
});

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Результат: цыплёнок намертво приклеен к курсору и мгновенно повторяет каждое его движение. Двигаешь мышь — цыплёнок прыгает ровно туда же, без задержки. Работает, но выглядит слишком резко и неживо.

Заметь: координаты курсора мы сохраняем прямо в состояние цыплёнка (chicken.x, chicken.y), а рисуем его уже в игровом цикле. Событие и отрисовка — разные вещи: событие лишь меняет данные, а рисует всегда loop.

Шаг 2. Плавное преследование

Чтобы было красиво, заведём отдельную точку target и будем не телепортировать цыплёнка, а каждый кадр сдвигать его на десятую часть пути до цели. Это старый добрый приём «плавного догоняния».

let chicken = { x: 200, y: 150 };
let target = { x: 200, y: 150 };

canvas.addEventListener('mousemove', function (event) {
  const pos = getCanvasPos(event);
  target.x = pos.x; // обновляем только цель
  target.y = pos.y;
});

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // каждый кадр проходим 10% оставшегося расстояния до цели
  chicken.x += (target.x - chicken.x) * 0.1;
  chicken.y += (target.y - chicken.y) * 0.1;
  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Результат: цыплёнок мягко скользит к курсору и плавно тормозит рядом с ним, а не прыгает рывком. Чем дальше курсор, тем быстрее цыплёнок стартует, и тем мягче подъезжает у самой цели. Выглядит как живое преследование.

Разберём магическую строку chicken.x += (target.x - chicken.x) * 0.1. Выражение target.x - chicken.x — это расстояние, которое осталось пройти. Мы берём от него 0.1 (десятую часть) и прибавляем к позиции. Пока цыплёнок далеко — шаг большой, он несётся. Чем ближе — тем меньше остаток и тем короче шаг, поэтому у цели он плавно замедляется. Хочешь сделать его шустрее — увеличь 0.1 до, скажем, 0.25; хочешь ленивее — уменьши до 0.05.

А если не вести, а кликать?

Иногда не нужно, чтобы цыплёнок гонялся за курсором всё время. Хочется иначе: ты кликнул в точку — и герой пошёл туда, как юниты в стратегиях. Для этого есть событие click: оно срабатывает один раз, когда ты нажал и отпустил кнопку мыши на холсте. Логика та же — обновляем target, но только по клику, а не на каждое движение.

canvas.addEventListener('click', function (event) {
  const pos = getCanvasPos(event); // та же функция перевода координат!
  target.x = pos.x;
  target.y = pos.y;
});

Результат: теперь цыплёнок стоит на месте, пока ты не кликнешь по холсту. Кликнул — он плавно побежал к этой точке и остановился там. Водить мышью больше не нужно: один клик задаёт цель, и цыплёнок сам до неё доезжает благодаря всё той же строке преследования в цикле.

Обрати внимание: функцию getCanvasPos мы переиспользуем без изменений. Перевод экранных координат в координаты холста одинаков и для mousemove, и для click — потому мы и вынесли его в отдельную функцию. Меньше копипасты — меньше багов.

Управление пальцем: касания

Теперь — телефон. На сенсорном экране нет курсора, зато есть касания. События называются по-своему: touchstart (палец коснулся), touchmove (палец ведёт по экрану). И координаты тут лежат не там, где у мыши, — это вторая ловушка урока.

У события касания может быть сразу несколько пальцев, поэтому координаты спрятаны в списке event.touches. Первый палец — это event.touches[0], и уже у него есть знакомые clientX и clientY.

function getTouchPos(event) {
  const rect = canvas.getBoundingClientRect();
  const touch = event.touches[0]; // берём первый палец
  return {
    x: touch.clientX - rect.left,
    y: touch.clientY - rect.top,
  };
}

canvas.addEventListener('touchmove', function (event) {
  event.preventDefault();          // не даём странице прокручиваться
  const pos = getTouchPos(event);
  target.x = pos.x;
  target.y = pos.y;
});

canvas.addEventListener('touchstart', function (event) {
  event.preventDefault();
  const pos = getTouchPos(event);
  target.x = pos.x; // при первом касании сразу задаём цель
  target.y = pos.y;
});

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

Тут две важные детали. Первая — координаты берём из event.touches[0], а перевод в систему холста делаем тем же вычитанием rect.left и rect.top, что и для мыши. Вторая — event.preventDefault(). Без неё телефон решит, что ты хочешь прокрутить страницу, и будет её таскать вместо управления игрой. Эта строчка говорит браузеру: «я сам разберусь с этим касанием, не делай ничего по умолчанию».

Один код для мыши и пальца

Заметил, что и для мыши, и для касания мы делаем одно и то же — обновляем target? Поэтому удобно завести одну функцию setTarget и звать её из обоих обработчиков. Так не придётся чинить логику в двух местах.

function setTarget(x, y) {
  target.x = x;
  target.y = y;
}

canvas.addEventListener('mousemove', function (event) {
  const p = getCanvasPos(event);
  setTarget(p.x, p.y);
});

canvas.addEventListener('touchmove', function (event) {
  event.preventDefault();
  const p = getTouchPos(event);
  setTarget(p.x, p.y);
});

Результат: игра одинаково управляется и мышью на компьютере, и пальцем на телефоне — цыплёнок бежит к цели независимо от того, чем ты её задал. Логику преследования мы при этом нигде не дублировали.

Частые ошибки новичков

На этих граблях спотыкаются почти все, кто впервые делает управление мышью. Глянь заранее.

1. Забыл вычесть getBoundingClientRect

Самая популярная беда. Берёшь event.clientX напрямую — и цыплёнок бежит не под курсор, а со сдвигом влево-вверх ровно на размер шапки и отступов. Лечится одной строкой: вычитай rect.left и rect.top. Если что-то «промахивается мимо курсора» — первым делом проверяй именно это.

2. Перепутал clientX у касания

У события касания нет event.clientX напрямую — будет undefined, и цыплёнок улетит в верхний левый угол. Координаты касания живут глубже: event.touches[0].clientX. Не забывай про [0] — это первый палец.

3. Забыл preventDefault на touchmove

Без event.preventDefault() в обработчике касания телефон будет прокручивать страницу под пальцем, и играть станет невозможно — экран ёрзает. Добавь эту строку в начало обработчиков touchstart и touchmove.

4. Меняешь chicken.x прямо в обработчике события вместо target

Если двигать цыплёнка прямо в mousemove, рисование и логика расползаются по разным местам, и плавное преследование сломается. Правило: событие только обновляет данные (нашу точку target), а двигает и рисует цыплёнка игровой цикл loop. Так устроены все нормальные игры.

5. Холст растянут стилями — координаты «врут»

Если у холста ширина в HTML одна (например, width="400"), а на экране CSS растянул его до 800 пикселей, то один пиксель экрана — это уже половина пикселя холста, и курсор будет мазать. Простое правило для начала: не растягивай холст стилями, держи его экранный размер равным его настоящим canvas.width и canvas.height. С масштабированием разберёмся в уроках про камеру.

Мини-проект: цыплёнок ловит зёрнышко

Теперь твоя очередь. Сделай так: по экрану в случайном месте лежит зёрнышко (нарисуй жёлтый кружок через ctx.arc). Цыплёнок бежит за курсором или пальцем, как мы научились. Как только цыплёнок дотронулся до зёрнышка — зёрнышко прыгает в новую случайную точку, а ты получаешь очко.

Вот каркас, который надо дописать:

let chicken = { x: 200, y: 150 };
let target = { x: 200, y: 150 };
let grain = { x: 100, y: 100 };
let score = 0;

// ...тут обработчики mousemove / touchmove, обновляющие target...

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  chicken.x += (target.x - chicken.x) * 0.1;
  chicken.y += (target.y - chicken.y) * 0.1;

  // рисуем зёрнышко
  ctx.fillStyle = 'gold';
  ctx.beginPath();
  ctx.arc(grain.x, grain.y, 8, 0, Math.PI * 2);
  ctx.fill();

  // ЗАДАНИЕ: если цыплёнок близко к зёрнышку —
  // переставь grain в случайную точку и прибавь score.
  // Расстояние посчитай так:
  // const dx = chicken.x - grain.x;
  // const dy = chicken.y - grain.y;
  // if (Math.sqrt(dx * dx + dy * dy) < 24) { ... }

  ctx.drawImage(chickenSprite, chicken.x, chicken.y);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Результат (когда допишешь): ты водишь курсором или пальцем, цыплёнок гоняется за твоей рукой, налетает на жёлтое зёрнышко — оно тут же перепрыгивает в новое случайное место, а счётчик очков растёт. Получается крошечная игра «накорми цыплёнка».

Подсказки: случайную координату дают Math.random() * canvas.width и Math.random() * canvas.height. Проверку «близко ли» мы делаем по расстоянию между центрами — это первый шажок к коллизиям, которые мы подробно разберём дальше. А пока хватит и простого «если ближе 24 пикселей — съели».

Итоги

Сегодня твой цыплёнок научился слушаться мыши и пальца. Что теперь у тебя в руках:

  • addEventListener('mousemove', ...) и 'click' ловят мышь; 'touchstart' и 'touchmove' — касания.
  • Координаты event.clientX/clientY отсчитываются от окна, поэтому их надо переводить в систему холста через getBoundingClientRect() — вычитать rect.left и rect.top.
  • Координаты касания лежат в event.touches[0], а не прямо в событии.
  • event.preventDefault() в обработчиках касания не даёт странице прокручиваться под пальцем.
  • Событие только обновляет данные (точку target), а двигает и рисует цыплёнка игровой цикл.
  • Приём chicken.x += (target.x - chicken.x) * 0.1 даёт плавное преследование цели.

Теперь твою игру можно гонять и на ноуте мышкой, и на телефоне пальцем — это сразу делает её куда дружелюбнее. В следующем уроке мы дадим цыплёнку настоящую физику движения: разложим скорость на пару чисел vx и vyвектор скорости — и заставим его разгоняться и тормозить плавно, а не дёргано. А пока — погоняй цыплёнка за курсором и порадуйся: твоя игра больше не привязана к клавиатуре.

Проверьте себя
1. Почему нельзя использовать event.clientX напрямую как координату на холсте?
AПотому что clientX отсчитывается от края окна браузера, а не от края холста
BПотому что clientX всегда равен нулю
CПотому что clientX работает только на телефонах
DПотому что холст не умеет читать события мыши
2. Что возвращает canvas.getBoundingClientRect() и зачем оно нужно?
AПрямоугольник холста с его left и top — точкой отсчёта для перевода координат
BСписок всех нажатых клавиш
CКоличество кадров в секунду
DКартинку спрайта цыплёнка
3. Где лежат координаты первого касания в событии touchmove?
AВ event.touches[0].clientX и event.touches[0].clientY
BПрямо в event.clientX и event.clientY
CВ event.key
DВ canvas.width и canvas.height
4. Зачем вызывать event.preventDefault() в обработчике touchmove?
AЧтобы телефон не прокручивал страницу под пальцем во время игры
BЧтобы увеличить FPS до 120
CЧтобы загрузить спрайт цыплёнка
DЧтобы перевести координаты в систему холста
5. Как правильно организовать движение цыплёнка за курсором?
AСобытие обновляет точку target, а двигает и рисует цыплёнка игровой цикл loop
BДвигать и рисовать цыплёнка прямо внутри обработчика mousemove
CРисовать цыплёнка в getBoundingClientRect()
DМенять координаты только при событии click, а в цикле ничего не делать
6. Что делает строка chicken.x += (target.x - chicken.x) * 0.1 каждый кадр?
AСдвигает цыплёнка на десятую часть оставшегося расстояния до цели — плавное догоняние
BМгновенно телепортирует цыплёнка в точку target
CОстанавливает игровой цикл
DОчищает холст перед рисованием