---
title: "Мышь и тач-ввод"
description: "Мышь и тач-ввод в Ebitengine: CursorPosition в координатах холста, кнопки и клики через inpututil, пальцы по TouchID, Wheel и единый pointer — с кодом."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/07-mysh-i-tach/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Мышь и тач-ввод

*Курс «Ebitengine: 2D-игры на Go», урок 7 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ebitengine/lessons/07-mysh-i-tach/*

Мышь в Ebitengine — это две функции: позиция курсора и состояние кнопок, а тач-ввод добавляет пальцы с собственными идентификаторами. Обе модели опрашиваются так же, как клавиатура из шестого урока, — каждый тик в Update, и в этом уроке мы соберём клик-в-точку, прицел и общий код для десктопа и телефона.

Клавиатура из прошлого урока отвечает на вопрос «куда движется игрок», но у целого жанра — стратегий, тап-игр, головоломок, 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, ровно та же модель, что у клавиатуры: никаких событий, только честные вопросы каждый тик.

### Клик-в-точку: герой едет к курсору

```go
type 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?

- Примерно (960, 720) — оконные пиксели
- Примерно (640, 480) — пиксели с DPI-масштабом
- ✅ Примерно (320, 240) — движок учёл масштаб сам
- (0, 0) — курсор за пределами холста не считается

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

### Клик против удержания: 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.

### Что сделает тач-ветка на десктопе?

```go
func (g *Game) Update() error {
        ids := ebiten.AppendTouchIDs(nil)
        if len(ids) > 0 {
                // ветка пальца
        }
        // дальше — ветка мыши
        return nil
}
```

**Что выведет программа?**

- Паника: тач-функции не определены на десктопах
- ✅ ids всегда пуст — AppendTouchIDs на десктопах ничего не добавляет
- Вернёт один фиктивный палец в (0, 0)
- Ошибка компиляции: нужен флаг сборки для тача

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

### Тач-ввод: пальцы со своими идентификаторами

Тач устроен иначе, чем мышь: пальцев может быть несколько, и каждому нужен адрес. В 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, когда игрок ведёт мышь над верхним полем?

- ✅ Координаты уйдут в минус по y: движок не отбрасывает поля, отсекать нужно самому
- (0, 0): над холстом позиция не отслеживается
- Паника: курсор вне игровой области недопустим
- Координаты прижмутся к краю холста: минимум ноль

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

> **Живое место: прицел, который стреляет сам.** Прицел рисуется в позиции курсора, а выстрел висит на 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()

Восстановите три пропуска: тач-список, позиция курсора и проверка кнопки.

```go
func pointer() (float64, float64, bool) {
        if ids := ebiten.___(nil); len(ids) > 0 {
                tx, ty := ebiten.TouchPosition(ids[0])
                return float64(tx), float64(ty), true
        }
        mx, my := ebiten.___()
        return float64(mx), float64(my),
                ebiten.___(ebiten.MouseButtonLeft)
}
```

Подсказка: Список пальцев — AppendTouchIDs, позиция курсора — CursorPosition, состояние кнопки — IsMouseButtonPressed.

Правильные ответы в пропусках: `AppendTouchIDs`, `CursorPosition`, `IsMouseButtonPressed`

### Клик-в-точку от курсора до героя

Соберите Update: клик ставит цель, герой тянется к ней шагом за тик.

Правильный порядок строк:

1. `func (g *Game) Update() error {`
2. `mx, my := ebiten.CursorPosition()`
3. `if !inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { return nil }`
4. `g.tx, g.ty = float64(mx), float64(my)`
5. `dx, dy := g.tx-g.x, g.ty-g.y`
6. `dist := math.Hypot(dx, dy)`
7. `g.x += dx / dist * g.speed / float64(ebiten.TPS())`
8. `}`

Ввод курса укомплектован: вектор из клавиш шестого урока, точка из курсора, пальцы со своими адресами — и всё это опрашивается из одного Update без единого события. Дальше вводу понадобится адресат: урок про [прямоугольники и позиции спрайта](/courses/ebitengine/lessons/08-pryamougolniki/) превратит «точку курсора» и «героя» в прямоугольники, из которых собираются хит-тесты и коллизии. Отдельные координатные тонкости — поля масштабирования и Layout — разобраны в уроке про [окно и холст](/courses/ebitengine/lessons/03-okno-i-holst/), а клавиатурная пара «зажата против только что» из [шестого урока](/courses/ebitengine/lessons/06-klaviatura/) здесь работает без изменений: мышь и тач живут на тех же примитивах. Практический тест понимания — собрать pointer() и убедиться, что на десктопе тач-ветка молчит, а в мобильной сборке молчит мышь.

### Термины урока

- **CursorPosition** — ebiten.CursorPosition() — (x, y) в логических координатах холста: движок сам учёл масштаб окна и поля. На мобильных всегда (0, 0).
- **IsMouseButtonJustPressed** — inpututil.IsMouseButtonJustPressed(b) — истинна в тике нажатия кнопки. Для кликов и одиночных действий; удержание — IsMouseButtonPressed.
- **ebiten.Wheel** — ebiten.Wheel() — (xoff, yoff) смещения колеса мыши или тачпад-скролла за тик; нулевая пара, когда колесо стоит. Зум и прокрутка списков.
- **TouchID** — Идентификатор касания. Список активных пальцев — ebiten.AppendTouchIDs; позиция по пальцу — ebiten.TouchPosition(id), (0, 0) если касание кончилось.
- **TouchPositionInPreviousTick** — inpututil.TouchPositionInPreviousTick(id) — позиция пальца в прошлом тике. Разница с текущей даёт скорость свайпа; используется в пинч-зуме.
- **pointer()** — Каркас урока: палец через AppendTouchIDs, иначе мышь через CursorPosition и кнопку. Один вызов работает на десктопе, телефоне и в браузере.

## Коротко: что запомнить

- Координаты курсора движок отдаёт уже в логической системе холста — масштаб и поля учтены, ручной пересчёт не нужен.
- Кнопки — Left, Middle, Right — опрашиваются за тик; клик — inpututil.IsMouseButtonJustPressed, удержание — IsMouseButtonPressed.
- Курсор над полем масштабирования даёт координаты за пределами холста: отсекайте их проверкой границ сами.
- Тач адресуется идентификаторами: AppendTouchIDs плюс TouchPosition по каждому пальцу; свайпам помогает позиция прошлого тика.
- AppendTouchIDs всегда пуст на десктопах, а CursorPosition на мобильных всегда (0, 0) — pointer() с двумя ветками закрывает все платформы.
- Опрашивайте все источники ввода параллельно: пустая ветка молчит, и логика не зависит от платформы.

## Домашнее задание

Соберите pointer() из урока и сделайте прицел: маленький спрайт или прямоугольник следует за точкой указателя каждый тик. Затем прикрутите стрельбу — счётчик выстрелов на IsMouseButtonJustPressed — и отдельно попробуйте очередь на IsMouseButtonPressed: наблюдение за разницей важнее результата. Добавьте клик-в-точку герою с нормализованным шагом из пятого урока и проверьте, что клик в угол холста приводит героя ровно к точке. Проверьте границы: клик над полом масштабирования не должен ставить цель — отсеките координаты за пределами холста. На телефоне или в эмуляторе проверьте тач-ветку: тап должен вести себя как клик.

## Вопросы и ответы по уроку

### Почему курсор над чёрной полосой даёт координаты за пределами холста?

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

### Как сделать пинч-зум двумя пальцами?

Возьмите AppendTouchIDs, выберите два первых идентификатора и посчитайте расстояние между их TouchPosition. В прошлом тике то же расстояние даёт TouchPositionInPreviousTick для обоих пальцев. Отношение расстояний — коэффициент зума текущего тика; умножайте на него масштаб камеры, и жест соберётся в десяток строк.

### Нужен ли отдельный код ввода для WASM-версии?

Нет: API одинаков на всех платформах, и весь разбор уже сведён в pointer(). В браузере на десктопе работает мышь, на телефоне — тач, а тач-ветка на десктопе гарантированно пуста по документации. Отдельного кода требует только экзотика вроде блокировки контекстного меню правой кнопки — и это забота страницы-хоста, не игры.
