J2ME игры: Опрос клавиатуры

Алексей "Георгиевич" Астафьев, ноябрь 2006

О чем статья
Соглашения
Существующие J2ME API и критика
Внутренние потоки MIDP
Проблема одновременного нажатия клавиш
Проблема с keyReleased
Эмуляторы и реальные устройства
Проблемы getKeyStates
keyReleased при Display.setCurrent
Методы опроса клавиш
Проблемы с нестандартными кодами клавиш
Проблема с getGameAction
Особенности SonyEricsson
Игры с несколькими потоками
Проблема с приоритетами потоков
Проблема синхронизации потоков
Проблема с пропуском события
Конечное решение
Заключение



О чем статья

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



Соглашения

Под J2ME устройствами я понимаю мобильные телефоны. Версии MIDP и CLDC в данной статье не важны.



Существующие J2ME API и критика

MIDP предоставляет три метода у класса javax.microedition.lcdui.Canvas и его наследников:


Canvas.keyPressed
Canvas.keyRepeated
Canvas.keyReleased

Эти методы [1] доставляют три события клавиатуры – нажатие, удержание и отпускание клавиши. Выяснить, посылает ли устройство события Canvas.keyRepeated, можно проверив Canvas.hasRepeatEvents .[2]
Метод keyRepeated мной не используется, поэтому возвращаться к нему я более не буду. Замечу лишь, что при удержании клавиш событие keyRepeated вызывается многократно, после некоторой задержки. Но интервалы – нигде не оговорены, и не оговорено само поведение keyRepeated.

____________________________________
1. Вместо трех методов достаточно было одного с парой int-параметров.
2. Вместо этого, следует создавать универсальные механизмы запроса возможностей.



Внутренние потоки MIDP

keyPressed, keyReleased и тому подобные события вызываются внутренним Java-потоком MIDP-библиотеки. Важно понимать эту особенность – MIDP библиотека при своей работе создает несколько потоков-"демонов". Потоки- демоны обычно спят. Но как только микропроцессорная программа устройства обнаруживает нажатие на клавишу, она помещает код клавиши в системную очередь Java-библиотеки, и пробуждает демон-поток. (Программа узнает о нажатии клавиши, постоянно сканируя клавиатуру, либо получая аппаратное прерывание). Далее, спящий поток пробуждается, считывает системную очередь событий, обнаруживает событие нажатия на клавишу, и вызывает keyPressed. После возврата из keyPressed поток проверяет очередь сообщений, и либо обрабатывает новое сообщение, либо снова засыпает.

Исследовать внутренние потоки MIDP весьма просто – достаточно запустить мидлет в режиме отладки (например, используя JBuilder), затем поставить точку останова внутри MIDP-метода и изучить окно отладчика. В случае с эмуляторами Siemens – мы увидим аналогичную java-машину, что и на настоящем устройстве. На SonyEricsson существует т.н. OnDeviceDebug – полноценная отладка на java-машине самого телефона. Я не рекомендую использовать эмуляторы WTK1, WTK2, ибо их архитектура не отражает реальные устройства (слишком "высокоуровневая"). И существует еще один способ – поместить в интересующем системном MIDP методе код:


System.out.println(Thread.currentThread().toString());

и посмотреть результат его исполнения.



Проблема одновременного нажатия клавиш

На большинстве устройств, в один момент времени может быть нажата только одна клавиша. Поначалу это удивляет – но как же тогда писать игры? Как сделать одновременное передвижение по-диагонали и стрельбу? Если присмотреться к существующим играм, то можно заметить, что персонаж делает что-то одно – или передвигается в одном направлении, или выполняет какое-то действие (стреляет). Соответственно, игру от самой идеи нужно заранее проектировать так, чтобы одновременные действия не требовались (т.е. чтобы можно было играть "одним пальцем").

Многие устройства из-за конструкции джойстика или клавиатурной крестовины физически не позволяют нажать джойстик или крестовину одновременно и вверх, и вбок. Только ИЛИ вбок ИЛИ вверх-вниз, ИЛИ в центр (FIRE).

Даже если физически это возможно, электроника телефона (матрица клавиатуры) может быть спроектирована так, что опрашивать можно только одну кнопку. Авторы MIDP этого до сих пор не заметили. Данное поведение не оговорено..

Некоторые трубки все же корректно передают пары keyPressedkeyReleased с кодами нажатых-отпущенных клавиш, т.е. гарантированно опрашивают несколько клавиш. Это поздние модели SonyEricsson и смартфоны. Если между парами событий keyPressed - keyReleased запоминать нажатую клавишу в массиве, то можно добиться искомого - эмулировать одновременное нажатие кнопок.



Проблема с keyReleased

Проблема заключается в том, что на некоторых устройствах при одновременном нажатии клавиш и отпускании их в обратном порядке (в любом, и даже корректном) нельзя рассчитывать на то, что к событию keyPressed придет соответствующая пара keyReleased, с корректным кодом клавиши. Некоторые устройства могут "проглотить" keyReleased. Решить эту проблему можно "отжимая" в keyReleased все нажатые клавиши сразу (сбросив все биты, или записывая во все переменные массива false).



Эмуляторы и реальные устройства

Многие эмуляторы не соблюдают поведение эмулируемых устройств – например, эмулятор Siemens M55 посылает события не в том порядке, который происходит на реальном телефоне M55. Более того, эмулятор может "заклинить" и он начинает циклично посылать keyPressed с ошибочными отрицательными кодами клавиш…



Проблемы getKeyStates

Класс javax.microedition.lcdui.game.GameCanvas содержит метод getKeyStates назначение, которого – дать возможность опрашивать состояние клавиатуры в любой момент. Впервые данный метод появился внутри Siemens API, в классе com.siemens.mp.color_game.GameCanvas. Затем все это мигрировало в MIDP 2.0., многие решения которого являются прямым переносом com.siemens.mp.* API.

Алгоритм getKeyStates таков:

По вызову getKeyStates возвращается битовая маска, где аккумулированы нажатия клавиш, (если их успело случиться несколько) и одновременно с возвратом результата все биты сбрасываются (маска обнуляется). По keyPressed или keyRepeated – по коду клавиши взводится соответствующий бит.

keyReleased не задействован, поэтому однажды установленный бит будет взведен до вызова getKeyStates.

Проблема в том, что одновременно считать несколько клавиш в подавляющем большинстве случаев все равно невозможно – Можно только лишь зафиксировать несколько разных клавиш, если период вызова getKeyStates весьма велик и в этот интервал пользователь успел поочередно нажать несколько клавиш.

Биты устанавливаются событием keyPressed или keyRepeated. А так как большинство устройств при удержании клавиши циклично посылают keyRepeated, то в сумме с регулярным опросом getKeyStates это дает "мерцание" кнопки, несмотря на то, что она все время удерживается нажатой.

В реализации getKeyStates заложена еще одна проблема, на сей раз конструктивная [3] . Метод getKeyStates работает благодаря перекрытым методам в классе GameCanvas keyPressed и keyRepeated. То есть, если наследоваться от класса GameCanvas, то нужно обязательно вызывать super.keyRepeated или super.keyPressed. Это огрех конструирования. Но именно этот огрех позволяет избавиться от нежелательного "мерцания" – достаточно перекрыть keyRepeated и НЕ вызывать из него super.keyRepeated.
____________________________________
3. Это ошибка проектирования. Нельзя проектировать библиотеки так, чтобы вмешательство в одном месте вызывало неочевидное изменение поведения в другом.



keyReleased при Display.setCurrent

При программировании пары Canvas.keyPressed и Canvas.keyReleased, следует учитывать, что если внутри keyPressed выполняется Display.getDisplay(midlet).setCurrent(other) с установкой Displayable отличного от текущего Canvas (к примеру, в экран ставится Form для ввода какой-либо текстовой информации), то keyReleased с кодом клавиши вам уже не придет, и есть шансы получить ошибку, в виде "залипания" нажатой клавиши. Происходит это потому, что клавиатурные события посылаются Canvas'у только в том случае, если он является текущим. Как только текущим становится не Canvas, keyReleased уже не приходит.



Методы опроса клавиш

При программировании может понадобиться следующее поведение:

1. Однократная реакция на нажатие – требуется при перемещении пунктов меню, выделений, задания направления персонажу.

2. Постоянная реакция в ответ на удержание клавиши нажатой - требуется при перемещении "прицела", для бега персонажа пока клавиша удерживается нажатой.

В терминах электроники первое – это реакция на "фронт" сигнала, второе – на "полку" (уровень сигнала).



Проблемы с нестандартными кодами клавиш

Нельзя полагаться, что на всех устройствах одни и те же коды клавиш курсора и софт-клавиш. Одни и те же коды сохраняются только для цифровых кнопок 0…9, # и *

Кроме того, нужно рассчитывать на то, что в любой момент может поступить неизвестный код клавиши – неизвестные коды следует игнорировать.



Проблема с getGameAction

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

1. Мэппинг Action на клавиши у разных трубок может быть разным – это приведет к проблемам, если вы желаете сделать справку в игре одну и ту же для всех моделей. Так, есть трубки у которых FIRE мэппится на *, есть трубки у которых getGameAction не транслирует коды джойстика, есть трубки у которых софт-клавиши транслируются в GAME_A, GAME_B, GAME_C, GAME_D, а есть у которых – нет. Кроме того, и это самое важное, трансляция GAME_A, GAME_B, GAME_C, GAME_D не гарантирована. Поэтому полагаться на такую совершенно вольную имплементацию стандарта очень сложно. Игра может GAME_D не дождаться никогда.

2. С помощью getGameAction невозможно обрабатывать Soft Keys, потому что они отсутствуют в стандарте, зато присутствуют практически в каждой трубке с Java и доступны для считывания (посылаются в мидлет) с помощью дополнительных кодов клавиш. Из этой ситуации есть выход – вместо софт- кнопок можно использовать * , #, 0 либо проектировать интерфейс так, что для навигации используются только вверх-вниз, влево, вправо и огонь. Правда рано или поздно, хотя бы одна дополнительная клавиша все равно понадобится. Например, для того чтобы по нажатию на нее выходить из игры в меню…

3. В поведение функции заложена неоднозначность – getGameAction должна возвращать 0, но также она может выкидывать исключение. Лучшее решение – отказаться от getGameAction вовсе и обрабатывать коды клавиш без нее.



Особенности SonyEricsson

У телефонов Sony Ericsson существует пара особенностей, с которыми постоянно сталкиваются многие разработчики. Дело в том, что если передать в getGameAction код софт-клавиши, то вместо возврата 0 возникает исключение. А так как getGameAction вызывается обычно из keyPressed, который доставляется системным MIDP-потоком, то возникающее исключение происходит в контексте MIDP-потока и остается незамеченным – это приводит к тому, что код разработчика в keyPressed прерывается где-то на середине, а разработчик никак не подозревает это. Вторая особенность – не полноэкранный midlet не получает нажатия софт-клавиш. Проблема обходится созданием двух "невидимых" объектов Command с пустой Label, добавления их к Canvas и установке Command Listener'а.



Игры с несколькими потоками

Простейшие игры вида "Нажатие на кнопку – изменение ситуации" можно построить целиком в обработчике события keyPressed. Подобной категории удовлетворяют разного рода логические головоломки, карточные игры, игры вида "вопрос-ответ" и т.д.

Подобная игра работают следующим образом:

1. Внутри keyPressed анализируется код клавиши, и далее анализируется игровая ситуация, и в ответ на нажатие производятся некоторые действия.

2. Экран перерисовывается вызовом repaint, serviceRepaints

- Данная схема игры реализует Event Driven Model, т.е. действие по событию.

Более сложные, динамичные игры строить по такой схеме проблематично. Подобные игры организуются следующим образом:

1. Существует бесконечный игровой цикл.

2. Игровой цикл выполняется в контексте дополнительного физического потока java.lang.Thread

3. Игровой поток последовательно в цикле выполняет опрос (поллинг) клавиатуры, обновление игровой ситуации и постоянную перерисовку экрана.

- такая схема подразумевают не реакцию на клавиши, а опрос состояния клавиатуры в произвольном месте.

Если с первой Event-Driven моделью все понятно (Sun WTK содержит примеры таких игр) то последняя модель нуждается в более подробном рассмотрении. Считывать состояние клавиатуры просто так откуда-то нельзя, для этого не существует API. Поэтому состояние клавиатуры приходится сохранять в переменной (переменных), которые обновляются из keyPressed, keyReleased и разделяются между двух потоков – системным внутренним MIDP потоком, и игровым потоком.



Проблема с приоритетами потоков

Если поток игрового цикла будет забирать все время исполнения, то MIDP потоку не остается квантов времени процессора, и события клавиатуры начинают приходить с некоторой задержкой. Проявляется это в "неохотной" реакции игры на нажатие клавиши. Говоря другими словами, игровой поток "забивает" системный поток. Если игровому потоку поднять приоритет до java.lang.Thread.MAX_PRIORITY, то это проявится еще в большей степени.

Проблема вызвана двумя причинами:

1. Некорректным построением менеджера потоков внутри KVM. "Проснувшийся" поток должен получать легкий приоритет над потоками с обычным приоритетом и запускаться первым. Это необходимо как раз для того, чтобы поток мог нормально проснуться и не "забивался" активно трудящимися параллельными потоками.

