Содержание урока · 10 разделов
Клавиатура из прошлого урока отвечает на вопрос «куда движется игрок», но у целого жанра — стратегий, тап-игр, головоломок, point-and-click — вопрос другой: «куда игрок показывает». Указатель точен: клавиша двигает героя вектором, а курсор называет конкретную точку холста. Здесь же возникает первое настоящее изменение стиля мышления в курсе: координата указателя — это не направление, а место, и с ним приходит своя семья вопросов. В какой системе координат движок отдаёт позицию курсора, если окно растянуто, а холст — 320 на 240? Чем клик отличается от удержания, если оба выглядят как зажатая кнопка? И как один и тот же код работает на десктопе мышью и на телефоне пальцем? Все четыре ответа — в этом уроке, и первый из них приятный: половину работы за вас уже сделал движок.
ebiten мышь: CursorPosition и кнопки#
Позиция курсора читается парой функций: ebiten.CursorPosition() возвращает два int — x и y, а добавленная в свежих версиях ebiten.CursorPositionF() — те же координаты в float64, с дробной точностью. Главное, что нужно знать про эти числа: движок отдаёт их уже в логических координатах холста. Документация формулирует это как «logical position, учитывающая масштаб экрана», и за этими словами стоит забота: какой бы ни был масштаб окна, растяжение из Layout и чёрные поля — движок сам переводит позицию указателя из оконных пикселей в систему координат вашей игры. При Layout 320 на 240 и окне вдвое больше курсор в правом нижнем углу игровой области вернёт примерно (320, 240), а не оконные (960, 720). Ручной пересчёт «окно минус поле, делить на масштаб» писать не нужно — и не нужно, потому что он ломает то, что движок уже учёл.
Кнопки опрашивает ebiten.IsMouseButtonPressed(button): константы MouseButtonLeft, MouseButtonMiddle, MouseButtonRight закрывают классическую трёхкнопочную мышь — по внутренностям это именованные алиасы MouseButton0, MouseButton1 и MouseButton2. У игровых мышей рядом живут MouseButton3 и MouseButton4: боковые кнопки читаются тем же вызовом с другой константой, без единой строки платформенного кода. Для колеса есть ebiten.Wheel() — пара float64 с горизонтальным и вертикальным смещением колеса или тачпад-скролла за тик, нулевая, когда колесо стоит; зум в картах и прокрутка инвентаря — её работа. Все три вызова — опрос за тик в Update, ровно та же модель, что у клавиатуры: никаких событий, только честные вопросы каждый тик.
Клик-в-точку: герой едет к курсору
GOtype Game struct {
x, y float64 // герой
tx, ty float64 // цель, куда кликнули
}
func (g *Game) Update() error {
mx, my := ebiten.CursorPosition()
// клик ставит цель: координаты уже логические
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
g.tx, g.ty = float64(mx), float64(my)
}
// движение к цели упрощено: полный разбор — в пятом уроке
if dx, dy := g.tx-g.x, g.ty-g.y; dx != 0 || dy != 0 {
g.x += dx * 0.05
g.y += dy * 0.05
}
return nil
}IsMouseButtonJustPressed срабатывает в одном тике — клик ставит цель ровно один раз, а не льёт цели потоком всё время удержания. CursorPosition отдаёт логические координаты холста, поэтому float64(mx) — единственное преобразование, которое нужно. Само движение к цели поджато до простой тяги для краткости.
Layout возвращает 320×240, окно растянуто вдвое, курсор — в правом нижнем углу игровой области. Что вернёт CursorPosition?
Клик против удержания: inpututil для кнопок#
Пара «зажата против только что» у мыши повторяет клавиатурную один в один. ebiten.IsMouseButtonPressed отвечает на состояние — кнопка зажата прямо сейчас; inpututil.IsMouseButtonJustPressed истинна в тике нажатия; inpututil.IsMouseButtonJustReleased — в тике отпускания; а inpututil.MouseButtonPressDuration возвращает, сколько тиков кнопку держат, — удобно для натяжения лука или зарядки удара. Практическое разбиение действий такое: одиночный выстрел, клик по кнопке интерфейса, выбор предмета — только JustPressed; полёт за курсором, рисование кистью, стрельба очередью — IsMouseButtonPressed на удержании; перетаскивание собирается из всех трёх: JustPressed начал тягать, IsMouseButtonPressed держит, JustReleased бросил. Заметьте, что и здесь работает правило шестого урока: функции inpututil обязаны вызываться в Update, не в Draw — кадр может выйти без тика, и тогда событие из Draw потеряется.
Правая и средняя кнопки — не экзотика: правая снимает выбор и открывает контекстное меню, средняя прокруивает камеру в редакторах. Опрашивайте нужные кнопки тем же вызовом с другой константой и не пытайтесь хранить «нажатия» между тиками: опрос дёшев, а ваше хранилище состояний обязательно устареет на один тик — ровно там, где случится баг.
Общий указатель: палец или мышь
GO// pointer возвращает позицию указателя: палец на телефоне,
// мышь на десктопе. down — «нажато прямо сейчас»
func pointer() (x, y float64, down bool) {
if ids := ebiten.AppendTouchIDs(nil); len(ids) > 0 {
tx, ty := ebiten.TouchPosition(ids[0])
return float64(tx), float64(ty), true
}
mx, my := ebiten.CursorPosition()
return float64(mx), float64(my),
ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft)
}AppendTouchIDs(nil) возвращает срез идентификаторов активных касаний и всегда пуст на десктопах, поэтому тач-ветка там молча пропускается. TouchPosition отдаёт координаты первого пальца. Иначе работают мышь и кнопка. Один вызов pointer() — и прицел, и выбор предметов живут на всех платформах без ветвлений по GOOS.
Что сделает тач-ветка на десктопе?
Прочитайте код и выберите, что он выведет:
func (g *Game) Update() error {
ids := ebiten.AppendTouchIDs(nil)
if len(ids) > 0 {
// ветка пальца
}
// дальше — ветка мыши
return nil
}Тач-ввод: пальцы со своими идентификаторами#
Тач устроен иначе, чем мышь: пальцев может быть несколько, и каждому нужен адрес. В Ebitengine адрес — это ebiten.TouchID, а полный словарь тач-ввода такой: ebiten.AppendTouchIDs дописывает в переданный срез идентификаторы всех активных касаний; ebiten.TouchPosition(id) возвращает позицию конкретного пальца парой int и (0, 0), если касание уже кончилось; inpututil.AppendJustPressedTouchIDs выделяет пальцы, коснувшиеся экрана именно в этом тике; inpututil.IsTouchJustReleased и inpututil.TouchPressDuration закрывают те же вопросы отпускания и длительности, что и для кнопок. Есть и тонкий прибор: inpututil.TouchPositionInPreviousTick возвращает позицию пальца в прошлом тике — разница текущей и прошлой позиций и есть скорость свайпа, а для пинч-зума берут расстояние между двумя пальцами и делят на него же из прошлого тика. Когда дробной точности не хватает — свайпы по сетке дрожат на усечении до пикселя, — в v2.10 появились TouchPositionF и TouchPositionFInPreviousTick: те же позиции в float64, без округления до целого. Официальный пример touch в репозитории движка собирает из этих примитивов жесты руками, и его пороги — полезные ориентиры: тап — касание, которое либо продержалось меньше тридцати тиков (при TPS 60 это около полусекунды), либо почти не съехало с точки касания; пан — одиночный палец, ушедший от точки касания; пинч — пара пальцев, чьё взаимное расстояние изменилось больше допустимой дельты.
Два практических замечания. Первое: мультитач — не экзотика, а две самые естественные жестовые механики, пинч и вращение, построены на втором пальце; держите идентификаторы в срезе и не предполагайте, что их всегда один. Второе — наследие: в пакете осталась функция TouchIDs, помеченная устаревшей с версии 2.2 — в новом коде используйте AppendTouchIDs, она переиспользует буфер и не создаёт мусор каждый тик. И не забудьте про телефон в целом: нативное мобильное приложение не имеет курсора, CursorPosition там всегда возвращает (0, 0), поэтому единственный живой ввод на телефоне — тач, и pointer() из этого урока — правильный каркас.
API мыши и тача урока#
| Вызов | Пакет | Что даёт |
|---|---|---|
| ebiten.CursorPosition() | ebiten | (x, y) в логических координатах холста, масштаб учтён |
| ebiten.IsMouseButtonPressed(b) | ebiten | зажата ли кнопка: Left, Middle, Right |
| inpututil.IsMouseButtonJustPressed(b) | inpututil | клик ровно в этом тике |
| ebiten.Wheel() | ebiten | (xoff, yoff) колеса или тачпада; 0, если стоит |
| ebiten.AppendTouchIDs(ids) | ebiten | срез TouchID активных пальцев; пуст на десктопах |
| inpututil.TouchPositionInPreviousTick(id) | inpututil | позиция пальца в прошлом тике — скорость свайпа |
Координаты курсора: масштаб уже учтён#
Вернёмся к координатам, потому что здесь спрятаны две тонкости, о которых лучше узнать из урока, а не из бага. Первая: до старта игрового цикла CursorPosition возвращает (0, 0) — спрашивать курсор в init бессмысленно, только из Update или позже. Вторая тонкость опаснее: если окно шире пропорций холста, движок при масштабировании оставляет поля — чёрные полосы слева и справа или сверху и снизу, и курсор над полем находится вне игровой области. При этом формула перевода в логические координаты поля не отбрасывает: над левой полосой x уедет в минус, над правой — за ширину холста. Движок честно отдаёт математически верное значение, а забота об отсечении — ваша: проверяйте, что координаты попадают в холст, прежде чем считать клик по миру.
Отсюда же мост к хит-тестам. Курсор — это точка, а игровой объект — прямоугольник: попадание точки в прямоугольник и станет основной операцией следующего урока про прямоугольники и позиции спрайта. Для окна и масштабов в целом — откуда берутся поля и как Layout дружит с окном — справочная база лежит в уроке про окно и холст; координаты курсора опираются на неё напрямую.
Курсор над полем масштабирования
Холст — 320×240, окно растянуто до 320×360: холст отмасштабирован в центре окна, сверху и снизу — поля по 60 пикселей. Что вернёт CursorPosition, когда игрок ведёт мышь над верхним полем?
Прицел рисуется в позиции курсора, а выстрел висит на IsMouseButtonPressed — и пока кнопка зажата, тик за тиком летят снаряды: очередь вместо одиночного, а кнопки интерфейса прожимаются пачкой за одно нажатие. Что сломалось бы дальше: перетаскивание путалось бы с кликами. Разводите примитивы: непрерывное — на IsMouseButtonPressed, дискретное — на inpututil.IsMouseButtonJustPressed.
Один код для трёх платформ#
Соберём платформенную картину целиком. Десктоп: мышь — главный указатель, тач-ветка всегда пуста, клавиатура из прошлого урока рядом. Телефон: CursorPosition всегда нулевая, и всё поведение живёт на AppendTouchIDs с пальцами; клавиатура существует только как подключённая периферия. Браузер: тот же WASM-бинарник умеет и мышь, и тач — на десктопе работает первая ветка pointer(), на телефоне вторая. Правило, из которого вытекает вся архитектура ввода курса: не выясняйте, чем управляет игрок, — опрашивайте все источники параллельно и позвольте пустым веткам молчать. Тогда перенос игры на новую платформу не трогает логику вовсе: меняются лишь физические жесты, а Update остаётся тем же.
Остался третий источник ввода, который мы сознательно откладываем: геймпад. Движок умеет его честно, и приборы знакомы по этому уроку: ebiten.AppendGamepadIDs перечисляет подключённые устройства с их GamepadID, ebiten.GamepadName называет устройство по-человечески, ebiten.IsGamepadButtonPressed и inpututil.IsGamepadButtonJustPressed работают по тому же принципу «зажата против только что», что клавиши и кнопки мыши. Курсу геймпад не нужен до итоговой аркады, а механика опроса у него ровно та же, что вы уже знаете. Каркас ввода полный: вектор из клавиш, точка из курсора, пальцы со своими идентификаторами. Дальше по курсу эти три источника станут входами одного автомата состояний игры, но фундамент опроса останется именно этим.
Курсор и пальцы в песочнице сайта не повторить: нужен локальный Go и настоящее окно с фокусом. Зато перевод координат, который делает движок, прозрачен для математики: тематическая программа в песочнице печатает, как оконная точка превращается в логическую при разных масштабах холста, — та же арифметика, что внутри CursorPosition.
Правила указателя одним списком#
- CursorPosition отдаёт логические координаты холста: масштаб окна движок учитывает сам
- Ручной пересчёт координат курсора не нужен — и ломает прицел задваиванием масштаба
- Клик — inpututil.IsMouseButtonJustPressed; удержание и перетаскивание — IsMouseButtonPressed плюс IsMouseButtonJustReleased
- Тач адресуется идентификаторами: AppendTouchIDs даёт пальцы, TouchPosition — позицию каждого
- AppendTouchIDs всегда пуст на десктопах — общий код мыши и тача безопасен
- На нативных мобильных CursorPosition всегда (0, 0): единственный ввод телефона — тач
Соберите общий pointer()
Восстановите три пропуска: тач-список, позиция курсора и проверка кнопки.
Клик-в-точку от курсора до героя
Соберите Update: клик ставит цель, герой тянется к ней шагом за тик.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Ввод курса укомплектован: вектор из клавиш шестого урока, точка из курсора, пальцы со своими адресами — и всё это опрашивается из одного Update без единого события. Дальше вводу понадобится адресат: урок про прямоугольники и позиции спрайта превратит «точку курсора» и «героя» в прямоугольники, из которых собираются хит-тесты и коллизии. Отдельные координатные тонкости — поля масштабирования и Layout — разобраны в уроке про окно и холст, а клавиатурная пара «зажата против только что» из шестого урока здесь работает без изменений: мышь и тач живут на тех же примитивах. Практический тест понимания — собрать pointer() и убедиться, что на десктопе тач-ветка молчит, а в мобильной сборке молчит мышь.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Координаты курсора движок отдаёт уже в логической системе холста — масштаб и поля учтены, ручной пересчёт не нужен.
- Кнопки — Left, Middle, Right — опрашиваются за тик; клик — inpututil.IsMouseButtonJustPressed, удержание — IsMouseButtonPressed.
- Курсор над полем масштабирования даёт координаты за пределами холста: отсекайте их проверкой границ сами.
- Тач адресуется идентификаторами: AppendTouchIDs плюс TouchPosition по каждому пальцу; свайпам помогает позиция прошлого тика.
- AppendTouchIDs всегда пуст на десктопах, а CursorPosition на мобильных всегда (0, 0) — pointer() с двумя ветками закрывает все платформы.
- Опрашивайте все источники ввода параллельно: пустая ветка молчит, и логика не зависит от платформы.
Домашнее задание
Соберите pointer() из урока и сделайте прицел: маленький спрайт или прямоугольник следует за точкой указателя каждый тик. Затем прикрутите стрельбу — счётчик выстрелов на IsMouseButtonJustPressed — и отдельно попробуйте очередь на IsMouseButtonPressed: наблюдение за разницей важнее результата. Добавьте клик-в-точку герою с нормализованным шагом из пятого урока и проверьте, что клик в угол холста приводит героя ровно к точке. Проверьте границы: клик над полом масштабирования не должен ставить цель — отсеките координаты за пределами холста. На телефоне или в эмуляторе проверьте тач-ветку: тап должен вести себя как клик.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему курсор над чёрной полосой даёт координаты за пределами холста?
Масштабирование с полями рисует холст в центре окна, но формула перевода оконных координат в логические не отбрасывает области вне игровой части: над левой полосой x уходит в минус, над правой — за ширину. Движок отдаёт математически честное значение, а проверка попадания в холст — ваша обязанность, как и любая граница мира.
Как сделать пинч-зум двумя пальцами?
Возьмите AppendTouchIDs, выберите два первых идентификатора и посчитайте расстояние между их TouchPosition. В прошлом тике то же расстояние даёт TouchPositionInPreviousTick для обоих пальцев. Отношение расстояний — коэффициент зума текущего тика; умножайте на него масштаб камеры, и жест соберётся в десяток строк.
Нужен ли отдельный код ввода для WASM-версии?
Нет: API одинаков на всех платформах, и весь разбор уже сведён в pointer(). В браузере на десктопе работает мышь, на телефоне — тач, а тач-ветка на десктопе гарантированно пуста по документации. Отдельного кода требует только экзотика вроде блокировки контекстного меню правой кнопки — и это забота страницы-хоста, не игры.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript