j2me: пример с падающим мячиком

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

В эмуляторе выглядит всё не так красиво, как в живую, поэтому продемонстрирую на телефоне(SE P1i)




Понравилось? Для начала работы с j2me - самое то.
Используется MIDP-2.0, CLDC-1.1.

Структура приложения

  • В /resource/ лежат как ни странно ресурсы - картинки
  • В /ru/pp/rux/fallingball находится сам мидлет. Он имеет несколько public static методов для доступа к нему извне, например когда требуется завершить работу или установить другой Displayable
  • В /ru/pp/rux/fallingball/util есть пара классов, которые помогают в работе. Это ColorGradient, рисующий градиент и SomeMath, реализующий математические функции(atan, pow, exp), которых нет в java.lang.Math в MicroEdition, а эти функции необходимы для расчёта физики
  • Канавы лежат в /ru/pp/rux/fallingball/canvas. Первая, самая простая, представляющая собой welcome screen - это класс WelcomeCanvas, на нём отображается приветствие - в центре экрана мячик, снизу надпись "нажмите куда-нибудь". Перед переходом к классу в котором происходит основное действо, настоятельно рекомендую ознакомиться с экраном приветствия, особенно если это ваши первые попытки изучать ME. Внешний вид - справа.

    В FallingBall классе происходит самое интересное - рисование мяча, обработка событий клавиатуры и нажатия на экран. Там достаточно много различных методов и полей, я взял на себя смелость не комментировать их - действительно некогда, но дал внятные имена.

Пристальный взгляд на FallingBall

По таймеру, который срабатывает 1000/20 раз в секунду выполняется задание PhysicsTimer, которое производит обсчёт физики, если разрешенно полем enabePhysics. Запрещено может быть только когда нажата клавиша на клавиатуре или нажат экран. Глядя методы keyPressed/keyReleased, pointerDragged/pointerReleased видно, что там меняется значение этого флажка.

Во время движения мячика "руками" по экрану, не важно чем - кнопками или касанием экрана вычисляется новый вектор скорости. Для этого во время (первого) нажатия запоминается текущее положение, а после отпускания экрана, кнопки(нужное подчеркнуть), происходит вычисления - новый угол и скорость. Это происходит в методе recalculateSpeed(int x, int y, boolean sum), принимающий новое положение мяча(места на котором стал мяч, после отпускания клавиши) и флажок - пересчитанную скорость нужно добавлять к существующий(нужно когда были нажатия клавиш) или заменять(когда установка нового вектора скорости была с помощью сенсорного экрана)

protected void recalculateSpeed(int x, int y, boolean sum) {
if ( !(x > startX - 2 && x < startX + 2) && !(y > startY - 2 && y < startY + 2) ) {
double d;
d = x - startX;
vx = (float) SomeMath.log(SomeMath.pow(d, 4)) * (d > 0 ? 1 : -1) + (sum ? vx : 0);
d = y - startY;
vy = (float) SomeMath.log(SomeMath.pow(d, 4)) * ( d > 0 ? 1 : -1) + (sum ? vx : 0);
}
}

Немного странно выглядит? И логарифмы и степень. Нужно просто посмотреть на график логарифма и станет ясно почему я так сделал.

Если было не большое перемещение, то нужно чтоб скорость мяча была небольшой. При среднем перемещении - средняя. А вот при большом перемещении(например с середины экрана до низу) скорость должна быть больше средней, но не так, чтоб за мячом было не уследить. Иначе говоря это попытка ввести некое сопротивление, пускай воздушное. В общем смотрим график справа

Разумеется, нужно предусмотреть команды выхода из приложения, а так же "о приложении". Для этого в конструкторе добавим их соответственно и объявим внутренний класс CommadsProcessor, который будет слушать что ему поступило. Кстати, в качестве About используется Alert с картинкой(а на картинке qr-code с адресом сайта:))

class CommandsProcessor implements CommandListener {
public void commandAction(Command c, Displayable d) {
switch (c.getCommandType()) {
case Command.EXIT:
SmallMidlet.getInstance().notifyDestroyed();
break;
case Command.HELP:
showAbout();
break;
}
}
}

Для создания About используется следующий singleton - метод:

protected Alert getAbout() {
if (about == null) {
Image qrcode;
try {
qrcode = Image.createImage("/resource/rux.pp.ru.png");
} catch (IOException ex) {
qrcode = null;
}
about = new Alert("About..", "http://ruX.pp.ru", qrcode, AlertType.INFO);
about.setTimeout(20000); // shown for 20 seconds

Command back = new Command("Go back", Command.BACK, 1);
about.addCommand(back);
}
return about;
}

Методы move* занимаются перемещением мяча на заданное смещение, при этом проверяется возможность перемещения границами. Если перемещение не удалось, то методы возвращают false. Это и использует метод CommandsProcessor.run() - если в сдвинуть не получается, значит мяч долетел до какой то преграды и нужно изменить направления. Стенки экрана не идеально упругие - на них теряется 30% "кинетической энергии". А так же вводится гравитация, просто добавлением коэф ускорения - я подобрал 0.45. Просто из формулы v = v0 + a*t (спасибо, Frozen). t у нас нет, но подразумевается, таймер же срабатывает через определённое время.

class PhysicsTimer extends TimerTask {
public void run(){
if (!enablePhysics) return;

if (!moveY((int)(vy))) vy *= -0.7;
if (!moveX((int)(vx))) vx *= -0.7;

vy += 0.45;

repaint();
}
}

keyRepeated() - длинный метод, который просто выбирает направление движения, в зависимости от нажатой кнопки(сначала проверяется на игровую кнопку, если не нашёл то на нажатую клавишу). Также в нем считается коэф. repeatKoeff позволяющий перемещать мяч с ускорением при нажатии на кнопку - с каждым разом он не на много увеличивается, но в целом это заметно. repeatKoeff показывает на сколько пикселей переместится в следующий раз мяч. Как только повторения нажатия прекратились, он сразу же обнуляется в defaultRepeatKoeff

Если имел место двойной хлопок по экрану(за время меньшее doubleTapInterval) срабатывает событие двойного нажатия - происходит обнуление скоростей по обоим осям(vx, vy), после этого на мяч действует только ускорение свободного падения. После каждого хлопка касания по экрану сохраняется текущее время в милисекундах в lastTap.

Ну и теперь весь код FallingBall, остальные файлы в архиве ниже

package ru.pp.rux.fallingball.canvas;

import java.io.IOException;
import java.util.Calendar;
import java.util.Timer;
import java.util.TimerTask;
import javax.microedition.lcdui.Alert;
import javax.microedition.lcdui.AlertType;
import javax.microedition.lcdui.Canvas;
import javax.microedition.lcdui.Command;
import javax.microedition.lcdui.CommandListener;
import javax.microedition.lcdui.Displayable;
import javax.microedition.lcdui.Graphics;
import javax.microedition.lcdui.Image;

import ru.pp.rux.fallingball.SmallMidlet;
import ru.pp.rux.fallingball.util.ColorGradient;
import ru.pp.rux.fallingball.util.SomeMath;

/**
*
* @author ruX[Ruslan Zaharov]
* 2010 (c) http://ruX.pp.ru
*/
public class FallingBall extends Canvas {

private Image background = null;
private Alert about = null;
private Image imgBall;

private boolean enablePhysics = true;
private int x, y;
private float vx = 0, vy = 0;

private int startX, startY;

private final float defaultRepeatKoeff = 2.0F;
private float repeatKoeff = defaultRepeatKoeff;

private final int doubleTapInterval = 400; // ms
private long lastTap;

public FallingBall() {
setFullScreenMode(true);
x = getWidth() / 2;
y = getHeight() / 2;

new Timer().scheduleAtFixedRate(new PhysicsTimer(), 500, 1000/20);
setCommandListener(new CommandsProcessor());
addCommand(new Command("Exit", Command.EXIT, 0));
addCommand(new Command("About", Command.HELP, 1));
repaint();
}

protected Image getBackground() {
if (background == null) {
background = Image.createImage(getWidth(), getHeight());
ColorGradient.draw(background.getGraphics(), 0, 0, getWidth(), getHeight(),
0xAA, 0x22, 0x00, 0x22, 0xEE, 0x00);
}
return background;
}

protected Image getBall() {
if (imgBall == null) {
try {
imgBall = Image.createImage("/resource/ball.png");
} catch (IOException ex) {
ex.printStackTrace();
}
}
return imgBall;
}

protected Alert getAbout() {
if (about == null) {
Image qrcode;
try {
qrcode = Image.createImage("/resource/rux.pp.ru.png");
} catch (IOException ex) {
qrcode = null;
}
about = new Alert("About..", "http://ruX.pp.ru", qrcode, AlertType.INFO);
about.setTimeout(20000); // shown for 20 seconds

Command back = new Command("Go back", Command.BACK, 1);
about.addCommand(back);
}
return about;
}

protected void showAbout() {
SmallMidlet.getDisplay().setCurrent(getAbout(), this);
}

protected void paint(Graphics g) {
g.drawImage(getBackground(), 0, 0, 0);
g.drawImage(getBall(), x - getBall().getWidth()/2, y - getBall().getHeight()/2, 0);
double length = Math.sqrt(SomeMath.pow(x - startX, 2) + SomeMath.pow(y - startY, 2));
double arrowlength = 14;
if (!enablePhysics && (length > getBall().getHeight()/2+arrowlength)) {
// ok.. lets begin to draw arrowhead
double angle = SomeMath.aTan2(y - startY, x - startX);
length -= getBall().getHeight() / 2;
int tx = (int)(startX + length * Math.cos(angle));
int ty = (int)(startY + length * Math.sin(angle));;

g.setColor(0, 0, 0);
g.drawLine(startX, startY, tx, ty);
g.drawLine(startX+1, startY, tx+1, ty);
g.drawLine(startX, startY+1, tx, ty+1);
g.drawLine(startX+1, startY+1, tx+1, ty+1);

double arrowdeg = Math.PI / 8;
int x1 = (int)(tx - arrowlength * Math.cos(angle - arrowdeg));
int y1 = (int)(ty - arrowlength * Math.sin(angle - arrowdeg));
int x2 = (int)(tx - arrowlength * Math.cos(angle + arrowdeg));
int y2 = (int)(ty - arrowlength * Math.sin(angle + arrowdeg));

g.drawLine(tx, ty, x1, y1);
g.drawLine(tx, ty, x2, y2);
g.drawLine(x1, y1, x2, y2);

g.setColor(0xffffff);
g.fillTriangle(tx, ty, x1, y1, x2, y2);
}
}

protected boolean moveX(int offset) {
if (offset > 0) {
if (x > getWidth() - getBall().getWidth() / 2 - offset) return false;
} else {
if (x < - offset + getBall().getWidth() / 2) return false;
}
x += offset;
return true;
}

protected boolean moveY(int offset) {
if (offset > 0) {
if (y > getHeight() - getBall().getHeight() / 2 - offset) return false;
} else {
if (y < - offset + getBall().getHeight() / 2) return false;
}
y += offset;
return true;
}

protected boolean moveXY(int offx, int offy) {
return moveX(offx) | moveY(offy);
}

protected void setXY(int nx, int ny) {
x = nx;
y = ny;
}

protected void keyPressed(int key) {
if (key == Canvas.KEY_STAR) {
showAbout();
return;
}
enablePhysics = false;
startX = x; startY = y;
keyRepeated(key);
}

protected void keyRepeated(int key) {
if (enablePhysics) return;

int rk = (int)repeatKoeff;
switch (getGameAction(key)) {
case Canvas.DOWN:
moveY(rk);
break;
case Canvas.UP:
moveY(-rk);
break;
case Canvas.RIGHT:
moveX(rk);
break;
case Canvas.LEFT:
moveX(-rk);
break;
default:
switch (key) {
case Canvas.KEY_NUM1:
moveXY(-rk, -rk);
break;
case Canvas.KEY_NUM2:
moveY(-rk);
break;
case Canvas.KEY_NUM3:
moveXY(rk, -rk);
break;
case Canvas.KEY_NUM4:
moveX(-rk);
break;
case Canvas.KEY_NUM6:
moveX(rk);
break;
case Canvas.KEY_NUM7:
moveXY(-rk, rk);
break;
case Canvas.KEY_NUM8:
moveY(rk);
break;
case Canvas.KEY_NUM9:
moveXY(rk, rk);
break;
}

}

repaint();
repeatKoeff *= 1.1F;
}

protected void keyReleased(int key) {
enablePhysics = true;
recalculateSpeed(x, y, true);
repeatKoeff = defaultRepeatKoeff;
}

protected void pointerPressed(int x, int y) {
long now = Calendar.getInstance().getTime().getTime();
setXY(x, y);
if (now - lastTap < doubleTapInterval) {
// double-tap?
vx = vy = 0;
return;
}
startX = x; startY = y;
lastTap = now;
}

protected void pointerDragged(int x, int y) {
enablePhysics = false;
setXY(x, y);
repaint();
}

protected void pointerReleased(int x, int y) {
recalculateSpeed(x, y, false);
enablePhysics = true;
}

protected void recalculateSpeed(int x, int y, boolean sum) {
if ( !(x > startX - 2 && x < startX + 2) && !(y > startY - 2 && y < startY + 2) ) {
double d;
d = x - startX;
vx = (float) SomeMath.log(SomeMath.pow(d, 4)) * (d > 0 ? 1 : -1) + (sum ? vx : 0);
d = y - startY;
vy = (float) SomeMath.log(SomeMath.pow(d, 4)) * ( d > 0 ? 1 : -1) + (sum ? vx : 0);
}
}

class PhysicsTimer extends TimerTask {
public void run(){
if (!enablePhysics) return;

if (!moveY((int)(vy))) vy *= -0.7;
if (!moveX((int)(vx))) vx *= -0.7;

vy += 0.45;

repaint();
}
}

class CommandsProcessor implements CommandListener {
public void commandAction(Command c, Displayable d) {
switch (c.getCommandType()) {
case Command.EXIT:
SmallMidlet.getInstance().notifyDestroyed();
break;
case Command.HELP:
showAbout();
break;
}
}
}
}

Files

Final

Надеюсь этот пример, несколько сложнее helloWorld окажется кому - то полезным. Хотя уже оказался - мне.

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