2. Некорректным построением MIDP. Системные потоки, доставляющие события должны иметь приоритет, превосходящий потоки пользователя . Так как зачастую в контексте этого потока требуется выполнять работу, то приоритет поднимать нельзя, поэтому должно быть первое, поток должен получить преимущество.

Правильное решение:

Необходимо выделять MIDP-потоку доставляющему клавиатурные события квант времени. Для этого игровой поток должен на короткое время усыпляться. Я ставлю такую паузу в самом начале игрового цикла, чтобы быть уверенным, что перед каждой итерацией была установлена переменная, содержащая код клавиши, то есть MIDP поток просыпался и доставил событие keyPressed.

Достаточную паузу в 1мс можно реализовать следующим кодом:


try
{
    Thread.sleep(1);
}
catch (InterruptedException e)
{
    ;
}

В реальности стоит ограничивать скорость работы игрового цикла в 15..25 FPS (frames per second). Для этого функция должна подсчитывать интервалы времени с которыми она вызывается, и корректировать задержку.



Проблема синхронизации потоков

Несмотря на то, что переменная состояния клавиатуры разделяется между двумя потоками (MIDP потоком, доставляющим событие keyPressed и игровым потоком) синхронизация при обращении к переменной не требуется. Модификация переменной атомарна, т.е. несинхроннизированное обращение не вызывает никаких рассогласованностей, поэтому synchronized не требуется. Единственное, что стоит сделать –определить переменную как volatile.

Возникает вопрос. Неужели нельзя иметь game loop, выполняющийся игровым потоком, и Event-driven модель, т.е. необходимые действия (определение кода клавиши и реакцию на нажатие клавиатуры) выполнять внутри keyPressed? В некоторых случаях это возможно без какой-либо синхронизации [4]. Но нужно учесть, что keyPressed возникает совершенно в непредсказуемый момент по отношению к игровому циклу. Иногда это не имеет значения. Но в некоторых случаях, когда данные игры будут модифицироваться из двух разных потоков, это будет вызывать редкие и очень трудноуловимые программные ошибки, или логические сбои. Для того чтобы их избежать, потребуется выполнить синхронизацию между потоками, для того чтобы они работали поочередно. Это тот случай, когда с многозадачностью приходится бороться.
____________________________________
4. Более того, многие игры написаны вообще без учета того, что между потоками возникает рассинхронизация, т.к. программист не обладал соотв. знаниями.



Проблема с пропуском события

Проблема выражается в том, что игра изредка пропускает нажатие на клавиши.

Для того чтобы понять, почему это происходит, рассмотрим код ниже:


// вызывается в контексте MIDP-потока

volatile int key;

protected void keyPressed(int k)
{
	key = k; // при нажатии записывается код клавиши
}

protected void keyReleased(int k)
{
	key = 0; // при отпускании переменная обнуляется
}


// вызывается в контексте игрового потока

if (key == KEYCODE_UP)
{
    // диагностировано "нажатие", сбрасываем факт нажатия
    key = 0;
    // и далее здесь как-то изменяем игровую ситуацию…
}

Теперь представим себе, что игровой поток работает медленно. Развивает не 25 FPS, а всего лишь 1 FPS. То есть считывание состояния клавиатуры (вызов кода ниже) происходит всего лишь 1 раз в секунду. Легко догадаться, что быстрые нажатия (протяженностью менее 1сек) иногда будут пропускаться.

Причина - в keyReleased, факт нажатия на кнопку фиксируется в переменной key, но до того как эта переменная будет считана, она может быть сброшена в keyReleased.

Когда игровой поток опрашивает переменную состояния клавиатуры значительно быстрее, это всего лишь уменьшает вероятность возникновения дефекта, но проблема не исчезает – очень короткие нажатия на клавиатуру все равно возможны, и как следствие – возникают пропуски.

Бороться с проблемой легко – не использовать keyReleased, и регистрацию нажатия сделать триггерной – то есть взводить однократно флаг клавиши, и сбрасывать его из игрового потока, по факту обнаружения и обработки нажатия.

Замечу, что при определении удержания клавиш поведение триггера не требуется (например, когда нужно двигать прицел).



Конечное решение

Я использую в играх следующий провереный java-код.

Для того чтобы определять нажатия из игрового цикла, выполняющегося в отдельном игровом потоке, заводится переменная key, которая устанавливается в методе keyPressed. Переменная key сохраняет код последней нажатой клавиши. Если key содержит ноль, то это означает, что ни одна клавиша не была нажата. Игра, исполняющаяся в отдельном потоке, определяет код клавиши, и сама сбрасывает переменную в ноль (см. листинг 2).

Если требуется определить, что клавиша удерживается, то для этого следует завести вторую переменную, содержимое которой обнуляется в методе keyReleased. Для целей проверки клавиш на факт нажатия и удержания следует обращаться именно к этой переменной. (Сбрасывается она сама в keyReleased).

Если устройство способно корректно передавать парой keyPressedkeyReleased коды нескольких нажимаемых клавиш, то заводится битовая маска нажатых клавиш. Далее листинг полного алгоритма.


volatile int keycode,      // скан-код состояния
клавиатуры
             keycode_trg,  // последний скан-код
             key,          // биты состояния клавиш
             key_trg;      // битовая маска

protected void keyPressed(int code) {
    keycode = keycode_trg = code;
    int bits = translatekey(code);
    key |= bits;
    key_trg |= bits;
}
protected void keyReleased(int code) {
    keycode = 0;
    key &= ~translatekey(code);
}
int translatekey(int code) {  // TODO: добавить коды
клавиш для своей моедли
    switch(code) {
        case '2':   // коды софткеев и джойстика для NOKIA и SE
        case -1:    // изменяются при препроцессинге
            return KEYUP;
        case '8':
        case -2:
            return KEYDOWN;
        case '4':
        case -3:
            return KEYLEFT;
        case '6':
        case -4:
            return KEYRIGHT;
        case '5':
        case -5:
            return KEYFIRE;
        case -6:
            return KEYLSOFT;
        case -7:
            return KEYRSOFT;
        default:
            return 0;
    }
}

// Интерфейс констант, включается в класс содержащий код
выше с помошью implements

interface Constants {
    // Битовые паттерны для кнопок курсора, расширяются если необходимо
    int KEYLEFT = 1;
    int KEYRIGHT = 2;
    int KEYUP = 4;
    int KEYDOWN = 8;
    int KEYFIRE = 16;
    int KEYLSOFT = 32;
    int KEYRSOFT = 64;
}

// код считывающий нажатие выглядит так:

if((key_trg & KEYLEFT) != 0) {
    key_trg &= ~KEYLEFT;

    // reaction code here

}

В том случае, если вы не планируете поддерживать устройства с одновременными нажатиями клавиш и проектируете игру для управления одной кнопкой (а я советую ориентироваться именно на этот случай), то алгоритм еще упрощается. Нет нужды фиксировать одновременно нажатые клавиши в виде битов. Достаточно лишь записывать последний код клавиши. Вот листинг этого алгоритма:


volatile int key;

protected void keyPressed(int key) {
    this.key = translatekey(key);
}

int translatekey(int code) {
    switch(code) {
        case '2':    // коды софткеев и джойстика для NOKIA и SE
        case -1:     // изменяются при препроцессинге
            return KEYUP;
        case '8':
        case -2:
            return KEYDOWN;
        case '4':
        case -3:
            return KEYLEFT;
        case '6':
        case -4:
            return KEYRIGHT;
        case '5':
        case -5:
            return KEYFIRE;
        case -6:
            return KEYLSOFT;
        case -7:
            return KEYRSOFT;
        default:
            return KEYNONE;
    }
}

// аналогичный интерфейс, содержащий внутренние константы.

interface Constants {
    int KEYNONE = 0;
    int KEYLEFT = 1;
    int KEYRIGHT = 2;
    int KEYUP = 3;
    int KEYDOWN = 4;
    int KEYFIRE = 5;
    int KEYLSOFT = 6;
    int KEYRSOFT = 7;
}


// код считывающий нажатие выглядит так:

if (key == KEYLEFT) {
    key = KEYNONE;

    // reaction code here

}

Возможны также произвольные варианты. Вместо анализа битов или кода клавиши можно завести несколько Boolean – переменных, и внутри keyPressed, в зависимости от кода поступившей клавиши устанавливать переменные в true.

Код становится даже несколько нагляднее:


if (left) {
    left = false;
    // реакция на клавишу
}




Заключение

Мной была рассмотрена методика опроса клавиатуры в J2ME играх и возникающие при этом типичные проблемы. Как полное решение, я привел исходный код, который можно использовать в своих играх. Если вы желаете добавить комментарии или задать вопросы, то вы можете связаться со мной по адресу . В теме письма обязательно пометьте: "J2ME игры: программирование клавиатуры".



home